Re organise project

This commit is contained in:
loucass003
2026-03-27 05:29:41 +01:00
parent fa1d2012e1
commit 9d1e7764e6
43 changed files with 1112 additions and 1292 deletions

View 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)
}
}

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View 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)
}
}

View File

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

View 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)
}
}

View File

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

View 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) },
)
}
}
}

View File

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

View 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)
}
}

View File

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

View File

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

View File

@@ -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())
}
},
)
}
}

View File

@@ -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()
}
},
)
}
}

View File

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

View File

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

View File

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

View File

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

View 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)
}
}

View File

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

View 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) })
}
}
}

View File

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

View File

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

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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