organise more stuff

This commit is contained in:
loucass003
2026-03-27 05:55:10 +01:00
parent 9d1e7764e6
commit 38743dc8b8
6 changed files with 148 additions and 95 deletions

View File

@@ -20,17 +20,18 @@ This is not accidental. It gives us:
The `Context<S, A>` type (`context/context.kt`) is the building block of every module:
```kotlin
data class Context<S, in A>(
val state: StateFlow<S>, // current state, readable by anyone
val dispatch: suspend (A) -> Unit,
val dispatchAll: suspend (List<A>) -> Unit,
val scope: CoroutineScope, // lifetime of this module
)
class Context<S, in A>(
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>)
}
```
`createContext` wires everything together:
1. Takes an `initialState` and a list of **reducers** (`(S, A) -> S`)
2. On each `dispatch`, folds all reducers over the current state
`Context.create` wires everything together:
1. Takes an `initialState` and a list of **behaviours**
2. On each `dispatch`, folds all behaviours' `reduce` over the current state
3. Publishes the new state on the `StateFlow`
**Never mutate state directly.** Always go through `dispatch`.
@@ -39,38 +40,93 @@ data class Context<S, in A>(
## Behaviours: Splitting Concerns
A `Behaviour` bundles two optional pieces:
A `Behaviour` is an interface with two methods, both with no-op defaults:
```kotlin
data class BasicBehaviour<S, A>(
val reducer: ((S, A) -> S)? = null,
val observer: ((Context<S, A>) -> Unit)? = null,
)
interface Behaviour<S, A, C> {
fun reduce(state: S, action: A): S = state
fun observe(receiver: C) {}
}
```
- **`reducer`**: Pure function. Handles the actions it cares about, returns the rest unchanged.
- **`observer`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows.
- **`reduce`**: Pure function. Handles the actions it cares about, returns the rest unchanged. Override only if the behaviour needs to modify state.
- **`observe`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. Override only if the behaviour has side effects.
Use `CustomBehaviour<S, A, C>` when the observer needs access to more than just the context — for example, a `UDPConnection` behaviour needs the connection's `send` function and `PacketDispatcher`, not just its `Context`.
The type parameter `C` is what the observer receives. For modules where the behaviour only needs the context, `C = Context<S, A>`. For modules where behaviours need access to the full service object (its `send` method, dispatchers, etc.), `C` is the service class itself:
```kotlin
// Observer receives only the context
typealias DeviceBehaviour = Behaviour<DeviceState, DeviceActions, DeviceContext>
// Observer receives the full connection object
typealias UDPConnectionBehaviour = Behaviour<UDPConnectionState, UDPConnectionActions, UDPConnection>
```
Every module follows the same construction pattern:
```kotlin
val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC)
val context = createContext(
initialState = ...,
reducers = behaviours.map { it.reducer },
scope = scope,
val context = Context.create(
initialState = ...,
scope = scope,
behaviours = behaviours,
)
behaviours.map { it.observer }.forEach { it?.invoke(context) }
val module = MyModule(context, ...)
behaviours.forEach { it.observe(module) } // or it.observe(context) for basic modules
```
This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers.
---
## Behaviour File Layout
Behaviours live in their own `behaviours.kt` file, separate from the module they belong to, within the same package:
```
udp/
├── behaviours.kt ← PacketBehaviour, PingBehaviour, HandshakeBehaviour, …
├── connection.kt ← UDPConnection class, state, actions, typealias
└── packets.kt ← packet type definitions
```
Group behaviours that share the same receiver type in a single file. Behaviours with dependencies on external services (e.g. `SerialBehaviour`, `FirmwareBehaviour`) are standalone classes — one per file is fine when they have distinct concerns.
---
## Stateless vs. Stateful Behaviours
**Stateless behaviours** (no dependencies at construction time) are `object`s:
```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) { ... }
}
```
**Behaviours with dependencies** are classes, constructed at the call site:
```kotlin
class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour {
override fun observe(receiver: SolarXRConnection) { ... }
}
// At the call site:
listOf(
DataFeedInitBehaviour,
FirmwareBehaviour(firmwareManager),
SerialBehaviour(serialServer),
)
```
---
## Actions
Actions are `sealed interface`s with `data class` variants. This means:
@@ -85,7 +141,7 @@ Use `data class Update(val transform: State.() -> State)` when you need a flexib
## The PacketDispatcher Pattern
`PacketDispatcher<T>` (`solarxr/solarxr.kt`) routes incoming messages to typed listeners without a giant `when` block:
`PacketDispatcher<T>` routes incoming messages to typed listeners without a giant `when` block:
```kotlin
dispatcher.on<SensorInfo> { packet -> /* only called for SensorInfo */ }
@@ -93,14 +149,14 @@ dispatcher.onAny { packet -> /* called for everything */ }
dispatcher.emit(packet) // routes to correct listeners
```
Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observer` — the dispatcher is passed as part of the module struct.
Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observe` — the dispatcher is passed as part of the module struct.
---
## Coroutines and Lifetime
- Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched.
- Observers should use `it.context.scope.launch { ... }` so their work is scoped to the module.
- Observers should use `receiver.context.scope.launch { ... }` so their work is scoped to the module.
- Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher.
- **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started.
@@ -124,59 +180,59 @@ 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
}
```
3. **Create type aliases** (keeps signatures readable):
```kotlin
typealias HIDConnectionContext = Context<HIDConnectionState, HIDConnectionActions>
typealias HIDConnectionBehaviour = CustomBehaviour<HIDConnectionState, HIDConnectionActions, HIDConnection>
typealias HIDConnectionBehaviour = Behaviour<HIDConnectionState, HIDConnectionActions, HIDConnection>
```
4. **Define the module struct** (holds context + extra runtime state):
4. **Define the module class** (holds context + extra runtime state):
```kotlin
data class HIDConnection(
val context: HIDConnectionContext,
val serverContext: VRServer,
val send: suspend (ByteArray) -> Unit,
)
class HIDConnection(
val context: HIDConnectionContext,
val serverContext: VRServer,
private val onSend: suspend (ByteArray) -> Unit,
) {
suspend fun send(bytes: ByteArray) = onSend(bytes)
}
```
5. **Write behaviours** as top-level `val`s (stateless, reusable):
5. **Write behaviours** in a separate `behaviours.kt` file:
```kotlin
val HIDHandshakeBehaviour = HIDConnectionBehaviour(
reducer = { s, a -> when (a) {
is HIDConnectionActions.Connected -> s.copy(deviceId = a.deviceId, connected = true)
is HIDConnectionActions.Disconnected -> s.copy(connected = false)
}},
observer = { conn ->
// launch coroutines, subscribe to events, etc.
}
)
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.
}
}
```
6. **Write a factory function**:
6. **Write a `companion object { fun create() }`**:
```kotlin
fun createHIDConnection(
serverContext: VRServer,
scope: CoroutineScope,
send: suspend (ByteArray) -> Unit,
): HIDConnection {
val behaviours = listOf(HIDHandshakeBehaviour, ...)
val context = createContext(initialState = ..., reducers = behaviours.map { it.reducer }, scope = scope)
val conn = HIDConnection(context, serverContext, send)
behaviours.map { it.observer }.forEach { it?.invoke(conn) }
return conn
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
}
}
```
@@ -184,7 +240,7 @@ fun createHIDConnection(
## Adding a New Behaviour to an Existing Module
Find the existing `createX` function, add your behaviour to the `behaviours` list. That's it. The behaviour's reducer and observer are automatically picked up.
Find the `create` function, add your behaviour to the `behaviours` list. That's it. The behaviour's `reduce` and `observe` are automatically picked up.
Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes.
@@ -192,14 +248,14 @@ Example: adding battery tracking to a HID connection requires only adding a `HID
## Adding a New UDP Packet Type
1. Add the packet class and its `read` function in `tracker/udp/packets.kt`
2. In the relevant behaviour's observer, register a listener:
1. Add the packet class and its `read` function in `udp/packets.kt`
2. In a behaviour's `observe`, register a listener:
```kotlin
connection.packetEvents.on<MyNewPacket> { event ->
// handle it
receiver.packetEvents.on<MyNewPacket> { event ->
// handle it
}
```
3. In `tracker/udp/server.kt`, route the new packet type to `emit`.
3. In `udp/server.kt`, route the new packet type to `emit`.
---
@@ -241,8 +297,8 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor
|---|---|
| `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) |
| `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions |
| `context/context.kt` | The `Context` / `Behaviour` / `createContext` primitives — do not add domain logic here |
| `tracker/udp/` | Everything specific to the SlimeVR UDP wire protocol |
| `context/context.kt` | The `Context` / `Behaviour` primitives — do not add domain logic here |
| `udp/` | Everything specific to the SlimeVR UDP wire protocol |
| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling |
| `config/` | JSON config read/write with autosave; no business logic |
| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol |
@@ -253,8 +309,8 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor
- **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function.
- **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it.
- Behaviours are top-level `val`s (or `object`s if they have no type parameters), not inner classes.
- Factory functions are named `createX`, not `XBuilder` or `X.create` (though `companion object { fun create() }` is acceptable when scoping makes it clearer, as in `VRServer.create`).
- Behaviours are `object`s (no dependencies) or `class`es (with dependencies), defined in a dedicated `behaviours.kt` file in the same package as the module they belong to.
- 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

@@ -50,7 +50,6 @@ typealias SettingsBehaviour = Behaviour<SettingsState, SettingsActions, Settings
class Settings(
val context: SettingsContext,
val configDir: String,
private val scope: CoroutineScope,
private val settingsDir: File,
) {
@@ -86,9 +85,9 @@ class Settings(
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)
val settings = Settings(context, scope = scope, settingsDir = settingsDir)
behaviours.forEach { it.observe(settings) }
return settings
}
}
}
}

View File

@@ -49,7 +49,6 @@ typealias UserConfigBehaviour = Behaviour<UserConfigState, UserConfigActions, Us
class UserConfig(
val context: UserConfigContext,
val configDir: String,
private val scope: CoroutineScope,
private val userConfigDir: File,
) {
@@ -85,9 +84,9 @@ class UserConfig(
val behaviours = listOf(DefaultUserBehaviour)
val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours)
val userConfig = UserConfig(context, userConfigDir.toString(), scope = scope, userConfigDir = userConfigDir)
val userConfig = UserConfig(context, scope = scope, userConfigDir = userConfigDir)
behaviours.forEach { it.observe(userConfig) }
return userConfig
}
}
}
}

View File

@@ -13,6 +13,7 @@ import solarxr_protocol.data_feed.DataFeedConfig
import solarxr_protocol.data_feed.DataFeedMessage
import solarxr_protocol.rpc.RpcMessage
import solarxr_protocol.rpc.RpcMessageHeader
import java.nio.ByteBuffer
data class SolarXRConnectionState(
val dataFeedConfigs: List<DataFeedConfig>,
@@ -26,6 +27,20 @@ sealed interface SolarXRConnectionActions {
typealias SolarXRConnectionContext = Context<SolarXRConnectionState, SolarXRConnectionActions>
typealias SolarXRConnectionBehaviour = Behaviour<SolarXRConnectionState, SolarXRConnectionActions, SolarXRConnection>
suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) {
val messageBundle = MessageBundle.fromByteBuffer(message)
messageBundle.dataFeedMsgs?.forEach {
val msg = it.message ?: return
context.dataFeedDispatcher.emit(msg)
}
messageBundle.rpcMsgs?.forEach {
val msg = it.message ?: return
context.rpcDispatcher.emit(msg)
}
}
class SolarXRConnection(
val context: SolarXRConnectionContext,
val serverContext: VRServer,
@@ -68,4 +83,4 @@ class SolarXRConnection(
return conn
}
}
}
}

View File

@@ -15,21 +15,6 @@ import solarxr_protocol.rpc.ResetRequest
import java.nio.ByteBuffer
const val SOLARXR_PORT = 21110
suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) {
val messageBundle = MessageBundle.fromByteBuffer(message)
messageBundle.dataFeedMsgs?.forEach {
val msg = it.message ?: return
context.dataFeedDispatcher.emit(msg)
}
messageBundle.rpcMsgs?.forEach {
val msg = it.message ?: return
context.rpcDispatcher.emit(msg)
}
}
suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List<SolarXRConnectionBehaviour>) {
val engine = embeddedServer(Netty, port = SOLARXR_PORT) {
install(WebSockets)

View File

@@ -25,10 +25,9 @@ typealias VRServerBehaviour = Behaviour<VRServerState, VRServerActions, VRServer
@OptIn(ExperimentalAtomicApi::class)
class VRServer(
val context: VRServerContext,
// Moved this outside of the context to make this faster and safer to use
private val handleCounter: AtomicInt,
) {
private val handleCounter: AtomicInt = AtomicInt(0)
fun nextHandle() = handleCounter.incrementAndFetch()
fun getTracker(id: Int) = context.state.value.trackers[id]
fun getDevice(id: Int) = context.state.value.devices[id]
@@ -41,9 +40,9 @@ class VRServer(
scope = scope,
behaviours = behaviours,
)
val server = VRServer(context = context, handleCounter = AtomicInt(0))
val server = VRServer(context = context)
behaviours.forEach { it.observe(server) }
return server
}
}
}
}