From 9d1e7764e6e353488ea6d3699fc06e669094b126 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 27 Mar 2026 05:29:41 +0100 Subject: [PATCH] Re organise project --- .../src/main/java/dev/slimevr/behaviours.kt | 18 + .../java/dev/slimevr/config/behaviours.kt | 22 ++ .../main/java/dev/slimevr/config/module.kt | 88 ++--- .../main/java/dev/slimevr/config/settings.kt | 64 ++-- .../src/main/java/dev/slimevr/config/user.kt | 64 ++-- .../main/java/dev/slimevr/context/context.kt | 57 ++- .../java/dev/slimevr/device/behaviours.kt | 15 + .../main/java/dev/slimevr/device/module.kt | 93 ++--- .../java/dev/slimevr/firmware/behaviours.kt | 18 + .../main/java/dev/slimevr/firmware/module.kt | 92 ++--- .../main/java/dev/slimevr/hid/behaviours.kt | 166 +++++++++ .../src/main/java/dev/slimevr/hid/module.kt | 262 +++---------- .../java/dev/slimevr/serial/behaviours.kt | 30 ++ .../java/dev/slimevr/serial/connection.kt | 69 ++-- .../main/java/dev/slimevr/serial/module.kt | 62 +--- .../main/java/dev/slimevr/solarxr/datafeed.kt | 54 ++- .../main/java/dev/slimevr/solarxr/firmware.kt | 24 +- .../main/java/dev/slimevr/solarxr/module.kt | 68 ++-- .../main/java/dev/slimevr/solarxr/serial.kt | 39 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 30 +- .../java/dev/slimevr/solarxr/ws-server.kt | 5 +- .../java/dev/slimevr/tracker/behaviours.kt | 15 + .../main/java/dev/slimevr/tracker/module.kt | 83 ++--- .../main/java/dev/slimevr/udp/behaviours.kt | 228 ++++++++++++ .../main/java/dev/slimevr/udp/connection.kt | 350 ++---------------- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- .../java/dev/slimevr/vrchat/behaviours.kt | 26 ++ .../main/java/dev/slimevr/vrchat/module.kt | 91 ++--- .../src/main/java/dev/slimevr/vrserver.kt | 66 +--- .../src/test/java/dev/slimevr/TestServer.kt | 17 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 37 +- .../reducers/FirmwareManagerReducerTest.kt | 6 +- .../dev/slimevr/serial/SerialServerTest.kt | 20 +- .../reducers/SerialConnectionReducerTest.kt | 12 +- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 18 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 40 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 3 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 7 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 17 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 4 +- .../java/dev/slimevr/desktop/serial/serial.kt | 6 +- .../dev/slimevr/desktop/vrchat/vrc-config.kt | 12 +- 43 files changed, 1112 insertions(+), 1292 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/config/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/device/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/firmware/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/hid/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/serial/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/udp/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt new file mode 100644 index 000000000..20ed766c2 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -0,0 +1,18 @@ +package dev.slimevr + +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object BaseBehaviour : VRServerBehaviour { + override fun reduce(state: VRServerState, action: VRServerActions) = when (action) { + is VRServerActions.NewTracker -> state.copy(trackers = state.trackers + (action.trackerId to action.context)) + is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context)) + } + + override fun observe(receiver: VRServer) { + receiver.context.state.distinctUntilChangedBy { it.trackers.size }.onEach { + println("tracker list size changed") + }.launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt new file mode 100644 index 000000000..7134bdbe1 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -0,0 +1,22 @@ +package dev.slimevr.config + +object DefaultGlobalConfigBehaviour : GlobalConfigBehaviour { + override fun reduce(state: GlobalConfigState, action: GlobalConfigActions) = when (action) { + is GlobalConfigActions.SetUserProfile -> state.copy(selectedUserProfile = action.name) + is GlobalConfigActions.SetSettingsProfile -> state.copy(selectedSettingsProfile = action.name) + } +} + +object DefaultSettingsBehaviour : SettingsBehaviour { + override fun reduce(state: SettingsState, action: SettingsActions) = when (action) { + is SettingsActions.Update -> state.copy(data = action.transform(state.data)) + is SettingsActions.LoadProfile -> action.newState + } +} + +object DefaultUserBehaviour : UserConfigBehaviour { + override fun reduce(state: UserConfigState, action: UserConfigActions) = when (action) { + is UserConfigActions.Update -> state.copy(data = action.transform(state.data)) + is UserConfigActions.LoadProfile -> action.newState + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 260bf4ea0..8325ff23e 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -1,8 +1,7 @@ package dev.slimevr.config -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -27,7 +26,7 @@ sealed interface GlobalConfigActions { } typealias GlobalConfigContext = Context -typealias GlobalConfigBehaviour = BasicBehaviour +typealias GlobalConfigBehaviour = Behaviour private fun migrateGlobalConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 @@ -42,61 +41,50 @@ private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState { return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json)) } -val DefaultGlobalConfigBehaviour = GlobalConfigBehaviour( - reducer = { s, a -> - when (a) { - is GlobalConfigActions.SetUserProfile -> s.copy(selectedUserProfile = a.name) - is GlobalConfigActions.SetSettingsProfile -> s.copy(selectedSettingsProfile = a.name) - } - }, -) - -data class AppConfig( +class AppConfig( val globalContext: GlobalConfigContext, val userConfig: UserConfig, val settings: Settings, - val switchUserProfile: suspend (String) -> Unit, - val switchSettingsProfile: suspend (String) -> Unit, -) - -suspend fun createAppConfig(scope: CoroutineScope, configFolder: File): AppConfig { - val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { - parseAndMigrateGlobalConfig(it) - } - - val behaviours = listOf(DefaultGlobalConfigBehaviour) - - val globalContext = createContext( - initialState = initialGlobal, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - launchAutosave( - scope = scope, - state = globalContext.state, - toFile = { File(configFolder, "global.json") }, - serialize = { jsonConfig.encodeToString(it) }, - ) - - val userConfig = createUserConfig(scope, configFolder, initialGlobal.selectedUserProfile) - val settings = createSettings(scope, configFolder, initialGlobal.selectedSettingsProfile) - - val switchUserProfile: suspend (String) -> Unit = { name -> +) { + suspend fun switchUserProfile(name: String) { globalContext.dispatch(GlobalConfigActions.SetUserProfile(name)) userConfig.swap(name) } - val switchSettingsProfile: suspend (String) -> Unit = { name -> + suspend fun switchSettingsProfile(name: String) { globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name)) settings.swap(name) } - return AppConfig( - globalContext = globalContext, - userConfig = userConfig, - settings = settings, - switchUserProfile = switchUserProfile, - switchSettingsProfile = switchSettingsProfile, - ) -} + companion object { + suspend fun create(scope: CoroutineScope, configFolder: File): AppConfig { + val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { + parseAndMigrateGlobalConfig(it) + } + + val behaviours = listOf(DefaultGlobalConfigBehaviour) + val globalContext = Context.create( + initialState = initialGlobal, + scope = scope, + behaviours = behaviours, + ) + behaviours.forEach { it.observe(globalContext) } + + launchAutosave( + scope = scope, + state = globalContext.state, + toFile = { File(configFolder, "global.json") }, + serialize = { jsonConfig.encodeToString(it) }, + ) + + val userConfig = UserConfig.create(scope, configFolder, initialGlobal.selectedUserProfile) + val settings = Settings.create(scope, configFolder, initialGlobal.selectedSettingsProfile) + + return AppConfig( + globalContext = globalContext, + userConfig = userConfig, + settings = settings, + ) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 8ccfb3af1..2848cd1a8 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -1,8 +1,7 @@ package dev.slimevr.config +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -47,50 +46,24 @@ sealed interface SettingsActions { } typealias SettingsContext = Context -typealias SettingsBehaviour = CustomBehaviour +typealias SettingsBehaviour = Behaviour -data class Settings( +class Settings( val context: SettingsContext, val configDir: String, - val swap: suspend (String) -> Unit, -) + private val scope: CoroutineScope, + private val settingsDir: File, +) { + private var autosaveJob: Job = startAutosave() -val DefaultSettingsBehaviour = SettingsBehaviour( - reducer = { s, a -> - when (a) { - is SettingsActions.Update -> s.copy(data = a.transform(s.data)) - is SettingsActions.LoadProfile -> a.newState - else -> s - } - }, -) - -suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String): Settings { - val settingsDir = File(configDir, "settings") - - val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { - parseAndMigrateSettingsConfig(it) - } - val initialState = SettingsState(name = name, data = initialData) - - val behaviours = listOf(DefaultSettingsBehaviour) - - val context = createContext( - initialState = initialState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - fun startAutosave() = launchAutosave( + private fun startAutosave() = launchAutosave( scope = scope, state = context.state, toFile = { state -> File(settingsDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) - var autosaveJob: Job = startAutosave() - - val swap: suspend (String) -> Unit = { newName -> + suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) { @@ -102,5 +75,20 @@ suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String) autosaveJob = startAutosave() } - return Settings(context, configDir = settingsDir.toString(), swap) -} + companion object { + suspend fun create(scope: CoroutineScope, configDir: File, name: String): Settings { + val settingsDir = File(configDir, "settings") + + val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { + parseAndMigrateSettingsConfig(it) + } + val initialState = SettingsState(name = name, data = initialData) + + val behaviours = listOf(DefaultSettingsBehaviour) + val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val settings = Settings(context, configDir = settingsDir.toString(), scope = scope, settingsDir = settingsDir) + behaviours.forEach { it.observe(settings) } + return settings + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 1ae00c6f9..ce9939f1d 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -1,8 +1,7 @@ package dev.slimevr.config +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -46,50 +45,24 @@ sealed interface UserConfigActions { } typealias UserConfigContext = Context -typealias UserConfigBehaviour = CustomBehaviour +typealias UserConfigBehaviour = Behaviour -data class UserConfig( +class UserConfig( val context: UserConfigContext, val configDir: String, - val swap: suspend (String) -> Unit, -) + private val scope: CoroutineScope, + private val userConfigDir: File, +) { + private var autosaveJob: Job = startAutosave() -val DefaultUserBehaviour = UserConfigBehaviour( - reducer = { s, a -> - when (a) { - is UserConfigActions.Update -> s.copy(data = a.transform(s.data)) - is UserConfigActions.LoadProfile -> a.newState - else -> s - } - }, -) - -suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: String): UserConfig { - val userConfigDir = File(configDir, "user") - - val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { - parseAndMigrateUserConfig(it) - } - val initialState = UserConfigState(name = name, data = initialData) - - val behaviours = listOf(DefaultUserBehaviour) - - val context = createContext( - initialState = initialState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - fun startAutosave() = launchAutosave( + private fun startAutosave() = launchAutosave( scope = scope, state = context.state, toFile = { state -> File(userConfigDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) - var autosaveJob: Job = startAutosave() - - val swap: suspend (String) -> Unit = { newName -> + suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) { @@ -101,5 +74,20 @@ suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: Strin autosaveJob = startAutosave() } - return UserConfig(context, userConfigDir.toString(), swap) -} + companion object { + suspend fun create(scope: CoroutineScope, configDir: File, name: String): UserConfig { + val userConfigDir = File(configDir, "user") + + val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { + parseAndMigrateUserConfig(it) + } + val initialState = UserConfigState(name = name, data = initialData) + + val behaviours = listOf(DefaultUserBehaviour) + val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val userConfig = UserConfig(context, userConfigDir.toString(), scope = scope, userConfigDir = userConfigDir) + behaviours.forEach { it.observe(userConfig) } + return userConfig + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index dd71f9acc..68f96f68e 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -7,47 +7,38 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update interface Behaviour { - val reducer: ((S, A) -> S)? - val observer: ((C) -> Unit)? + fun reduce(state: S, action: A): S = state + fun observe(receiver: C) {} } -data class BasicBehaviour( - override val reducer: ((S, A) -> S)? = null, - override val observer: ((Context) -> Unit)? = null, -) : Behaviour> - -data class CustomBehaviour( - override val reducer: ((S, A) -> S)? = null, - override val observer: ((C) -> Unit)? = null, -) : Behaviour - -data class Context( - val state: StateFlow, - val dispatch: suspend (A) -> Unit, - val dispatchAll: suspend (List) -> Unit, +class Context( + private val mutableStateFlow: MutableStateFlow, + private val applyAction: (S, A) -> S, val scope: CoroutineScope, -) +) { + val state: StateFlow = mutableStateFlow.asStateFlow() -fun createContext( - initialState: S, - scope: CoroutineScope, - reducers: List<((S, A) -> S)?>, -): Context { - val mutableStateFlow = MutableStateFlow(initialState) - - val applyAction: (S, A) -> S = { currentState, action -> - reducers.filterNotNull().fold(currentState) { s, reducer -> reducer(s, action) } - } - - val dispatch: suspend (A) -> Unit = { action -> + fun dispatch(action: A) { mutableStateFlow.update { applyAction(it, action) } } - val dispatchAll: suspend (List) -> Unit = { actions -> + fun dispatchAll(actions: List) { mutableStateFlow.update { currentState -> actions.fold(currentState) { s, action -> applyAction(s, action) } } } - val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope) - return context -} + + companion object { + fun create( + initialState: S, + scope: CoroutineScope, + behaviours: List>, + ): Context { + val mutableStateFlow = MutableStateFlow(initialState) + val applyAction: (S, A) -> S = { currentState, action -> + behaviours.fold(currentState) { s, b -> b.reduce(s, action) } + } + return Context(mutableStateFlow, applyAction, scope) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt new file mode 100644 index 000000000..8a418f94a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -0,0 +1,15 @@ +package dev.slimevr.device + +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object DeviceStatsBehaviour : DeviceBehaviour { + override fun reduce(state: DeviceState, action: DeviceActions) = + if (action is DeviceActions.Update) action.transform(state) else state + + override fun observe(receiver: DeviceContext) { + receiver.state.onEach { +// AppLogger.device.info("Device state changed", it) + }.launchIn(receiver.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 08fdf3771..8869db614 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -1,12 +1,8 @@ package dev.slimevr.device -import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.McuType @@ -39,59 +35,42 @@ sealed interface DeviceActions { data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions } -val DeviceStatsBehaviour = DeviceBehaviour( - reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, - observer = { - it.state.onEach { state -> -// AppLogger.device.info("Device state changed", state) - }.launchIn(it.scope) - }, -) - typealias DeviceContext = Context -typealias DeviceBehaviour = BasicBehaviour +typealias DeviceBehaviour = Behaviour -data class Device( +class Device( val context: DeviceContext, -) +) { + companion object { + fun create( + scope: CoroutineScope, + id: Int, + address: String, + macAddress: String? = null, + origin: DeviceOrigin, + protocolVersion: Int, + ): Device { + val deviceState = DeviceState( + id = id, + name = "Device $id", + batteryLevel = 0f, + batteryVoltage = 0f, + origin = origin, + address = address, + macAddress = macAddress, + protocolVersion = protocolVersion, + ping = null, + signalStrength = null, + status = TrackerStatus.DISCONNECTED, + mcuType = McuType.Other, + boardType = BoardType.UNKNOWN, + firmware = null, + ) -fun createDevice( - scope: CoroutineScope, - id: Int, - address: String, - macAddress: String? = null, - origin: DeviceOrigin, - protocolVersion: Int, - serverContext: VRServer, -): Device { - val deviceState = DeviceState( - id = id, - name = "Device $id", - batteryLevel = 0f, - batteryVoltage = 0f, - origin = origin, - address = address, - macAddress = macAddress, - protocolVersion = protocolVersion, - ping = null, - signalStrength = null, - status = TrackerStatus.DISCONNECTED, - mcuType = McuType.Other, - boardType = BoardType.UNKNOWN, - firmware = null - ) - - val behaviours = listOf(DeviceStatsBehaviour) - - val context = createContext( - initialState = deviceState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - - return Device( - context = context, - ) -} + val behaviours = listOf(DeviceStatsBehaviour) + val context = Context.create(initialState = deviceState, scope = scope, behaviours = behaviours) + behaviours.forEach { it.observe(context) } + return Device(context = context) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt new file mode 100644 index 000000000..fada70219 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -0,0 +1,18 @@ +package dev.slimevr.firmware + +object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { + override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) { + is FirmwareManagerActions.UpdateJob -> state.copy( + jobs = state.jobs + ( + action.portLocation to FirmwareJobStatus( + portLocation = action.portLocation, + firmwareDeviceId = action.firmwareDeviceId, + status = action.status, + progress = action.progress, + ) + ), + ) + + is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index c904c0c20..1441e74de 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -1,9 +1,8 @@ package dev.slimevr.firmware import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -38,51 +37,23 @@ sealed interface FirmwareManagerActions { } typealias FirmwareManagerContext = Context -typealias FirmwareManagerBehaviour = BasicBehaviour +typealias FirmwareManagerBehaviour = Behaviour -val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( - reducer = { s, a -> - when (a) { - is FirmwareManagerActions.UpdateJob -> s.copy( - jobs = s.jobs + - ( - a.portLocation to FirmwareJobStatus( - portLocation = a.portLocation, - firmwareDeviceId = a.firmwareDeviceId, - status = a.status, - progress = a.progress, - ) - ), - ) - - is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) - } - }, - observer = null, -) - -data class FirmwareManager( +class FirmwareManager( val context: FirmwareManagerContext, - val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, - val otaFlash: suspend (deviceIp: String, firmwareDeviceId: FirmwareUpdateDeviceId, part: FirmwarePart, VRServer) -> Unit, - val cancelAll: suspend () -> Unit, -) + private val serialServer: SerialServer, + private val scope: CoroutineScope, +) { + private val runningJobs = mutableMapOf() -fun createFirmwareManager( - serialServer: SerialServer, - scope: CoroutineScope, -): FirmwareManager { - val behaviours = listOf(FirmwareManagerBaseBehaviour) - - val context = createContext( - initialState = FirmwareManagerState(jobs = mapOf()), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val runningJobs = mutableMapOf() - - val flash: suspend (String, List, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server -> + suspend fun flash( + portLocation: String, + parts: List, + needManualReboot: Boolean, + ssid: String?, + password: String?, + server: VRServer, + ) { runningJobs[portLocation]?.cancelAndJoin() runningJobs[portLocation] = scope.launch { doSerialFlash( @@ -108,7 +79,12 @@ fun createFirmwareManager( } } - val otaFlash: suspend (String, FirmwareUpdateDeviceId, FirmwarePart, VRServer) -> Unit = { deviceIp, firmwareDeviceId, part, server -> + suspend fun otaFlash( + deviceIp: String, + firmwareDeviceId: FirmwareUpdateDeviceId, + part: FirmwarePart, + server: VRServer, + ) { runningJobs[deviceIp]?.cancelAndJoin() runningJobs[deviceIp] = scope.launch { doOtaFlash( @@ -130,18 +106,22 @@ fun createFirmwareManager( } } - val cancelAll: suspend () -> Unit = { + suspend fun cancelAll() { runningJobs.values.forEach { it.cancelAndJoin() } runningJobs.clear() } - val manager = FirmwareManager( - context = context, - flash = flash, - otaFlash = otaFlash, - cancelAll = cancelAll, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - return manager -} + companion object { + fun create(serialServer: SerialServer, scope: CoroutineScope): FirmwareManager { + val behaviours = listOf(FirmwareManagerBaseBehaviour) + val context = Context.create( + initialState = FirmwareManagerState(jobs = mapOf()), + scope = scope, + behaviours = behaviours, + ) + val manager = FirmwareManager(context = context, serialServer = serialServer, scope = scope) + behaviours.forEach { it.observe(context) } + return manager + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt new file mode 100644 index 000000000..704894cc8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -0,0 +1,166 @@ +package dev.slimevr.hid + +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import solarxr_protocol.datatypes.TrackerStatus + +object HIDRegistrationBehaviour : HIDReceiverBehaviour { + override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { + is HIDReceiverActions.DeviceRegistered -> state.copy( + trackers = state.trackers + (action.hidId to HIDTrackerRecord( + hidId = action.hidId, + address = action.address, + deviceId = action.deviceId, + trackerId = null, + )), + ) + else -> state + } + + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + val existing = state.trackers[packet.hidId] + if (existing != null) return@onPacket + + val existingDevice = receiver.serverContext.context.state.value.devices.values + .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingDevice != null) { + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) + AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") + return@onPacket + } + + val deviceId = receiver.serverContext.nextHandle() + val device = Device.create( + scope = receiver.serverContext.context.scope, + id = deviceId, + address = packet.address, + macAddress = packet.address, + origin = DeviceOrigin.HID, + protocolVersion = 0, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) + AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") + } + } +} + +object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { + override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) { + is HIDReceiverActions.TrackerRegistered -> { + val existing = state.trackers[action.hidId] ?: return state + state.copy(trackers = state.trackers + (action.hidId to existing.copy(trackerId = action.trackerId))) + } + else -> state + } + + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val device = receiver.getDevice(packet.hidId) ?: return@onPacket + + device.context.dispatch( + DeviceActions.Update { + copy( + boardType = packet.boardType, + mcuType = packet.mcuType, + firmware = packet.firmware, + batteryLevel = packet.batteryLevel, + batteryVoltage = packet.batteryVoltage, + signalStrength = packet.rssi, + ) + }, + ) + + val tracker = receiver.getTracker(packet.hidId) + if (tracker == null) { + val deviceState = device.context.state.value + + val existingTracker = receiver.serverContext.context.state.value.trackers.values + .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingTracker != null) { + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) + existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } else { + val trackerId = receiver.serverContext.nextHandle() + val newTracker = Tracker.create( + scope = receiver.serverContext.context.scope, + id = trackerId, + deviceId = deviceState.id, + sensorType = packet.imuType, + hardwareId = deviceState.address, + origin = DeviceOrigin.HID, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) + AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") + } + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + } else { + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } + } + } +} + +object HIDRotationBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + } +} + +object HIDBatteryBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { + copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) + }, + ) + } + + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(signalStrength = packet.rssi) }, + ) + } + } +} + +object HIDStatusBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + if (receiver.getTracker(packet.hidId) == null) return@onPacket + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, + ) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index fe8c90a01..238f9fa76 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -1,19 +1,12 @@ package dev.slimevr.hid -import dev.slimevr.AppLogger import dev.slimevr.EventDispatcher import dev.slimevr.VRServer -import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin -import dev.slimevr.device.createDevice import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions -import dev.slimevr.tracker.createTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation @@ -48,7 +41,7 @@ sealed interface HIDReceiverActions { } typealias HIDReceiverContext = Context -typealias HIDReceiverBehaviour = CustomBehaviour +typealias HIDReceiverBehaviour = Behaviour typealias HIDPacketDispatcher = EventDispatcher @Suppress("UNCHECKED_CAST") @@ -56,169 +49,7 @@ inline fun HIDPacketDispatcher.onPacket(crossinline call register(T::class) { callback(it as T) } } -val HIDRegistrationBehaviour = HIDReceiverBehaviour( - reducer = { s, a -> - when (a) { - is HIDReceiverActions.DeviceRegistered -> s.copy( - trackers = s.trackers + (a.hidId to HIDTrackerRecord( - hidId = a.hidId, - address = a.address, - deviceId = a.deviceId, - trackerId = null, - )), - ) - - else -> s - } - }, - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val state = receiver.context.state.value - val existing = state.trackers[packet.hidId] - if (existing != null) return@onPacket - - val existingDevice = receiver.serverContext.context.state.value.devices.values - .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } - - if (existingDevice != null) { - receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) - AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") - return@onPacket - } - - val deviceId = receiver.serverContext.nextHandle() - val device = createDevice( - scope = receiver.serverContext.context.scope, - id = deviceId, - address = packet.address, - macAddress = packet.address, - origin = DeviceOrigin.HID, - protocolVersion = 0, - serverContext = receiver.serverContext, - ) - receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) - receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) - AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") - } - }, -) - -val HIDDeviceInfoBehaviour = HIDReceiverBehaviour( - reducer = { s, a -> - when (a) { - is HIDReceiverActions.TrackerRegistered -> { - val existing = s.trackers[a.hidId] ?: return@HIDReceiverBehaviour s - s.copy(trackers = s.trackers + (a.hidId to existing.copy(trackerId = a.trackerId))) - } - - else -> s - } - }, - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val device = receiver.getDevice(packet.hidId) ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy( - boardType = packet.boardType, - mcuType = packet.mcuType, - firmware = packet.firmware, - batteryLevel = packet.batteryLevel, - batteryVoltage = packet.batteryVoltage, - signalStrength = packet.rssi, - ) - }, - ) - - val tracker = receiver.getTracker(packet.hidId) - if (tracker == null) { - val deviceState = device.context.state.value - - val existingTracker = receiver.serverContext.context.state.value.trackers.values - .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } - - if (existingTracker != null) { - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) - existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) - } else { - val trackerId = receiver.serverContext.nextHandle() - val newTracker = createTracker( - scope = receiver.serverContext.context.scope, - id = trackerId, - deviceId = deviceState.id, - sensorType = packet.imuType, - hardwareId = deviceState.address, - origin = DeviceOrigin.HID, - serverContext = receiver.serverContext, - ) - receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) - AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") - } - - device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) - } else { - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) - } - } - }, -) - -val HIDRotationBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - }, -) - -val HIDBatteryBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { - copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) - }, - ) - } - - receiver.packetEvents.onPacket { packet -> - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { copy(signalStrength = packet.rssi) }, - ) - } - }, -) - -val HIDStatusBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - if (receiver.getTracker(packet.hidId) == null) return@onPacket - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, - ) - } - }, -) - -data class HIDReceiver( +class HIDReceiver( val context: HIDReceiverContext, val serverContext: VRServer, val packetEvents: HIDPacketDispatcher, @@ -233,58 +64,57 @@ data class HIDReceiver( val trackerId = record.trackerId ?: return null return serverContext.getTracker(trackerId) } -} -fun createHIDReceiver( - serialNumber: String, - data: Flow, - serverContext: VRServer, - scope: CoroutineScope, -): HIDReceiver { - val behaviours = listOf( - HIDRegistrationBehaviour, - HIDDeviceInfoBehaviour, - HIDRotationBehaviour, - HIDBatteryBehaviour, - HIDStatusBehaviour, - ) + companion object { + fun create( + serialNumber: String, + data: Flow, + serverContext: VRServer, + scope: CoroutineScope, + ): HIDReceiver { + val behaviours = listOf( + HIDRegistrationBehaviour, + HIDDeviceInfoBehaviour, + HIDRotationBehaviour, + HIDBatteryBehaviour, + HIDStatusBehaviour, + ) - val context = createContext( - initialState = HIDReceiverState( - serialNumber = serialNumber, - trackers = mapOf(), - ), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) + val context = Context.create( + initialState = HIDReceiverState(serialNumber = serialNumber, trackers = mapOf()), + scope = scope, + behaviours = behaviours, + ) - val dispatcher = HIDPacketDispatcher() + val dispatcher = HIDPacketDispatcher() - val receiver = HIDReceiver( - context = context, - serverContext = serverContext, - packetEvents = dispatcher, - ) + val receiver = HIDReceiver( + context = context, + serverContext = serverContext, + packetEvents = dispatcher, + ) - behaviours.map { it.observer }.forEach { it?.invoke(receiver) } + behaviours.forEach { it.observe(receiver) } - data - .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } - .launchIn(scope) + data + .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } + .launchIn(scope) - scope.launch { - try { - awaitCancellation() - } finally { - withContext(NonCancellable) { - for (record in context.state.value.trackers.values) { - serverContext.getDevice(record.deviceId)?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, - ) + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + for (record in context.state.value.trackers.values) { + serverContext.getDevice(record.deviceId)?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } + } } } + + return receiver } } - - return receiver -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt new file mode 100644 index 000000000..403c5c817 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -0,0 +1,30 @@ +package dev.slimevr.serial + +internal const val MAX_LOG_LINES = 500 + +object SerialServerBaseBehaviour : SerialServerBehaviour { + override fun reduce(state: SerialServerState, action: SerialServerActions) = when (action) { + is SerialServerActions.PortDetected -> + state.copy(availablePorts = state.availablePorts + (action.info.portLocation to action.info)) + + is SerialServerActions.PortLost -> + state.copy(availablePorts = state.availablePorts - action.portLocation) + + is SerialServerActions.RegisterConnection -> + state.copy(connections = state.connections + (action.portLocation to action.connection)) + + is SerialServerActions.RemoveConnection -> + state.copy(connections = state.connections - action.portLocation) + } +} + +object SerialLogBehaviour : SerialConnectionBehaviour { + override fun reduce(state: SerialConnectionState, action: SerialConnectionActions) = when (action) { + is SerialConnectionActions.LogLine -> { + val lines = if (state.logLines.size >= MAX_LOG_LINES) state.logLines.drop(1) else state.logLines + state.copy(logLines = lines + action.line) + } + + SerialConnectionActions.Disconnected -> state.copy(connected = false) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index 31999de3a..dffa104fd 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -1,12 +1,9 @@ package dev.slimevr.serial +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope -private const val MAX_LOG_LINES = 500 - data class SerialPortHandle( val portLocation: String, val descriptivePortName: String, @@ -27,50 +24,32 @@ sealed interface SerialConnectionActions { } typealias SerialConnectionContext = Context -typealias SerialConnectionBehaviour = - CustomBehaviour +typealias SerialConnectionBehaviour = Behaviour sealed interface SerialConnection { - data class Console( + class Console( val context: SerialConnectionContext, val handle: SerialPortHandle, - ) : SerialConnection + ) : SerialConnection { + companion object { + fun create(handle: SerialPortHandle, scope: CoroutineScope): Console { + val behaviours = listOf(SerialLogBehaviour) + val context = Context.create( + initialState = SerialConnectionState( + portLocation = handle.portLocation, + descriptivePortName = handle.descriptivePortName, + connected = true, + logLines = listOf(), + ), + scope = scope, + behaviours = behaviours, + ) + val conn = Console(context = context, handle = handle) + behaviours.forEach { it.observe(conn) } + return conn + } + } + } data object Flashing : SerialConnection -} - -val SerialLogBehaviour = SerialConnectionBehaviour( - reducer = { s, a -> - when (a) { - is SerialConnectionActions.LogLine -> { - val lines = if (s.logLines.size >= MAX_LOG_LINES) s.logLines.drop(1) else s.logLines - s.copy(logLines = lines + a.line) - } - - SerialConnectionActions.Disconnected -> s.copy(connected = false) - } - }, - observer = null, -) - -fun createSerialConnection( - handle: SerialPortHandle, - scope: CoroutineScope, -): SerialConnection.Console { - val behaviours = listOf(SerialLogBehaviour) - - val context = createContext( - initialState = SerialConnectionState( - portLocation = handle.portLocation, - descriptivePortName = handle.descriptivePortName, - connected = true, - logLines = listOf(), - ), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val conn = SerialConnection.Console(context = context, handle = handle) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } - return conn -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 348061f1c..22d327dc9 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -1,9 +1,8 @@ package dev.slimevr.serial import dev.llelievr.espflashkotlin.FlasherSerialInterface -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import solarxr_protocol.rpc.SerialDevice @@ -35,43 +34,23 @@ sealed interface SerialServerActions { } typealias SerialServerContext = Context -typealias SerialServerBehaviour = BasicBehaviour - -val SerialServerBaseBehaviour = SerialServerBehaviour( - reducer = { s, a -> - when (a) { - is SerialServerActions.PortDetected -> - s.copy(availablePorts = s.availablePorts + (a.info.portLocation to a.info)) - - is SerialServerActions.PortLost -> - s.copy(availablePorts = s.availablePorts - a.portLocation) - - is SerialServerActions.RegisterConnection -> - s.copy(connections = s.connections + (a.portLocation to a.connection)) - - is SerialServerActions.RemoveConnection -> - s.copy(connections = s.connections - a.portLocation) - } - }, - observer = null, -) +typealias SerialServerBehaviour = Behaviour class SerialServer( val context: SerialServerContext, private val scope: CoroutineScope, private val openPortFactory: ( portLocation: String, - scope: CoroutineScope, - onDataReceived: suspend (portLocation: String, line: String) -> Unit, - onPortDisconnected: suspend (portLocation: String) -> Unit, + onDataReceived: (portLocation: String, line: String) -> Unit, + onPortDisconnected: (portLocation: String) -> Unit, ) -> SerialPortHandle?, private val openFlashingPortFactory: () -> FlashingHandler, ) { - suspend fun onPortDetected(info: SerialPortInfo) { + fun onPortDetected(info: SerialPortInfo) { context.dispatch(SerialServerActions.PortDetected(info)) } - suspend fun onPortLost(portLocation: String) { + fun onPortLost(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn is SerialConnection.Console) { conn.handle.close() @@ -80,12 +59,12 @@ class SerialServer( context.dispatch(SerialServerActions.PortLost(portLocation)) } - suspend fun onDataReceived(portLocation: String, line: String) { + fun onDataReceived(portLocation: String, line: String) { val conn = context.state.value.connections[portLocation] if (conn is SerialConnection.Console) conn.context.dispatch(SerialConnectionActions.LogLine(line)) } - suspend fun onPortDisconnected(portLocation: String) { + fun onPortDisconnected(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn !is SerialConnection.Console) return conn.context.dispatch(SerialConnectionActions.Disconnected) @@ -93,14 +72,14 @@ class SerialServer( context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } - suspend fun openConnection(portLocation: String) { + fun openConnection(portLocation: String) { val state = context.state.value if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return - val handle = openPortFactory(portLocation, scope, ::onDataReceived, ::onPortDisconnected) ?: return - context.dispatch(SerialServerActions.RegisterConnection(portLocation, createSerialConnection(handle, scope))) + val handle = openPortFactory(portLocation, ::onDataReceived, ::onPortDisconnected) ?: return + context.dispatch(SerialServerActions.RegisterConnection(portLocation, SerialConnection.Console.create(handle, scope))) } - suspend fun closeConnection(portLocation: String) { + fun closeConnection(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn !is SerialConnection.Console) return conn.context.dispatch(SerialConnectionActions.Disconnected) @@ -108,7 +87,7 @@ class SerialServer( context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } - suspend fun openForFlashing(portLocation: String): FlashingHandler? { + fun openForFlashing(portLocation: String): FlashingHandler? { val state = context.state.value if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return null closeConnection(portLocation) @@ -124,20 +103,15 @@ class SerialServer( companion object { fun create( - openPort: ( - portLocation: String, - scope: CoroutineScope, - onDataReceived: suspend (portLocation: String, line: String) -> Unit, - onPortDisconnected: suspend (portLocation: String) -> Unit, - ) -> SerialPortHandle?, + openPort: (portLocation: String, onDataReceived: (String, String) -> Unit, onPortDisconnected: (String) -> Unit) -> SerialPortHandle?, openFlashingPort: () -> FlashingHandler, scope: CoroutineScope, ): SerialServer { val behaviours = listOf(SerialServerBaseBehaviour) - val context = createContext( + val context = Context.create( initialState = SerialServerState(availablePorts = mapOf(), connections = mapOf()), - reducers = behaviours.map { it.reducer }, scope = scope, + behaviours = behaviours, ) val server = SerialServer( context = context, @@ -145,8 +119,8 @@ class SerialServer( openPortFactory = openPort, openFlashingPortFactory = openFlashingPort, ) - behaviours.map { it.observer }.forEach { it?.invoke(context) } + behaviours.forEach { it.observe(context) } return server } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 4fce6a38e..3ce91ab36 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -84,11 +84,9 @@ fun createDatafeedFrame( index: Int = 0, ): DataFeedMessageHeader { val serverState = serverContext.context.state.value - val trackers = - serverState.trackers.values.map { it.context.state.value } - val devices = - serverState.devices.values.map { it.context.state.value } - .map { device -> createDevice(device, trackers, datafeedConfig) } + val trackers = serverState.trackers.values.map { it.context.state.value } + val devices = serverState.devices.values.map { it.context.state.value } + .map { device -> createDevice(device, trackers, datafeedConfig) } return DataFeedMessageHeader( message = DataFeedUpdate( devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, @@ -97,23 +95,22 @@ fun createDatafeedFrame( ) } -val DataFeedInitBehaviour = SolarXRConnectionBehaviour( - reducer = { s, a -> - when (a) { - is SolarXRConnectionActions.SetConfig -> s.copy( - dataFeedConfigs = a.configs, - datafeedTimers = a.timers, - ) - } - }, - observer = { context -> - context.dataFeedDispatcher.on { event -> +object DataFeedInitBehaviour : SolarXRConnectionBehaviour { + override fun reduce(state: SolarXRConnectionState, action: SolarXRConnectionActions) = when (action) { + is SolarXRConnectionActions.SetConfig -> state.copy( + dataFeedConfigs = action.configs, + datafeedTimers = action.timers, + ) + } + + override fun observe(receiver: SolarXRConnection) { + receiver.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on - context.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } + receiver.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } val timers = datafeeds.mapIndexed { index, config -> - context.context.scope.launch { + receiver.context.scope.launch { val fbb = FlatBufferBuilder(1024) val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { @@ -121,36 +118,33 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(context.serverContext, config, index), + createDatafeedFrame(receiver.serverContext, config, index), ), ).encode(fbb), ) - context.send(fbb.dataBuffer().moveToByteArray()) + receiver.send(fbb.dataBuffer().moveToByteArray()) delay(minTime) } } } - context.context.dispatch( - SolarXRConnectionActions.SetConfig( - datafeeds, - timers = timers, - ), + receiver.context.dispatch( + SolarXRConnectionActions.SetConfig(datafeeds, timers = timers), ) } - context.dataFeedDispatcher.on { event -> + receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on val fbb = FlatBufferBuilder(1024) fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config), + createDatafeedFrame(serverContext = receiver.serverContext, datafeedConfig = config), ), ).encode(fbb), ) - context.send(fbb.dataBuffer().moveToByteArray()) + receiver.send(fbb.dataBuffer().moveToByteArray()) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index a0f8b38e8..c0218676e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.firmware.FirmwareJobStatus +import dev.slimevr.firmware.FirmwareManager import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -12,10 +13,9 @@ import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest import solarxr_protocol.rpc.OTAFirmwareUpdate import solarxr_protocol.rpc.SerialFirmwareUpdate -val FirmwareBehaviour = SolarXRConnectionBehaviour( - observer = { conn -> - val scope = conn.context.scope - val firmwareManager = conn.serverContext.firmwareManager +class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + val scope = receiver.context.scope var prevJobs: Map = firmwareManager.context.state.value.jobs @@ -25,7 +25,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( .onEach { jobs -> jobs.forEach { (portLocation, jobStatus) -> if (prevJobs[portLocation] != jobStatus) { - conn.sendRpc( + receiver.sendRpc( FirmwareUpdateStatusResponse( deviceId = jobStatus.firmwareDeviceId, status = jobStatus.status, @@ -38,7 +38,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( } .launchIn(scope) - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> when (val method = req.method) { is SerialFirmwareUpdate -> { val portLocation = method.deviceId?.port ?: return@on @@ -49,24 +49,24 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( method.needmanualreboot, method.ssid, method.password, - conn.serverContext, + receiver.serverContext, ) } is OTAFirmwareUpdate -> { val deviceId = method.deviceId ?: return@on val part = method.firmwarePart ?: return@on - val device = conn.serverContext.getDevice(deviceId.id.toInt()) ?: return@on + val device = receiver.serverContext.getDevice(deviceId.id.toInt()) ?: return@on val deviceIp = device.context.state.value.address - firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, conn.serverContext) + firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, receiver.serverContext) } else -> return@on } } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { firmwareManager.cancelAll() } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 59e73efed..0992c98b0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -3,9 +3,8 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.EventDispatcher import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -25,36 +24,18 @@ sealed interface SolarXRConnectionActions { } typealias SolarXRConnectionContext = Context -typealias SolarXRConnectionBehaviour = CustomBehaviour +typealias SolarXRConnectionBehaviour = Behaviour -data class SolarXRConnection( +class SolarXRConnection( val context: SolarXRConnectionContext, val serverContext: VRServer, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, - val send: suspend (ByteArray) -> Unit, - val sendRpc: suspend (RpcMessage) -> Unit, -) + private val onSend: suspend (ByteArray) -> Unit, +) { + suspend fun send(bytes: ByteArray) = onSend(bytes) -fun createSolarXRConnection( - serverContext: VRServer, - onSend: suspend (ByteArray) -> Unit, - scope: CoroutineScope, -): SolarXRConnection { - val state = SolarXRConnectionState( - dataFeedConfigs = listOf(), - datafeedTimers = listOf(), - ) - - val behaviours = listOf(DataFeedInitBehaviour, SerialConsoleBehaviour, FirmwareBehaviour, VRCBehaviour) - - val context = createContext( - initialState = state, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val sendRpc: suspend (RpcMessage) -> Unit = { message -> + suspend fun sendRpc(message: RpcMessage) { val fbb = FlatBufferBuilder(256) fbb.finish( MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message))).encode(fbb), @@ -62,16 +43,29 @@ fun createSolarXRConnection( onSend(fbb.dataBuffer().moveToByteArray()) } - val conn = SolarXRConnection( - context = context, - serverContext = serverContext, - dataFeedDispatcher = EventDispatcher(), - rpcDispatcher = EventDispatcher(), - send = onSend, - sendRpc = sendRpc, - ) + companion object { + fun create( + serverContext: VRServer, + onSend: suspend (ByteArray) -> Unit, + scope: CoroutineScope, + behaviours: List, + ): SolarXRConnection { + val context = Context.create( + initialState = SolarXRConnectionState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + scope = scope, + behaviours = behaviours, + ) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } + val conn = SolarXRConnection( + context = context, + serverContext = serverContext, + dataFeedDispatcher = EventDispatcher(), + rpcDispatcher = EventDispatcher(), + onSend = onSend, + ) - return conn -} + behaviours.forEach { it.observe(conn) } + return conn + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index a666b6803..99332c7f5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -2,6 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.serial.SerialServer import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -21,13 +22,11 @@ import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest import solarxr_protocol.rpc.SerialTrackerRebootRequest import solarxr_protocol.rpc.SerialUpdateResponse -val SerialConsoleBehaviour = SolarXRConnectionBehaviour( - observer = { conn -> - val scope = conn.context.scope - val serialServer = conn.serverContext.serialServer +class SerialBehaviour(private val serialServer: SerialServer) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + val scope = receiver.context.scope - // We assume that you can only subscribe to one serial console - // at a time + // We assume that you can only subscribe to one serial console at a time var logSubscription: Job? = null var activePortLocation: String? = null @@ -40,14 +39,14 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( .distinctUntilChanged() .onEach { ports -> (ports.keys - prevPortKeys).forEach { key -> - conn.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) + receiver.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) } prevPortKeys = ports.keys.toSet() } .launchIn(scope) - conn.rpcDispatcher.on { - conn.sendRpc( + receiver.rpcDispatcher.on { + receiver.sendRpc( SerialDevicesResponse( devices = serialServer.context.state.value.availablePorts.values .map { it.toSerialDevice() }, @@ -55,7 +54,7 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( ) } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val portLocation = if (req.auto == true) { serialServer.context.state.value.availablePorts.keys.firstOrNull() } else { @@ -80,54 +79,54 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( if (disconnected) return@collect connState.logLines.drop(lastSentCount).forEach { line -> - conn.sendRpc(SerialUpdateResponse(log = line + "\n")) + receiver.sendRpc(SerialUpdateResponse(log = line + "\n")) } lastSentCount = connState.logLines.size if (!connState.connected) { disconnected = true activePortLocation = null - conn.sendRpc(SerialUpdateResponse(closed = true)) + receiver.sendRpc(SerialUpdateResponse(closed = true)) } } } } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { logSubscription?.cancel() logSubscription = null activePortLocation = null } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("REBOOT") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET INFO") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("FRST") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET WIFISCAN") } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val portLocation = activePortLocation ?: return@on val command = req.command ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand(command) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index f74dcf6f8..ff5ed5d41 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.vrchat.VRCConfigActions +import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrchat.computeRecommendedValues import dev.slimevr.vrchat.computeValidity import kotlinx.coroutines.flow.drop @@ -10,15 +11,16 @@ import solarxr_protocol.rpc.VRCConfigSettingToggleMute import solarxr_protocol.rpc.VRCConfigStateChangeResponse import solarxr_protocol.rpc.VRCConfigStateRequest -val VRCBehaviour = SolarXRConnectionBehaviour( - observer = { conn -> - val vrcManager = conn.serverContext.vrcConfigManager - +class VrcBehaviour( + private val vrcManager: VRCConfigManager, + private val userHeight: () -> Double, +) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { fun buildCurrentResponse(): VRCConfigStateChangeResponse { val state = vrcManager.context.state.value val values = state.currentValues if (!state.isSupported || values == null) return VRCConfigStateChangeResponse(isSupported = false) - val recommended = computeRecommendedValues(conn.serverContext, vrcManager.userHeight()) + val recommended = computeRecommendedValues(receiver.serverContext, userHeight()) return VRCConfigStateChangeResponse( isSupported = true, validity = computeValidity(values, recommended), @@ -28,20 +30,18 @@ val VRCBehaviour = SolarXRConnectionBehaviour( ) } - // Note here that we drop the first one here - // that is because we don't need the initial value - // we just want to send new response when the vrch config change + // Drop the initial value — we only want to push updates when the config changes vrcManager.context.state.drop(1).onEach { - conn.sendRpc(buildCurrentResponse()) - }.launchIn(conn.context.scope) + receiver.sendRpc(buildCurrentResponse()) + }.launchIn(receiver.context.scope) - conn.rpcDispatcher.on { - conn.sendRpc(buildCurrentResponse()) + receiver.rpcDispatcher.on { + receiver.sendRpc(buildCurrentResponse()) } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val key = req.key ?: return@on vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key)) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index 179d6001a..5e3e053f0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -30,19 +30,20 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { } } -suspend fun createSolarXRWebsocketServer(serverContext: VRServer) { +suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) routing { webSocket { AppLogger.solarxr.info("[WS] New connection") - val solarxrConnection = createSolarXRConnection( + val solarxrConnection = SolarXRConnection.create( serverContext, scope = this, onSend = { send(Frame.Binary(fin = true, data = it)) }, + behaviours = behaviours, ) for (frame in incoming) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt new file mode 100644 index 000000000..ff2bab5a8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -0,0 +1,15 @@ +package dev.slimevr.tracker + +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object TrackerInfosBehaviour : TrackerBehaviour { + override fun reduce(state: TrackerState, action: TrackerActions) = + if (action is TrackerActions.Update) action.transform(state) else state + + override fun observe(receiver: TrackerContext) { + receiver.state.onEach { +// AppLogger.tracker.info("Tracker state changed {State}", it) + }.launchIn(receiver.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 09e38f1df..5a4d1d29f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,14 +1,10 @@ package dev.slimevr.tracker -import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.hardware_info.ImuType @@ -31,53 +27,36 @@ sealed interface TrackerActions { } typealias TrackerContext = Context -typealias TrackerBehaviour = BasicBehaviour +typealias TrackerBehaviour = Behaviour -data class Tracker( +class Tracker( val context: TrackerContext, -) +) { + companion object { + fun create( + scope: CoroutineScope, + id: Int, + deviceId: Int, + sensorType: ImuType, + hardwareId: String, + origin: DeviceOrigin, + ): Tracker { + val trackerState = TrackerState( + id = id, + hardwareId = hardwareId, + name = "Tracker #$id", + rawRotation = Quaternion.IDENTITY, + bodyPart = null, + origin = origin, + deviceId = deviceId, + customName = null, + sensorType = sensorType, + ) -val TrackerInfosBehaviour = TrackerBehaviour( - reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, - observer = { - it.state.onEach { state -> -// AppLogger.tracker.info("Tracker state changed {State}", state) - }.launchIn(it.scope) - }, -) - -fun createTracker( - scope: CoroutineScope, - id: Int, - deviceId: Int, - sensorType: ImuType, - hardwareId: String, - origin: DeviceOrigin, - serverContext: VRServer, -): Tracker { - val trackerState = TrackerState( - id = id, - hardwareId = hardwareId, - name = "Tracker #$id", - rawRotation = Quaternion.IDENTITY, - bodyPart = null, - origin = origin, - deviceId = deviceId, - customName = null, - sensorType = sensorType, - ) - - val behaviours = listOf(TrackerInfosBehaviour) - - val context = createContext( - initialState = trackerState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - - return Tracker( - context = context, - ) -} + val behaviours = listOf(TrackerInfosBehaviour) + val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) + behaviours.forEach { it.observe(context) } + return Tracker(context = context) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt new file mode 100644 index 000000000..eecada377 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -0,0 +1,228 @@ +package dev.slimevr.udp + +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.TrackerIdNum +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.TrackerStatus + +internal const val CONNECTION_TIMEOUT_MS = 5000L + +object PacketBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.LastPacket -> { + var newState = state.copy(lastPacket = action.time) + if (action.packetNum != null) newState = newState.copy(lastPacketNum = action.packetNum) + newState + } + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onAny { packet -> + val state = receiver.context.state.value + val now = System.currentTimeMillis() + if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { + receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) + AppLogger.udp.info("Reconnecting") + } else if (packet.packetNumber < state.lastPacketNum) { + AppLogger.udp.warn("WARN: Received packet with wrong packet number") + return@onAny + } else { + receiver.context.dispatch(UDPConnectionActions.LastPacket(time = now)) + } + } + } +} + +object PingBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime)) + is UDPConnectionActions.ReceivedPong -> state.copy(lastPing = state.lastPing.copy(duration = action.duration, id = action.id)) + else -> state + } + + override fun observe(receiver: UDPConnection) { + // Send the ping every 1s + receiver.context.scope.launch { + while (isActive) { + val state = receiver.context.state.value + if (state.didHandshake) { + receiver.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) + receiver.send(PingPong(state.lastPing.id + 1)) + } + delay(1000) + } + } + + // listen for the pong + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + val deviceId = state.deviceId ?: return@onPacket + + if (packet.data.pingId != state.lastPing.id + 1) { + AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") + return@onPacket + } + + val ping = System.currentTimeMillis() - state.lastPing.startTime + val device = receiver.serverContext.getDevice(deviceId) ?: return@onPacket + + receiver.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.data.pingId, duration = ping)) + device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) + } + } +} + +object HandshakeBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.Handshake -> state.copy(didHandshake = true, deviceId = action.deviceId) + is UDPConnectionActions.Disconnected -> state.copy(didHandshake = false) + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + + val device = if (state.deviceId == null) { + val deviceId = receiver.serverContext.nextHandle() + val newDevice = Device.create( + id = deviceId, + scope = receiver.serverContext.context.scope, + address = state.address, + macAddress = packet.data.macString, + origin = DeviceOrigin.UDP, + protocolVersion = packet.data.protocolVersion, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + receiver.context.dispatch(UDPConnectionActions.Handshake(deviceId)) + newDevice + } else { + receiver.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) + receiver.getDevice() ?: run { + AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") + receiver.send(Handshake()) + return@onPacket + } + } + + // Apply handshake fields to device, always, for both first connect and reconnect + device.context.dispatch( + DeviceActions.Update { + copy( + macAddress = packet.data.macString ?: macAddress, + boardType = packet.data.boardType, + mcuType = packet.data.mcuType, + firmware = packet.data.firmware ?: firmware, + protocolVersion = packet.data.protocolVersion, + ) + }, + ) + + receiver.send(Handshake()) + } + } +} + +object TimeoutBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.context.scope.launch { + while (isActive) { + val state = receiver.context.state.value + if (!state.didHandshake) { + delay(500) + continue + } + val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) + if (timeUntilTimeout <= 0) { + AppLogger.udp.info("Connection timed out for ${state.id}") + receiver.context.dispatch(UDPConnectionActions.Disconnected) + receiver.getDevice()?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } else { + delay(timeUntilTimeout + 1) + } + } + } + } +} + +object DeviceStatsBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() ?: return@onPacket + device.context.dispatch( + DeviceActions.Update { + copy(batteryLevel = event.data.level, batteryVoltage = event.data.voltage) + }, + ) + } + + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() ?: return@onPacket + device.context.dispatch(DeviceActions.Update { copy(signalStrength = event.data.signal) }) + } + } +} + +object SensorInfoBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.AssignTracker -> state.copy(trackerIds = state.trackerIds + action.trackerId) + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() + ?: error("invalid state - a device should exist at this point") + + device.context.dispatch(DeviceActions.Update { copy(status = event.data.status) }) + + val tracker = receiver.getTracker(event.data.sensorId) + val action = TrackerActions.Update { copy(sensorType = event.data.imuType) } + + if (tracker != null) { + tracker.context.dispatch(action) + } else { + val deviceState = device.context.state.value + val trackerId = receiver.serverContext.nextHandle() + val newTracker = Tracker.create( + id = trackerId, + hardwareId = "${deviceState.address}:${event.data.sensorId}", + sensorType = event.data.imuType, + deviceId = deviceState.id, + origin = DeviceOrigin.UDP, + scope = receiver.serverContext.context.scope, + ) + + receiver.serverContext.context.dispatch( + VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), + ) + receiver.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId), + ), + ) + newTracker.context.dispatch(action) + } + } + } +} + +object SensorRotationBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 1d3cc00a0..f716003f0 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -1,29 +1,18 @@ package dev.slimevr.udp -import dev.slimevr.AppLogger import dev.slimevr.EventDispatcher import dev.slimevr.VRServer -import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import dev.slimevr.device.Device -import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum -import dev.slimevr.device.createDevice -import dev.slimevr.tracker.createTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray -import solarxr_protocol.datatypes.TrackerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -56,316 +45,35 @@ sealed interface UDPConnectionActions { } typealias UDPConnectionContext = Context -typealias UDPConnectionBehaviour = CustomBehaviour +typealias UDPConnectionBehaviour = Behaviour -private const val CONNECTION_TIMEOUT_MS = 5000L - -val PacketBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.LastPacket -> { - var newState = s.copy(lastPacket = a.time) - - if (a.packetNum != null) { - newState = newState.copy(lastPacketNum = a.packetNum) - } - - newState - } - - else -> s - } - }, - observer = { - it.packetEvents.onAny { packet -> - val state = it.context.state.value - - val now = System.currentTimeMillis() - if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { - it.context.dispatch( - UDPConnectionActions.LastPacket( - packetNum = 0, - time = now, - ), - ) - AppLogger.udp.info("Reconnecting") - } else if (packet.packetNumber < state.lastPacketNum) { - AppLogger.udp.warn("WARN: Received packet with wrong packet number") - return@onAny - } else { - it.context.dispatch(UDPConnectionActions.LastPacket(time = now)) - } - } - }, -) - -val PingBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.StartPing -> { - s.copy(lastPing = s.lastPing.copy(startTime = a.startTime)) - } - - is UDPConnectionActions.ReceivedPong -> { - s.copy(lastPing = s.lastPing.copy(duration = a.duration, id = a.id)) - } - - else -> s - } - }, - observer = { - // Send the ping every 1s - it.context.scope.launch { - while (isActive) { - val state = it.context.state.value - if (state.didHandshake) { - it.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) - it.send(PingPong(state.lastPing.id + 1)) - } - delay(1000) - } - } - - // listen for the pong - it.packetEvents.onPacket { packet -> - val state = it.context.state.value - val deviceId = state.deviceId ?: return@onPacket - - if (packet.data.pingId != state.lastPing.id + 1) { - AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") - return@onPacket - } - - val ping = System.currentTimeMillis() - state.lastPing.startTime - - val device = it.serverContext.getDevice(deviceId) ?: return@onPacket - - it.context.dispatch( - UDPConnectionActions.ReceivedPong( - id = packet.data.pingId, - duration = ping, - ), - ) - device.context.dispatch( - DeviceActions.Update { - copy(ping = ping) - }, - ) - } - }, -) - -val HandshakeBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.Handshake -> s.copy( - didHandshake = true, - deviceId = a.deviceId, - ) - - is UDPConnectionActions.Disconnected -> s.copy( - didHandshake = false, - ) - - else -> s - } - }, - observer = { - it.packetEvents.onPacket { packet -> - val state = it.context.state.value - - val device = if (state.deviceId == null) { - val deviceId = it.serverContext.nextHandle() - val newDevice = createDevice( - id = deviceId, - scope = it.serverContext.context.scope, - address = state.address, - macAddress = packet.data.macString, - origin = DeviceOrigin.UDP, - protocolVersion = packet.data.protocolVersion, - serverContext = it.serverContext, - ) - it.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) - it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - newDevice - } else { - it.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) - it.getDevice() ?: run { - AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") - it.send(Handshake()) - return@onPacket - } - } - - // Apply handshake fields to device, always, for both first connect and reconnect - device.context.dispatch( - DeviceActions.Update { - copy( - macAddress = packet.data.macString ?: macAddress, - boardType = packet.data.boardType, - mcuType = packet.data.mcuType, - firmware = packet.data.firmware ?: firmware, - protocolVersion = packet.data.protocolVersion, - ) - }, - ) - - it.send(Handshake()) - } - }, -) - -val TimeoutBehaviour = UDPConnectionBehaviour( - observer = { - it.context.scope.launch { - while (isActive) { - val state = it.context.state.value - if (!state.didHandshake) { - delay(500) - continue - } - val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) - if (timeUntilTimeout <= 0) { - AppLogger.udp.info("Connection timed out for ${state.id}") - it.context.dispatch(UDPConnectionActions.Disconnected) - it.getDevice()?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, - ) - } else { - delay(timeUntilTimeout + 1) - } - } - } - }, -) - -val DeviceStatsBehaviour = UDPConnectionBehaviour( - observer = { - it.packetEvents.onPacket { event -> - val device = it.getDevice() ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy( - batteryLevel = event.data.level, - batteryVoltage = event.data.voltage, - ) - }, - ) - } - - it.packetEvents.onPacket { event -> - val device = it.getDevice() ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy(signalStrength = event.data.signal) - }, - ) - } - }, -) - -val SensorInfoBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.AssignTracker -> { - s.copy(trackerIds = s.trackerIds + a.trackerId) - } - - else -> s - } - }, - observer = { observerContext -> - observerContext.packetEvents.onPacket { event -> - val device = observerContext.getDevice() - ?: error("invalid state - a device should exist at this point") - - device.context.dispatch( - DeviceActions.Update { - copy(status = event.data.status) - }, - ) - - val tracker = observerContext.getTracker(event.data.sensorId) - - val action = TrackerActions.Update { - copy( - sensorType = event.data.imuType, - ) - } - - if (tracker != null) { - tracker.context.dispatch(action) - } else { - val deviceState = device.context.state.value - val trackerId = observerContext.serverContext.nextHandle() - val newTracker = createTracker( - id = trackerId, - hardwareId = "${deviceState.address}:${event.data.sensorId}", - sensorType = event.data.imuType, - deviceId = deviceState.id, - origin = DeviceOrigin.UDP, - serverContext = observerContext.serverContext, - scope = observerContext.serverContext.context.scope, - ) - - observerContext.serverContext.context.dispatch( - VRServerActions.NewTracker( - trackerId = trackerId, - context = newTracker, - ), - ) - observerContext.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum( - id = trackerId, - trackerNum = event.data.sensorId, - ), - ), - ) - newTracker.context.dispatch(action) - } - } - }, -) - -val SensorRotationBehaviour = UDPConnectionBehaviour( - observer = { context -> - context.packetEvents.onPacket { event -> - val tracker = context.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch( - TrackerActions.Update { - copy(rawRotation = event.data.rotation) - }, - ) - } - }, -) - -data class UDPConnection( +class UDPConnection( val context: UDPConnectionContext, val serverContext: VRServer, val packetEvents: UDPPacketDispatcher, val packetChannel: Channel>, - val send: (UDPPacket) -> Unit, + private val socket: DatagramSocket, + private val remoteInetAddress: InetAddress, + private val remotePort: Int, + private val scope: CoroutineScope, ) { + fun send(packet: UDPPacket) { + scope.launch(Dispatchers.IO) { + val buf = Buffer() + writePacket(buf, packet) + val bytes = buf.readByteArray() + socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) + } + } + fun getDevice(): Device? { val deviceId = context.state.value.deviceId - return if (deviceId != null) { - serverContext.getDevice(deviceId) - } else { - null - } + return if (deviceId != null) serverContext.getDevice(deviceId) else null } fun getTracker(id: Int): Tracker? { val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - return if (trackerId != null) { - serverContext.getTracker(trackerId.id) - } else { - null - } + return if (trackerId != null) serverContext.getTracker(trackerId.id) else null } companion object { @@ -387,7 +95,7 @@ data class UDPConnection( SensorRotationBehaviour, ) - val context = createContext( + val context = Context.create( initialState = UDPConnectionState( id = id, lastPacket = System.currentTimeMillis(), @@ -399,8 +107,8 @@ data class UDPConnection( deviceId = null, trackerIds = listOf(), ), - reducers = behaviours.map { it.reducer }, scope = scope, + behaviours = behaviours, ) val dispatcher = EventDispatcher> { it.data::class } @@ -410,19 +118,15 @@ data class UDPConnection( val conn = UDPConnection( context = context, serverContext = serverContext, - dispatcher, + packetEvents = dispatcher, packetChannel = packetChannel, - send = { packet: UDPPacket -> - scope.launch(Dispatchers.IO) { - val buf = Buffer() - writePacket(buf, packet) - val bytes = buf.readByteArray() - socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) - } - }, + socket = socket, + remoteInetAddress = remoteInetAddress, + remotePort = remotePort, + scope = scope, ) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } + behaviours.forEach { it.observe(conn) } // Dedicated coroutine per connection so the receive loop is never blocked by packet processing scope.launch { @@ -440,4 +144,4 @@ data class UDPConnection( return conn } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 649d14549..2130b37c1 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -13,7 +13,7 @@ import java.net.DatagramPacket import java.net.DatagramSocket import kotlin.time.measureTime -data class UDPTrackerServerState( +class UDPTrackerServerState( val port: Int, val connections: MutableMap, ) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt new file mode 100644 index 000000000..7128f8166 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt @@ -0,0 +1,26 @@ +package dev.slimevr.vrchat + +import dev.slimevr.config.SettingsActions +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +object DefaultVRCConfigBehaviour : VRCConfigBehaviour { + override fun reduce(state: VRCConfigState, action: VRCConfigActions) = when (action) { + is VRCConfigActions.UpdateValues -> state.copy(currentValues = action.values) + is VRCConfigActions.ToggleMutedWarning -> { + if (action.key !in VRC_VALID_KEYS) state + else if (action.key in state.mutedWarnings) state.copy(mutedWarnings = state.mutedWarnings - action.key) + else state.copy(mutedWarnings = state.mutedWarnings + action.key) + } + } + + override fun observe(receiver: VRCConfigManager) { + receiver.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> + receiver.config.settings.context.dispatch(SettingsActions.Update { + copy(mutedVRCWarnings = warnings) + }) + }.launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 676316425..a4b733e4f 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -2,17 +2,10 @@ package dev.slimevr.vrchat import dev.slimevr.VRServer import dev.slimevr.config.AppConfig -import dev.slimevr.config.SettingsActions -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.VRCAvatarMeasurementType @@ -47,34 +40,41 @@ sealed interface VRCConfigActions { } typealias VRCConfigContext = Context -typealias VRCConfigBehaviour = CustomBehaviour +typealias VRCConfigBehaviour = Behaviour -data class VRCConfigManager( +class VRCConfigManager( val context: VRCConfigContext, val config: AppConfig, - val userHeight: () -> Double, -) +) { + companion object { + fun create( + config: AppConfig, + scope: CoroutineScope, + isSupported: Boolean, + values: Flow, + ): VRCConfigManager { + val behaviours = listOf(DefaultVRCConfigBehaviour) -val DefaultVRCConfigBehaviour = VRCConfigBehaviour( - reducer = { s, a -> - when (a) { - is VRCConfigActions.UpdateValues -> s.copy(currentValues = a.values) - is VRCConfigActions.ToggleMutedWarning -> { - if (a.key !in VRC_VALID_KEYS) s - else if (a.key in s.mutedWarnings) s.copy(mutedWarnings = s.mutedWarnings - a.key) - else s.copy(mutedWarnings = s.mutedWarnings + a.key) + val context = Context.create( + initialState = VRCConfigState( + currentValues = null, + isSupported = isSupported, + mutedWarnings = listOf(), + ), + scope = scope, + behaviours = behaviours, + ) + + scope.launch { + values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } - } - }, - observer = { context -> - context.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> - context.config.settings.context.dispatch(SettingsActions.Update { - copy(mutedVRCWarnings = warnings) - }) - }.launchIn(scope = context.context.scope) + val manager = VRCConfigManager(context = context, config = config) + behaviours.forEach { it.observe(manager) } + return manager + } } -) +} fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRecommendedValues { val trackers = server.context.state.value.trackers.values @@ -117,35 +117,4 @@ fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedVa calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType, shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, - ) - -fun createVRCConfigManager( - config: AppConfig, - scope: CoroutineScope, - userHeight: () -> Double, - isSupported: Boolean, - values: Flow, -): VRCConfigManager { - val modules = listOf(DefaultVRCConfigBehaviour) - - val initialState = VRCConfigState( - currentValues = null, - isSupported = isSupported, - mutedWarnings = listOf(), - ) - - val context = createContext( - initialState = initialState, - reducers = modules.map { it.reducer }, - scope = scope, - ) - - scope.launch { - values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } - } - - val manager = VRCConfigManager(context = context, userHeight = userHeight, config = config) - modules.map { it.observer }.forEach { it?.invoke(manager) } - - return manager -} + ) \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index f454bcc76..5c1507dc5 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,17 +1,10 @@ package dev.slimevr +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import dev.slimevr.device.Device -import dev.slimevr.firmware.FirmwareManager -import dev.slimevr.serial.SerialServer import dev.slimevr.tracker.Tracker -import dev.slimevr.vrchat.VRCConfigManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -27,32 +20,11 @@ sealed interface VRServerActions { } typealias VRServerContext = Context -typealias VRServerBehaviour = CustomBehaviour - -val BaseBehaviour = VRServerBehaviour( - reducer = { s, a -> - when (a) { - is VRServerActions.NewTracker -> s.copy(trackers = s.trackers + (a.trackerId to a.context)) - is VRServerActions.NewDevice -> s.copy(devices = s.devices + (a.deviceId to a.context)) - } - }, - observer = { context -> - context.context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach { - println("tracker list size changed") - }.launchIn(context.context.scope) - - context.serialServer.context.state.distinctUntilChangedBy { state -> state.availablePorts.size }.onEach { - println("Avalable ports $it") - }.launchIn(context.context.scope) - }, -) +typealias VRServerBehaviour = Behaviour @OptIn(ExperimentalAtomicApi::class) -data class VRServer( +class VRServer( val context: VRServerContext, - val serialServer: SerialServer, - val firmwareManager: FirmwareManager, - val vrcConfigManager: VRCConfigManager, // Moved this outside of the context to make this faster and safer to use private val handleCounter: AtomicInt, @@ -62,35 +34,15 @@ data class VRServer( fun getDevice(id: Int) = context.state.value.devices[id] companion object { - fun create( - scope: CoroutineScope, - serialServer: SerialServer, - firmwareManager: FirmwareManager, - vrcConfigManager: VRCConfigManager, - ): VRServer { - val state = VRServerState( - trackers = mapOf(), - devices = mapOf(), - ) - + fun create(scope: CoroutineScope): VRServer { val behaviours = listOf(BaseBehaviour) - - val context = createContext( - initialState = state, - reducers = behaviours.map { it.reducer }, + val context = Context.create( + initialState = VRServerState(trackers = mapOf(), devices = mapOf()), scope = scope, + behaviours = behaviours, ) - - val server = VRServer( - context = context, - serialServer = serialServer, - firmwareManager = firmwareManager, - vrcConfigManager = vrcConfigManager, - handleCounter = AtomicInt(0), - ) - - behaviours.map { it.observer }.forEach { it?.invoke(server) } - + val server = VRServer(context = context, handleCounter = AtomicInt(0)) + behaviours.forEach { it.observe(server) } return server } } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 0d993063d..d508baea1 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,15 +1,12 @@ package dev.slimevr import dev.llelievr.espflashkotlin.FlasherSerialInterface -import dev.slimevr.firmware.createFirmwareManager import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer -import dev.slimevr.vrchat.createVRCConfigManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.emptyFlow fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( - openPort = { loc, _, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, + openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, openFlashingPort = { object : FlasherSerialInterface { override fun openSerial(port: Any) = Unit @@ -27,14 +24,4 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( scope = scope, ) -fun buildTestVrServer(scope: CoroutineScope): VRServer { - val serialServer = buildTestSerialServer(scope) - return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope), - createVRCConfigManager( - scope = scope, - userHeight = { 1.6 }, - isSupported = false, - values = emptyFlow(), - ) - ) -} +fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 8deff7c9a..33f751ed0 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -8,8 +8,7 @@ import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer import dev.slimevr.device.DeviceOrigin -import dev.slimevr.device.createDevice -import dev.slimevr.vrchat.createVRCConfigManager +import dev.slimevr.device.Device import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -50,7 +49,7 @@ private fun buildSerialServer( scope: kotlinx.coroutines.CoroutineScope, flashHandler: () -> FlasherSerialInterface = ::failingFlashHandler, ) = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = flashHandler, scope = scope, ) @@ -59,18 +58,9 @@ private fun buildSerialServer( // backgroundScope lets those run on the test scheduler but doesn't cause // UncompletedCoroutinesError when the test ends. private fun buildVrServer( - mainScope: kotlinx.coroutines.CoroutineScope, backgroundScope: kotlinx.coroutines.CoroutineScope, - serialServer: SerialServer, ): VRServer { - val firmwareManager = createFirmwareManager(serialServer, mainScope) - val vrcConfigManager = createVRCConfigManager( - scope = mainScope, - userHeight = { 1.6 }, - isSupported = false, - values = kotlinx.coroutines.flow.emptyFlow(), - ) // FIXME this is annoying. we need to find better - return VRServer.create(backgroundScope, serialServer, firmwareManager, vrcConfigManager) + return VRServer.create(backgroundScope) } class DoSerialFlashTest { @@ -87,7 +77,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -109,7 +99,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -130,7 +120,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -151,7 +141,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) @@ -173,7 +163,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -199,7 +189,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -229,7 +219,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -261,7 +251,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -286,7 +276,7 @@ class DoSerialFlashTest { val server = buildSerialServer(this) server.onPortDetected(fakePort()) server.openConnection("COM1") - val vrServer = buildVrServer(this, backgroundScope, server) + val vrServer = buildVrServer(backgroundScope) val statuses = mutableListOf() launch { @@ -307,14 +297,13 @@ class DoSerialFlashTest { delay(200) server.onDataReceived("COM1", "looking for the server") delay(300) - val device = createDevice( + val device = Device.create( backgroundScope, id = vrServer.nextHandle(), address = "192.168.1.100", macAddress = "AA:BB:CC:DD:EE:FF", origin = DeviceOrigin.UDP, protocolVersion = 0, - serverContext = vrServer, ) vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index 9f8c4d400..a6cf46443 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -1,6 +1,6 @@ package dev.slimevr.firmware.reducers -import dev.slimevr.context.createContext +import dev.slimevr.context.Context import dev.slimevr.firmware.FirmwareManagerActions import dev.slimevr.firmware.FirmwareManagerBaseBehaviour import dev.slimevr.firmware.FirmwareManagerState @@ -21,9 +21,9 @@ private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int ) class FirmwareManagerReducerTest { - private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext( + private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), - reducers = listOf(FirmwareManagerBaseBehaviour.reducer), + behaviours = listOf(FirmwareManagerBaseBehaviour), scope = scope, ) diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt index 705a30dab..1197d9f9f 100644 --- a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -34,7 +34,7 @@ class SerialServerTest { @Test fun `openForFlashing registers Flashing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -49,7 +49,7 @@ class SerialServerTest { @Test fun `openForFlashing returns null when port has an existing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -64,7 +64,7 @@ class SerialServerTest { @Test fun `openForFlashing returns null for unknown port`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -79,7 +79,7 @@ class SerialServerTest { @Test fun `closeSerial removes Flashing connection asynchronously`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -100,7 +100,7 @@ class SerialServerTest { @Test fun `openConnection registers Console connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -114,7 +114,7 @@ class SerialServerTest { @Test fun `onPortLost closes Console and removes connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -130,7 +130,7 @@ class SerialServerTest { @Test fun `openConnection while flashing is a no-op`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -147,7 +147,7 @@ class SerialServerTest { @Test fun `port can be flashed again after previous flash completes`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -167,7 +167,7 @@ class SerialServerTest { @Test fun `openConnection succeeds after flash completes`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -184,7 +184,7 @@ class SerialServerTest { @Test fun `onPortLost during flash removes Flashing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt index 1eaa0401a..1bd3b9ccb 100644 --- a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -8,8 +8,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse class SerialConnectionReducerTest { - private val reducer = SerialLogBehaviour.reducer!! - private fun state(lines: List = emptyList(), connected: Boolean = true) = SerialConnectionState( portLocation = "COM1", descriptivePortName = "Test Port", @@ -19,20 +17,20 @@ class SerialConnectionReducerTest { @Test fun `LogLine appends to empty log`() { - val result = reducer(state(), SerialConnectionActions.LogLine("hello")) + val result = SerialLogBehaviour.reduce(state(), SerialConnectionActions.LogLine("hello")) assertEquals(listOf("hello"), result.logLines) } @Test fun `LogLine appends to existing log`() { - val result = reducer(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) + val result = SerialLogBehaviour.reduce(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) assertEquals(listOf("a", "b", "c"), result.logLines) } @Test fun `LogLine drops oldest line when at capacity`() { val full = state(lines = List(500) { "line $it" }) - val result = reducer(full, SerialConnectionActions.LogLine("new")) + val result = SerialLogBehaviour.reduce(full, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 1", result.logLines.first()) assertEquals("new", result.logLines.last()) @@ -41,14 +39,14 @@ class SerialConnectionReducerTest { @Test fun `LogLine does not drop below capacity`() { val almostFull = state(lines = List(499) { "line $it" }) - val result = reducer(almostFull, SerialConnectionActions.LogLine("new")) + val result = SerialLogBehaviour.reduce(almostFull, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 0", result.logLines.first()) } @Test fun `Disconnected sets connected to false`() { - val result = reducer(state(connected = true), SerialConnectionActions.Disconnected) + val result = SerialLogBehaviour.reduce(state(connected = true), SerialConnectionActions.Disconnected) assertFalse(result.connected) } } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 7d0cc17db..91d784296 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -10,6 +10,14 @@ import solarxr_protocol.data_feed.StartDataFeed import kotlin.test.Test import kotlin.test.assertEquals +private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit) = + SolarXRConnection.create( + buildTestVrServer(backgroundScope), + onSend = onSend, + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour), + ) + private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort()) @OptIn(ExperimentalCoroutinesApi::class) @@ -18,7 +26,7 @@ class DataFeedTest { @Test fun `StartDataFeed sends frames at the configured interval`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) @@ -30,7 +38,7 @@ class DataFeedTest { @Test fun `StartDataFeed with multiple configs runs each at its own frequency`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100), config(200)))) @@ -43,7 +51,7 @@ class DataFeedTest { @Test fun `PollDataFeed sends exactly one frame without starting a repeating timer`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(PollDataFeed(config = config(100))) @@ -54,7 +62,7 @@ class DataFeedTest { @Test fun `StartDataFeed cancels old timers when called a second time`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) @@ -70,7 +78,7 @@ class DataFeedTest { @Test fun `StartDataFeed with empty list stops all existing timers`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt index 11f2c740c..2d09303d7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -3,13 +3,17 @@ package dev.slimevr.desktop import dev.slimevr.VRServer -import dev.slimevr.config.createAppConfig +import dev.slimevr.config.AppConfig import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager -import dev.slimevr.firmware.createFirmwareManager +import dev.slimevr.firmware.FirmwareManager import dev.slimevr.resolveConfigDirectory +import dev.slimevr.solarxr.DataFeedInitBehaviour +import dev.slimevr.solarxr.FirmwareBehaviour +import dev.slimevr.solarxr.SerialBehaviour +import dev.slimevr.solarxr.VrcBehaviour import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.udp.createUDPTrackerServer import kotlinx.coroutines.launch @@ -17,27 +21,29 @@ import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") - val config = createAppConfig(this, configFolder = configFolder.toFile()) + val config = AppConfig.create(this, configFolder = configFolder.toFile()) + val server = VRServer.create(this) + val serialServer = createDesktopSerialServer(this) - val firmwareManager = createFirmwareManager(serialServer = serialServer, scope = this) - val vrcConfigManager = createDesktopVRCConfigManager( - config = config, - scope = this, - userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }, - ) - val server = VRServer.create(this, serialServer, firmwareManager, vrcConfigManager) + val firmwareManager = FirmwareManager.create(serialServer = serialServer, scope = this) + + val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) launch { createUDPTrackerServer(server, config) } - launch { - createSolarXRWebsocketServer(server) - } - launch { - createIpcServers(server) - } launch { createDesktopHIDManager(server, this) } + + val solarXRBehaviours = listOf( + DataFeedInitBehaviour, + SerialBehaviour(serialServer), + FirmwareBehaviour(firmwareManager), + VrcBehaviour(vrcConfigManager, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), + ) + launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } + launch { createIpcServers(server, solarXRBehaviours) } + Unit -} +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index ea4c9ce17..5d93560d8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -6,7 +6,6 @@ import dev.slimevr.hid.HID_TRACKER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID import dev.slimevr.hid.HIDReceiver -import dev.slimevr.hid.createHIDReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -102,7 +101,7 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { } } - val receiver = createHIDReceiver( + val receiver = HIDReceiver.create( serialNumber = serial, data = dataFlow, serverContext = serverContext, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 14d8e1bf9..66060451b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,6 +3,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.VRServer +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -14,18 +15,18 @@ const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" -suspend fun createIpcServers(server: VRServer) = coroutineScope { +suspend fun createIpcServers(server: VRServer, behaviours: List) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { launch { createUnixDriverSocket(server) } launch { createUnixFeederSocket(server) } - launch { createUnixSolarXRSocket(server) } + launch { createUnixSolarXRSocket(server, behaviours) } } Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } launch { createWindowsFeederPipe(server) } - launch { createWindowsSolarXRPipe(server) } + launch { createWindowsSolarXRPipe(server, behaviours) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 05fd369ee..3b3494ce3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -2,6 +2,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer import dev.slimevr.getSocketDirectory +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -32,11 +33,12 @@ suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_ ) } -suspend fun createUnixSolarXRSocket(server: VRServer) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> +suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> handleSolarXRConnection( server = server, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + behaviours = behaviours ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 38e758f0e..6ab5e17e5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -6,13 +6,14 @@ import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version -import dev.slimevr.solarxr.createSolarXRConnection +import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.SolarXRConnection import dev.slimevr.solarxr.onSolarXRMessage import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.TrackerActions -import dev.slimevr.device.createDevice -import dev.slimevr.tracker.createTracker +import dev.slimevr.device.Device +import dev.slimevr.tracker.Tracker import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -112,26 +113,24 @@ suspend fun handleFeederConnection( server.getDevice(existingTracker.context.state.value.deviceId) ?: error("could not find existing device") } else { val deviceId = server.nextHandle() - val newDevice = createDevice( + val newDevice = Device.create( scope = this, id = deviceId, address = serial, macAddress = serial, // FIXME: prob not correct origin = DeviceOrigin.FEEDER, protocolVersion = protocolVersion, - serverContext = server, ) server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) val trackerId = server.nextHandle() - val tracker = createTracker( + val tracker = Tracker.create( scope = this, id = trackerId, deviceId = deviceId, sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional hardwareId = serial, origin = DeviceOrigin.FEEDER, - serverContext = server, ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) @@ -165,11 +164,13 @@ suspend fun handleSolarXRConnection( server: VRServer, messages: Flow, send: suspend (ByteArray) -> Unit, + behaviours: List, ) = coroutineScope { - val connection = createSolarXRConnection( + val connection = SolarXRConnection.create( serverContext = server, scope = this, onSend = send, + behaviours = behaviours, ) messages.collect { bytes -> diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 4c89afb74..190b911de 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -7,6 +7,7 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.VRServer +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -35,11 +36,12 @@ suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEE ) } -suspend fun createWindowsSolarXRPipe(server: VRServer) = acceptWindowsClients(SOLARXR_PIPE) { handle -> +suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> handleSolarXRConnection( server = server, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + behaviours = behaviours ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index 552e2357b..2af92a6d8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -104,7 +104,11 @@ private suspend fun runSerialPoller(server: SerialServer) { } fun createDesktopSerialServer(scope: CoroutineScope): SerialServer { - val server = SerialServer.create(openPort = ::openPort, openFlashingPort = { DesktopFlashingHandler() }, scope = scope) + val server = SerialServer.create( + openPort = { portLocation, onDataReceived, onPortDisconnected -> openPort(portLocation, scope, onDataReceived, onPortDisconnected) }, + openFlashingPort = { DesktopFlashingHandler() }, + scope = scope, + ) scope.launch { runSerialPoller(server) } return server } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt index 7bb0af89f..f429f969e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt @@ -4,7 +4,6 @@ import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.config.AppConfig import dev.slimevr.vrchat.VRCConfigManager -import dev.slimevr.vrchat.createVRCConfigManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.emptyFlow import solarxr_protocol.rpc.VRCAvatarMeasurementType @@ -14,26 +13,23 @@ import solarxr_protocol.rpc.VRCTrackerModel internal const val VRC_REG_PATH = "Software\\VRChat\\VRChat" -fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope, userHeight: () -> Double): VRCConfigManager = +fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope): VRCConfigManager = when (CURRENT_PLATFORM) { - Platform.WINDOWS -> createVRCConfigManager( + Platform.WINDOWS -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = true, values = windowsVRCConfigFlow(), ) - Platform.LINUX -> createVRCConfigManager( + Platform.LINUX -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = true, values = linuxVRCConfigFlow(), ) - else -> createVRCConfigManager( + else -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = false, values = emptyFlow(), )