Browse Source

Pulse, RPM, past milestones, program stop bugfix, skipped events

main
Stian Fredrik Aune 2 years ago
parent
commit
c95cbf3f5e
  1. 9
      webui-react/src/contexts/RuntimeContext.tsx
  2. 12
      webui-react/src/hooks/milestones.ts
  3. 2
      webui-react/src/models/Shared.ts
  4. 29
      webui-react/src/pages/PlayPage.tsx
  5. 23
      webui-react/src/pages/runtime/ControlsBoi.tsx
  6. 2
      webui-react/src/pages/runtime/MilestoneBoi.tsx
  7. 5
      webui-react/src/pages/runtime/ProgramBoi.sass
  8. 2
      webui-react/src/pages/runtime/ProgramBoi.tsx
  9. 11
      webui-react/src/primitives/Shared.tsx
  10. 4
      webui-react/src/primitives/misc/Misc.sass
  11. 37
      webui-react/src/primitives/misc/Misc.tsx
  12. 89
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Milestones.kt
  13. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Workout.kt
  14. 6
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt
  15. 11
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/FlowBus.kt
  16. 4
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Value.kt
  17. 69
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/MilestoneChecker.kt
  18. 12
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt
  19. 14
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt
  20. 4
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
  21. 8
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt
  22. 11
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt
  23. 1
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt
  24. 17
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt
  25. 10
      ykonsole-mysql/src/main/resources/migrations/tables/workout_state.xml

9
webui-react/src/contexts/RuntimeContext.tsx

