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