diff --git a/server/README.md b/server/README.md index c0c605a41..1bd19f1a8 100644 --- a/server/README.md +++ b/server/README.md @@ -21,11 +21,11 @@ The `Context` type (`context/context.kt`) is the building block of every m ```kotlin class Context( - val state: StateFlow, // current state, readable by anyone - val scope: CoroutineScope, // lifetime of this module + val state: StateFlow, // current state, readable by anyone + val scope: CoroutineScope, // lifetime of this module ) { - fun dispatch(action: A) - fun dispatchAll(actions: List) + fun dispatch(action: A) + fun dispatchAll(actions: List) } ``` @@ -44,8 +44,8 @@ A `Behaviour` is an interface with two methods, both with no-op defaults: ```kotlin interface Behaviour { - fun reduce(state: S, action: A): S = state - fun observe(receiver: C) {} + fun reduce(state: S, action: A): S = state + fun observe(receiver: C) {} } ``` @@ -68,9 +68,9 @@ Every module follows the same construction pattern: val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) val context = Context.create( - initialState = ..., - scope = scope, - behaviours = behaviours, + initialState = ..., + scope = scope, + behaviours = behaviours, ) val module = MyModule(context, ...) @@ -102,11 +102,11 @@ Group behaviours that share the same receiver type in a single file. Behaviours ```kotlin object PacketBehaviour : UDPConnectionBehaviour { - override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { - is UDPConnectionActions.LastPacket -> state.copy(...) - else -> state - } - override fun observe(receiver: UDPConnection) { ... } + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.LastPacket -> state.copy(...) + else -> state + } + override fun observe(receiver: UDPConnection) { ... } } ``` @@ -114,14 +114,14 @@ object PacketBehaviour : UDPConnectionBehaviour { ```kotlin class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { ... } + override fun observe(receiver: SolarXRConnection) { ... } } // At the call site: listOf( - DataFeedInitBehaviour, - FirmwareBehaviour(firmwareManager), - SerialBehaviour(serialServer), + DataFeedInitBehaviour, + FirmwareBehaviour(firmwareManager), + SerialBehaviour(serialServer), ) ``` @@ -180,16 +180,16 @@ To add a new major section of the server (say, a HID device connection): 1. **Define the state**: ```kotlin data class HIDConnectionState( - val deviceId: Int?, - val connected: Boolean, + val deviceId: Int?, + val connected: Boolean, ) ``` 2. **Define sealed actions**: ```kotlin sealed interface HIDConnectionActions { - data class Connected(val deviceId: Int) : HIDConnectionActions - data object Disconnected : HIDConnectionActions + data class Connected(val deviceId: Int) : HIDConnectionActions + data object Disconnected : HIDConnectionActions } ``` @@ -202,37 +202,37 @@ typealias HIDConnectionBehaviour = Behaviour Unit, + val context: HIDConnectionContext, + val serverContext: VRServer, + private val onSend: suspend (ByteArray) -> Unit, ) { - suspend fun send(bytes: ByteArray) = onSend(bytes) + suspend fun send(bytes: ByteArray) = onSend(bytes) } ``` 5. **Write behaviours** in a separate `behaviours.kt` file: ```kotlin object HIDHandshakeBehaviour : HIDConnectionBehaviour { - override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { - is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) - is HIDConnectionActions.Disconnected -> state.copy(connected = false) - } - override fun observe(receiver: HIDConnection) { - // launch coroutines, subscribe to events, etc. - } + override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { + is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) + is HIDConnectionActions.Disconnected -> state.copy(connected = false) + } + override fun observe(receiver: HIDConnection) { + // launch coroutines, subscribe to events, etc. + } } ``` 6. **Write a `companion object { fun create() }`**: ```kotlin companion object { - fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { - val behaviours = listOf(HIDHandshakeBehaviour, ...) - val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) - val conn = HIDConnection(context, serverContext, send) - behaviours.forEach { it.observe(conn) } - return conn - } + fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { + val behaviours = listOf(HIDHandshakeBehaviour, ...) + val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) + val conn = HIDConnection(context, serverContext, send) + behaviours.forEach { it.observe(conn) } + return conn + } } ``` @@ -252,7 +252,7 @@ Example: adding battery tracking to a HID connection requires only adding a `HID 2. In a behaviour's `observe`, register a listener: ```kotlin receiver.packetEvents.on { event -> - // handle it + // handle it } ``` 3. In `udp/server.kt`, route the new packet type to `emit`. @@ -313,4 +313,4 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor - Module creation lives in `companion object { fun create(...) }`. - State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. - **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. -- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file +- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index d83077cf8..90498f796 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -157,8 +157,8 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) // Wait for the device to come back online after reboot. - // which don't emit a new VRServerState, are also observed. // flatMapLatest switches to the matched device's own state flow so that status changes, + // which don't emit a new VRServerState, are also observed. @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val connected = withTimeoutOrNull(60_000) { server.context.state diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index 1134d5410..db57446f7 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -37,6 +37,7 @@ private fun UserHeightCalibrationStatus.isTerminal() = when (this) { UserHeightCalibrationStatus.ERROR_TOO_HIGH, UserHeightCalibrationStatus.ERROR_TOO_SMALL, -> true + else -> false } @@ -51,13 +52,12 @@ private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean { } object CalibrationBehaviour : HeightCalibrationBehaviourType { - override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = - when (action) { - is HeightCalibrationActions.Update -> state.copy( - status = action.status, - currentHeight = action.currentHeight, - ) - } + override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = when (action) { + is HeightCalibrationActions.Update -> state.copy( + status = action.status, + currentHeight = action.currentHeight, + ) + } } internal suspend fun runCalibrationSession( @@ -84,7 +84,6 @@ internal suspend fun runCalibrationSession( dispatch(UserHeightCalibrationStatus.RECORDING_FLOOR) withTimeoutOrNull(TIMEOUT_MS) { - // Floor phase: collect controller updates until the floor level is locked in controllerUpdates .sample(SAMPLE_INTERVAL_MS) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 3d1d749e7..b5fcbf87c 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -64,12 +64,14 @@ class HeightCalibrationManager( (bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && it.context.state.value.position != null } if (controllers.isEmpty()) return@flatMapLatest emptyFlow() - combine(controllers.map { controller -> - controller.context.state.map { s -> - val position = s.position ?: error("hands (or Controller) will always have a position in this case") - TrackerSnapshot(position = position, rotation = s.rawRotation) - } - }) { snapshots -> snapshots.minByOrNull { it.position.y }!! } + combine( + controllers.map { controller -> + controller.context.state.map { s -> + val position = s.position ?: error("hands (or Controller) will always have a position in this case") + TrackerSnapshot(position = position, rotation = s.rawRotation) + } + }, + ) { snapshots -> snapshots.minByOrNull { it.position.y }!! } } fun start() { diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index d21d1bc9c..2a8d14a45 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -20,7 +20,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { deviceId = action.deviceId, trackerId = null, ) - ), + ), ) else -> state 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 42928a919..c40abd6c5 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -55,10 +55,12 @@ class SerialServer( if (conn is SerialConnection.Console) { conn.handle.close() } - context.dispatchAll(listOf( - SerialServerActions.RemoveConnection(portLocation), - SerialServerActions.PortLost(portLocation) - )) + context.dispatchAll( + listOf( + SerialServerActions.RemoveConnection(portLocation), + SerialServerActions.PortLost(portLocation), + ), + ) } fun onDataReceived(portLocation: String, line: String) { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt index 9bda92efd..dd17b0869 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt @@ -25,6 +25,7 @@ private interface RegistryNotify : Library { const val REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 } + @Suppress("FunctionName") fun RegNotifyChangeKeyValue( hKey: WinReg.HKEY, bWatchSubtree: Boolean, @@ -86,15 +87,25 @@ internal fun windowsVRCConfigFlow(): Flow = flow { try { if (hEvent != null) { RegistryNotify.INSTANCE.RegNotifyChangeKeyValue( - phkResult.value, false, RegistryNotify.REG_NOTIFY_CHANGE_LAST_SET, hEvent, true, + phkResult.value, + false, + RegistryNotify.REG_NOTIFY_CHANGE_LAST_SET, + hEvent, + true, ) } val keys = windowsGetVRChatKeys(VRC_REG_PATH) - emit(if (keys.isEmpty()) null else buildVRCConfigValues( - intValue = { key -> keys[key]?.let { windowsGetDwordValue(VRC_REG_PATH, it) } }, - doubleValue = { key -> keys[key]?.let { windowsGetQwordValue(VRC_REG_PATH, it) } }, - )) + emit( + if (keys.isEmpty()) { + null + } else { + buildVRCConfigValues( + intValue = { key -> keys[key]?.let { windowsGetDwordValue(VRC_REG_PATH, it) } }, + doubleValue = { key -> keys[key]?.let { windowsGetQwordValue(VRC_REG_PATH, it) } }, + ) + }, + ) if (hEvent != null) { withContext(Dispatchers.IO) { Kernel32.INSTANCE.WaitForSingleObject(hEvent, WinBase.INFINITE) }