Browse Source

first commit

main
Stian Fredrik Aune 2 years ago
commit
45f520e9d1
  1. 4
      .gitignore
  2. 127
      pom.xml
  3. 58
      ykonsole-core/pom.xml
  4. 13
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/YKonsoleException.kt
  5. 4
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/env/Env.kt
  6. 8
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/logging/Log.kt
  7. 55
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt
  8. 7
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Device.kt
  9. 23
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/DeviceRepository.kt
  10. 13
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ID.kt
  11. 21
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Program.kt
  12. 23
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ProgramRepository.kt
  13. 23
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Workout.kt
  14. 28
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutRepository.kt
  15. 14
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt
  16. 18
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStateRepository.kt
  17. 3
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStatus.kt
  18. 16
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Command.kt
  19. 8
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Driver.kt
  20. 15
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Event.kt
  21. 44
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/FlowBus.kt
  22. 21
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Value.kt
  23. 74
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt
  24. 57
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt
  25. 38
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ActiveDriver.kt
  26. 14
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ReactiveDriver.kt
  27. 75
      ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt
  28. 27
      ykonsole-core/src/main/resources/log4j2.xml
  29. 20
      ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/domain/models/IDTest.kt
  30. 97
      ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcerTest.kt
  31. 69
      ykonsole-iconsole/pom.xml
  32. 72
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/Demo.kt
  33. 250
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
  34. 55
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Client.kt
  35. 57
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Request.kt
  36. 93
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Response.kt
  37. 13
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Session.kt
  38. 26
      ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/RequestTest.kt
  39. 22
      ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/ResponseBodyTest.kt
  40. 72
      ykonsole-ktor/pom.xml
  41. 48
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/Server.kt
  42. 55
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt
  43. 68
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Devices.kt
  44. 66
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Programs.kt
  45. 80
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt
  46. 16
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/WorkoutStates.kt
  47. 93
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Workouts.kt
  48. 83
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt
  49. 21
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketInput.kt
  50. 21
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt
  51. 37
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ktor/Responses.kt
  52. 65
      ykonsole-mysql/pom.xml
  53. 15
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/Migration.kt
  54. 17
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/application/Migration.kt
  55. 43
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/DataSource.kt
  56. 60
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepository.kt
  57. 168
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepository.kt
  58. 79
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepository.kt
  59. 77
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt
  60. 12
      ykonsole-mysql/src/main/resources/migrations/changelog.xml
  61. 23
      ykonsole-mysql/src/main/resources/migrations/tables/device.xml
  62. 20
      ykonsole-mysql/src/main/resources/migrations/tables/program.xml
  63. 33
      ykonsole-mysql/src/main/resources/migrations/tables/program_step.xml
  64. 41
      ykonsole-mysql/src/main/resources/migrations/tables/program_step_value.xml
  65. 45
      ykonsole-mysql/src/main/resources/migrations/tables/workout.xml
  66. 29
      ykonsole-mysql/src/main/resources/migrations/tables/workout_state.xml
  67. 26
      ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/SetUp.kt
  68. 56
      ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepositoryTest.kt
  69. 129
      ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepositoryTest.kt
  70. 122
      ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepositoryTest.kt
  71. 57
      ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepositoryTest.kt
  72. 42
      ykonsole-server/pom.xml
  73. 49
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

4
.gitignore

@ -0,0 +1,4 @@
.idea/
*/target/
.env
*.iml

127
pom.xml

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

58
ykonsole-core/pom.xml

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

13
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/YKonsoleException.kt

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

4
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/env/Env.kt

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

8
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/logging/Log.kt

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

55
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt

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

7
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Device.kt

@ -0,0 +1,7 @@
package net.aiterp.git.ykonsole2.domain.models
data class Device(
val id: String,
var name: String,
var connectionString: String,
)

23
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/DeviceRepository.kt

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

13
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ID.kt

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

21
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Program.kt

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

23
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/ProgramRepository.kt

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

23
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/Workout.kt

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

28
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutRepository.kt

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

14
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt

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

18
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStateRepository.kt

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

3
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/models/WorkoutStatus.kt

@ -0,0 +1,3 @@
package net.aiterp.git.ykonsole2.domain.models
enum class WorkoutStatus { Created, Connected, Started, Stopped, Disconnected }

16
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Command.kt

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

8
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Driver.kt

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

15
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Event.kt

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

44
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/FlowBus.kt

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

21
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/domain/runtime/Value.kt

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

74
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt

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

57
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt

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

38
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ActiveDriver.kt

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

14
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/ReactiveDriver.kt

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

75
ykonsole-core/src/main/java/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt

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

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

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

20
ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/domain/models/IDTest.kt

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

97
ykonsole-core/src/test/java/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcerTest.kt

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

69
ykonsole-iconsole/pom.xml

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

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

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

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

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

55
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Client.kt

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

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

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

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

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

13
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Session.kt

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

26
ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/RequestTest.kt

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

22
ykonsole-iconsole/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/ResponseBodyTest.kt

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

72
ykonsole-ktor/pom.xml

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

48
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/Server.kt

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

55
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt

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

68
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Devices.kt

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

66
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Programs.kt

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

80
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt

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

16
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/WorkoutStates.kt

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

93
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Workouts.kt

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

83
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt

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

21
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketInput.kt

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

21
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt

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

37
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ktor/Responses.kt

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

65
ykonsole-mysql/pom.xml

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

15
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/Migration.kt

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

17
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/application/Migration.kt

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

43
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/DataSource.kt

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

60
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepository.kt

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

168
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepository.kt

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

79
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepository.kt

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

77
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt

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

12
ykonsole-mysql/src/main/resources/migrations/changelog.xml

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

23
ykonsole-mysql/src/main/resources/migrations/tables/device.xml

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

20
ykonsole-mysql/src/main/resources/migrations/tables/program.xml

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

33
ykonsole-mysql/src/main/resources/migrations/tables/program_step.xml

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

41
ykonsole-mysql/src/main/resources/migrations/tables/program_step_value.xml

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

45
ykonsole-mysql/src/main/resources/migrations/tables/workout.xml

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

29
ykonsole-mysql/src/main/resources/migrations/tables/workout_state.xml

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

26
ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/SetUp.kt

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

56
ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlDeviceRepositoryTest.kt

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

129
ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepositoryTest.kt

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

122
ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepositoryTest.kt

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

57
ykonsole-mysql/src/test/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepositoryTest.kt

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

42
ykonsole-server/pom.xml

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

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

@ -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()
}
}
Loading…
Cancel
Save