This commit is contained in:
loucass003
2026-03-30 11:24:49 +02:00
parent 02ca403e24
commit 2fc64b3e8b
7 changed files with 81 additions and 67 deletions

View File

@@ -21,11 +21,11 @@ The `Context<S, A>` type (`context/context.kt`) is the building block of every m
```kotlin
class Context<S, in A>(
val state: StateFlow<S>, // current state, readable by anyone
val scope: CoroutineScope, // lifetime of this module
val state: StateFlow<S>, // current state, readable by anyone
val scope: CoroutineScope, // lifetime of this module
) {
fun dispatch(action: A)
fun dispatchAll(actions: List<A>)
fun dispatch(action: A)
fun dispatchAll(actions: List<A>)
}
```
@@ -44,8 +44,8 @@ A `Behaviour` is an interface with two methods, both with no-op defaults:
```kotlin
interface Behaviour<S, A, C> {
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<HIDConnectionState, HIDConnectionAc
4. **Define the module class** (holds context + extra runtime state):
```kotlin
class HIDConnection(
val context: HIDConnectionContext,
val serverContext: VRServer,
private val onSend: suspend (ByteArray) -> 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<MyNewPacket> { 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.
- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead.

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour {
deviceId = action.deviceId,
trackerId = null,
)
),
),
)
else -> state

View File

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

View File

@@ -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<VRCConfigValues?> = 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) }