@ -67,6 +67,7 @@ interface SocketOutput {
workout: CurrentWorkout | null
workoutStates: WorkoutState[] | null
milestone: Milestone | null
oldMilestones: Milestone[] | null
event: { name: string } | null
error: { message: string } | null
}
@ -132,8 +133,8 @@ export function RuntimeContextProvider({children}: WithChildren): JSX.Element {
setEnded(false);
setLastEvent(null);
const socket = runtimeRepo().openWebsocket();
socket.onmessage = event => {
const newSocket = runtimeRepo().openWebsocket();
newSocket.onmessage = event => {
const data = socketOutput(event.data);
setLastEvent(data);
setReady(true);
@ -181,11 +182,11 @@ export function RuntimeContextProvider({children}: WithChildren): JSX.Element {
}
};
socket.onclose = () => {
newSocket.onclose = () => {
setEnded(true);
};
setSocket(socket);
setSocket(newSocket);
}, []);
const create = useCallback((options: CreateWorkoutOptions) => {

12
webui-react/src/hooks/milestones.ts

@ -3,7 +3,7 @@ import RuntimeContext from "../contexts/RuntimeContext";
import {Milestone} from "../models/Milestone";
import {ValueKey} from "../models/Shared";
export function useCurrentMilestone(): Milestone | null {
export function useCurrentMilestone(showOld: boolean = false): Milestone | null {
const {lastEvent} = useContext(RuntimeContext);
const [milestone, setMilestone] = useState<Milestone | null>(null);
@ -12,7 +12,13 @@ export function useCurrentMilestone(): Milestone | null {
if (lastEvent?.milestone) {
setMilestone(lastEvent.milestone);
}
}, [lastEvent]);
if (showOld && lastEvent?.oldMilestones) {
for (const milestone of lastEvent.oldMilestones) {
setMilestone(milestone);
}
}
}, [showOld, lastEvent]);
useEffect(() => {
const handle = setTimeout(() => setMilestone(null), 1000);
@ -25,7 +31,7 @@ export function useCurrentMilestone(): Milestone | null {
export function useLastMilestoneValue(valueKey: ValueKey, primaryKey: ValueKey) {
const [value, setValue] = useState<number>(0);
const current = useCurrentMilestone();
const current = useCurrentMilestone(true);
useEffect(() => {
if (current && current.primaryKey === primaryKey) {

2
webui-react/src/models/Shared.ts

@ -17,6 +17,8 @@ export interface Values {
calories?: number
distance?: number
level?: number
rpmSpeed?: number
pulse?: number
}
export type ColorName = "gray" | "green" | "blue" | "red" | "yellow" | "indigo";

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

@ -8,7 +8,7 @@ import ProgramContext from "../contexts/ProgramContext";
import {Device} from "../models/Devices";
import {Program, subTitleOfProgram} from "../models/Programs";
import {useKey, usePlusMinus} from "../hooks/keyboard";
import {TitleLine, Value} from "../primitives/misc/Misc";
import {FluffyValue, TitleLine, Value} from "../primitives/misc/Misc";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {Icon} from "../primitives/Shared";
import {faClose, faPlay} from "@fortawesome/free-solid-svg-icons";
@ -27,13 +27,15 @@ function PlayPage(): JSX.Element {
useEffect(() => {
if (!active) {
resume();
} else if (active && ended) {
if (workout) {
navigate(`/workouts/${workout.id}`, {replace: true});
reset();
}
}
}, [active, ready, ended, workout, resume]);
}, [active, resume]);
useEffect(() => {
if (active && ended && workout) {
navigate(`/workouts/${workout.id}`, {replace: true});
reset();
}
}, [active, ready, ended, workout]);
if (active && ready && workout === null) {
return <CreatePlayPage/>;
@ -185,14 +187,13 @@ function RunPlayPage(): JSX.Element {
{lastState && (
<Boi vertical="center" horizontal="left" style={{padding: "0.5vmax", paddingBottom: "0"}}>
<span style={{fontSize: "125%"}}>
<Value raw={lastState} valueKey="time"/>
<FluffyValue raw={lastState} valueKey="time"/>
</span>
<br/>
<Value raw={lastState} valueKey="calories"/>
<br/>
<Value raw={lastState} valueKey="distance"/>
<br/>
<Value raw={lastState} valueKey="level"/>
<FluffyValue raw={lastState} valueKey="calories"/>
<FluffyValue raw={lastState} valueKey="distance"/>
<FluffyValue raw={lastState} valueKey="level"/>
<FluffyValue raw={lastState} valueKey="rpmSpeed"/>
<FluffyValue raw={lastState} valueKey="pulse"/>
</Boi>
)}
{workout?.status === WorkoutStatus.Connected && <MessageBoi text="Trykk Enter for å begynne"/>}

23
webui-react/src/pages/runtime/ControlsBoi.tsx

@ -23,7 +23,7 @@ export function ControlsBoi() {
const {workout, disconnect, start, stop, setLevel, skip} = useContext(RuntimeContext);
const lastState = useLastState();
const [mode, setMode] = useState<"default" | "level" | "skip">("default");
const [mode, setMode] = useState<"default" | "level" | "skip" | "disconnect">("default");
const [nextSkip, setNextSkip] = useState(-1);
const [lastTime, setLastTime] = useState(0);
@ -50,7 +50,10 @@ export function ControlsBoi() {
}
if (isStopped) {
btnList.push({icon: faStop, onClick: disconnect});
btnList.push({icon: faStop, onClick: () => {
disconnect();
setMode("disconnect");
}});
}
return btnList;
@ -66,7 +69,7 @@ export function ControlsBoi() {
if (mode !== "default") {
if (mode === "level") {
setLevel(sel + 1);
} else {
} else if (mode === "skip") {
if (sel === 1) {
setNextSkip(lastTime + 60 - (lastTime % 60));
} else if (sel === 2) {
@ -81,6 +84,8 @@ export function ControlsBoi() {
} else {
if (options[sel]) {
options[sel].onClick();
} else if (options.length > 0) {
options[0].onClick()
}
}
@ -102,7 +107,7 @@ export function ControlsBoi() {
if (lastState?.level && mode === "level") {
setSel(lastState.level - 1);
}
}, [mode]);
}, [lastState, mode]);
useEffect(() => {
if (lastState?.time) {
@ -117,6 +122,12 @@ export function ControlsBoi() {
}
}, [lastTime, nextSkip]);
useEffect(() => {
if (mode === "default") {
setSel(0);
}
}, [mode]);
if (mode === "level") {
return <MessageBoi text={`${sel + 1} +/-`}/>;
}
@ -125,6 +136,10 @@ export function ControlsBoi() {
return <MessageBoi text={`${skipMessages[sel]} +/-`}/>;
}
if (mode === "disconnect") {
return <MessageBoi text="Kobler fra..."/>
}
return (
<Boi vertical="top" horizontal="right" style={{fontSize: "2vmax"}}>
{options.map((o, i) => (

2
webui-react/src/pages/runtime/MilestoneBoi.tsx

@ -16,7 +16,7 @@ export default function MilestoneBoi() {
const top = <Value raw={primary} valueKey={primaryKey}/>;
const bottom = [];
for (const key in diff) {
bottom.push(<div>+<Value raw={diff} valueKey={(key) as ValueKey}/></div>);
bottom.push(<div key={key}>+<Value raw={diff} valueKey={(key) as ValueKey}/></div>);
}
return (

5
webui-react/src/pages/runtime/ProgramBoi.sass

@ -12,14 +12,13 @@
overflow-x: hidden
.HealthBar-entry-text
padding: 2px
padding: 0.1vmax
font-size: 2.75vmax
white-space: nowrap
overflow-x: hidden
&:first-child
padding: 4px
padding: 0.2vmax
.ProgressBar
padding: 0.1vmax

2
webui-react/src/pages/runtime/ProgramBoi.tsx

@ -213,7 +213,7 @@ function HealthBarProgress({progress}: ProgressProps) {
return (
<div className="HealthBar">
{steps.map((step) => <HealthBarProgressStep progress={progress} step={step} offset={offset}/>)}
{steps.map((step, i) => <HealthBarProgressStep key={i} progress={progress} step={step} offset={offset}/>)}
</div>
);
}

11
webui-react/src/primitives/Shared.tsx

@ -1,4 +1,4 @@
import React, {CSSProperties, PropsWithChildren} from "react";
import React, {CSSProperties, PropsWithChildren, useMemo} from "react";
import {IconProp} from "@fortawesome/fontawesome-svg-core";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
@ -10,8 +10,13 @@ interface IconProps {
value: IconProp
spin?: boolean
flash?: boolean
subtle?: boolean
}
export function Icon({value, spin, flash}: IconProps) {
return <FontAwesomeIcon icon={value} spin={spin} beatFade={flash}/>;
export function Icon({value, spin, flash, subtle}: IconProps) {
const style: CSSProperties = useMemo(() => {
return subtle ? {opacity: 0.5} : {};
}, [subtle]);
return <FontAwesomeIcon style={style} icon={value} spin={spin} beatFade={flash}/>;
}

4
webui-react/src/primitives/misc/Misc.sass

@ -8,3 +8,7 @@
padding-bottom: 0.1em
color: $title-line
border-bottom: $blob-background 1px solid
.FluffyValue
display: block
min-height: 4vmax

37
webui-react/src/primitives/misc/Misc.tsx

@ -1,6 +1,7 @@
import "./Misc.sass";
import {WithChildren} from "../Shared";
import {Icon, WithChildren} from "../Shared";
import {Values} from "../../models/Shared";
import {faArrowUpRightDots, faHeart} from "@fortawesome/free-solid-svg-icons";
export function TitleLine({children}: WithChildren) {
return (
@ -13,6 +14,18 @@ interface ValueProps {
valueKey: keyof Values
}
export function FluffyValue({raw, valueKey}: ValueProps): JSX.Element | null {
if (typeof raw !== "number" && (raw[valueKey] === undefined || raw[valueKey] === null)) {
return null;
}
return (
<div className="FluffyValue">
<Value raw={raw} valueKey={valueKey}/>
</div>
);
}
export function Value({raw, valueKey}: ValueProps): JSX.Element | null {
const actual = typeof raw === "number" ? raw : (raw[valueKey]);
@ -35,7 +48,27 @@ export function Value({raw, valueKey}: ValueProps): JSX.Element | null {
}
if (valueKey === "level") {
return <><strong>{actual}</strong> lvl</>;
return (
<>
<Icon subtle value={faArrowUpRightDots}/>
{" "}
<strong>{actual}</strong>
</>
);
}
if (valueKey === "rpmSpeed") {
return <><strong>{actual}</strong> rpm</>
}
if (valueKey === "pulse") {
return (
<>
<Icon subtle value={faHeart}/>
{" "}
<strong>{actual}</strong>
</>
);
}
}

89
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Milestones.kt

@ -0,0 +1,89 @@
package net.aiterp.git.ykonsole2.domain.models
import net.aiterp.git.ykonsole2.domain.runtime.*
fun MutableMap<Value, List<Value>>.tryMilestone(event: ValuesReceived): MilestoneReached? = tryMilestone(event.values)
fun List<WorkoutState>.makeMilestoneReachedEvents(): List<MilestoneReached> {
val cache = mutableMapOf<Value, List<Value>>()
return mapNotNull { cache.tryMilestone(it) }
}
private fun MutableMap<Value, List<Value>>.tryMilestone(state: WorkoutState): MilestoneReached? =
tryMilestone(state.asValueList())
private fun MutableMap<Value, List<Value>>.tryMilestone(newValues: List<Value>): MilestoneReached? {
val time = newValues.find<Time>() ?: return null
val calories = newValues.find<Calories>() ?: return null
val distance = newValues.find<Distance>() ?: return null
var next: MilestoneReached? = null
val roundDistance = distance.roundedDown
if (roundDistance.isMilestone && !containsKey(roundDistance)) {
val last = getOrDefault(distance.lastMilestone, default)
this[roundDistance] = listOf(time, calories)
next = MilestoneReached(
roundDistance, listOf(time, calories), listOf(
Time(time.seconds - last.findInt<Time>()),
Calories(calories.kcal - last.findInt<Calories>()),
)
)
}
val roundedCals = calories.roundedDown
if (roundedCals.isMilestone && !containsKey(roundedCals)) {
val last = getOrDefault(roundedCals.lastMilestone, default)
this[roundedCals] = listOf(time, distance)
next = MilestoneReached(
roundedCals, listOf(time, distance), listOf(
Time(time.seconds - last.findInt<Time>()),
Distance(distance.meters - last.findInt<Distance>()),
)
)
}
val roundedTime = time.roundedDown
if (roundedTime.isMilestone && !containsKey(roundedTime)) {
val last = this.getOrDefault(time.lastMilestone, default)
this[roundedTime] = listOf(calories, distance)
next = MilestoneReached(
roundedTime, listOf(calories, distance), listOf(
Calories(calories.kcal - last.findInt<Calories>()),
Distance(distance.meters - last.findInt<Distance>()),
)
)
}
return next
}
private val default = listOf(Time(0), Calories(0), Distance(0))
private val Value.roundedDown
get() = when (this) {
is Time -> Time(seconds - (seconds % 2))
is Calories -> Calories(kcal - (kcal % 4))
is Distance -> Distance(meters - (meters % 10))
else -> error("Not milestone-able")
}
private val Value.isMilestone
get() = when (this) {
is Time -> seconds > 0 && seconds % 60 == 0
is Calories -> kcal > 0 && kcal % 100 == 0
is Distance -> meters > 0 && meters % 1000 == 0
else -> error("Not milestone-able")
}
private val Value.lastMilestone
get() = when (this) {
is Time -> Time(maxOf(0, (seconds - 1) - ((seconds - 1) % 60)))
is Calories -> Calories(maxOf(0, (kcal - 1) - ((kcal - 1) % 100)))
is Distance -> Distance(maxOf(0, (meters - 1) - ((meters - 1) % 1000)))
else -> error("Not milestone-able")
}

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Workout.kt

@ -20,5 +20,7 @@ data class Workout(
calories = values.find(),
level = values.find(),
distance = values.find(),
rpmSpeed = values.find(),
pulse = values.find(),
)
}

6
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt

@ -8,4 +8,8 @@ data class WorkoutState(
val calories: Calories? = null,
val level: Level? = null,
val distance: Distance? = null,
)
val rpmSpeed: RpmSpeed? = null,
val pulse: Pulse? = null,
) {
fun asValueList() = listOfNotNull(time, calories, level, distance, rpmSpeed, pulse)
}

11
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/FlowBus.kt

@ -1,9 +1,6 @@
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.flow.*
import kotlinx.coroutines.runBlocking
interface FlowBus<T : Any> {
@ -15,7 +12,7 @@ interface FlowBus<T : Any> {
/**
* Subscribe and read flow of [T].
*/
suspend fun collect(action: suspend (value: T) -> Unit)
suspend fun collect(forceAll: Boolean = false, action: suspend (value: T) -> Unit)
/**
* Emit an event to all subscribers.
@ -33,7 +30,9 @@ private class FlowBusImpl<T : Any> : FlowBus<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 collect(forceAll: Boolean, action: suspend (value: T) -> Unit) = if (forceAll) {
shared.buffer(capacity = 3).collect(action)
} else shared.collectLatest(action)
override suspend fun emit(data: T) = internal.emit(data)
}

4
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Value.kt

@ -8,6 +8,8 @@ sealed class Value {
is Distance -> meters
is Calories -> kcal
is Level -> raw
is RpmSpeed -> rpm
is Pulse -> bpm
}
}
@ -15,6 +17,8 @@ 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()
data class RpmSpeed(val rpm: Int) : Value()
data class Pulse(val bpm: 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

69
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/MilestoneChecker.kt

@ -1,5 +1,6 @@
package net.aiterp.git.ykonsole2.infrastructure.drivers
import net.aiterp.git.ykonsole2.domain.models.tryMilestone
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.PassiveDriver
import java.util.concurrent.ConcurrentHashMap
@ -8,76 +9,12 @@ class MilestoneChecker : PassiveDriver() {
private val milestoneMap = ConcurrentHashMap<Value, List<Value>>()
override suspend fun onEvent(event: Event, output: FlowBus<Event>) {
if (event is Connected || event is Disconnected) {
if (event is Disconnected) {
milestoneMap.clear()
}
if (event is ValuesReceived) {
val time = event.values.find<Time>() ?: return
val calories = event.values.find<Calories>() ?: return
val distance = event.values.find<Distance>() ?: return
var next: MilestoneReached? = null
val roundDistance = distance.roundedDown
if (roundDistance.isMilestone && !milestoneMap.containsKey(roundDistance)) {
val last = milestoneMap.getOrDefault(distance.lastMilestone, default)
milestoneMap[roundDistance] = listOf(time, calories)
next = MilestoneReached(roundDistance, listOf(time, calories), listOf(
Time(time.seconds - last.findInt<Time>()),
Calories(calories.kcal - last.findInt<Calories>()),
))
}
val roundedCals = calories.roundedDown
if (roundedCals.isMilestone && !milestoneMap.containsKey(roundedCals)) {
val last = milestoneMap.getOrDefault(roundedCals.lastMilestone, default)
milestoneMap[roundedCals] = listOf(time, distance)
next = MilestoneReached(roundedCals, listOf(time, distance), listOf(
Time(time.seconds - last.findInt<Time>()),
Distance(distance.meters - last.findInt<Distance>()),
))
}
val roundedTime = time.roundedDown
if (roundedTime.isMilestone && !milestoneMap.containsKey(roundedTime)) {
val last = milestoneMap.getOrDefault(time.lastMilestone, default)
milestoneMap[roundedTime] = listOf(calories, distance)
next = MilestoneReached(roundedTime, listOf(calories, distance), listOf(
Calories(calories.kcal - last.findInt<Calories>()),
Distance(distance.meters - last.findInt<Distance>()),
))
}
next?.let { output.emit(it) }
}
}
companion object {
private val default = listOf(Time(0), Calories(0), Distance(0))
private val Value.roundedDown get() = when (this) {
is Time -> Time(seconds - (seconds % 2))
is Calories -> Calories(kcal - (kcal % 4))
is Distance -> Distance(meters - (meters % 10))
else -> error("Not milestone-able")
}
private val Value.isMilestone get() = when (this) {
is Time -> seconds > 0 && seconds % 60 == 0
is Calories -> kcal > 0 && kcal % 100 == 0
is Distance -> meters > 0 && meters % 1000 == 0
else -> error("Not milestone-able")
}
private val Value.lastMilestone get() = when (this) {
is Time -> Time(maxOf(0, (seconds - 1) - ((seconds - 1) % 60)))
is Calories -> Calories(maxOf(0, (kcal - 1) - ((kcal - 1) % 100)))
is Distance -> Distance(maxOf(0, (meters - 1) - ((meters - 1) % 1000)))
else -> error("Not milestone-able")
milestoneMap.tryMilestone(event)?.let { output.emit(it) }
}
}
}

12
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt

@ -1,5 +1,6 @@
package net.aiterp.git.ykonsole2.infrastructure.drivers
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ReactiveDriver
@ -11,16 +12,17 @@ class ProgramEnforcer(
private var lastTransition: WorkoutState = WorkoutState()
private var lastValues: ValuesReceived? = null
private var lastStep: ProgramStep? = null
private var stepIndex: Int = 0
private var program: Program? = null
private suspend fun runTransition(input: CommandBus, newEvent: ValuesReceived? = null) {
val step = lastStep ?: return
val prog = program ?: return
val event = newEvent ?: lastValues ?: return
val stepIndex = prog.steps.indexOf(step)
if (prog.steps.size > stepIndex + 1) {
stepIndex += 1
log.info("$stepIndex: Jumped!")
// Go to next step and send the change
lastTransition = lastTransition.copy(
time = event.values.find() ?: lastTransition.time,
@ -28,9 +30,10 @@ class ProgramEnforcer(
distance = event.values.find(),
level = event.values.find(),
)
lastStep = prog.steps[stepIndex + 1]
lastStep = prog.steps[stepIndex]
lastStep?.values?.forEach { input.emit(SetValueCommand(it)) }
} else {
log.info("$stepIndex: Stopped!")
// The program is done, let's stop it
lastStep = null
program = null
@ -46,6 +49,7 @@ class ProgramEnforcer(
}?.programId ?: return
program = programRepo.findById(programId)?.takeIf { it.steps.isNotEmpty() }
lastStep = program?.steps?.firstOrNull()
stepIndex = 0
}
if (event is Started) {

14
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt

@ -5,6 +5,7 @@ import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
import kotlinx.coroutines.delay
import net.aiterp.git.ykonsole2.application.logging.log
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Duration
@ -15,6 +16,8 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
private var calories = 0.0
private var distance = 0.0
private var level = 0
private var speed = 50.0
private var pulse = 70.0
private var running = false
private var connected = false
@ -23,7 +26,9 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
Level(level),
Time(time),
Calories(calories.toInt()),
Distance(if (abs(distance % 1000) < 0) distance.toInt() else distance.toInt()),
Distance(distance.toInt()),
Pulse(pulse.roundToInt()),
RpmSpeed(speed.roundToInt()),
)
override suspend fun onCommand(command: Command, output: FlowBus<Event>) {
@ -57,6 +62,7 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
level = command.value.raw
ValuesReceived(currentState)
}
else -> null
}
@ -72,8 +78,10 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
override suspend fun onTick(output: FlowBus<Event>) {
if (running && connected) {
time += 1
calories += (level.toDouble() / 10) * Random.nextDouble(0.1, 0.4)
distance += (level.toDouble() / 10) * Random.nextDouble(1.5, 2.5)
calories += ((level + 1).toDouble() / 10) * Random.nextDouble(0.1, 0.4)
distance += ((level + 1).toDouble() / 10) * Random.nextDouble(1.5, 2.5)
speed = 100.0 - (level * Random.nextDouble(1.0, 2.5))
pulse = maxOf(60.0, minOf(140.0, Random.nextDouble(pulse - (level / 10), pulse + (level / 10))))
output.emit(ValuesReceived(currentState))
}

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

@ -177,11 +177,13 @@ class IConsole : ActiveDriver() {
output.emitBlocking(
ValuesReceived(
listOf(
listOfNotNull(
Time(lastTime + bonusTime),
Calories(lastCals + bonusCals),
Distance(lastMeters + bonusMeters),
Level(res.level),
RpmSpeed(res.rpm),
res.pulse.takeIf { it >= 70 }?.let { Pulse(it) },
)
)
)

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

@ -11,6 +11,8 @@ data class ValueDTO(
val distance: Int? = null,
val calories: Int? = null,
val level: Int? = null,
val rpmSpeed: Int? = null,
val pulse: Int? = null,
) {
private val values by lazy {
buildList {
@ -18,6 +20,8 @@ data class ValueDTO(
distance?.let { add(Distance(it)) }
calories?.let { add(Calories(it)) }
level?.let { add(Level(it)) }
rpmSpeed?.let { add(RpmSpeed(it)) }
pulse?.let { add(Pulse(it)) }
}
}
@ -27,13 +31,15 @@ data class ValueDTO(
companion object {
fun from(value: Value?) = from(listOfNotNull(value))
fun from(state: WorkoutState) = state.run { from(listOfNotNull(time, calories, level, distance)) }
fun from(state: WorkoutState) = state.run { from(asValueList()) }
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,
rpmSpeed = values.find<RpmSpeed>()?.rpm,
pulse = values.find<Pulse>()?.bpm,
)
}
}

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

@ -13,7 +13,6 @@ 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 net.aiterp.git.ykonsole2.infrastructure.drivers.MilestoneChecker
import java.lang.Exception
private object WebSocketHandler
@ -41,14 +40,18 @@ fun Route.sockets(
val program = active.programId?.let { programRepo.findById(it) }
sendSerialized(SocketOutput(workout = WorkoutDTO(active, device, program)))
log.info("$clientId: Sending workout states...")
log.info("$clientId: Sending workout states and milestones...")
val workoutStates = workoutStateRepo.fetchByWorkoutId(active.id)
sendSerialized(SocketOutput(workoutStates = workoutStates.map { ValueDTO.from(it) }))
val mrEvents = workoutStates.makeMilestoneReachedEvents()
sendSerialized(SocketOutput(
workoutStates = workoutStates.map { ValueDTO.from(it) },
oldMilestones = mrEvents.map { MilestoneDTO.from(it) },
))
job = launch {
log.info("$clientId: Starting event listener...")
eventBus.collect { event ->
eventBus.collect(forceAll = true) { event ->
when (event) {
Connected, Started, Stopped, Disconnected, Skipped -> {
sendSerialized(SocketOutput(event = SocketOutput.EventDTO(name = event.name)))

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

@ -15,6 +15,7 @@ data class SocketOutput(
val workout: WorkoutDTO? = null,
val workoutStates: List<ValueDTO>? = null,
val milestone: MilestoneDTO? = null,
val oldMilestones: List<MilestoneDTO>? = null,
val event: EventDTO? = null,
val error: Error? = null,
) {

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

@ -2,10 +2,7 @@ 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.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.getIntOrNull
import net.aiterp.git.ykonsole2.infrastructure.prepare
import net.aiterp.git.ykonsole2.infrastructure.runQuery
@ -27,6 +24,8 @@ val DataSource.workoutStateRepo get() = object : WorkoutStateRepository {
calories = getIntOrNull("ws_kcal")?.let { Calories(it) },
level = getIntOrNull("ws_level")?.let { Level(it) },
distance = getIntOrNull("ws_meters")?.let { Distance(it) },
rpmSpeed = getIntOrNull("ws_rpm")?.let { RpmSpeed(it) },
pulse = getIntOrNull("ws_pulse")?.let { Pulse(it) },
),
)
}
@ -38,11 +37,13 @@ val DataSource.workoutStateRepo get() = object : WorkoutStateRepository {
withConnection {
prepare(
"""
INSERT INTO workout_state (workout_id, ws_seconds, ws_kcal, ws_level, ws_meters)
VALUES (?, ?, ?, ?, ?)
INSERT INTO workout_state (workout_id, ws_seconds, ws_kcal, ws_level, ws_meters, ws_rpm, ws_pulse)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ws_kcal = VALUES(ws_kcal),
ws_level = VALUES(ws_level),
ws_meters = VALUES(ws_meters)
ws_meters = VALUES(ws_meters),
ws_rpm = VALUES(ws_rpm),
ws_pulse = VALUES(ws_pulse)
""".trimIndent()
) {
setString(1, state.workoutId)
@ -50,6 +51,8 @@ val DataSource.workoutStateRepo get() = object : WorkoutStateRepository {
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)
if (state.rpmSpeed != null) setInt(6, state.rpmSpeed!!.rpm) else setNull(6, Types.INTEGER)
if (state.pulse != null) setInt(7, state.pulse!!.bpm) else setNull(7, Types.INTEGER)
execute()
}
}

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

@ -26,4 +26,14 @@
</column>
</createTable>
</changeSet>
<changeSet id="2" author="stian">
<addColumn tableName="workout_state">
<column name="ws_rpm" type="INT">
<constraints nullable="true"/>
</column>
<column name="ws_pulse" type="INT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
Loading…
Cancel
Save