Stian Fredrik Aune
2 years ago
commit
45f520e9d1
73 changed files with 3473 additions and 0 deletions
-
4.gitignore
-
127pom.xml
-
58ykonsole-core/pom.xml
-
13ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/YKonsoleException.kt
-
4ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/env/Env.kt
-
8ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/logging/Log.kt
-
55ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt
-
7ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Device.kt
-
23ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/DeviceRepository.kt
-
13ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ID.kt
-
21ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Program.kt
-
23ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ProgramRepository.kt
-
23ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Workout.kt
-
28ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutRepository.kt
-
14ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt
-
18ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStateRepository.kt
-
3ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStatus.kt
-
16ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Command.kt
-
8ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Driver.kt
-
15ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Event.kt
-
44ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/FlowBus.kt
-
21ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Value.kt
-
74ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt
-
57ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt
-
38ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ActiveDriver.kt
-
14ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ReactiveDriver.kt
-
75ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt
-
27ykonsole-core/src/main/resources/log4j2.xml
-
20ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/domain/models/IDTest.kt
-
97ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcerTest.kt
-
69ykonsole-iconsole/pom.xml
-
72ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/Demo.kt
-
250ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
-
55ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Client.kt
-
57ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Request.kt
-
93ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Response.kt
-
13ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Session.kt
-
26ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/RequestTest.kt
-
22ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/ResponseBodyTest.kt
-
72ykonsole-ktor/pom.xml
-
48ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/Server.kt
-
55ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt
-
68ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Devices.kt
-
66ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Programs.kt
-
80ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt
-
16ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/WorkoutStates.kt
-
93ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Workouts.kt
-
83ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt
-
21ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketInput.kt
-
21ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt
-
37ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ktor/Responses.kt
-
65ykonsole-mysql/pom.xml
-
15ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/Migration.kt
-
17ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/application/Migration.kt
-
43ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/DataSource.kt
-
60ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepository.kt
-
168ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepository.kt
-
79ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepository.kt
-
77ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt
-
12ykonsole-mysql/src/main/resources/migrations/changelog.xml
-
23ykonsole-mysql/src/main/resources/migrations/tables/device.xml
-
20ykonsole-mysql/src/main/resources/migrations/tables/program.xml
-
33ykonsole-mysql/src/main/resources/migrations/tables/program_step.xml
-
41ykonsole-mysql/src/main/resources/migrations/tables/program_step_value.xml
-
45ykonsole-mysql/src/main/resources/migrations/tables/workout.xml
-
29ykonsole-mysql/src/main/resources/migrations/tables/workout_state.xml
-
26ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/SetUp.kt
-
56ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepositoryTest.kt
-
129ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepositoryTest.kt
-
122ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepositoryTest.kt
-
57ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepositoryTest.kt
-
42ykonsole-server/pom.xml
-
49ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
@ -0,0 +1,4 @@ |
|||||
|
.idea/ |
||||
|
*/target/ |
||||
|
.env |
||||
|
*.iml |
@ -0,0 +1,127 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xmlns="http://maven.apache.org/POM/4.0.0" |
||||
|
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> |
||||
|
|
||||
|
<artifactId>ykonsole</artifactId> |
||||
|
<modules> |
||||
|
<module>ykonsole-core</module> |
||||
|
<module>ykonsole-iconsole</module> |
||||
|
<module>ykonsole-mysql</module> |
||||
|
<module>ykonsole-server</module> |
||||
|
<module>ykonsole-ktor</module> |
||||
|
</modules> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<version>2.0.0</version> |
||||
|
<packaging>pom</packaging> |
||||
|
|
||||
|
<name>ykonsole</name> |
||||
|
|
||||
|
<properties> |
||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
||||
|
<kotlin.code.style>official</kotlin.code.style> |
||||
|
<kotlin.compiler.jvmTarget>11</kotlin.compiler.jvmTarget> |
||||
|
<kotlin.version>1.7.10</kotlin.version> |
||||
|
</properties> |
||||
|
|
||||
|
<repositories> |
||||
|
<repository> |
||||
|
<id>mavenCentral</id> |
||||
|
<url>https://repo1.maven.org/maven2/</url> |
||||
|
</repository> |
||||
|
</repositories> |
||||
|
|
||||
|
<dependencies> |
||||
|
<!-- Kotlin --> |
||||
|
<dependency> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-stdlib-jdk8</artifactId> |
||||
|
<version>1.7.10</version> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- Logging --> |
||||
|
<dependency> |
||||
|
<groupId>org.slf4j</groupId> |
||||
|
<artifactId>slf4j-api</artifactId> |
||||
|
<version>1.7.36</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.apache.logging.log4j</groupId> |
||||
|
<artifactId>log4j-slf4j-impl</artifactId> |
||||
|
<version>2.18.0</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.apache.logging.log4j</groupId> |
||||
|
<artifactId>log4j-api</artifactId> |
||||
|
<version>2.18.0</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.apache.logging.log4j</groupId> |
||||
|
<artifactId>log4j-core</artifactId> |
||||
|
<version>2.18.0</version> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- Testing --> |
||||
|
<dependency> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-test-junit5</artifactId> |
||||
|
<version>1.7.0</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.junit.jupiter</groupId> |
||||
|
<artifactId>junit-jupiter-engine</artifactId> |
||||
|
<version>5.6.0</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.mockk</groupId> |
||||
|
<artifactId>mockk</artifactId> |
||||
|
<version>1.12.4</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.jetbrains.kotlinx</groupId> |
||||
|
<artifactId>kotlinx-coroutines-test-jvm</artifactId> |
||||
|
<version>1.6.4</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
|
||||
|
<build> |
||||
|
<sourceDirectory>src/main/kotlin</sourceDirectory> |
||||
|
<testSourceDirectory>src/test/kotlin</testSourceDirectory> |
||||
|
<plugins> |
||||
|
<plugin> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-maven-plugin</artifactId> |
||||
|
<version>1.7.10</version> |
||||
|
<executions> |
||||
|
<execution> |
||||
|
<id>compile</id> |
||||
|
<phase>compile</phase> |
||||
|
<goals> |
||||
|
<goal>compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
<execution> |
||||
|
<id>test-compile</id> |
||||
|
<phase>test-compile</phase> |
||||
|
<goals> |
||||
|
<goal>test-compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
</executions> |
||||
|
</plugin> |
||||
|
<plugin> |
||||
|
<artifactId>maven-surefire-plugin</artifactId> |
||||
|
<version>2.22.2</version> |
||||
|
</plugin> |
||||
|
<plugin> |
||||
|
<artifactId>maven-failsafe-plugin</artifactId> |
||||
|
<version>2.22.2</version> |
||||
|
</plugin> |
||||
|
</plugins> |
||||
|
</build> |
||||
|
</project> |
@ -0,0 +1,58 @@ |
|||||
|
<?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-core</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>org.jetbrains.kotlinx</groupId> |
||||
|
<artifactId>kotlinx-coroutines-core-jvm</artifactId> |
||||
|
<version>1.6.4</version> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
|
||||
|
<build> |
||||
|
<plugins> |
||||
|
<plugin> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-maven-plugin</artifactId> |
||||
|
<version>${kotlin.version}</version> |
||||
|
<executions> |
||||
|
<execution> |
||||
|
<id>compile</id> |
||||
|
<phase>compile</phase> |
||||
|
<goals> |
||||
|
<goal>compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
<execution> |
||||
|
<id>test-compile</id> |
||||
|
<phase>test-compile</phase> |
||||
|
<goals> |
||||
|
<goal>test-compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
</executions> |
||||
|
<configuration> |
||||
|
<jvmTarget>${maven.compiler.target}</jvmTarget> |
||||
|
</configuration> |
||||
|
</plugin> |
||||
|
</plugins> |
||||
|
</build> |
||||
|
|
||||
|
|
||||
|
</project> |
@ -0,0 +1,13 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
sealed class YKonsoleException : RuntimeException { |
||||
|
constructor(message: String?) : super(message) |
||||
|
constructor(message: String?, cause: Throwable?) : super(message, cause) |
||||
|
constructor(cause: Throwable?) : super(cause) |
||||
|
} |
||||
|
|
||||
|
class InfrastructureException(cause: Throwable) : YKonsoleException(cause) |
||||
|
|
||||
|
class BadInputException(message: String) : YKonsoleException(message) |
||||
|
|
||||
|
object BusyDriverException : YKonsoleException("Driver is busy") |
@ -0,0 +1,4 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.env |
||||
|
|
||||
|
fun optStrEnv(key: String): String? = System.getenv(key) |
||||
|
fun strEnv(key: String): String = optStrEnv(key) ?: error("Missing env. variable: $key") |
@ -0,0 +1,8 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.logging |
||||
|
|
||||
|
import org.apache.logging.log4j.LogManager |
||||
|
import org.apache.logging.log4j.Logger |
||||
|
|
||||
|
private val loggerCache = mutableMapOf<Class<*>, Logger>() |
||||
|
|
||||
|
val <T : Any> T.log get() = loggerCache.computeIfAbsent(javaClass) { LogManager.getLogger(it) } |
@ -0,0 +1,55 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.services |
||||
|
|
||||
|
import kotlinx.coroutines.CoroutineScope |
||||
|
import kotlinx.coroutines.delay |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Command |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Driver |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Event |
||||
|
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 java.lang.Exception |
||||
|
import java.util.concurrent.atomic.AtomicInteger |
||||
|
import kotlin.system.exitProcess |
||||
|
import kotlin.time.Duration |
||||
|
import kotlin.time.Duration.Companion.seconds |
||||
|
|
||||
|
class DriverStarter( |
||||
|
private val drivers: List<Driver>, |
||||
|
private val input: FlowBus<Command>, |
||||
|
private val output: FlowBus<Event>, |
||||
|
private val retryInterval: Duration = 10.seconds |
||||
|
) { |
||||
|
val logger = log |
||||
|
|
||||
|
fun startDrivers() = runBlocking { |
||||
|
val jobs = drivers.map { driver -> |
||||
|
val name = driver.javaClass.kotlin.simpleName |
||||
|
|
||||
|
logger.info("Starting driver \"$name\"...") |
||||
|
launch { |
||||
|
val health = AtomicInteger(12) |
||||
|
|
||||
|
while (true) { |
||||
|
try { |
||||
|
driver.start(input, output) |
||||
|
} catch (e: Exception) { |
||||
|
logger.error("Driver \"$name\" interrupted by exception: ${e.message}", e) |
||||
|
|
||||
|
if (health.decrementAndGet() <= 0) { |
||||
|
logger.error("Driver \"$name\" was interrupted too many times, will not be reset") |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
delay(retryInterval) |
||||
|
logger.info("Restarting driver \"$name\"...") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logger.info("Started ${drivers.size} drivers") |
||||
|
jobs.forEach { it.join() } |
||||
|
} |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
data class Device( |
||||
|
val id: String, |
||||
|
var name: String, |
||||
|
var connectionString: String, |
||||
|
) |
@ -0,0 +1,23 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
interface DeviceRepository { |
||||
|
/** |
||||
|
* Find a [Device] by its [id], if it exists. |
||||
|
*/ |
||||
|
fun findById(id: String): Device? |
||||
|
|
||||
|
/** |
||||
|
* Fetch all available [Device]s. |
||||
|
*/ |
||||
|
fun fetchAll(): List<Device> |
||||
|
|
||||
|
/** |
||||
|
* Persist a [Device] to storage. |
||||
|
*/ |
||||
|
fun save(device: Device) |
||||
|
|
||||
|
/** |
||||
|
* Remove a [Device] from storage. |
||||
|
*/ |
||||
|
fun delete(device: Device) |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
import java.security.SecureRandom |
||||
|
|
||||
|
private val rand by lazy { SecureRandom() } |
||||
|
|
||||
|
const val ID_LENGTH = 10 |
||||
|
|
||||
|
fun randomId(): String = buildString { |
||||
|
while (length < ID_LENGTH) { |
||||
|
append(rand.nextInt().toUInt().toString(16).padStart(8, '0')) |
||||
|
} |
||||
|
}.substring(0 until ID_LENGTH) |
@ -0,0 +1,21 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.BadInputException |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Value |
||||
|
|
||||
|
data class Program( |
||||
|
val id: String, |
||||
|
val name: String, |
||||
|
val steps: List<ProgramStep>, |
||||
|
) |
||||
|
|
||||
|
data class ProgramStep( |
||||
|
val values: List<Value>, |
||||
|
val duration: Value?, |
||||
|
) { |
||||
|
init { |
||||
|
if (values.map { it.toString() }.toSet().size < values.size) { |
||||
|
throw BadInputException("Only one value of each type allowed in a program step") |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,23 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
interface ProgramRepository { |
||||
|
/** |
||||
|
* Fetch a [Program] by its [id], if it exists. |
||||
|
*/ |
||||
|
fun findById(id: String): Program? |
||||
|
|
||||
|
/** |
||||
|
* Fetch all available [Program]s. |
||||
|
*/ |
||||
|
fun fetchAll(): List<Program> |
||||
|
|
||||
|
/** |
||||
|
* Persist a [Program] to storage. |
||||
|
*/ |
||||
|
fun save(program: Program) |
||||
|
|
||||
|
/** |
||||
|
* Remove a [Program] from storage. |
||||
|
*/ |
||||
|
fun delete(program: Program) |
||||
|
} |
@ -0,0 +1,23 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Time |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Value |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.find |
||||
|
import java.time.Instant |
||||
|
|
||||
|
data class Workout( |
||||
|
val id: String = randomId(), |
||||
|
val createdAt: Instant = Instant.now(), |
||||
|
val deviceId: String = "", |
||||
|
val programId: String? = null, |
||||
|
var status: WorkoutStatus = WorkoutStatus.Created, |
||||
|
var message: String = "", |
||||
|
) { |
||||
|
fun makeState(values: Collection<Value>) = WorkoutState( |
||||
|
workoutId = id, |
||||
|
time = values.find() ?: Time(0), |
||||
|
calories = values.find(), |
||||
|
level = values.find(), |
||||
|
distance = values.find(), |
||||
|
) |
||||
|
} |
@ -0,0 +1,28 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
interface WorkoutRepository { |
||||
|
/** |
||||
|
* Fetch a [Workout] by its [id], if it exists. |
||||
|
*/ |
||||
|
fun findById(id: String): Workout? |
||||
|
|
||||
|
/** |
||||
|
* Fetch all workouts, regardless of status. |
||||
|
*/ |
||||
|
fun fetchAll(): List<Workout> |
||||
|
|
||||
|
/** |
||||
|
* Fetch the latest [Workout] that hasn't been finished (disconnected). |
||||
|
*/ |
||||
|
fun findActive(): Workout? |
||||
|
|
||||
|
/** |
||||
|
* Persist a [Workout] to storage. |
||||
|
*/ |
||||
|
fun save(workout: Workout) |
||||
|
|
||||
|
/** |
||||
|
* Remove a [Workout] from storage. |
||||
|
*/ |
||||
|
fun delete(workout: Workout) |
||||
|
} |
@ -0,0 +1,14 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Calories |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Distance |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Level |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Time |
||||
|
|
||||
|
data class WorkoutState( |
||||
|
val workoutId: String = "", |
||||
|
val time: Time = Time(0), |
||||
|
val calories: Calories? = null, |
||||
|
val level: Level? = null, |
||||
|
val distance: Distance? = null, |
||||
|
) |
@ -0,0 +1,18 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
interface WorkoutStateRepository { |
||||
|
/** |
||||
|
* Fetch a [List] of [WorkoutState]s associated with a single [workoutId] of a [Workout]. |
||||
|
*/ |
||||
|
fun fetchByWorkoutId(workoutId: String): List<WorkoutState> |
||||
|
|
||||
|
/** |
||||
|
* Persist a [WorkoutState] to storage. |
||||
|
*/ |
||||
|
fun save(state: WorkoutState) |
||||
|
|
||||
|
/** |
||||
|
* Remove the given [WorkoutState]s from storage. |
||||
|
*/ |
||||
|
fun deleteAll(states: Collection<WorkoutState>) |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
enum class WorkoutStatus { Created, Connected, Started, Stopped, Disconnected } |
@ -0,0 +1,16 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.runtime |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
|
||||
|
sealed class Command { |
||||
|
override fun toString() = "${javaClass.kotlin.simpleName}()" |
||||
|
} |
||||
|
|
||||
|
data class ConnectCommand(val device: Device) : Command() |
||||
|
|
||||
|
object StartCommand : Command() |
||||
|
object StopCommand : Command() |
||||
|
|
||||
|
data class SetValueCommand(val value: Value) : Command() |
||||
|
|
||||
|
object DisconnectCommand : Command() |
@ -0,0 +1,8 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.runtime |
||||
|
|
||||
|
interface Driver { |
||||
|
/** |
||||
|
* Set up the driver to read from the [input] and output to the [output] |
||||
|
*/ |
||||
|
suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) |
||||
|
} |
@ -0,0 +1,15 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.runtime |
||||
|
|
||||
|
sealed class Event { |
||||
|
val name: String = "${javaClass.kotlin.simpleName}" |
||||
|
|
||||
|
override fun toString() = "${javaClass.kotlin.simpleName}()" |
||||
|
} |
||||
|
|
||||
|
data class ValuesReceived(val values: List<Value>) : Event() |
||||
|
data class ErrorOccurred(val message: String) : Event() |
||||
|
object Started : Event() |
||||
|
object Stopped : Event() |
||||
|
|
||||
|
object Connected : Event() |
||||
|
object Disconnected : Event() |
@ -0,0 +1,44 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.runtime |
||||
|
|
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.SharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
import kotlinx.coroutines.flow.collectLatest |
||||
|
import kotlinx.coroutines.runBlocking |
||||
|
|
||||
|
interface FlowBus<T : Any> { |
||||
|
/** |
||||
|
* Return this as shared flow |
||||
|
*/ |
||||
|
fun asSharedFlow(): SharedFlow<T> |
||||
|
|
||||
|
/** |
||||
|
* Subscribe and read flow of [T]. |
||||
|
*/ |
||||
|
suspend fun collect(action: suspend (value: T) -> Unit) |
||||
|
|
||||
|
/** |
||||
|
* Emit an event to all subscribers. |
||||
|
*/ |
||||
|
suspend fun emit(data: T) |
||||
|
|
||||
|
/** |
||||
|
* Emit [data] outside a coroutine. |
||||
|
*/ |
||||
|
fun emitBlocking(data: T) = runBlocking { emit(data) } |
||||
|
} |
||||
|
|
||||
|
private class FlowBusImpl<T : Any> : FlowBus<T> { |
||||
|
private val internal = MutableSharedFlow<T>() |
||||
|
private val shared = internal.asSharedFlow() |
||||
|
|
||||
|
override fun asSharedFlow() = shared |
||||
|
override suspend fun collect(action: suspend (value: T) -> Unit) = shared.collectLatest(action) |
||||
|
override suspend fun emit(data: T) = internal.emit(data) |
||||
|
} |
||||
|
|
||||
|
fun CommandBus(): CommandBus = FlowBusImpl() |
||||
|
typealias CommandBus = FlowBus<Command> |
||||
|
|
||||
|
fun EventBus(): EventBus = FlowBusImpl() |
||||
|
typealias EventBus = FlowBus<Event> |
@ -0,0 +1,21 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.runtime |
||||
|
|
||||
|
sealed class Value { |
||||
|
val name get() = "${javaClass.kotlin.simpleName}" |
||||
|
|
||||
|
fun toInt() = when (this) { |
||||
|
is Time -> seconds |
||||
|
is Distance -> meters |
||||
|
is Calories -> kcal |
||||
|
is Level -> raw |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
data class Time(val seconds: Int) : Value() |
||||
|
data class Distance(val meters: Int) : Value() |
||||
|
data class Calories(val kcal: Int) : Value() |
||||
|
data class Level(val raw: Int) : Value() |
||||
|
|
||||
|
inline fun <reified T : Value> Collection<Value>.find(): T? = find { it is T }?.let { it as T } |
||||
|
inline fun <reified T : Value> Collection<Value>.findInt(): Int = find { it is T }?.toInt() ?: 0 |
||||
|
fun Value?.toInt(): Int = this?.toInt() ?: 0 |
@ -0,0 +1,74 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.drivers |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ReactiveDriver |
||||
|
|
||||
|
class ProgramEnforcer( |
||||
|
private val programRepo: ProgramRepository, |
||||
|
private val workoutRepo: WorkoutRepository, |
||||
|
) : ReactiveDriver() { |
||||
|
private var lastTransition: WorkoutState = WorkoutState() |
||||
|
private var lastStep: ProgramStep? = null |
||||
|
private var program: Program? = null |
||||
|
|
||||
|
override suspend fun onEvent(event: Event, input: FlowBus<Command>) { |
||||
|
if (event is Connected) { |
||||
|
// Start program on connecting |
||||
|
val programId = workoutRepo.findActive()?.apply { |
||||
|
lastTransition = WorkoutState(workoutId = id) |
||||
|
}?.programId ?: return |
||||
|
program = programRepo.findById(programId)?.takeIf { it.steps.isNotEmpty() } |
||||
|
lastStep = program?.steps?.firstOrNull() |
||||
|
} |
||||
|
|
||||
|
if (event is Started) { |
||||
|
// Set up the first step |
||||
|
lastStep?.apply { |
||||
|
values.map { input.emit(SetValueCommand(it)) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (event is ValuesReceived && program != null && lastStep != null) { |
||||
|
// If there's a program, look for next transition |
||||
|
|
||||
|
val step = lastStep!! |
||||
|
val prog = program!! |
||||
|
|
||||
|
val transition = when (step.duration) { |
||||
|
is Time -> (event.values.findInt<Time>()) >= lastTransition.time.toInt() + step.duration.toInt() |
||||
|
is Calories -> (event.values.findInt<Calories>()) >= (lastTransition.calories.toInt()) + step.duration.toInt() |
||||
|
is Distance -> (event.values.findInt<Distance>()) >= (lastTransition.distance.toInt()) + step.duration.toInt() |
||||
|
else -> false |
||||
|
} |
||||
|
|
||||
|
if (transition) { |
||||
|
val stepIndex = prog.steps.indexOf(step) |
||||
|
|
||||
|
if (prog.steps.size > stepIndex + 1) { |
||||
|
// Go to next step and send the change |
||||
|
lastTransition = lastTransition.copy( |
||||
|
time = event.values.find() ?: lastTransition.time, |
||||
|
calories = event.values.find(), |
||||
|
distance = event.values.find(), |
||||
|
level = event.values.find(), |
||||
|
) |
||||
|
lastStep = prog.steps[stepIndex + 1] |
||||
|
lastStep?.values?.forEach { input.emit(SetValueCommand(it)) } |
||||
|
} else { |
||||
|
// The program is done, let's stop it |
||||
|
lastStep = null |
||||
|
program = null |
||||
|
input.emit(StopCommand) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (event is Disconnected) { |
||||
|
// Let's clear everything |
||||
|
lastTransition = WorkoutState() |
||||
|
lastStep = null |
||||
|
program = null |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,57 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.drivers |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ReactiveDriver |
||||
|
|
||||
|
class WorkoutWriter( |
||||
|
private val workoutRepo: WorkoutRepository, |
||||
|
private val stateRepo: WorkoutStateRepository, |
||||
|
) : ReactiveDriver() { |
||||
|
private var workout: Workout? = null |
||||
|
|
||||
|
override suspend fun onEvent(event: Event, input: FlowBus<Command>) { |
||||
|
workout = workout ?: workoutRepo.findActive() |
||||
|
|
||||
|
if (workout != null) { |
||||
|
val foundWorkout = workout!! |
||||
|
|
||||
|
when (event) { |
||||
|
is ValuesReceived -> { |
||||
|
val newState = foundWorkout.makeState(event.values) |
||||
|
|
||||
|
stateRepo.save(newState) |
||||
|
} |
||||
|
|
||||
|
Connected -> { |
||||
|
foundWorkout.status = WorkoutStatus.Connected |
||||
|
workoutRepo.save(foundWorkout) |
||||
|
} |
||||
|
|
||||
|
Disconnected -> { |
||||
|
foundWorkout.status = WorkoutStatus.Disconnected |
||||
|
workoutRepo.save(foundWorkout) |
||||
|
|
||||
|
workout = null |
||||
|
} |
||||
|
|
||||
|
Started -> { |
||||
|
foundWorkout.status = WorkoutStatus.Started |
||||
|
workoutRepo.save(foundWorkout) |
||||
|
} |
||||
|
|
||||
|
Stopped -> { |
||||
|
foundWorkout.status = WorkoutStatus.Stopped |
||||
|
workoutRepo.save(foundWorkout) |
||||
|
} |
||||
|
|
||||
|
is ErrorOccurred -> { |
||||
|
foundWorkout.message = "Error: ${event.message}" |
||||
|
workoutRepo.save(foundWorkout) |
||||
|
|
||||
|
workout = null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,38 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Command |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Driver |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Event |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.FlowBus |
||||
|
import kotlinx.coroutines.coroutineScope |
||||
|
import kotlinx.coroutines.joinAll |
||||
|
import kotlinx.coroutines.launch |
||||
|
|
||||
|
abstract class ActiveDriver : Driver { |
||||
|
/** |
||||
|
* Handle a [Command] sent on the command bus, sending [Event]s on the [output] if appropriate. |
||||
|
*/ |
||||
|
protected abstract suspend fun onCommand(command: Command, output: FlowBus<Event>) |
||||
|
|
||||
|
/** |
||||
|
* This method is run repeatedly, and is meant for polling. |
||||
|
* |
||||
|
* Keep it from returning if it does not need to repeat. |
||||
|
*/ |
||||
|
protected abstract suspend fun onTick(output: FlowBus<Event>) |
||||
|
|
||||
|
override suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) { |
||||
|
coroutineScope { |
||||
|
val inputJob = launch { |
||||
|
input.collect { onCommand(it, output) } |
||||
|
} |
||||
|
val outputJob = launch { |
||||
|
while (true) { |
||||
|
onTick(output) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
joinAll(inputJob, outputJob) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,14 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Command |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Driver |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Event |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.FlowBus |
||||
|
|
||||
|
abstract class ReactiveDriver : Driver { |
||||
|
protected abstract suspend fun onEvent(event: Event, input: FlowBus<Command>) |
||||
|
|
||||
|
override suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) { |
||||
|
output.collect { onEvent(it, input) } |
||||
|
} |
||||
|
} |
@ -0,0 +1,75 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.testing |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver |
||||
|
import kotlinx.coroutines.delay |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import kotlin.random.Random |
||||
|
import kotlin.random.nextInt |
||||
|
import kotlin.time.Duration |
||||
|
import kotlin.time.Duration.Companion.milliseconds |
||||
|
|
||||
|
class TestDriver(private val secondLength: Duration) : ActiveDriver() { |
||||
|
private var time = 0 |
||||
|
private var calories = 0.0 |
||||
|
private var distance = 0.0 |
||||
|
private var level = 0 |
||||
|
private var running = false |
||||
|
private var connected = false |
||||
|
|
||||
|
private val currentState |
||||
|
get() = listOf( |
||||
|
Level(level), |
||||
|
Time(time), |
||||
|
Calories(calories.toInt()), |
||||
|
Distance(distance.toInt()), |
||||
|
) |
||||
|
|
||||
|
override suspend fun onCommand(command: Command, output: FlowBus<Event>) { |
||||
|
log.info("Received command: $command") |
||||
|
val ev = when (command) { |
||||
|
is ConnectCommand -> { |
||||
|
if (command.device.connectionString.startsWith("test:")) { |
||||
|
delay(Random.nextInt(100..300).milliseconds) |
||||
|
connected = true |
||||
|
Connected |
||||
|
} else { |
||||
|
log.info("Ignoring device: ${command.device.name}") |
||||
|
null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
DisconnectCommand -> Disconnected.also { |
||||
|
connected = false |
||||
|
time = 0 |
||||
|
calories = 0.0 |
||||
|
distance = 0.0 |
||||
|
} |
||||
|
|
||||
|
is SetValueCommand -> when (command.value) { |
||||
|
is Level -> { |
||||
|
level = command.value.raw |
||||
|
ValuesReceived(currentState) |
||||
|
} |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
StartCommand -> if (connected) Started.also { running = true } else null |
||||
|
StopCommand -> if (connected) Stopped.also { running = false } else null |
||||
|
} |
||||
|
|
||||
|
ev?.let { output.emit(it) } |
||||
|
} |
||||
|
|
||||
|
override suspend fun onTick(output: FlowBus<Event>) { |
||||
|
if (running && connected) { |
||||
|
time += 1 |
||||
|
calories += Random.nextDouble(0.2, 0.8) |
||||
|
distance += 1.8 |
||||
|
|
||||
|
output.emit(ValuesReceived(currentState)) |
||||
|
} |
||||
|
|
||||
|
delay(secondLength) |
||||
|
} |
||||
|
} |
@ -0,0 +1,27 @@ |
|||||
|
<Configuration status="INFO"> |
||||
|
<Appenders> |
||||
|
<Console name="Console1" target="SYSTEM_OUT"> |
||||
|
<PatternLayout pattern="%style{%d{yyyy-MM-dd HH:mm:ss.SSS}}{bright,green} %style{%-5level}{bright,yellow} %style{%c{1.}(%L):}{bright,cyan} %msg%n"/> |
||||
|
</Console> |
||||
|
<Console name="Console2" target="SYSTEM_OUT"> |
||||
|
<PatternLayout pattern="%style{%d{yyyy-MM-dd HH:mm:ss.SSS}}{bright,green} %style{%-5level}{bright,yellow} %style{%c{1.}(%L):}{bright,blue} %msg%n"/> |
||||
|
</Console> |
||||
|
<Console name="Console3" target="SYSTEM_OUT"> |
||||
|
<PatternLayout pattern="%style{%d{yyyy-MM-dd HH:mm:ss.SSS}}{bright,green} %style{%-5level}{bright,yellow} %style{%c{1.}(%L):}{bright,red} %msg%n"/> |
||||
|
</Console> |
||||
|
</Appenders> |
||||
|
<Loggers> |
||||
|
<Logger name="net.aiterp.git.ykonsole2" level="DEBUG" additivity="false"> |
||||
|
<AppenderRef ref="Console1"/> |
||||
|
</Logger> |
||||
|
<Logger name="liquibase" level="INFO" additivity="false"> |
||||
|
<AppenderRef ref="Console2"/> |
||||
|
</Logger> |
||||
|
<Logger name="ktor" level="INFO" additivity="false"> |
||||
|
<AppenderRef ref="Console2"/> |
||||
|
</Logger> |
||||
|
<Root level="WARN" additivity="false"> |
||||
|
<AppenderRef ref="Console3"/> |
||||
|
</Root> |
||||
|
</Loggers> |
||||
|
</Configuration> |
@ -0,0 +1,20 @@ |
|||||
|
package net.aiterp.git.ykonsole2.domain.models |
||||
|
|
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class IDTest { |
||||
|
@Test |
||||
|
fun `IDs are of the right length and unique`() { |
||||
|
val set = mutableSetOf<String>() |
||||
|
|
||||
|
repeat(100_000) { |
||||
|
val newId = randomId() |
||||
|
|
||||
|
assertEquals(ID_LENGTH, newId.length) |
||||
|
assertFalse { newId in set } |
||||
|
|
||||
|
set += newId |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,97 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.drivers |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import io.mockk.coEvery |
||||
|
import io.mockk.every |
||||
|
import io.mockk.mockk |
||||
|
import kotlinx.coroutines.cancelAndJoin |
||||
|
import kotlinx.coroutines.flow.* |
||||
|
import kotlinx.coroutines.launch |
||||
|
import kotlinx.coroutines.test.runTest |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class ProgramEnforcerTest { |
||||
|
private val programRepo = mockk<ProgramRepository>() |
||||
|
private val workoutRepo = mockk<WorkoutRepository>() |
||||
|
private val enforcer = ProgramEnforcer(programRepo, workoutRepo) |
||||
|
private val input = CommandBus() |
||||
|
private val output = mockk<EventBus>() |
||||
|
|
||||
|
@Test |
||||
|
fun `program end warmup step`() = validProgramTest(listOf( |
||||
|
ValuesReceived(listOf(Time(0), Calories(0), Distance(0))), |
||||
|
ValuesReceived(listOf(Time(599), Calories(234), Distance(3715))), |
||||
|
ValuesReceived(listOf(Time(600), Calories(234), Distance(3716))), |
||||
|
ValuesReceived(listOf(Time(601), Calories(235), Distance(3718))), |
||||
|
), listOf( |
||||
|
SetValueCommand(Level(15)), |
||||
|
SetValueCommand(Level(18)), |
||||
|
)) |
||||
|
|
||||
|
@Test |
||||
|
fun `program end warmup and main step`() = validProgramTest(listOf( |
||||
|
ValuesReceived(listOf(Time(0), Calories(0), Distance(0))), |
||||
|
ValuesReceived(listOf(Time(600), Calories(234), Distance(3716))), |
||||
|
ValuesReceived(listOf(Time(601), Calories(235), Distance(3718))), |
||||
|
ValuesReceived(listOf(Time(1553), Calories(734), Distance(8199))), |
||||
|
ValuesReceived(listOf(Time(1554), Calories(735), Distance(8201))), |
||||
|
ValuesReceived(listOf(Time(1555), Calories(735), Distance(8202))), |
||||
|
), listOf( |
||||
|
SetValueCommand(Level(15)), |
||||
|
SetValueCommand(Level(18)), |
||||
|
SetValueCommand(Level(14)), |
||||
|
)) |
||||
|
|
||||
|
@Test |
||||
|
fun `program goes through`() = validProgramTest(listOf( |
||||
|
ValuesReceived(listOf(Time(0), Calories(0), Distance(0))), |
||||
|
ValuesReceived(listOf(Time(600), Calories(234), Distance(3716))), |
||||
|
ValuesReceived(listOf(Time(601), Calories(235), Distance(3718))), |
||||
|
ValuesReceived(listOf(Time(1554), Calories(735), Distance(8201))), |
||||
|
ValuesReceived(listOf(Time(1555), Calories(735), Distance(8202))), |
||||
|
ValuesReceived(listOf(Time(1822), Calories(844), Distance(9201))), |
||||
|
ValuesReceived(listOf(Time(1823), Calories(845), Distance(9202))), |
||||
|
), listOf( |
||||
|
SetValueCommand(Level(15)), |
||||
|
SetValueCommand(Level(18)), |
||||
|
SetValueCommand(Level(14)), |
||||
|
StopCommand, |
||||
|
)) |
||||
|
|
||||
|
private fun validProgramTest(events: List<Event>, expectedCommands: List<Command>) = runTest { |
||||
|
val program = Program("test123", "Test program", listOf( |
||||
|
ProgramStep(listOf(Level(15)), Time(600)), |
||||
|
ProgramStep(listOf(Level(18)), Calories(700)), |
||||
|
ProgramStep(listOf(Level(14)), Distance(1000)), |
||||
|
)) |
||||
|
val workout = Workout(id = "1234", programId = program.id) |
||||
|
|
||||
|
every { workoutRepo.findActive() } returns workout |
||||
|
every { programRepo.findById(program.id) } returns program |
||||
|
|
||||
|
coEvery { output.collect(any()) } coAnswers { |
||||
|
val func = arg<suspend (value: Event) -> Unit>(0) |
||||
|
|
||||
|
func.invoke(Connected) |
||||
|
func.invoke(Started) |
||||
|
events.forEach { func.invoke(it) } |
||||
|
} |
||||
|
|
||||
|
val listener = launch { |
||||
|
val actualCommands = input.asSharedFlow() |
||||
|
.take(expectedCommands.size) |
||||
|
.toList() |
||||
|
|
||||
|
assertEquals(expectedCommands, actualCommands) |
||||
|
} |
||||
|
|
||||
|
val starter = launch { |
||||
|
enforcer.start(input, output) |
||||
|
} |
||||
|
|
||||
|
listener.join() |
||||
|
starter.cancelAndJoin() |
||||
|
} |
||||
|
} |
@ -0,0 +1,69 @@ |
|||||
|
<?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-iconsole</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> |
||||
|
|
||||
|
<repositories> |
||||
|
<repository> |
||||
|
<id>jitpack</id> |
||||
|
<url>https://jitpack.io</url> |
||||
|
</repository> |
||||
|
</repositories> |
||||
|
|
||||
|
<dependencies> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-core</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>com.github.weliem.blessed-bluez</groupId> |
||||
|
<artifactId>blessed</artifactId> |
||||
|
<version>0.40</version> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
|
||||
|
<build> |
||||
|
<plugins> |
||||
|
<plugin> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-maven-plugin</artifactId> |
||||
|
<version>${kotlin.version}</version> |
||||
|
<executions> |
||||
|
<execution> |
||||
|
<id>compile</id> |
||||
|
<phase>compile</phase> |
||||
|
<goals> |
||||
|
<goal>compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
<execution> |
||||
|
<id>test-compile</id> |
||||
|
<phase>test-compile</phase> |
||||
|
<goals> |
||||
|
<goal>test-compile</goal> |
||||
|
</goals> |
||||
|
</execution> |
||||
|
</executions> |
||||
|
<configuration> |
||||
|
<jvmTarget>${maven.compiler.target}</jvmTarget> |
||||
|
</configuration> |
||||
|
</plugin> |
||||
|
</plugins> |
||||
|
</build> |
||||
|
|
||||
|
</project> |
@ -0,0 +1,72 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
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.IConsole |
||||
|
import java.util.concurrent.atomic.AtomicInteger |
||||
|
import kotlin.system.exitProcess |
||||
|
import kotlin.time.Duration.Companion.milliseconds |
||||
|
import kotlin.time.Duration.Companion.seconds |
||||
|
|
||||
|
object IConsoleDemo |
||||
|
|
||||
|
fun main() = runBlocking { |
||||
|
val logger = IConsoleDemo.log |
||||
|
|
||||
|
logger.info("Starting IConsole demo...") |
||||
|
|
||||
|
val input = CommandBus() |
||||
|
val output = EventBus() |
||||
|
val device = Device(randomId(), "i-CONSOLE", "iconsole:E8:5D:86:02:27:E3") |
||||
|
|
||||
|
launch { |
||||
|
IConsole().start(input, output) |
||||
|
} |
||||
|
|
||||
|
delay(1000.milliseconds) |
||||
|
input.emit(ConnectCommand(device)) |
||||
|
|
||||
|
val health = AtomicInteger(2) |
||||
|
output.collect { 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>() == 30) { |
||||
|
input.emit(SetValueCommand(Level(18))) |
||||
|
} |
||||
|
|
||||
|
if (event is ValuesReceived && event.values.findInt<Time>() == 60) { |
||||
|
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,250 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure |
||||
|
|
||||
|
import com.welie.blessed.* |
||||
|
import com.welie.blessed.BluetoothGattCharacteristic.WriteType.WITHOUT_RESPONSE |
||||
|
import kotlinx.coroutines.* |
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.iconsole.* |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import java.util.* |
||||
|
import kotlin.time.Duration |
||||
|
import kotlin.time.Duration.Companion.milliseconds |
||||
|
|
||||
|
class IConsole : ActiveDriver() { |
||||
|
private val logger = log |
||||
|
|
||||
|
private var lastTime = 0 |
||||
|
private var lastLevel = 0 |
||||
|
private var running = false |
||||
|
private var connected = false |
||||
|
|
||||
|
private val queue = Collections.synchronizedSet<Request?>(LinkedHashSet()) |
||||
|
|
||||
|
private var central: BluetoothCentralManager? = null |
||||
|
private var current: BluetoothPeripheral? = null |
||||
|
|
||||
|
private var btCommandInput: BluetoothGattCharacteristic? = null |
||||
|
|
||||
|
private fun onConnect(device: Device, output: FlowBus<Event>) { |
||||
|
if (!device.connectionString.startsWith("iconsole:")) { |
||||
|
logger.info("Ignoring non-iConsole $device") |
||||
|
return |
||||
|
} |
||||
|
val connectionString = device.connectionString.substring("iconsole:".length) |
||||
|
|
||||
|
val cbPeripheral = object : BluetoothPeripheralCallback() { |
||||
|
override fun onServicesDiscovered(peripheral: BluetoothPeripheral, services: MutableList<BluetoothGattService>) { |
||||
|
logger.info("Checking device...") |
||||
|
|
||||
|
val service1 = services.firstOrNull { it.uuid == S1_SERVICE } |
||||
|
val service2 = services.firstOrNull { it.uuid == S2_SERVICE } |
||||
|
|
||||
|
if (service1 != null && service2 != null) { |
||||
|
btCommandInput = service1.getCharacteristic(S1_CHAR_COMMAND_INPUT) |
||||
|
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) { |
||||
|
logger.warn("Something is off about this i-CONSOLE device") |
||||
|
output.emitBlocking(ErrorOccurred("(Maybe) a malfunctioning i-CONSOLE device")) |
||||
|
peripheral.cancelConnection() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
peripheral.setNotify(btCommandInput!!, true) |
||||
|
peripheral.setNotify(s1out, true) |
||||
|
peripheral.setNotify(s1mys, true) |
||||
|
peripheral.setNotify(s2mys, true) |
||||
|
|
||||
|
logger.info("Device setup successfully; sending first request...") |
||||
|
|
||||
|
runBlocking { |
||||
|
peripheral.writeCharacteristic( |
||||
|
btCommandInput!!, |
||||
|
AckRequest.toBytes(), |
||||
|
WITHOUT_RESPONSE, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
current = peripheral |
||||
|
} else { |
||||
|
logger.warn("This is maybe not an i-CONSOLE device, since it lacks the required services") |
||||
|
output.emitBlocking(ErrorOccurred("(Maybe) not an i-CONSOLE device")) |
||||
|
peripheral.cancelConnection() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onCharacteristicUpdate( |
||||
|
peripheral: BluetoothPeripheral, |
||||
|
value: ByteArray?, |
||||
|
characteristic: BluetoothGattCharacteristic, |
||||
|
status: BluetoothCommandStatus, |
||||
|
) { |
||||
|
val res = value?.let { Response.fromBytes(it) } |
||||
|
|
||||
|
if (!connected && res is AckResponse) { |
||||
|
logger.info("Device is now ready") |
||||
|
|
||||
|
connected = true |
||||
|
output.emitBlocking(Connected) |
||||
|
} |
||||
|
|
||||
|
if (res is ControlStateResponse) { |
||||
|
if (res.value == 1) { |
||||
|
running = true |
||||
|
output.emitBlocking(Started) |
||||
|
} else { |
||||
|
running = false |
||||
|
output.emitBlocking(Stopped) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (res is WorkoutStatusResponse) { |
||||
|
val currentTime = (res.minutes * 60) + res.seconds |
||||
|
|
||||
|
if (currentTime > lastTime) { |
||||
|
lastTime = currentTime |
||||
|
output.emitBlocking(ValuesReceived(listOf( |
||||
|
Time(lastTime), |
||||
|
Calories(res.calories), |
||||
|
Distance((res.distance * 1000).toInt()), |
||||
|
Level(res.level), |
||||
|
))) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val cbScan = object : BluetoothCentralManagerCallback() { |
||||
|
override fun onScanStarted() { |
||||
|
logger.info("Scanning for $connectionString...") |
||||
|
} |
||||
|
|
||||
|
override fun onScanStopped() { |
||||
|
logger.info("Scan stopped") |
||||
|
} |
||||
|
|
||||
|
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) { |
||||
|
logger.info("Connecting to ${peripheral.name} (${peripheral.address})...") |
||||
|
|
||||
|
central?.connectPeripheral(peripheral, cbPeripheral) |
||||
|
central?.stopScan() |
||||
|
} |
||||
|
|
||||
|
override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: BluetoothCommandStatus) { |
||||
|
if (peripheral.address == current?.address) { |
||||
|
current = null |
||||
|
output.emitBlocking(Disconnected) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
central = BluetoothCentralManager(cbScan).apply { |
||||
|
scanForPeripheralsWithAddresses(arrayOf(connectionString)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun onDisconnect() { |
||||
|
lastTime = 0 |
||||
|
running = false |
||||
|
connected = false |
||||
|
|
||||
|
current?.cancelConnection() |
||||
|
} |
||||
|
|
||||
|
private fun onSetValue(value: Value) { |
||||
|
if (current == null || btCommandInput === null || !running) return |
||||
|
|
||||
|
if (value is Level) { |
||||
|
lastLevel = value.toInt() |
||||
|
queue += SetResistanceLevelRequest(value.toInt()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun onStart() { |
||||
|
if (current == null || btCommandInput === null || running) return |
||||
|
|
||||
|
if (lastTime == 0) { |
||||
|
logger.info("STARTED") |
||||
|
queue += AckRequest |
||||
|
queue += SetWorkoutModeRequest(0) |
||||
|
queue += SetWorkoutParamsRequest() |
||||
|
queue += SetWorkoutControlStateRequest(1) |
||||
|
} else { |
||||
|
logger.info("RESUMED") |
||||
|
queue += SetWorkoutControlStateRequest(1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun onStop() { |
||||
|
if (current == null || btCommandInput === null || !running) return |
||||
|
|
||||
|
queue += SetWorkoutControlStateRequest(2) |
||||
|
} |
||||
|
|
||||
|
override suspend fun onCommand(command: Command, output: FlowBus<Event>) { |
||||
|
when (command) { |
||||
|
is ConnectCommand -> onConnect(command.device, output) |
||||
|
DisconnectCommand -> onDisconnect() |
||||
|
is SetValueCommand -> onSetValue(command.value) |
||||
|
StartCommand -> onStart() |
||||
|
StopCommand -> onStop() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override suspend fun onTick(output: FlowBus<Event>) { |
||||
|
current?.apply { |
||||
|
if (connected && btCommandInput != null) { |
||||
|
val s1input = btCommandInput!! |
||||
|
|
||||
|
// Fetch one queued event when running, otherwise take up to five at a time |
||||
|
val max = if (running) 1 else 5 |
||||
|
for (i in 0 until max) { |
||||
|
if (queue.isEmpty()) { |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
val first = queue.first() |
||||
|
writeCharacteristic(s1input, first.toBytes(), WITHOUT_RESPONSE) |
||||
|
queue -= first |
||||
|
delay(pollDuration) |
||||
|
} |
||||
|
|
||||
|
if (running) { |
||||
|
// Fetch current state |
||||
|
writeCharacteristic( |
||||
|
s1input, |
||||
|
GetWorkoutStateRequest.toBytes(), |
||||
|
WITHOUT_RESPONSE, |
||||
|
) |
||||
|
} else { |
||||
|
// This is just to keep the connection alive, as far as I know… |
||||
|
runBlocking { |
||||
|
writeCharacteristic( |
||||
|
s1input, |
||||
|
GetMaxLevelRequest.toBytes(), |
||||
|
WITHOUT_RESPONSE, |
||||
|
) |
||||
|
delay(pollDuration) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
delay(pollDuration) |
||||
|
} |
||||
|
|
||||
|
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 val S2_SERVICE = UUID.fromString("49535343-5d82-6099-9348-7aac4d5fbc51") |
||||
|
private val S2_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-026e-3a9b-954c-97daef17e26e") |
||||
|
|
||||
|
private val pollDuration: Duration = (500).milliseconds |
||||
|
} |
||||
|
} |
@ -0,0 +1,55 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import com.welie.blessed.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Connected |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Disconnected |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.ErrorOccurred |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Event |
||||
|
import kotlinx.coroutines.channels.Channel |
||||
|
import kotlinx.coroutines.channels.trySendBlocking |
||||
|
import kotlinx.coroutines.runBlocking |
||||
|
|
||||
|
internal class Client( |
||||
|
private val connectionString: String, |
||||
|
private val eventCh: Channel<Event>, |
||||
|
) { |
||||
|
private lateinit var central: BluetoothCentralManager |
||||
|
private var current: BluetoothPeripheral? = null |
||||
|
|
||||
|
init { |
||||
|
val cbPeripheral = object : BluetoothPeripheralCallback() { |
||||
|
override fun onCharacteristicUpdate( |
||||
|
peripheral: BluetoothPeripheral, |
||||
|
value: ByteArray, |
||||
|
characteristic: BluetoothGattCharacteristic, |
||||
|
status: BluetoothCommandStatus, |
||||
|
) { |
||||
|
val response = Response.fromBytes(value) |
||||
|
} |
||||
|
} |
||||
|
val cbScan = object : BluetoothCentralManagerCallback() { |
||||
|
override fun onConnectedPeripheral(peripheral: BluetoothPeripheral) { |
||||
|
current = peripheral |
||||
|
|
||||
|
runBlocking { eventCh.send(Connected) } |
||||
|
} |
||||
|
|
||||
|
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: BluetoothCommandStatus) { |
||||
|
runBlocking { |
||||
|
eventCh.send(ErrorOccurred("$connectionString: $status")) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) { |
||||
|
if (peripheral.address != connectionString) return |
||||
|
|
||||
|
central.stopScan() |
||||
|
central.connectPeripheral(peripheral, cbPeripheral) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
central = BluetoothCentralManager(cbScan) |
||||
|
central.scanForPeripheralsWithNames(arrayOf()) |
||||
|
central.getPeripheral("") |
||||
|
} |
||||
|
} |
@ -0,0 +1,57 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asOneByte |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asTwoBytes |
||||
|
|
||||
|
sealed class Request( |
||||
|
private val actionId: Int, |
||||
|
private val bytes: ByteArray = byteArrayOf(), |
||||
|
private val clientId: Int = 0, |
||||
|
private val meterId: Int = 0, |
||||
|
) { |
||||
|
fun toBytes(): ByteArray { |
||||
|
val prefix = byteArrayOf( |
||||
|
(0xf0).asOneByte(false), |
||||
|
(actionId).asOneByte(false), |
||||
|
(clientId).asOneByte(true), |
||||
|
(meterId).asOneByte(true), |
||||
|
) |
||||
|
|
||||
|
val checkSum = (prefix + bytes).asSequence() |
||||
|
.map { it.toInt() } |
||||
|
.reduce { acc, i -> acc + i } |
||||
|
.asOneByte(addOne = false) |
||||
|
|
||||
|
return prefix + bytes + checkSum |
||||
|
} |
||||
|
|
||||
|
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()) |
||||
|
|
||||
|
override fun hashCode(): Int = bytes.contentHashCode() |
||||
|
override fun equals(other: Any?) = other is Request && other.bytes.contentEquals(bytes) |
||||
|
} |
||||
|
|
||||
|
object AckRequest : Request(0xa0) |
||||
|
object GetMaxLevelRequest : Request(0xa1) |
||||
|
object GetWorkoutStateRequest : Request(0xa2) |
||||
|
class SetWorkoutModeRequest(mode: Int) : Request(0xa3, bytes = byteArrayOf(mode.asOneByte())) |
||||
|
class SetWorkoutParamsRequest( |
||||
|
minutes: Int = 0, |
||||
|
kilometers: Int = 0, |
||||
|
calories: Int = 0, |
||||
|
pulse: Int = 0, |
||||
|
watt: Int = 0, |
||||
|
unit: Int = 0, |
||||
|
) : Request( |
||||
|
0xa4, |
||||
|
byteArrayOf() |
||||
|
+ minutes.asOneByte() |
||||
|
+ (kilometers * 10).asTwoBytes() |
||||
|
+ calories.asTwoBytes() |
||||
|
+ pulse.asTwoBytes() |
||||
|
+ (watt * 10).asTwoBytes() |
||||
|
+ unit.asOneByte(), |
||||
|
) |
||||
|
class SetWorkoutControlStateRequest(state: Int) : Request(0xa5, byteArrayOf(state.asOneByte())) |
||||
|
class SetResistanceLevelRequest(level: Int) : Request(0xa6, byteArrayOf(level.asOneByte())) |
@ -0,0 +1,93 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import kotlin.math.roundToInt |
||||
|
|
||||
|
sealed class Response { |
||||
|
override fun toString(): String = javaClass.simpleName |
||||
|
|
||||
|
companion object { |
||||
|
private const val KIND_ACK = (0xb0).toByte() |
||||
|
private const val KIND_MAX_LEVEL = (0xb1).toByte() |
||||
|
private const val KIND_STATUS = (0xb2).toByte() |
||||
|
private const val KIND_MODE = (0xb3).toByte() |
||||
|
private const val KIND_PARAMS = (0xb4).toByte() |
||||
|
private const val KIND_CONTROL_STATE = (0xb5).toByte() |
||||
|
private const val KIND_RESISTANCE_LEVEL = (0xb6).toByte() |
||||
|
private const val KIND_CLIENT_IDS = (0xb7).toByte() |
||||
|
|
||||
|
fun fromBytes(bytes: ByteArray): Response { |
||||
|
val kind = bytes[1] |
||||
|
val clientId = bytes[2] |
||||
|
val meterId = bytes[3] |
||||
|
val params = bytes.slice(4 until bytes.size - 1) |
||||
|
|
||||
|
return when (kind) { |
||||
|
KIND_ACK -> AckResponse |
||||
|
KIND_MAX_LEVEL -> MaxLevelResponse(params.int8(0)) |
||||
|
KIND_STATUS -> WorkoutStatusResponse( |
||||
|
minutes = params.int8(0), |
||||
|
seconds = params.int8(1), |
||||
|
speed = (params.double16(2) / 10.0), |
||||
|
rpm = params.int16(4), |
||||
|
distance = (params.double16(6) / 10.0), |
||||
|
calories = params.int16(8), |
||||
|
pulse = params.int16(10), |
||||
|
watt = (params.double16(12) / 10.0), |
||||
|
level = params.int8(14), |
||||
|
) |
||||
|
KIND_MODE -> WorkoutModeResponse(params.int8(0)) |
||||
|
KIND_PARAMS -> WorkoutParamsResponse( |
||||
|
minutes = params.int8(0), |
||||
|
kilometers = (params.double16(1) / 10.0).roundToInt(), |
||||
|
calories = params.int16(3), |
||||
|
pulse = params.int16(5), |
||||
|
watt = (params.double16(7) / 10.0).roundToInt(), |
||||
|
unit = params.int16(9), |
||||
|
) |
||||
|
KIND_CONTROL_STATE -> ControlStateResponse(params.int8(0)) |
||||
|
KIND_RESISTANCE_LEVEL -> ResistanceLevelResponse(params.int8(0)) |
||||
|
KIND_CLIENT_IDS -> ClientIdsResponse(clientId.toInt(), meterId.toInt()) |
||||
|
else -> UnknownResponse(kind, params) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun List<Byte>.int8(index: Int) = get(index).toUInt().toInt() - 1 |
||||
|
private fun List<Byte>.int16(index: Int) = (int8(index) * 100) + int8(index + 1) |
||||
|
private fun List<Byte>.double16(index: Int) = int16(index).toDouble() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
object AckResponse : Response() |
||||
|
|
||||
|
data class MaxLevelResponse(val level: Int) : Response() |
||||
|
|
||||
|
data class WorkoutStatusResponse( |
||||
|
val minutes: Int, |
||||
|
val seconds: Int, |
||||
|
val speed: Double, |
||||
|
val rpm: Int, |
||||
|
val distance: Double, |
||||
|
val calories: Int, |
||||
|
val pulse: Int, |
||||
|
val watt: Double, |
||||
|
val level: Int, |
||||
|
) : Response() |
||||
|
|
||||
|
data class WorkoutModeResponse(val mode: Int) : Response() |
||||
|
|
||||
|
data class WorkoutParamsResponse( |
||||
|
val minutes: Int, |
||||
|
val kilometers: Int, |
||||
|
val calories: Int, |
||||
|
val pulse: Int, |
||||
|
val watt: Int, |
||||
|
val unit: Int, |
||||
|
) : Response() |
||||
|
|
||||
|
data class ControlStateResponse(val value: Int) : Response() |
||||
|
|
||||
|
data class ResistanceLevelResponse(val value: Int) : Response() |
||||
|
|
||||
|
data class ClientIdsResponse(val clientId: Int, val meterId: Int) : Response() |
||||
|
|
||||
|
data class UnknownResponse(val kind: Byte, val params: List<Byte>) : Response() |
@ -0,0 +1,13 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import com.welie.blessed.BluetoothCentralManager |
||||
|
import com.welie.blessed.BluetoothPeripheral |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.EventBus |
||||
|
|
||||
|
class Session( |
||||
|
private val central: BluetoothCentralManager, |
||||
|
private val current: BluetoothPeripheral, |
||||
|
private val onResponse: (Response) -> Unit, |
||||
|
) { |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class RequestTest { |
||||
|
@Test |
||||
|
fun `ACK cmd gets correct checksum`() { |
||||
|
val expected = listOf(0xf0, 0xa0, 0x01, 0x01, 0x92).map { it.toByte() } |
||||
|
val actual = AckRequest.toBytes().toList() |
||||
|
|
||||
|
assertEquals(expected, actual) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `workout control state`() { |
||||
|
val expectedOne = listOf(0xf0, 0xa5, 0x01, 0x01, 0x02, 0x99).map { it.toByte() } |
||||
|
val expectedTwo = listOf(0xf0, 0xa5, 0x01, 0x01, 0x03, 0x9A).map { it.toByte() } |
||||
|
|
||||
|
val actualOne = SetWorkoutControlStateRequest(1).toBytes().toList() |
||||
|
val actualTwo = SetWorkoutControlStateRequest(2).toBytes().toList() |
||||
|
|
||||
|
assertEquals(expectedOne, actualOne) |
||||
|
assertEquals(expectedTwo, actualTwo) |
||||
|
} |
||||
|
} |
@ -0,0 +1,22 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.iconsole |
||||
|
|
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class ResponseBodyTest { |
||||
|
@Test |
||||
|
fun `fromBytes - ack`() { |
||||
|
val bytes = byteArrayOf(0x00, 0xb0, 0x00, 0x00, 0x00) |
||||
|
|
||||
|
assertEquals(AckResponse, Response.fromBytes(bytes)) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `fromBytes - max level`() { |
||||
|
val bytes = byteArrayOf(0x00, 0xb1, 0x00, 0x00, 0x13, 0x00) |
||||
|
|
||||
|
assertEquals(MaxLevelResponse(18), Response.fromBytes(bytes)) |
||||
|
} |
||||
|
|
||||
|
private fun byteArrayOf(vararg i: Int): ByteArray = i.map { it.toByte() }.toByteArray() |
||||
|
} |
@ -0,0 +1,72 @@ |
|||||
|
<?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-ktor</artifactId> |
||||
|
|
||||
|
<properties> |
||||
|
<maven.compiler.source>11</maven.compiler.source> |
||||
|
<maven.compiler.target>11</maven.compiler.target> |
||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
||||
|
<ktor.version>2.0.3</ktor.version> |
||||
|
</properties> |
||||
|
|
||||
|
<dependencies> |
||||
|
<!-- YKonsole --> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-core</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- ktor --> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-server-core-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-server-netty-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-server-websockets-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-server-content-negotiation-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-serialization-jackson-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>com.fasterxml.jackson.datatype</groupId> |
||||
|
<artifactId>jackson-datatype-jsr310</artifactId> |
||||
|
<version>2.13.3</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>io.ktor</groupId> |
||||
|
<artifactId>ktor-server-status-pages-jvm</artifactId> |
||||
|
<version>${ktor.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>org.jetbrains.kotlin</groupId> |
||||
|
<artifactId>kotlin-test-junit</artifactId> |
||||
|
<version>1.7.10</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
</project> |
@ -0,0 +1,48 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.application.routes.devices |
||||
|
import net.aiterp.git.ykonsole2.domain.models.DeviceRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.ProgramRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutStateRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.EventBus |
||||
|
import io.ktor.server.engine.* |
||||
|
import io.ktor.server.netty.* |
||||
|
import io.ktor.server.routing.* |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import net.aiterp.git.ykonsole2.application.plugins.installPlugins |
||||
|
import net.aiterp.git.ykonsole2.application.routes.programs |
||||
|
import net.aiterp.git.ykonsole2.application.routes.workoutStates |
||||
|
import net.aiterp.git.ykonsole2.application.routes.workouts |
||||
|
import net.aiterp.git.ykonsole2.application.routes.ws.sockets |
||||
|
|
||||
|
private object YKonsoleServer |
||||
|
|
||||
|
fun createServer( |
||||
|
deviceRepo: DeviceRepository, |
||||
|
programRepo: ProgramRepository, |
||||
|
workoutRepo: WorkoutRepository, |
||||
|
workoutStateRepo: WorkoutStateRepository, |
||||
|
commandBus: CommandBus, |
||||
|
eventBus: EventBus, |
||||
|
) = embeddedServer(Netty, port = 8080) { |
||||
|
val log = YKonsoleServer.log |
||||
|
|
||||
|
log.info("Starting YKonsole server...") |
||||
|
|
||||
|
installPlugins() |
||||
|
|
||||
|
routing { |
||||
|
route("/api") { |
||||
|
devices(deviceRepo) |
||||
|
programs(programRepo) |
||||
|
workouts(deviceRepo, programRepo, workoutRepo) |
||||
|
workoutStates(workoutStateRepo) |
||||
|
|
||||
|
sockets(deviceRepo, programRepo, workoutRepo, workoutStateRepo, commandBus, eventBus) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
log.info("YKonsole server initialized") |
||||
|
} |
@ -0,0 +1,55 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.plugins |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonMappingException |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper |
||||
|
import com.fasterxml.jackson.databind.SerializationFeature |
||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule |
||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper |
||||
|
import io.ktor.http.* |
||||
|
import io.ktor.serialization.jackson.* |
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.plugins.* |
||||
|
import io.ktor.server.plugins.contentnegotiation.* |
||||
|
import io.ktor.server.plugins.statuspages.* |
||||
|
import io.ktor.server.websocket.* |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.YKonsoleResponseException |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykStatusResponse |
||||
|
|
||||
|
internal object YKonsoleErrorHandler |
||||
|
|
||||
|
val ykObjectMapper: ObjectMapper = jacksonObjectMapper().apply { setupObjectMapper() } |
||||
|
|
||||
|
private fun ObjectMapper.setupObjectMapper() { |
||||
|
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) |
||||
|
registerModule(JavaTimeModule()) |
||||
|
} |
||||
|
|
||||
|
fun Application.installPlugins() { |
||||
|
install(ContentNegotiation) { |
||||
|
jackson { setupObjectMapper() } |
||||
|
} |
||||
|
|
||||
|
install(StatusPages) { |
||||
|
exception<YKonsoleResponseException> { _, ex -> |
||||
|
if (ex.worthReporting) { |
||||
|
YKonsoleErrorHandler.log.warn("Caught exception: ${ex.message}") |
||||
|
} |
||||
|
ex.respond() |
||||
|
} |
||||
|
|
||||
|
exception<BadRequestException> { call, ex -> |
||||
|
YKonsoleErrorHandler.log.warn("JsonMappingException from request: ${ex.message}", ex) |
||||
|
call.ykStatusResponse(HttpStatusCode.BadRequest) |
||||
|
} |
||||
|
|
||||
|
exception<Throwable> { call, ex -> |
||||
|
YKonsoleErrorHandler.log.error("Uncaught exception: ${ex.message}", ex) |
||||
|
call.ykStatusResponse(HttpStatusCode.InternalServerError) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
install(WebSockets) { |
||||
|
contentConverter = JacksonWebsocketContentConverter(ykObjectMapper) |
||||
|
} |
||||
|
} |
@ -0,0 +1,68 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.DeviceRepository |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykDataResponse |
||||
|
import io.ktor.http.HttpStatusCode.Companion.BadRequest |
||||
|
import io.ktor.http.HttpStatusCode.Companion.NotFound |
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.request.* |
||||
|
import io.ktor.server.routing.* |
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykException |
||||
|
|
||||
|
data class DevicePostInput(val name: String, val connectionString: String) |
||||
|
data class DevicePutInput(val name: String? = null, val connectionString: String? = null) |
||||
|
|
||||
|
fun Route.devices(deviceRepo: DeviceRepository) { |
||||
|
route("/devices") { |
||||
|
get { |
||||
|
call.ykDataResponse(deviceRepo.fetchAll()) |
||||
|
} |
||||
|
|
||||
|
post { |
||||
|
val (name, connectionString) = call.receive<DevicePostInput>() |
||||
|
if (name.isBlank() && connectionString.isBlank()) { |
||||
|
throw call.ykException(BadRequest, "'name' and 'connectionString' required") |
||||
|
} |
||||
|
|
||||
|
val device = Device(randomId(), name.trim(), connectionString.trim()) |
||||
|
deviceRepo.save(device) |
||||
|
call.ykDataResponse(device) |
||||
|
} |
||||
|
|
||||
|
route("/{id}") { |
||||
|
get { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val device = deviceRepo.findById(id) ?: throw call.ykException(NotFound) |
||||
|
|
||||
|
call.ykDataResponse(device) |
||||
|
} |
||||
|
|
||||
|
put { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val device = deviceRepo.findById(id) ?: throw call.ykException(NotFound) |
||||
|
val input = call.receive<DevicePutInput>() |
||||
|
|
||||
|
if (!input.name.isNullOrBlank()) { |
||||
|
device.name = input.name |
||||
|
} |
||||
|
|
||||
|
if (!input.connectionString.isNullOrBlank()) { |
||||
|
device.connectionString = input.connectionString |
||||
|
} |
||||
|
|
||||
|
deviceRepo.save(device) |
||||
|
call.ykDataResponse(device) |
||||
|
} |
||||
|
|
||||
|
delete { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val device = deviceRepo.findById(id) ?: throw call.ykException(NotFound) |
||||
|
|
||||
|
deviceRepo.delete(device) |
||||
|
call.ykDataResponse(null) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,66 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes |
||||
|
|
||||
|
import io.ktor.http.* |
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.request.* |
||||
|
import io.ktor.server.routing.* |
||||
|
import net.aiterp.git.ykonsole2.domain.models.Program |
||||
|
import net.aiterp.git.ykonsole2.domain.models.ProgramRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykDataResponse |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykException |
||||
|
|
||||
|
data class ProgramPostInput(val name: String, val steps: List<ProgramStepDTO>) |
||||
|
data class ProgramPutInput(val name: String? = null, val steps: List<ProgramStepDTO>? = null) |
||||
|
|
||||
|
fun Route.programs(programRepo: ProgramRepository) { |
||||
|
route("/programs") { |
||||
|
get { |
||||
|
call.ykDataResponse(programRepo.fetchAll().map { ProgramDTO.from(it) }) |
||||
|
} |
||||
|
|
||||
|
post { |
||||
|
val (name, steps) = call.receive<ProgramPostInput>() |
||||
|
if (name.isBlank() && steps.isEmpty()) { |
||||
|
throw call.ykException(HttpStatusCode.BadRequest, "'name' and 'steps' required") |
||||
|
} |
||||
|
|
||||
|
val program = Program(randomId(), name, steps.map(ProgramStepDTO::toProgramStep)) |
||||
|
|
||||
|
programRepo.save(program) |
||||
|
call.ykDataResponse(ProgramDTO.from(program)) |
||||
|
} |
||||
|
|
||||
|
route("/{id}") { |
||||
|
get { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val program = programRepo.findById(id) ?: throw call.ykException(HttpStatusCode.NotFound) |
||||
|
|
||||
|
call.ykDataResponse(ProgramDTO.from(program)) |
||||
|
} |
||||
|
|
||||
|
put { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val input = call.receive<ProgramPutInput>() |
||||
|
|
||||
|
val program = programRepo.findById(id) ?: throw call.ykException(HttpStatusCode.NotFound) |
||||
|
|
||||
|
val changed = program.copy( |
||||
|
name = input.name ?: program.name, |
||||
|
steps = input.steps?.map { it.toProgramStep() } ?: program.steps, |
||||
|
) |
||||
|
|
||||
|
programRepo.save(changed) |
||||
|
call.ykDataResponse(ProgramDTO.from(changed)) |
||||
|
} |
||||
|
|
||||
|
delete { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
val program = programRepo.findById(id) ?: throw call.ykException(HttpStatusCode.NotFound) |
||||
|
|
||||
|
programRepo.delete(program) |
||||
|
call.ykDataResponse(null) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,80 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude |
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import java.time.Instant |
||||
|
|
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
data class ValueDTO( |
||||
|
val time: Int? = null, |
||||
|
val distance: Int? = null, |
||||
|
val calories: Int? = null, |
||||
|
val level: Int? = null, |
||||
|
) { |
||||
|
private val values by lazy { |
||||
|
buildList { |
||||
|
time?.let { add(Time(it)) } |
||||
|
distance?.let { add(Distance(it)) } |
||||
|
calories?.let { add(Calories(it)) } |
||||
|
level?.let { add(Level(it)) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun toValues(): List<Value> = values |
||||
|
fun toValueOrNull() = values.firstOrNull() |
||||
|
|
||||
|
companion object { |
||||
|
fun from(value: Value?) = from(listOfNotNull(value)) |
||||
|
|
||||
|
fun from(state: WorkoutState) = state.run { from(listOfNotNull(time, calories, level, distance)) } |
||||
|
|
||||
|
fun from(values: List<Value>) = ValueDTO( |
||||
|
time = values.find<Time>()?.seconds, |
||||
|
distance = values.find<Distance>()?.meters, |
||||
|
calories = values.find<Calories>()?.kcal, |
||||
|
level = values.find<Level>()?.raw, |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
data class ProgramDTO( |
||||
|
val id: String, |
||||
|
val name: String, |
||||
|
val steps: List<ProgramStepDTO>, |
||||
|
) { |
||||
|
companion object { |
||||
|
fun from(program: Program) = ProgramDTO( |
||||
|
id = program.id, |
||||
|
name = program.name, |
||||
|
steps = program.steps.map { ProgramStepDTO(null, ValueDTO.from(it.values), ValueDTO.from(it.duration)) } |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
data class ProgramStepDTO( |
||||
|
val index: Int? = null, |
||||
|
val values: ValueDTO, |
||||
|
val duration: ValueDTO? = null, |
||||
|
) { |
||||
|
fun toProgramStep() = ProgramStep(values.toValues(), duration?.toValueOrNull()) |
||||
|
} |
||||
|
|
||||
|
data class WorkoutDTO( |
||||
|
val id: String, |
||||
|
val createdAt: Instant, |
||||
|
val device: Device?, |
||||
|
val program: Program?, |
||||
|
val status: WorkoutStatus, |
||||
|
val message: String, |
||||
|
) { |
||||
|
constructor(workout: Workout, device: Device?, program: Program?) : this( |
||||
|
id = workout.id, |
||||
|
createdAt = workout.createdAt, |
||||
|
device = device, |
||||
|
program = program, |
||||
|
status = workout.status, |
||||
|
message = workout.message, |
||||
|
) |
||||
|
} |
@ -0,0 +1,16 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes |
||||
|
|
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.routing.* |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutStateRepository |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykDataResponse |
||||
|
|
||||
|
fun Route.workoutStates(workoutStateRepo: WorkoutStateRepository) { |
||||
|
get("/workouts/{id}/states") { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
|
||||
|
val states = workoutStateRepo.fetchByWorkoutId(id) |
||||
|
|
||||
|
call.ykDataResponse(states.map { ValueDTO.from(it) }) |
||||
|
} |
||||
|
} |
@ -0,0 +1,93 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes |
||||
|
|
||||
|
import io.ktor.http.* |
||||
|
import io.ktor.http.HttpStatusCode.Companion.BadRequest |
||||
|
import io.ktor.http.HttpStatusCode.Companion.NotFound |
||||
|
import io.ktor.http.HttpStatusCode.Companion.OK |
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.request.* |
||||
|
import io.ktor.server.routing.* |
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykDataResponse |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykStatusResponse |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykException |
||||
|
|
||||
|
data class WorkoutInput(val deviceId: String, val programId: String? = null) |
||||
|
|
||||
|
fun Route.workouts( |
||||
|
deviceRepo: DeviceRepository, |
||||
|
programRepo: ProgramRepository, |
||||
|
workoutRepo: WorkoutRepository, |
||||
|
) { |
||||
|
route("/workouts") { |
||||
|
get { |
||||
|
val workouts = workoutRepo.fetchAll() |
||||
|
val devices = deviceRepo.fetchAll() |
||||
|
val programs = programRepo.fetchAll() |
||||
|
|
||||
|
val output = workouts.map { workout -> |
||||
|
WorkoutDTO( |
||||
|
workout = workout, |
||||
|
device = devices.firstOrNull { it.id == workout.deviceId }, |
||||
|
program = workout.programId?.let { programId -> |
||||
|
programs.firstOrNull { it.id == programId } |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
call.ykDataResponse(output) |
||||
|
} |
||||
|
|
||||
|
post { |
||||
|
workoutRepo.findActive()?.let { |
||||
|
throw call.ykException(BadRequest, "Workout already active: ${it.id}") |
||||
|
} |
||||
|
|
||||
|
val input = call.receive<WorkoutInput>() |
||||
|
|
||||
|
val device = deviceRepo.findById(input.deviceId) ?: throw call.ykException(BadRequest, "Invalid device ID") |
||||
|
val program = input.programId?.let { progId -> |
||||
|
programRepo.findById(progId) ?: throw call.ykException(BadRequest, "Invalid program ID") |
||||
|
} |
||||
|
|
||||
|
val workout = Workout(deviceId = device.id, programId = program?.id) |
||||
|
|
||||
|
workoutRepo.save(workout) |
||||
|
call.ykDataResponse(WorkoutDTO(workout, device, program)) |
||||
|
} |
||||
|
|
||||
|
get("/active") { |
||||
|
val workout = workoutRepo.findActive() |
||||
|
if (workout != null) { |
||||
|
val device = deviceRepo.findById(workout.deviceId) |
||||
|
val program = workout.programId?.let { programRepo.findById(it) } |
||||
|
|
||||
|
call.ykDataResponse(WorkoutDTO(workout, device, program)) |
||||
|
} else call.ykStatusResponse(OK, "No active workout") |
||||
|
} |
||||
|
|
||||
|
route("/{id}") { |
||||
|
get { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
|
||||
|
val workout = workoutRepo.findById(id) ?: throw call.ykException(NotFound) |
||||
|
val device = deviceRepo.findById(workout.deviceId) |
||||
|
val program = workout.programId?.let { programRepo.findById(it) } |
||||
|
|
||||
|
call.ykDataResponse(WorkoutDTO(workout, device, program)) |
||||
|
} |
||||
|
|
||||
|
delete { |
||||
|
val id = call.parameters["id"] ?: "" |
||||
|
|
||||
|
val workout = workoutRepo.findById(id) ?: throw call.ykException(NotFound) |
||||
|
if (workout.status != WorkoutStatus.Disconnected) { |
||||
|
throw call.ykException(BadRequest, "Please quit the workout before deleting it") |
||||
|
} |
||||
|
|
||||
|
workoutRepo.delete(workout) |
||||
|
call.ykDataResponse(null) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,83 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes.ws |
||||
|
|
||||
|
import io.ktor.server.routing.* |
||||
|
import io.ktor.server.websocket.* |
||||
|
import io.ktor.websocket.* |
||||
|
import kotlinx.coroutines.Job |
||||
|
import kotlinx.coroutines.cancelAndJoin |
||||
|
import kotlinx.coroutines.launch |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import net.aiterp.git.ykonsole2.application.plugins.ykObjectMapper |
||||
|
import net.aiterp.git.ykonsole2.application.routes.ValueDTO |
||||
|
import net.aiterp.git.ykonsole2.application.routes.WorkoutDTO |
||||
|
import net.aiterp.git.ykonsole2.domain.models.* |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import java.lang.Exception |
||||
|
|
||||
|
private object WebSocketHandler |
||||
|
|
||||
|
fun Route.sockets( |
||||
|
deviceRepo: DeviceRepository, |
||||
|
programRepo: ProgramRepository, |
||||
|
workoutRepo: WorkoutRepository, |
||||
|
workoutStateRepo: WorkoutStateRepository, |
||||
|
commandBus: CommandBus, |
||||
|
eventBus: EventBus, |
||||
|
) { |
||||
|
val log = WebSocketHandler.log |
||||
|
|
||||
|
webSocket("/workouts/active") { |
||||
|
val clientId = "ws-client-${randomId()}" |
||||
|
|
||||
|
val active = workoutRepo.findActive() |
||||
|
if (active != null) { |
||||
|
var job: Job? = null |
||||
|
|
||||
|
try { |
||||
|
log.info("$clientId: Sending workout metadata...") |
||||
|
val device = deviceRepo.findById(active.deviceId)!! |
||||
|
val program = active.programId?.let { programRepo.findById(it) } |
||||
|
sendSerialized(SocketOutput(workout = WorkoutDTO(active, device, program))) |
||||
|
|
||||
|
log.info("$clientId: Sending workout states...") |
||||
|
val workoutStates = workoutStateRepo.fetchByWorkoutId(active.id) |
||||
|
sendSerialized(SocketOutput(workoutStates = workoutStates.map { ValueDTO.from(it) })) |
||||
|
|
||||
|
job = launch { |
||||
|
log.info("$clientId: Starting event listener...") |
||||
|
|
||||
|
eventBus.collect { event -> |
||||
|
when (event) { |
||||
|
Connected, Started, Stopped, Disconnected -> { |
||||
|
sendSerialized(SocketOutput(event = SocketOutput.EventDTO(name = event.name))) |
||||
|
|
||||
|
if (event is Disconnected) close() |
||||
|
} |
||||
|
|
||||
|
is ErrorOccurred -> sendSerialized(SocketOutput(error = SocketOutput.Error(event.message))) |
||||
|
is ValuesReceived -> sendSerialized(SocketOutput(workoutStates = listOf(ValueDTO.from(event.values)))) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
log.info("$clientId: Ready for incoming frames") |
||||
|
|
||||
|
for (frame in incoming) { |
||||
|
frame as? Frame.Text ?: continue |
||||
|
|
||||
|
val input: SocketInput = ykObjectMapper.readValue(frame.readText(), SocketInput::class.java) |
||||
|
input.makeCommands(device).forEach { commandBus.emit(it) } |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
log.info("$clientId: Interrupted by exception: ${e.message}", e) |
||||
|
} finally { |
||||
|
log.info("$clientId: Terminating event listener...") |
||||
|
job?.cancelAndJoin() |
||||
|
log.info("$clientId: Disconnected") |
||||
|
} |
||||
|
} else { |
||||
|
log.info("$clientId: Rejected due to missing workout") |
||||
|
sendSerialized(SocketOutput(error = SocketOutput.Error("No active workout found"))) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,21 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes.ws |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.application.routes.ValueDTO |
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
|
||||
|
data class SocketInput( |
||||
|
val start: Boolean = false, |
||||
|
val stop: Boolean = false, |
||||
|
val connect: Boolean = false, |
||||
|
val disconnect: Boolean = false, |
||||
|
val setValue: ValueDTO? = null, |
||||
|
) { |
||||
|
fun makeCommands(device: Device) = buildList { |
||||
|
if (start) add(StartCommand) |
||||
|
if (stop) add(StopCommand) |
||||
|
if (connect) add(ConnectCommand(device)) |
||||
|
if (disconnect) add(DisconnectCommand) |
||||
|
setValue?.toValues()?.forEach { add(SetValueCommand(it)) } |
||||
|
} |
||||
|
} |
@ -0,0 +1,21 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application.routes.ws |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude |
||||
|
import net.aiterp.git.ykonsole2.application.routes.ValueDTO |
||||
|
import net.aiterp.git.ykonsole2.application.routes.WorkoutDTO |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutState |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Event |
||||
|
import java.time.Instant |
||||
|
import java.time.temporal.ChronoUnit |
||||
|
|
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
data class SocketOutput( |
||||
|
val sentAt: Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS), |
||||
|
val workout: WorkoutDTO? = null, |
||||
|
val workoutStates: List<ValueDTO>? = null, |
||||
|
val event: EventDTO? = null, |
||||
|
val error: Error? = null, |
||||
|
) { |
||||
|
data class EventDTO(val name: String) |
||||
|
data class Error(val message: String) |
||||
|
} |
@ -0,0 +1,37 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.ktor |
||||
|
|
||||
|
import io.ktor.http.* |
||||
|
import io.ktor.server.application.* |
||||
|
import io.ktor.server.response.* |
||||
|
import java.lang.Exception |
||||
|
|
||||
|
data class Response( |
||||
|
val code: Int, |
||||
|
val message: String, |
||||
|
val data: Any? = null, |
||||
|
) |
||||
|
|
||||
|
suspend fun <T> ApplicationCall.ykDataResponse(data: T) { |
||||
|
val r = Response(HttpStatusCode.OK.value, HttpStatusCode.OK.description, data) |
||||
|
respond(r) |
||||
|
} |
||||
|
|
||||
|
suspend fun ApplicationCall.ykStatusResponse(code: HttpStatusCode, message: String? = null) { |
||||
|
val r = Response(code.value, message ?: code.description, null) |
||||
|
respond(code, r) |
||||
|
} |
||||
|
|
||||
|
fun ApplicationCall.ykException( |
||||
|
code: HttpStatusCode, |
||||
|
message: String? = null, |
||||
|
) = YKonsoleResponseException(this, code, message) |
||||
|
|
||||
|
class YKonsoleResponseException( |
||||
|
private val applicationCall: ApplicationCall, |
||||
|
private val code: HttpStatusCode, |
||||
|
private val customMessage: String?, |
||||
|
) : Exception(customMessage ?: code.description) { |
||||
|
val worthReporting = customMessage != null |
||||
|
|
||||
|
suspend fun respond() = applicationCall.ykStatusResponse(code, customMessage) |
||||
|
} |
@ -0,0 +1,65 @@ |
|||||
|
<?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-mysql</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> |
||||
|
<!-- YKonsole --> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-core</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- Database --> |
||||
|
<dependency> |
||||
|
<groupId>mysql</groupId> |
||||
|
<artifactId>mysql-connector-java</artifactId> |
||||
|
<version>8.0.29</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>com.zaxxer</groupId> |
||||
|
<artifactId>HikariCP</artifactId> |
||||
|
<version>5.0.1</version> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- Liquibase --> |
||||
|
<dependency> |
||||
|
<groupId>org.liquibase</groupId> |
||||
|
<artifactId>liquibase-core</artifactId> |
||||
|
<version>4.14.0</version> |
||||
|
<exclusions> |
||||
|
<exclusion> |
||||
|
<groupId>org.slf4j</groupId> |
||||
|
<artifactId>slf4j-api</artifactId> |
||||
|
</exclusion> |
||||
|
<exclusion> |
||||
|
<groupId>ch.qos.logback</groupId> |
||||
|
<artifactId>logback-classic</artifactId> |
||||
|
</exclusion> |
||||
|
</exclusions> |
||||
|
</dependency> |
||||
|
|
||||
|
<!-- Testing --> |
||||
|
<dependency> |
||||
|
<groupId>com.h2database</groupId> |
||||
|
<artifactId>h2</artifactId> |
||||
|
<version>2.1.214</version> |
||||
|
<scope>test</scope> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
</project> |
@ -0,0 +1,15 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.application.env.strEnv |
||||
|
import net.aiterp.git.ykonsole2.application.runMigrations |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.makeDataSource |
||||
|
|
||||
|
internal fun main() { |
||||
|
val dataSource = makeDataSource( |
||||
|
url = strEnv("MYSQL_URL"), |
||||
|
username = strEnv("MYSQL_USERNAME"), |
||||
|
password = strEnv("MYSQL_PASSWORD"), |
||||
|
) |
||||
|
|
||||
|
dataSource.runMigrations() |
||||
|
} |
@ -0,0 +1,17 @@ |
|||||
|
package net.aiterp.git.ykonsole2.application |
||||
|
|
||||
|
import liquibase.Contexts |
||||
|
import liquibase.LabelExpression |
||||
|
import liquibase.Liquibase |
||||
|
import liquibase.database.DatabaseFactory |
||||
|
import liquibase.database.jvm.JdbcConnection |
||||
|
import liquibase.resource.ClassLoaderResourceAccessor |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
internal fun DataSource.runMigrations() { |
||||
|
connection.use { conn -> |
||||
|
val lbDatabase = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(conn)) |
||||
|
val liquibase = Liquibase("migrations/changelog.xml", ClassLoaderResourceAccessor(), lbDatabase) |
||||
|
liquibase.update(Contexts(), LabelExpression()) |
||||
|
} |
||||
|
} |
@ -0,0 +1,43 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure |
||||
|
|
||||
|
import com.zaxxer.hikari.HikariConfig |
||||
|
import com.zaxxer.hikari.HikariDataSource |
||||
|
import net.aiterp.git.ykonsole2.InfrastructureException |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import org.intellij.lang.annotations.Language |
||||
|
import java.sql.Connection |
||||
|
import java.sql.PreparedStatement |
||||
|
import java.sql.ResultSet |
||||
|
import java.sql.SQLException |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
fun makeDataSource( |
||||
|
url: String, |
||||
|
username: String, |
||||
|
password: String, |
||||
|
poolSize: Int = 4, |
||||
|
driverClassName: String? = null, |
||||
|
): DataSource = HikariDataSource(HikariConfig().also { cfg -> |
||||
|
cfg.jdbcUrl = url |
||||
|
cfg.username = username |
||||
|
cfg.password = password |
||||
|
cfg.poolName = "ykonsole2-pool-${randomId()}" |
||||
|
cfg.maximumPoolSize = poolSize |
||||
|
|
||||
|
if (driverClassName != null) { |
||||
|
cfg.driverClassName = driverClassName |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
fun <T : Any?> DataSource.withConnection(func: Connection.() -> T): T = try { |
||||
|
connection.use(func) |
||||
|
} catch (e: SQLException) { |
||||
|
throw InfrastructureException(e) |
||||
|
} |
||||
|
|
||||
|
fun <T : Any?> Connection.prepare(@Language("MySQL") sql: String, func: PreparedStatement.() -> T): T = |
||||
|
prepareStatement(sql).use(func) |
||||
|
|
||||
|
fun <T : Any?> PreparedStatement.runQuery(func: ResultSet.() -> T): T = executeQuery().use(func) |
||||
|
|
||||
|
fun ResultSet.getIntOrNull(key: String) = getInt(key).takeIf { !wasNull() } |
@ -0,0 +1,60 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.models.DeviceRepository |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.prepare |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.runQuery |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import java.sql.ResultSet |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
val DataSource.deviceRepo get() = object : DeviceRepository { |
||||
|
override fun findById(id: String): Device? = withConnection { |
||||
|
prepare("SELECT * FROM device WHERE id = ?") { |
||||
|
setString(1, id) |
||||
|
|
||||
|
runQuery { if (next()) makeDevice() else null } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun fetchAll(): List<Device> = withConnection { |
||||
|
prepare("SELECT * FROM device") { |
||||
|
runQuery { |
||||
|
buildList { while (next()) add(makeDevice()) } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun save(device: Device) { |
||||
|
withConnection { |
||||
|
prepare( |
||||
|
""" |
||||
|
INSERT INTO device (id, name, connection_string) |
||||
|
VALUES (?, ?, ?) |
||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name), |
||||
|
connection_string = VALUES(connection_string) |
||||
|
""".trimIndent() |
||||
|
) { |
||||
|
setString(1, device.id) |
||||
|
setString(2, device.name) |
||||
|
setString(3, device.connectionString) |
||||
|
execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun delete(device: Device) { |
||||
|
withConnection { |
||||
|
prepare("DELETE FROM device WHERE id = ?") { |
||||
|
setString(1, device.id) |
||||
|
execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun ResultSet.makeDevice() = Device( |
||||
|
id = getString("id"), |
||||
|
name = getString("name"), |
||||
|
connectionString = getString("connection_string"), |
||||
|
) |
||||
|
} |
@ -0,0 +1,168 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Program |
||||
|
import net.aiterp.git.ykonsole2.domain.models.ProgramRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.ProgramStep |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.prepare |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.runQuery |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
val DataSource.programRepo get() = object : ProgramRepository { |
||||
|
override fun findById(id: String): Program? = withConnection { |
||||
|
val (programId, name) = prepare("SELECT * FROM program WHERE id = ?") { |
||||
|
setString(1, id) |
||||
|
|
||||
|
runQuery { if (next()) getString("id") to getString("name") else null } |
||||
|
} ?: return@withConnection null |
||||
|
|
||||
|
val valueMap: Map<Int, List<Value>> = prepare("SELECT * FROM program_step_value WHERE id = ?") { |
||||
|
setString(1, programId) |
||||
|
|
||||
|
runQuery { |
||||
|
buildMap { |
||||
|
while (next()) { |
||||
|
val value = when (val type = getString("value_type")) { |
||||
|
"Level" -> Level(getInt("value_int")) |
||||
|
"Distance" -> Distance(getInt("value_int")) |
||||
|
"Calories" -> Calories(getInt("value_int")) |
||||
|
"Time" -> Time(getInt("value_int")) |
||||
|
else -> error("Unknown program step value type: $type") |
||||
|
} |
||||
|
|
||||
|
merge(getInt("step_index"), listOf(value)) { a, b -> a + b } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val durationMap: Map<Int, Value?> = prepare("SELECT * FROM program_step WHERE id = ?") { |
||||
|
setString(1, programId) |
||||
|
|
||||
|
runQuery { |
||||
|
buildMap { |
||||
|
while (next()) { |
||||
|
val duration = when (val type = getString("duration_type")) { |
||||
|
"Time" -> Time(getInt("duration_value")) |
||||
|
"Distance" -> Distance(getInt("duration_value")) |
||||
|
"Calories" -> Calories(getInt("duration_value")) |
||||
|
"Level" -> Level(getInt("duration_value")) |
||||
|
"None" -> null |
||||
|
else -> error("Unknown program step duration type: $type") |
||||
|
} |
||||
|
|
||||
|
put(getInt("step_index"), duration) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val steps = durationMap.toSortedMap().map { (index, duration) -> |
||||
|
ProgramStep(valueMap[index] ?: emptyList(), duration) |
||||
|
} |
||||
|
|
||||
|
Program(programId, name, steps) |
||||
|
} |
||||
|
|
||||
|
override fun fetchAll(): List<Program> { |
||||
|
val ids = withConnection { |
||||
|
prepare("SELECT id FROM program ORDER BY name") { |
||||
|
runQuery { buildList { while (next()) add(getString("id")) } } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ids.mapNotNull { findById(it) } |
||||
|
} |
||||
|
|
||||
|
override fun save(program: Program) { |
||||
|
withConnection { |
||||
|
// Update program record |
||||
|
prepare("REPLACE INTO program (id, name) VALUES (?, ?)") { |
||||
|
setString(1, program.id) |
||||
|
setString(2, program.name) |
||||
|
execute() |
||||
|
} |
||||
|
|
||||
|
// Remove unused program steps |
||||
|
prepare("DELETE FROM program_step WHERE id = ? AND step_index >= ?") { |
||||
|
setString(1, program.id) |
||||
|
setInt(2, program.steps.size) |
||||
|
execute() |
||||
|
} |
||||
|
prepare("DELETE FROM program_step_value WHERE id = ? AND step_index >= ?") { |
||||
|
setString(1, program.id) |
||||
|
setInt(2, program.steps.size) |
||||
|
execute() |
||||
|
} |
||||
|
|
||||
|
// Write durations |
||||
|
prepare("REPLACE INTO program_step (id, step_index, duration_type, duration_value) VALUES (?, ?, ?, ?)") { |
||||
|
program.steps.forEachIndexed { index, step -> |
||||
|
setString(1, program.id) |
||||
|
setInt(2, index) |
||||
|
setString(3, step.duration?.name ?: "None") |
||||
|
setInt(4, step.duration?.toInt() ?: 0) |
||||
|
addBatch() |
||||
|
} |
||||
|
executeBatch() |
||||
|
} |
||||
|
|
||||
|
// Clean up removed values |
||||
|
when (val valueCount = program.steps.sumOf { it.values.size }) { |
||||
|
0 -> prepare("DELETE FROM program_step_value WHERE id = ?") { |
||||
|
setString(1, program.id) |
||||
|
execute() |
||||
|
} |
||||
|
else -> prepare( |
||||
|
""" |
||||
|
DELETE FROM program_step_value |
||||
|
WHERE id = ? |
||||
|
AND (step_index, value_type) NOT IN (${(List(valueCount) { "(?, ?)" }).joinToString(", ")}) |
||||
|
""".trimIndent() |
||||
|
) { |
||||
|
var i = 0 |
||||
|
setString(++i, program.id) |
||||
|
program.steps.forEachIndexed { index, step -> |
||||
|
for (value in step.values) { |
||||
|
setInt(++i, index) |
||||
|
setString(++i, value.name) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Write values |
||||
|
prepare("REPLACE INTO program_step_value (id, step_index, value_type, value_int) VALUES (?, ?, ?, ?)") { |
||||
|
program.steps.forEachIndexed { index, step -> |
||||
|
for (value in step.values) { |
||||
|
setString(1, program.id) |
||||
|
setInt(2, index) |
||||
|
setString(3, value.name) |
||||
|
setInt(4, value.toInt()) |
||||
|
} |
||||
|
|
||||
|
addBatch() |
||||
|
} |
||||
|
executeBatch() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun delete(program: Program) { |
||||
|
withConnection { |
||||
|
prepare("DELETE FROM program WHERE id = ?") { |
||||
|
setString(1, program.id) |
||||
|
execute() |
||||
|
} |
||||
|
prepare("DELETE FROM program_step WHERE id = ?") { |
||||
|
setString(1, program.id) |
||||
|
execute() |
||||
|
} |
||||
|
prepare("DELETE FROM program_step_value WHERE id = ?") { |
||||
|
setString(1, program.id) |
||||
|
execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,79 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Workout |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.prepare |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.runQuery |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import java.sql.ResultSet |
||||
|
import java.sql.Timestamp |
||||
|
import java.time.ZoneOffset |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
val DataSource.workoutRepo get() = object : WorkoutRepository { |
||||
|
override fun findById(id: String) = withConnection { |
||||
|
prepare("SELECT * FROM workout WHERE id = ?") { |
||||
|
setString(1, id) |
||||
|
|
||||
|
runQuery { |
||||
|
if (next()) makeWorkout() else null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun fetchAll(): List<Workout> = withConnection { |
||||
|
prepare("SELECT * FROM workout") { |
||||
|
runQuery { |
||||
|
buildList { while (next()) add(makeWorkout()) } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun findActive() = withConnection { |
||||
|
prepareStatement("SELECT * FROM workout WHERE status != 'Disconnected' ORDER BY created_at DESC").use { ps -> |
||||
|
ps.executeQuery().use { rs -> |
||||
|
if (rs.next()) rs.makeWorkout() else null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun save(workout: Workout) { |
||||
|
withConnection { |
||||
|
prepareStatement( |
||||
|
""" |
||||
|
INSERT INTO workout (id, created_at, device_id, program_id, status, message) |
||||
|
VALUES (?, ?, ?, ? , ?, ?) |
||||
|
ON DUPLICATE KEY UPDATE status = VALUES(status), |
||||
|
message = VALUES(message) |
||||
|
""".trimIndent() |
||||
|
).use { ps -> |
||||
|
ps.setString(1, workout.id) |
||||
|
ps.setTimestamp(2, Timestamp.valueOf(workout.createdAt.atZone(ZoneOffset.UTC).toLocalDateTime())) |
||||
|
ps.setString(3, workout.deviceId) |
||||
|
ps.setString(4, workout.programId ?: "") |
||||
|
ps.setString(5, workout.status.name) |
||||
|
ps.setString(6, workout.message) |
||||
|
ps.execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun delete(workout: Workout) { |
||||
|
connection.use { conn -> |
||||
|
conn.prepareStatement("DELETE FROM workout WHERE id = ?").use { ps -> |
||||
|
ps.setString(1, workout.id) |
||||
|
ps.execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun ResultSet.makeWorkout() = Workout( |
||||
|
id = getString("id"), |
||||
|
createdAt = getTimestamp("created_at").toLocalDateTime().atZone(ZoneOffset.UTC).toInstant(), |
||||
|
deviceId = getString("device_id"), |
||||
|
programId = getString("program_id").takeIf { it.isNotBlank() }, |
||||
|
status = WorkoutStatus.valueOf(getString("status")), |
||||
|
message = getString("message"), |
||||
|
) |
||||
|
} |
@ -0,0 +1,77 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutState |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutStateRepository |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Calories |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Distance |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Level |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Time |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.getIntOrNull |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.prepare |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.runQuery |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import java.sql.Types |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
val DataSource.workoutStateRepo get() = object : WorkoutStateRepository { |
||||
|
override fun fetchByWorkoutId(workoutId: String) = withConnection { |
||||
|
prepare("SELECT * FROM workout_state WHERE workout_id = ?") { |
||||
|
setString(1, workoutId) |
||||
|
|
||||
|
runQuery { |
||||
|
sequence { |
||||
|
while (next()) yield( |
||||
|
WorkoutState( |
||||
|
workoutId = getString("workout_id"), |
||||
|
time = Time(getInt("ws_seconds")), |
||||
|
calories = getIntOrNull("ws_kcal")?.let { Calories(it) }, |
||||
|
level = getIntOrNull("ws_level")?.let { Level(it) }, |
||||
|
distance = getIntOrNull("ws_meters")?.let { Distance(it) }, |
||||
|
), |
||||
|
) |
||||
|
}.toList() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun save(state: WorkoutState) { |
||||
|
withConnection { |
||||
|
prepare( |
||||
|
""" |
||||
|
INSERT INTO workout_state (workout_id, ws_seconds, ws_kcal, ws_level, ws_meters) |
||||
|
VALUES (?, ?, ?, ?, ?) |
||||
|
ON DUPLICATE KEY UPDATE ws_kcal = VALUES(ws_kcal), |
||||
|
ws_level = VALUES(ws_level), |
||||
|
ws_meters = VALUES(ws_meters) |
||||
|
""".trimIndent() |
||||
|
) { |
||||
|
setString(1, state.workoutId) |
||||
|
setInt(2, state.time.seconds) |
||||
|
if (state.calories != null) setInt(3, state.calories!!.kcal) else setNull(3, Types.INTEGER) |
||||
|
if (state.level != null) setInt(4, state.level!!.raw) else setNull(4, Types.INTEGER) |
||||
|
if (state.distance != null) setInt(5, state.distance!!.meters) else setNull(5, Types.INTEGER) |
||||
|
execute() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun deleteAll(states: Collection<WorkoutState>) { |
||||
|
withConnection { |
||||
|
prepare("DELETE FROM workout_state WHERE workout_id = ? AND ws_seconds = ?") { |
||||
|
states.forEachIndexed { index, state -> |
||||
|
setString(1, state.workoutId) |
||||
|
setInt(2, state.time.seconds) |
||||
|
addBatch() |
||||
|
|
||||
|
if ((index + 1) % 100 == 0) { |
||||
|
executeBatch() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (states.size % 100 != 0) { |
||||
|
executeBatch() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
<databaseChangeLog |
||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd" |
||||
|
> |
||||
|
<include file="tables/device.xml" relativeToChangelogFile="true"/> |
||||
|
<include file="tables/program.xml" relativeToChangelogFile="true"/> |
||||
|
<include file="tables/program_step.xml" relativeToChangelogFile="true"/> |
||||
|
<include file="tables/program_step_value.xml" relativeToChangelogFile="true"/> |
||||
|
<include file="tables/workout.xml" relativeToChangelogFile="true"/> |
||||
|
<include file="tables/workout_state.xml" relativeToChangelogFile="true"/> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,23 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="device"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="device"> |
||||
|
<column name="id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="name" type="VARCHAR(255)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="connection_string" type="VARCHAR(255)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,20 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="program"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="program"> |
||||
|
<column name="id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="name" type="VARCHAR(255)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,33 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="program_step"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="program_step"> |
||||
|
<column name="id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="step_index" type="INT"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="duration_type" type="ENUM('Time', 'Distance', 'Calories')"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="duration_value" type="INT"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
<changeSet id="3" author="stian"> |
||||
|
<modifyDataType |
||||
|
tableName="program_step" |
||||
|
columnName="duration_type" |
||||
|
newDataType="ENUM('None', 'Level', 'Time', 'Distance', 'Calories')" |
||||
|
/> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,41 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="program_step_value"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="program_step_value"> |
||||
|
<column name="id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="step_index" type="INT"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="value_type" type="ENUM('Level')"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="value" type="INT"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
<changeSet id="2" author="stian"> |
||||
|
<modifyDataType |
||||
|
tableName="program_step_value" |
||||
|
columnName="value_type" |
||||
|
newDataType="ENUM('Level', 'Time', 'Distance', 'Calories')" |
||||
|
/> |
||||
|
</changeSet> |
||||
|
<changeSet id="3" author="stian"> |
||||
|
<renameColumn |
||||
|
tableName="program_step_value" |
||||
|
oldColumnName="value" |
||||
|
newColumnName="value_int" |
||||
|
columnDataType="INT NOT NULL" |
||||
|
/> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,45 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog |
||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd" |
||||
|
> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="workout"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="workout"> |
||||
|
<column name="id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="created_at" type="TIMESTAMP" defaultValueComputed="0"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="device_id" type="CHAR(10)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="program_id" type="CHAR(10)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="status" type="ENUM ('Created', 'Connected', 'Started', 'Stopped', 'Disconnected')"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
<column name="message" type="VARCHAR(255)"> |
||||
|
<constraints nullable="false"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
|
||||
|
<changeSet id="2" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<indexExists tableName="workout" indexName="idx_status"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createIndex tableName="workout" indexName="idx_status" unique="false"> |
||||
|
<column name="status"/> |
||||
|
</createIndex> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,29 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> |
||||
|
<changeSet id="1" author="stian"> |
||||
|
<preConditions onFail="MARK_RAN"> |
||||
|
<not> |
||||
|
<tableExists tableName="workout_state"/> |
||||
|
</not> |
||||
|
</preConditions> |
||||
|
<createTable tableName="workout_state"> |
||||
|
<column name="workout_id" type="CHAR(10)"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="ws_seconds" type="INT"> |
||||
|
<constraints nullable="false" primaryKey="true"/> |
||||
|
</column> |
||||
|
<column name="ws_kcal" type="INT"> |
||||
|
<constraints nullable="true"/> |
||||
|
</column> |
||||
|
<column name="ws_level" type="INT"> |
||||
|
<constraints nullable="true"/> |
||||
|
</column> |
||||
|
<column name="ws_meters" type="INT"> |
||||
|
<constraints nullable="true"/> |
||||
|
</column> |
||||
|
</createTable> |
||||
|
</changeSet> |
||||
|
</databaseChangeLog> |
@ -0,0 +1,26 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.application.runMigrations |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.makeDataSource |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import org.h2.Driver |
||||
|
import javax.sql.DataSource |
||||
|
|
||||
|
private val testDataSource by lazy { |
||||
|
makeDataSource( |
||||
|
url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MYSQL", |
||||
|
username = "sa", |
||||
|
password = "", |
||||
|
driverClassName = Driver::class.qualifiedName |
||||
|
).apply { runMigrations() } |
||||
|
} |
||||
|
|
||||
|
fun withDatabase(func: DataSource.() -> Unit) { |
||||
|
testDataSource.apply { |
||||
|
withConnection { |
||||
|
prepareStatement("TRUNCATE TABLE workout").use { it.execute() } |
||||
|
} |
||||
|
|
||||
|
func() |
||||
|
} |
||||
|
} |
@ -0,0 +1,56 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.withDatabase |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class MySqlDeviceRepositoryTest { |
||||
|
@Test |
||||
|
fun `life cycle`() = withDatabase { |
||||
|
deviceRepo.apply { |
||||
|
val ids = listOf(randomId(), randomId()).sorted() |
||||
|
val device1 = Device(ids[0], "Trimsykkel", "iconsole:${randomId()}") |
||||
|
val device2 = Device(ids[1], "Tredemølle", "iconsole:${randomId()}") |
||||
|
|
||||
|
// Still empty |
||||
|
assertNull(findById(device1.id)) |
||||
|
assertNull(findById(device2.id)) |
||||
|
assertEquals(emptyList<Device>(), fetchAll()) |
||||
|
|
||||
|
// Adding one |
||||
|
save(device1) |
||||
|
assertEquals(device1, findById(device1.id)) |
||||
|
assertNull(findById(device2.id)) |
||||
|
assertEquals(listOf(device1), fetchAll()) |
||||
|
|
||||
|
// Adding the other one |
||||
|
save(device2) |
||||
|
assertEquals(device1, findById(device1.id)) |
||||
|
assertEquals(device2, findById(device2.id)) |
||||
|
assertEquals(listOf(device1, device2), fetchAll()) |
||||
|
|
||||
|
// Modifying both of them |
||||
|
device1.connectionString = "iconsole:${randomId()}" |
||||
|
device2.name = "3D-Mølle" |
||||
|
save(device1) |
||||
|
save(device2) |
||||
|
assertEquals(device1, findById(device1.id)) |
||||
|
assertEquals(device2, findById(device2.id)) |
||||
|
assertEquals(listOf(device1, device2), fetchAll()) |
||||
|
|
||||
|
// Deleting one of them |
||||
|
delete(device1) |
||||
|
assertNull(findById(device1.id)) |
||||
|
assertEquals(device2, findById(device2.id)) |
||||
|
assertEquals(listOf(device2), fetchAll()) |
||||
|
|
||||
|
// Deleting the other one |
||||
|
delete(device2) |
||||
|
assertNull(findById(device1.id)) |
||||
|
assertNull(findById(device2.id)) |
||||
|
assertEquals(emptyList<Device>(), fetchAll()) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,129 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Program |
||||
|
import net.aiterp.git.ykonsole2.domain.models.ProgramStep |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Calories |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Distance |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Level |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Time |
||||
|
import net.aiterp.git.ykonsole2.withDatabase |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class MySqlProgramRepositoryTest { |
||||
|
@Test |
||||
|
fun `life cycle`() = withDatabase { |
||||
|
programRepo.apply { |
||||
|
val ids = listOf(randomId(), randomId(), randomId()).sorted() |
||||
|
val program1 = Program(ids[0], "Base 700", listOf( |
||||
|
ProgramStep(listOf(Level(15)), Calories(200)), |
||||
|
ProgramStep(listOf(Level(18)), Calories(400)), |
||||
|
ProgramStep(listOf(Level(15)), Calories(100)), |
||||
|
)) |
||||
|
val program2 = Program(ids[1], "Classic", listOf( |
||||
|
ProgramStep(listOf(Level(15)), Time(600)), |
||||
|
ProgramStep(listOf(Level(18)), null), |
||||
|
ProgramStep(listOf(Level(15)), Time(300)), |
||||
|
)) |
||||
|
val program3 = Program(ids[2], "ProgramEnforcerTest example", listOf( |
||||
|
ProgramStep(listOf(Level(15)), Time(600)), |
||||
|
ProgramStep(listOf(Level(18)), Calories(700)), |
||||
|
ProgramStep(listOf(Level(14)), Distance(1000)), |
||||
|
)) |
||||
|
|
||||
|
// No change |
||||
|
assertNull(findById(program1.id)) |
||||
|
assertNull(findById(program2.id)) |
||||
|
assertNull(findById(program3.id)) |
||||
|
assertEquals(emptyList<Program>(), fetchAll()) |
||||
|
|
||||
|
// Adding program 1 |
||||
|
save(program1) |
||||
|
assertEquals(program1, findById(program1.id)) |
||||
|
assertNull(findById(program2.id)) |
||||
|
assertNull(findById(program3.id)) |
||||
|
assertEquals(listOf(program1), fetchAll()) |
||||
|
|
||||
|
// Adding program 2 |
||||
|
save(program2) |
||||
|
assertEquals(program1, findById(program1.id)) |
||||
|
assertEquals(program2, findById(program2.id)) |
||||
|
assertNull(findById(program3.id)) |
||||
|
assertEquals(listOf(program1, program2), fetchAll()) |
||||
|
|
||||
|
// Adding program 3 |
||||
|
save(program3) |
||||
|
assertEquals(program1, findById(program1.id)) |
||||
|
assertEquals(program2, findById(program2.id)) |
||||
|
assertEquals(program3, findById(program3.id)) |
||||
|
assertEquals(listOf(program1, program2, program3), fetchAll()) |
||||
|
|
||||
|
// Modifying program 1 (edit step) |
||||
|
val changed1 = program1.copy( |
||||
|
name = "Base 1000", |
||||
|
steps = listOf(program1.steps[0], ProgramStep(listOf(Level(18)), Calories(400)), program1.steps[2]), |
||||
|
) |
||||
|
save(changed1) |
||||
|
assertEquals(changed1, findById(program1.id)) |
||||
|
assertEquals(program2, findById(program2.id)) |
||||
|
assertEquals(program3, findById(program3.id)) |
||||
|
assertEquals(listOf(changed1, program2, program3), fetchAll()) |
||||
|
|
||||
|
// Modifying program 2 (delete and edit step) |
||||
|
val changed2 = program2.copy( |
||||
|
steps = listOf(program2.steps[0], ProgramStep(listOf(Level(20)), Time(900))), |
||||
|
) |
||||
|
save(changed2) |
||||
|
assertEquals(changed1, findById(program1.id)) |
||||
|
assertEquals(changed2, findById(program2.id)) |
||||
|
assertEquals(program3, findById(program3.id)) |
||||
|
assertEquals(listOf(changed1, changed2, program3), fetchAll()) |
||||
|
|
||||
|
// Modifying program 3 (add and edit step) |
||||
|
val changed3 = program3.copy(steps = listOf( |
||||
|
ProgramStep(listOf(Level(15)), Time(300)), |
||||
|
program3.steps[1], |
||||
|
program3.steps[2], |
||||
|
ProgramStep(listOf(), Distance(500)), |
||||
|
)) |
||||
|
save(changed3) |
||||
|
assertEquals(changed1, findById(program1.id)) |
||||
|
assertEquals(changed2, findById(program2.id)) |
||||
|
assertEquals(changed3, findById(program3.id)) |
||||
|
assertEquals(listOf(changed1, changed2, changed3), fetchAll()) |
||||
|
|
||||
|
// Modifying program 1 again (remove all the steps) |
||||
|
val changed1again = program1.copy( |
||||
|
name = "Base 0", |
||||
|
steps = listOf(), |
||||
|
) |
||||
|
save(changed1again) |
||||
|
assertEquals(changed1again, findById(program1.id)) |
||||
|
assertEquals(changed2, findById(program2.id)) |
||||
|
assertEquals(changed3, findById(program3.id)) |
||||
|
assertEquals(listOf(changed1again, changed2, changed3), fetchAll()) |
||||
|
|
||||
|
// Deleting program 1 |
||||
|
delete(changed1again) |
||||
|
assertNull(findById(program1.id)) |
||||
|
assertEquals(changed2, findById(program2.id)) |
||||
|
assertEquals(changed3, findById(program3.id)) |
||||
|
assertEquals(listOf(changed2, changed3), fetchAll()) |
||||
|
|
||||
|
// Deleting program 3 |
||||
|
delete(changed3) |
||||
|
assertNull(findById(program1.id)) |
||||
|
assertEquals(changed2, findById(program2.id)) |
||||
|
assertNull(findById(program3.id)) |
||||
|
assertEquals(listOf(changed2), fetchAll()) |
||||
|
|
||||
|
// Delete program 2 |
||||
|
delete(program2) |
||||
|
assertNull(findById(program1.id)) |
||||
|
assertNull(findById(program2.id)) |
||||
|
assertNull(findById(program3.id)) |
||||
|
assertEquals(emptyList<Program>(), fetchAll()) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,122 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.Workout |
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.withConnection |
||||
|
import net.aiterp.git.ykonsole2.withDatabase |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
import java.time.Instant |
||||
|
import java.time.LocalDate |
||||
|
import java.time.ZoneOffset |
||||
|
import java.time.temporal.ChronoUnit |
||||
|
|
||||
|
internal class MySqlWorkoutRepositoryTest { |
||||
|
@Test |
||||
|
fun `findById - not found`() = withDatabase { |
||||
|
assertNull(workoutRepo.findById(randomId())) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `findById - found`() = withDatabase { |
||||
|
val id = randomId() |
||||
|
val deviceId = randomId() |
||||
|
val programId = randomId() |
||||
|
val createdAt = LocalDate.of(2022, 8, 7).atTime(20, 49).atZone(ZoneOffset.UTC).toInstant() |
||||
|
|
||||
|
withConnection { |
||||
|
prepareStatement( |
||||
|
""" |
||||
|
INSERT INTO workout (id, created_at, device_id, program_id, status, message) |
||||
|
VALUES('$id', '2022-08-07 20:49:00', '$deviceId', '$programId', 'Created', 'Dude!') |
||||
|
""".trimIndent() |
||||
|
).use { it.execute() } |
||||
|
} |
||||
|
|
||||
|
val expected = Workout(id, createdAt, deviceId, programId, WorkoutStatus.Created, "Dude!") |
||||
|
|
||||
|
assertEquals(expected, workoutRepo.findById(id)) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `findByActive - not found`() = withDatabase { |
||||
|
withConnection { |
||||
|
prepareStatement( |
||||
|
""" |
||||
|
INSERT INTO workout (id, created_at, device_id, program_id, status, message) |
||||
|
VALUES('${randomId()}', '2022-08-07 20:49:00', '${randomId()}', '${randomId()}', 'Disconnected', 'Dude!') |
||||
|
""".trimIndent() |
||||
|
).use { it.execute() } |
||||
|
} |
||||
|
|
||||
|
assertNull(workoutRepo.findActive()) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `findByActive - found`() = withDatabase { |
||||
|
val id = randomId() |
||||
|
val deviceId = randomId() |
||||
|
val programId = randomId() |
||||
|
val createdAt = LocalDate.of(2022, 8, 7).atTime(20, 49).atZone(ZoneOffset.UTC).toInstant() |
||||
|
|
||||
|
withConnection { |
||||
|
prepareStatement( |
||||
|
""" |
||||
|
INSERT INTO workout (id, created_at, device_id, program_id, status, message) |
||||
|
VALUES('$id', '2022-08-07 20:49:00', '$deviceId', '$programId', 'Created', 'Dude!') |
||||
|
""".trimIndent() |
||||
|
).use { it.execute() } |
||||
|
} |
||||
|
|
||||
|
val expected = Workout(id, createdAt, deviceId, programId, WorkoutStatus.Created, "Dude!") |
||||
|
|
||||
|
assertEquals(expected, workoutRepo.findActive()) |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `save - creating and updating`() = withDatabase { |
||||
|
workoutRepo.apply { |
||||
|
val workout = Workout(randomId(), Instant.now().truncatedTo(ChronoUnit.SECONDS), randomId(), randomId()) |
||||
|
assertNull(findById(workout.id)) |
||||
|
|
||||
|
save(workout) |
||||
|
assertEquals(workout, findById(workout.id)) |
||||
|
|
||||
|
workout.status = WorkoutStatus.Disconnected |
||||
|
workout.message = "Connection error" |
||||
|
save(workout) |
||||
|
assertEquals(workout, findById(workout.id)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
fun `delete - it's there and then it's gone`() = withDatabase { |
||||
|
val createdAt = LocalDate.of(2022, 8, 7).atTime(21, 5).atZone(ZoneOffset.UTC).toInstant() |
||||
|
|
||||
|
val workout = Workout(randomId(), createdAt, randomId(), randomId()) |
||||
|
|
||||
|
workout.apply { |
||||
|
withConnection { |
||||
|
prepareStatement( |
||||
|
""" |
||||
|
INSERT INTO workout (id, created_at, device_id, program_id, status, message) |
||||
|
VALUES('$id', '2022-08-07 21:05:00', '$deviceId', '$programId', 'Created', '') |
||||
|
""".trimIndent() |
||||
|
).use { it.execute() } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
workoutRepo.apply { |
||||
|
// It should be there |
||||
|
assertEquals(workout, findById(workout.id)) |
||||
|
|
||||
|
// Delete it and it should be gone |
||||
|
delete(workout) |
||||
|
assertNull(findById(workout.id)) |
||||
|
|
||||
|
// Deleting afterwards is fine |
||||
|
delete(workout) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,57 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure.repositories |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.models.WorkoutState |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Calories |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Distance |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Level |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.Time |
||||
|
import net.aiterp.git.ykonsole2.withDatabase |
||||
|
import org.junit.jupiter.api.Assertions.* |
||||
|
import org.junit.jupiter.api.Test |
||||
|
|
||||
|
internal class MySqlWorkoutStateRepositoryTest { |
||||
|
@Test |
||||
|
fun `life cycle`() = withDatabase { |
||||
|
val workoutId = randomId() |
||||
|
|
||||
|
workoutStateRepo.apply { |
||||
|
// None in the start |
||||
|
assertEquals(emptyList<WorkoutState>(), fetchByWorkoutId(workoutId)) |
||||
|
|
||||
|
// Add a few states |
||||
|
val states = listOf( |
||||
|
WorkoutState(workoutId, Time(10)), |
||||
|
WorkoutState(workoutId, Time(20), calories = Calories(10)), |
||||
|
WorkoutState(workoutId, Time(30), calories = Calories(15), level = Level(18)), |
||||
|
WorkoutState(workoutId, Time(40), calories = Calories(20), distance = Distance(600)), |
||||
|
WorkoutState(workoutId, Time(50), calories = Calories(25), level = Level(18), distance = Distance(750)), |
||||
|
WorkoutState(workoutId, Time(60), level = Level(18)), |
||||
|
WorkoutState(workoutId, Time(70), level = Level(18), distance = Distance(1050)), |
||||
|
WorkoutState(workoutId, Time(80), distance = Distance(1200)) |
||||
|
) |
||||
|
states.forEach { save(it) } |
||||
|
assertEquals(states, fetchByWorkoutId(workoutId)) |
||||
|
|
||||
|
// Overwrite the lot of them |
||||
|
val newStates = listOf( |
||||
|
WorkoutState(workoutId, Time(10), distance = Distance(150)), |
||||
|
WorkoutState(workoutId, Time(20), level = Level(18), distance = Distance(300)), |
||||
|
WorkoutState(workoutId, Time(30), level = Level(18)), |
||||
|
WorkoutState(workoutId, Time(40), calories = Calories(20), level = Level(18), distance = Distance(600)), |
||||
|
WorkoutState(workoutId, Time(50), calories = Calories(25), distance = Distance(750)), |
||||
|
WorkoutState(workoutId, Time(60), calories = Calories(30), level = Level(18)), |
||||
|
WorkoutState(workoutId, Time(70), calories = Calories(35)), |
||||
|
WorkoutState(workoutId, Time(80)), |
||||
|
) |
||||
|
newStates.forEach { save(it) } |
||||
|
assertEquals(newStates, fetchByWorkoutId(workoutId)) |
||||
|
|
||||
|
// Delete them, four at a time |
||||
|
deleteAll(newStates.subList(0, 4)) |
||||
|
assertEquals(newStates.subList(4, 8), fetchByWorkoutId(workoutId)) |
||||
|
deleteAll(newStates.subList(4, 8)) |
||||
|
assertEquals(emptyList<WorkoutState>(), fetchByWorkoutId(workoutId)) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,42 @@ |
|||||
|
<?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-server</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>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-iconsole</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-ktor</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-mysql</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
</project> |
@ -0,0 +1,49 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
import kotlinx.coroutines.runBlocking |
||||
|
import net.aiterp.git.ykonsole2.application.createServer |
||||
|
import net.aiterp.git.ykonsole2.application.env.strEnv |
||||
|
import net.aiterp.git.ykonsole2.application.services.DriverStarter |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.EventBus |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.IConsole |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.WorkoutWriter |
||||
|
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 kotlin.time.Duration.Companion.seconds |
||||
|
|
||||
|
fun main(): Unit = runBlocking { |
||||
|
makeDataSource( |
||||
|
url = strEnv("MYSQL_URL"), |
||||
|
username = strEnv("MYSQL_USERNAME"), |
||||
|
password = strEnv("MYSQL_PASSWORD"), |
||||
|
).apply { |
||||
|
val commandBus = CommandBus() |
||||
|
val eventBus = EventBus() |
||||
|
|
||||
|
val iConsole = IConsole() |
||||
|
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo) |
||||
|
val testDriver = TestDriver(secondLength = 1.seconds) |
||||
|
val workoutWriter = WorkoutWriter(workoutRepo, workoutStateRepo) |
||||
|
|
||||
|
createServer( |
||||
|
deviceRepo = deviceRepo, |
||||
|
programRepo = programRepo, |
||||
|
workoutRepo = workoutRepo, |
||||
|
workoutStateRepo = workoutStateRepo, |
||||
|
commandBus = commandBus, |
||||
|
eventBus = eventBus, |
||||
|
).start(wait = false) |
||||
|
|
||||
|
DriverStarter( |
||||
|
drivers = listOf(testDriver, iConsole, workoutWriter, programEnforcer), |
||||
|
input = commandBus, |
||||
|
output = eventBus, |
||||
|
).startDrivers() |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue