mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Re organise project
This commit is contained in:
18
server/core/src/main/java/dev/slimevr/behaviours.kt
Normal file
18
server/core/src/main/java/dev/slimevr/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
22
server/core/src/main/java/dev/slimevr/config/behaviours.kt
Normal file
22
server/core/src/main/java/dev/slimevr/config/behaviours.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<GlobalConfigState, GlobalConfigActions>
|
||||
typealias GlobalConfigBehaviour = BasicBehaviour<GlobalConfigState, GlobalConfigActions>
|
||||
typealias GlobalConfigBehaviour = Behaviour<GlobalConfigState, GlobalConfigActions, GlobalConfigContext>
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsState, SettingsActions>
|
||||
typealias SettingsBehaviour = CustomBehaviour<SettingsState, SettingsActions, Settings>
|
||||
typealias SettingsBehaviour = Behaviour<SettingsState, SettingsActions, Settings>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UserConfigState, UserConfigActions>
|
||||
typealias UserConfigBehaviour = CustomBehaviour<UserConfigState, UserConfigActions, UserConfig>
|
||||
typealias UserConfigBehaviour = Behaviour<UserConfigState, UserConfigActions, UserConfig>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,47 +7,38 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
interface Behaviour<S, A, C> {
|
||||
val reducer: ((S, A) -> S)?
|
||||
val observer: ((C) -> Unit)?
|
||||
fun reduce(state: S, action: A): S = state
|
||||
fun observe(receiver: C) {}
|
||||
}
|
||||
|
||||
data class BasicBehaviour<S, A>(
|
||||
override val reducer: ((S, A) -> S)? = null,
|
||||
override val observer: ((Context<S, A>) -> Unit)? = null,
|
||||
) : Behaviour<S, A, Context<S, A>>
|
||||
|
||||
data class CustomBehaviour<S, A, C>(
|
||||
override val reducer: ((S, A) -> S)? = null,
|
||||
override val observer: ((C) -> Unit)? = null,
|
||||
) : Behaviour<S, A, C>
|
||||
|
||||
data class Context<S, in A>(
|
||||
val state: StateFlow<S>,
|
||||
val dispatch: suspend (A) -> Unit,
|
||||
val dispatchAll: suspend (List<A>) -> Unit,
|
||||
class Context<S, in A>(
|
||||
private val mutableStateFlow: MutableStateFlow<S>,
|
||||
private val applyAction: (S, A) -> S,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
) {
|
||||
val state: StateFlow<S> = mutableStateFlow.asStateFlow()
|
||||
|
||||
fun <S, A> createContext(
|
||||
initialState: S,
|
||||
scope: CoroutineScope,
|
||||
reducers: List<((S, A) -> S)?>,
|
||||
): Context<S, A> {
|
||||
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<A>) -> Unit = { actions ->
|
||||
fun dispatchAll(actions: List<A>) {
|
||||
mutableStateFlow.update { currentState ->
|
||||
actions.fold(currentState) { s, action -> applyAction(s, action) }
|
||||
}
|
||||
}
|
||||
val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope)
|
||||
return context
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <S, A> create(
|
||||
initialState: S,
|
||||
scope: CoroutineScope,
|
||||
behaviours: List<Behaviour<S, A, *>>,
|
||||
): Context<S, A> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
server/core/src/main/java/dev/slimevr/device/behaviours.kt
Normal file
15
server/core/src/main/java/dev/slimevr/device/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceState, DeviceActions>
|
||||
typealias DeviceBehaviour = BasicBehaviour<DeviceState, DeviceActions>
|
||||
typealias DeviceBehaviour = Behaviour<DeviceState, DeviceActions, DeviceContext>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
server/core/src/main/java/dev/slimevr/firmware/behaviours.kt
Normal file
18
server/core/src/main/java/dev/slimevr/firmware/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<FirmwareManagerState, FirmwareManagerActions>
|
||||
typealias FirmwareManagerBehaviour = BasicBehaviour<FirmwareManagerState, FirmwareManagerActions>
|
||||
typealias FirmwareManagerBehaviour = Behaviour<FirmwareManagerState, FirmwareManagerActions, FirmwareManagerContext>
|
||||
|
||||
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<FirmwarePart>, 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<String, Job>()
|
||||
|
||||
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<String, Job>()
|
||||
|
||||
val flash: suspend (String, List<FirmwarePart>, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server ->
|
||||
suspend fun flash(
|
||||
portLocation: String,
|
||||
parts: List<FirmwarePart>,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
166
server/core/src/main/java/dev/slimevr/hid/behaviours.kt
Normal file
166
server/core/src/main/java/dev/slimevr/hid/behaviours.kt
Normal file
@@ -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<HIDDeviceRegister> { 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<HIDDeviceInfo> { 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<HIDRotation> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationMag> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { 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<HIDRotationBattery> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(signalStrength = packet.rssi) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HIDStatusBehaviour : HIDReceiverBehaviour {
|
||||
override fun observe(receiver: HIDReceiver) {
|
||||
receiver.packetEvents.onPacket<HIDStatus> { packet ->
|
||||
if (receiver.getTracker(packet.hidId) == null) return@onPacket
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HIDReceiverState, HIDReceiverActions>
|
||||
typealias HIDReceiverBehaviour = CustomBehaviour<HIDReceiverState, HIDReceiverActions, HIDReceiver>
|
||||
typealias HIDReceiverBehaviour = Behaviour<HIDReceiverState, HIDReceiverActions, HIDReceiver>
|
||||
typealias HIDPacketDispatcher = EventDispatcher<HIDPacket>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -56,169 +49,7 @@ inline fun <reified T : HIDPacket> 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<HIDDeviceRegister> { 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<HIDDeviceInfo> { 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<HIDRotation> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationMag> { packet ->
|
||||
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { 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<HIDRotationBattery> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
|
||||
receiver.getDevice(packet.hidId)?.context?.dispatch(
|
||||
DeviceActions.Update { copy(signalStrength = packet.rssi) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val HIDStatusBehaviour = HIDReceiverBehaviour(
|
||||
observer = { receiver ->
|
||||
receiver.packetEvents.onPacket<HIDStatus> { 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<ByteArray>,
|
||||
serverContext: VRServer,
|
||||
scope: CoroutineScope,
|
||||
): HIDReceiver {
|
||||
val behaviours = listOf(
|
||||
HIDRegistrationBehaviour,
|
||||
HIDDeviceInfoBehaviour,
|
||||
HIDRotationBehaviour,
|
||||
HIDBatteryBehaviour,
|
||||
HIDStatusBehaviour,
|
||||
)
|
||||
companion object {
|
||||
fun create(
|
||||
serialNumber: String,
|
||||
data: Flow<ByteArray>,
|
||||
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
|
||||
}
|
||||
}
|
||||
30
server/core/src/main/java/dev/slimevr/serial/behaviours.kt
Normal file
30
server/core/src/main/java/dev/slimevr/serial/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<SerialConnectionState, SerialConnectionActions>
|
||||
typealias SerialConnectionBehaviour =
|
||||
CustomBehaviour<SerialConnectionState, SerialConnectionActions, SerialConnection.Console>
|
||||
typealias SerialConnectionBehaviour = Behaviour<SerialConnectionState, SerialConnectionActions, SerialConnection.Console>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<SerialServerState, SerialServerActions>
|
||||
typealias SerialServerBehaviour = BasicBehaviour<SerialServerState, SerialServerActions>
|
||||
|
||||
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<SerialServerState, SerialServerActions, SerialServerContext>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StartDataFeed> { 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<StartDataFeed> { 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<PollDataFeed> { event ->
|
||||
receiver.dataFeedDispatcher.on<PollDataFeed> { 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())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String, FirmwareJobStatus> = 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<FirmwareUpdateRequest> { req ->
|
||||
receiver.rpcDispatcher.on<FirmwareUpdateRequest> { 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<FirmwareUpdateStopQueuesRequest> {
|
||||
receiver.rpcDispatcher.on<FirmwareUpdateStopQueuesRequest> {
|
||||
firmwareManager.cancelAll()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<SolarXRConnectionState, SolarXRConnectionActions>
|
||||
typealias SolarXRConnectionBehaviour = CustomBehaviour<SolarXRConnectionState, SolarXRConnectionActions, SolarXRConnection>
|
||||
typealias SolarXRConnectionBehaviour = Behaviour<SolarXRConnectionState, SolarXRConnectionActions, SolarXRConnection>
|
||||
|
||||
data class SolarXRConnection(
|
||||
class SolarXRConnection(
|
||||
val context: SolarXRConnectionContext,
|
||||
val serverContext: VRServer,
|
||||
val dataFeedDispatcher: EventDispatcher<DataFeedMessage>,
|
||||
val rpcDispatcher: EventDispatcher<RpcMessage>,
|
||||
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<SolarXRConnectionBehaviour>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SerialDevicesRequest> {
|
||||
conn.sendRpc(
|
||||
receiver.rpcDispatcher.on<SerialDevicesRequest> {
|
||||
receiver.sendRpc(
|
||||
SerialDevicesResponse(
|
||||
devices = serialServer.context.state.value.availablePorts.values
|
||||
.map { it.toSerialDevice() },
|
||||
@@ -55,7 +54,7 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour(
|
||||
)
|
||||
}
|
||||
|
||||
conn.rpcDispatcher.on<OpenSerialRequest> { req ->
|
||||
receiver.rpcDispatcher.on<OpenSerialRequest> { 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<CloseSerialRequest> {
|
||||
receiver.rpcDispatcher.on<CloseSerialRequest> {
|
||||
logSubscription?.cancel()
|
||||
logSubscription = null
|
||||
activePortLocation = null
|
||||
}
|
||||
|
||||
conn.rpcDispatcher.on<SerialTrackerRebootRequest> {
|
||||
receiver.rpcDispatcher.on<SerialTrackerRebootRequest> {
|
||||
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<SerialTrackerGetInfoRequest> {
|
||||
receiver.rpcDispatcher.on<SerialTrackerGetInfoRequest> {
|
||||
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<SerialTrackerFactoryResetRequest> {
|
||||
receiver.rpcDispatcher.on<SerialTrackerFactoryResetRequest> {
|
||||
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<SerialTrackerGetWifiScanRequest> {
|
||||
receiver.rpcDispatcher.on<SerialTrackerGetWifiScanRequest> {
|
||||
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<SerialTrackerCustomCommandRequest> { req ->
|
||||
receiver.rpcDispatcher.on<SerialTrackerCustomCommandRequest> { 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<VRCConfigStateRequest> {
|
||||
conn.sendRpc(buildCurrentResponse())
|
||||
receiver.rpcDispatcher.on<VRCConfigStateRequest> {
|
||||
receiver.sendRpc(buildCurrentResponse())
|
||||
}
|
||||
|
||||
conn.rpcDispatcher.on<VRCConfigSettingToggleMute> { req ->
|
||||
receiver.rpcDispatcher.on<VRCConfigSettingToggleMute> { req ->
|
||||
val key = req.key ?: return@on
|
||||
vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,19 +30,20 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSolarXRWebsocketServer(serverContext: VRServer) {
|
||||
suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List<SolarXRConnectionBehaviour>) {
|
||||
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) {
|
||||
|
||||
15
server/core/src/main/java/dev/slimevr/tracker/behaviours.kt
Normal file
15
server/core/src/main/java/dev/slimevr/tracker/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<TrackerState, TrackerActions>
|
||||
typealias TrackerBehaviour = BasicBehaviour<TrackerState, TrackerActions>
|
||||
typealias TrackerBehaviour = Behaviour<TrackerState, TrackerActions, TrackerContext>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
228
server/core/src/main/java/dev/slimevr/udp/behaviours.kt
Normal file
228
server/core/src/main/java/dev/slimevr/udp/behaviours.kt
Normal file
@@ -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<PingPong> { 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<Handshake> { 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<BatteryLevel> { event ->
|
||||
val device = receiver.getDevice() ?: return@onPacket
|
||||
device.context.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(batteryLevel = event.data.level, batteryVoltage = event.data.voltage)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
receiver.packetEvents.onPacket<SignalStrength> { 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<SensorInfo> { 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<RotationData> { event ->
|
||||
val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket
|
||||
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UDPConnectionState, UDPConnectionActions>
|
||||
typealias UDPConnectionBehaviour = CustomBehaviour<UDPConnectionState, UDPConnectionActions, UDPConnection>
|
||||
typealias UDPConnectionBehaviour = Behaviour<UDPConnectionState, UDPConnectionActions, UDPConnection>
|
||||
|
||||
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<PingPong> { 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<Handshake> { 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<BatteryLevel> { event ->
|
||||
val device = it.getDevice() ?: return@onPacket
|
||||
|
||||
device.context.dispatch(
|
||||
DeviceActions.Update {
|
||||
copy(
|
||||
batteryLevel = event.data.level,
|
||||
batteryVoltage = event.data.voltage,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
it.packetEvents.onPacket<SignalStrength> { 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<SensorInfo> { 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<RotationData> { 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<PacketEvent<UDPPacket>>,
|
||||
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<PacketEvent<UDPPacket>> { 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, UDPConnection>,
|
||||
)
|
||||
|
||||
26
server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt
Normal file
26
server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<VRCConfigState, VRCConfigActions>
|
||||
typealias VRCConfigBehaviour = CustomBehaviour<VRCConfigState, VRCConfigActions, VRCConfigManager>
|
||||
typealias VRCConfigBehaviour = Behaviour<VRCConfigState, VRCConfigActions, VRCConfigManager>
|
||||
|
||||
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<VRCConfigValues?>,
|
||||
): 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<VRCConfigValues?>,
|
||||
): 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
|
||||
}
|
||||
)
|
||||
@@ -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<VRServerState, VRServerActions>
|
||||
typealias VRServerBehaviour = CustomBehaviour<VRServerState, VRServerActions, VRServer>
|
||||
|
||||
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<VRServerState, VRServerActions, VRServer>
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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<FirmwareUpdateStatus>()
|
||||
|
||||
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) })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -8,8 +8,6 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class SerialConnectionReducerTest {
|
||||
private val reducer = SerialLogBehaviour.reducer!!
|
||||
|
||||
private fun state(lines: List<String> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>) = 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SolarXRConnectionBehaviour>) = 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
|
||||
|
||||
@@ -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<SolarXRConnectionBehaviour>) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel ->
|
||||
handleSolarXRConnection(
|
||||
server = server,
|
||||
messages = readFramedMessages(channel),
|
||||
send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } },
|
||||
behaviours = behaviours
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ByteArray>,
|
||||
send: suspend (ByteArray) -> Unit,
|
||||
behaviours: List<SolarXRConnectionBehaviour>,
|
||||
) = coroutineScope {
|
||||
val connection = createSolarXRConnection(
|
||||
val connection = SolarXRConnection.create(
|
||||
serverContext = server,
|
||||
scope = this,
|
||||
onSend = send,
|
||||
behaviours = behaviours,
|
||||
)
|
||||
|
||||
messages.collect { bytes ->
|
||||
|
||||
@@ -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<SolarXRConnectionBehaviour>) = acceptWindowsClients(SOLARXR_PIPE) { handle ->
|
||||
handleSolarXRConnection(
|
||||
server = server,
|
||||
messages = readFramedMessages(handle),
|
||||
send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } },
|
||||
behaviours = behaviours
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user