Basic config system

This commit is contained in:
loucass003
2026-03-23 04:37:43 +01:00
parent 334be5f7cc
commit a42ed79003
14 changed files with 653 additions and 157 deletions

View File

@@ -5,62 +5,98 @@ import dev.slimevr.context.Context
import dev.slimevr.context.createContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
@Serializable
data class UserConfigState(
val userHeight: Float = 1.6f,
)
@Serializable
data class SettingsConfigState(
val trackerPort: Int = 6969,
)
private const val GLOBAL_CONFIG_VERSION = 1
@Serializable
data class GlobalConfigState(
val selectedUserProfile: String = "default",
val selectedSettingsProfile: String = "default",
val version: Int = GLOBAL_CONFIG_VERSION,
)
data class ConfigState(
val userConfig: UserConfigState = UserConfigState(),
val settingsConfig: SettingsConfigState = SettingsConfigState(),
val globalConfig: GlobalConfigState = GlobalConfigState(),
)
sealed interface ConfigAction {
data class ChangeProfile(val settingsProfile: String? = null, val userProfile: String? = null) : ConfigAction
sealed interface GlobalConfigActions {
data class SetUserProfile(val name: String) : GlobalConfigActions
data class SetSettingsProfile(val name: String) : GlobalConfigActions
}
typealias ConfigContext = Context<ConfigState, ConfigAction>
typealias ConfigModule = BasicModule<ConfigState, ConfigAction>
typealias GlobalConfigContext = Context<GlobalConfigState, GlobalConfigActions>
typealias GlobalConfigModule = BasicModule<GlobalConfigState, GlobalConfigActions>
val ConfigModuleTest = ConfigModule(
private fun migrateGlobalConfig(json: JsonObject): JsonObject {
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
return when {
// add migration branches here as: version < N -> migrateGlobalConfig(...)
else -> json
}
}
private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState {
val json = jsonConfig.parseToJsonElement(raw).jsonObject
return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json))
}
val DefaultGlobalConfigModule = GlobalConfigModule(
reducer = { s, a ->
when (a) {
is ConfigAction.ChangeProfile -> if (a.settingsProfile != null) {
s.copy(globalConfig = s.globalConfig.copy(selectedSettingsProfile = a.settingsProfile))
} else if (a.userProfile != null) {
s.copy(globalConfig = s.globalConfig.copy(selectedUserProfile = a.userProfile))
} else {
s
}
is GlobalConfigActions.SetUserProfile -> s.copy(selectedUserProfile = a.name)
is GlobalConfigActions.SetSettingsProfile -> s.copy(selectedSettingsProfile = a.name)
}
},
)
suspend fun createConfig(scope: CoroutineScope): ConfigContext {
val modules = listOf(ConfigModuleTest)
data class AppConfig(
val globalContext: GlobalConfigContext,
val userConfig: UserConfig,
val settings: Settings,
val switchUserProfile: suspend (String) -> Unit,
val switchSettingsProfile: suspend (String) -> Unit,
)
val context = createContext(
initialState = ConfigState(),
reducers = modules.map { it.reducer },
suspend fun createAppConfig(scope: CoroutineScope, configFolder: File): AppConfig {
val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) {
parseAndMigrateGlobalConfig(it)
}
val globalContext = createContext(
initialState = initialGlobal,
reducers = listOf(DefaultGlobalConfigModule.reducer),
scope = scope,
)
modules.map { it.observer }.forEach { it?.invoke(context) }
launchAutosave(
scope = scope,
state = globalContext.state,
toFile = { File(configFolder, "global.json") },
serialize = { jsonConfig.encodeToString(it) },
)
context.dispatch(ConfigAction.ChangeProfile(settingsProfile = "Test"))
val userConfig = createUserConfig(scope, configFolder, initialGlobal.selectedUserProfile)
val settings = createSettings(scope, configFolder, initialGlobal.selectedSettingsProfile)
return context
val switchUserProfile: suspend (String) -> Unit = { name ->
globalContext.dispatch(GlobalConfigActions.SetUserProfile(name))
userConfig.swap(name)
}
val switchSettingsProfile: suspend (String) -> Unit = { name ->
globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name))
settings.swap(name)
}
return AppConfig(
globalContext = globalContext,
userConfig = userConfig,
settings = settings,
switchUserProfile = switchUserProfile,
switchSettingsProfile = switchSettingsProfile,
)
}

View File

@@ -0,0 +1,92 @@
package dev.slimevr.config
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
val jsonConfig = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
suspend fun atomicWriteFile(file: File, content: String) = withContext(Dispatchers.IO) {
file.parentFile?.mkdirs()
val tmp = File(file.parent, "${file.name}.tmp")
tmp.writeText(content)
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
Unit
}
suspend inline fun <reified T> loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T =
withContext(Dispatchers.IO) {
if (!file.exists()) {
atomicWriteFile(file, jsonConfig.encodeToString(default))
return@withContext default
}
try {
deserialize(file.readText())
} catch (e: Exception) {
System.err.println("Failed to load ${file.absolutePath}: ${e.message}")
if (file.exists()) {
try {
val bakTmp = File(file.parent, "${file.name}.bak.tmp")
file.copyTo(bakTmp, overwrite = true)
Files.move(
bakTmp.toPath(),
File(file.parent, "${file.name}.bak").toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (e2: Exception) {
System.err.println("Failed to back up corrupted file: ${e2.message}")
}
}
default
}
}
/**
* Launches a debounced autosave coroutine. Skips the initial state (already on
* disk at start time) and any state that was already successfully persisted.
* Cancel and restart to switch profiles. the new job treats the current state
* as already saved.
*/
@OptIn(FlowPreview::class)
fun <S> launchAutosave(
scope: CoroutineScope,
state: StateFlow<S>,
toFile: (S) -> File,
serialize: (S) -> String,
): Job {
var lastSaved = state.value
return merge(state.debounce(500L), state.sample(2000L))
.distinctUntilChanged()
.filter { it != lastSaved }
.onEach { s ->
try {
val file = toFile(s)
atomicWriteFile(file, serialize(s))
lastSaved = s
println("Saved ${file.absolutePath}")
} catch (e: Exception) {
System.err.println("Failed to save: ${e.message}")
}
}
.launchIn(scope)
}

View File

@@ -0,0 +1,101 @@
package dev.slimevr.config
import dev.slimevr.context.Context
import dev.slimevr.context.CustomModule
import dev.slimevr.context.createContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
private const val SETTINGS_CONFIG_VERSION = 1
@Serializable
data class SettingsConfigState(
val trackerPort: Int = 6969,
val version: Int = SETTINGS_CONFIG_VERSION,
)
private fun migrateSettingsConfig(json: JsonObject): JsonObject {
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
return when {
// add migration branches here as: version < N -> migrateSettingsConfig(...)
else -> json
}
}
private fun parseAndMigrateSettingsConfig(raw: String): SettingsConfigState {
val json = jsonConfig.parseToJsonElement(raw).jsonObject
return jsonConfig.decodeFromJsonElement(migrateSettingsConfig(json))
}
data class SettingsState(
val data: SettingsConfigState,
val name: String,
)
sealed interface SettingsActions {
data class LoadProfile(val newState: SettingsState) : SettingsActions
}
typealias SettingsContext = Context<SettingsState, SettingsActions>
typealias SettingsModule = CustomModule<SettingsState, SettingsActions, Settings>
data class Settings(
val context: SettingsContext,
val configDir: String,
val swap: suspend (String) -> Unit,
)
val DefaultSettingsModule = SettingsModule(
reducer = { s, a ->
when (a) {
is SettingsActions.LoadProfile -> a.newState
}
},
)
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 modules = listOf(DefaultSettingsModule)
val context = createContext(
initialState = initialState,
reducers = modules.map { it.reducer },
scope = scope
)
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 ->
autosaveJob.cancelAndJoin()
val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) {
parseAndMigrateSettingsConfig(it)
}
val newState = SettingsState(name = newName, data = newData)
context.dispatch(SettingsActions.LoadProfile(newState))
autosaveJob = startAutosave()
}
return Settings(context, configDir = settingsDir.toString(), swap)
}

View File

@@ -0,0 +1,103 @@
package dev.slimevr.config
import dev.slimevr.context.Context
import dev.slimevr.context.CustomModule
import dev.slimevr.context.createContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
private const val USER_CONFIG_VERSION = 1
@Serializable
data class UserConfigData(
val userHeight: Float = 1.6f,
val version: Int = USER_CONFIG_VERSION,
)
private fun migrateUserConfig(json: JsonObject): JsonObject {
val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0
return when {
// add migration branches here as: version < N -> migrateUserConfig(...)
else -> json
}
}
private fun parseAndMigrateUserConfig(raw: String): UserConfigData {
val json = jsonConfig.parseToJsonElement(raw).jsonObject
return jsonConfig.decodeFromJsonElement(migrateUserConfig(json))
}
data class UserConfigState(
val data: UserConfigData,
val name: String,
)
sealed interface UserConfigActions {
data class Update(val transform: UserConfigState.() -> UserConfigState) : UserConfigActions
data class LoadProfile(val newState: UserConfigState) : UserConfigActions
}
typealias UserConfigContext = Context<UserConfigState, UserConfigActions>
typealias UserConfigModule = CustomModule<UserConfigState, UserConfigActions, UserConfig>
data class UserConfig(
val context: UserConfigContext,
val configDir: String,
val swap: suspend (String) -> Unit,
)
val DefaultUserModule = UserConfigModule(
reducer = { s, a ->
when (a) {
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 modules = listOf(DefaultUserModule)
val context = createContext(
initialState = initialState,
reducers = modules.map { it.reducer },
scope = scope
)
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 ->
autosaveJob.cancelAndJoin()
val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) {
parseAndMigrateUserConfig(it)
}
val newState = UserConfigState(name = newName, data = newData)
context.dispatch(UserConfigActions.LoadProfile(newState))
autosaveJob = startAutosave()
}
return UserConfig(context, userConfigDir.toString(), swap)
}

View File

@@ -6,10 +6,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
interface Module<S, A, C> {
val reducer: ((S, A) -> S)?
val observer: ((C) -> Unit)?
}
data class BasicModule<S, A>(
val reducer: ((S, A) -> S)? = null,
val observer: ((Context<S, A>) -> Unit)? = null,
)
override val reducer: ((S, A) -> S)? = null,
override val observer: ((Context<S, A>) -> Unit)? = null,
) : Module<S, A, Context<S, A>>
data class CustomModule<S, A, C>(
override val reducer: ((S, A) -> S)? = null,
override val observer: ((C) -> Unit)? = null,
) : Module<S, A, C>
data class Context<S, in A>(
val state: StateFlow<S>,

View File

@@ -0,0 +1,90 @@
package io.eiren.util
import java.io.File
import java.nio.file.Path
import java.util.*
import kotlin.io.path.Path
import kotlin.io.path.exists
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
enum class Platform {
LINUX, WINDOWS, OSX, UNKNOWN
}
val CURRENT_PLATFORM: Platform = detectPlatform()
private fun detectPlatform(): Platform {
val os = System.getProperty("os.name").lowercase(Locale.getDefault())
if (os.contains("win")) return Platform.WINDOWS
if (os.contains("mac") || os.contains("darwin")) return Platform.OSX
if (os.contains("linux") || os.contains("unix")) return Platform.LINUX
return Platform.UNKNOWN
}
fun getJavaExecutable(forceConsole: Boolean): String {
val bin = System.getProperty("java.home") + File.separator + "bin" + File.separator
if (CURRENT_PLATFORM == Platform.WINDOWS && !forceConsole) {
val javaw = bin + "javaw.exe"
if (File(javaw).isFile) return javaw
}
if (CURRENT_PLATFORM == Platform.WINDOWS) return bin + "java.exe"
return bin + "java"
}
fun getSocketDirectory(): String {
val envDir = System.getenv("SLIMEVR_SOCKET_DIR")
if (envDir != null) return envDir
if (CURRENT_PLATFORM == Platform.LINUX) {
val xdg = System.getenv("XDG_RUNTIME_DIR")
if (xdg != null) return xdg
}
return System.getProperty("java.io.tmpdir")
}
fun resolveConfigDirectory(): Path? {
if (Path("config/").exists()) { // this is only for dev
return Path("config/")
}
val home = System.getenv("HOME")
return when (CURRENT_PLATFORM) {
Platform.WINDOWS -> {
val appData = System.getenv("AppData")
if (appData != null) Path(appData, SLIMEVR_IDENTIFIER) else null
}
Platform.LINUX -> {
val xdg = System.getenv("XDG_CONFIG_HOME")
if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER)
else if (home != null) Path(home, ".config", SLIMEVR_IDENTIFIER)
else null
}
Platform.OSX -> {
if (home != null) Path(home, "Library", "Application Support", SLIMEVR_IDENTIFIER) else null
}
else -> null
}
}
fun resolveLogDirectory(): Path? {
val home = System.getenv("HOME")
val appData = System.getenv("AppData")
return when (CURRENT_PLATFORM) {
Platform.WINDOWS -> if (appData != null) Path(appData, SLIMEVR_IDENTIFIER, "logs") else null
Platform.OSX -> if (home != null) Path(home, "Library", "Logs", SLIMEVR_IDENTIFIER) else null
Platform.LINUX -> {
val xdg = System.getenv("XDG_DATA_HOME")
if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER, "logs")
else if (home != null) Path(home, ".local", "share", SLIMEVR_IDENTIFIER, "logs")
else null
}
else -> null
}
}

View File

@@ -0,0 +1,20 @@
package dev.slimevr.skeleton
import io.github.axisangles.ktmath.Quaternion
import solarxr_protocol.datatypes.BodyPart
data class Bone(
val bodyPart: BodyPart,
val localRotation: Quaternion,
val parentBone: Bone?,
val childBone: List<Bone>
) {
val globalRotation: Quaternion
get() = localRotation //FIXME: do maths LMAO
}
data class SkeletonState(
val bones: Map<BodyPart, Bone>,
val rootBone: Bone,
)

View File

@@ -0,0 +1,145 @@
package dev.slimevr.solarxr
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.VRServer
import dev.slimevr.tracker.DeviceState
import dev.slimevr.tracker.TrackerState
import io.ktor.util.moveToByteArray
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import solarxr_protocol.MessageBundle
import solarxr_protocol.data_feed.DataFeedConfig
import solarxr_protocol.data_feed.DataFeedMessageHeader
import solarxr_protocol.data_feed.DataFeedUpdate
import solarxr_protocol.data_feed.PollDataFeed
import solarxr_protocol.data_feed.StartDataFeed
import solarxr_protocol.data_feed.device_data.DeviceData
import solarxr_protocol.data_feed.tracker.TrackerData
import solarxr_protocol.data_feed.tracker.TrackerDataMask
import solarxr_protocol.data_feed.tracker.TrackerInfo
import solarxr_protocol.datatypes.DeviceId
import solarxr_protocol.datatypes.TrackerId
import solarxr_protocol.datatypes.hardware_info.HardwareStatus
import solarxr_protocol.datatypes.math.Quat
import kotlin.time.measureTime
private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData =
TrackerData(
trackerId = TrackerId(
trackerNum = tracker.id.toUByte(),
deviceId = DeviceId(device.id.toUByte())
),
status = if (trackerMask.status == true) tracker.status else null,
rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null,
info = if (trackerMask.info == true) TrackerInfo(
imuType = tracker.sensorType,
bodyPart = tracker.bodyPart,
displayName = tracker.name,
) else null
)
private fun createDevice(
device: DeviceState,
trackers: List<TrackerState>,
datafeedConfig: DataFeedConfig
): DeviceData {
val trackerMask = datafeedConfig.dataMask?.trackerData;
return DeviceData(
id = DeviceId(device.id.toUByte()),
hardwareStatus = HardwareStatus(
batteryVoltage = device.batteryVoltage,
batteryPctEstimate = device.batteryLevel.toUInt()
.toUByte(),
ping = device.ping?.toUShort()
),
trackers = if (trackerMask != null)
trackers.filter { it.deviceId == device.id }
.map { tracker -> createTracker(device, tracker, trackerMask) }
else null
)
}
fun createDatafeedFrame(
serverContext: VRServer,
datafeedConfig: DataFeedConfig
): 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) }
return DataFeedMessageHeader(
message = DataFeedUpdate(
devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null
)
)
}
val DataFeedInitModule = SolarXRConnectionModule(
observer = { context ->
context.dataFeedDispatcher.on<StartDataFeed> { event ->
val datafeeds = event.dataFeeds ?: return@on
val state = context.context.state.value
state.datafeedTimers.forEach {
it.cancelAndJoin()
}
val timers = datafeeds.map { config ->
val minTime = config.minimumTimeSinceLast ?: return@map null
return@map context.context.scope.launch(start = CoroutineStart.LAZY) {
val fbb = FlatBufferBuilder(1024)
while (isActive) {
val timeTaken = measureTime {
fbb.clear()
fbb.finish(
MessageBundle(
dataFeedMsgs = listOf(
createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config)
)
).encode(fbb)
)
context.send(fbb.dataBuffer().moveToByteArray())
}
val remainingDelay = (minTime.toLong() - timeTaken.inWholeMilliseconds)
assert(remainingDelay > 0)
delay(remainingDelay)
}
}
}.filterNotNull()
context.context.dispatch(
SolarXRConnectionActions.SetConfig(
datafeeds,
timers = timers
)
)
timers.joinAll()
}
context.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)
)
).encode(fbb)
)
context.send(fbb.dataBuffer().moveToByteArray())
}
}
)

View File

@@ -1,33 +1,18 @@
package dev.slimevr.solarxr
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.VRServer
import dev.slimevr.context.Context
import dev.slimevr.context.CustomModule
import dev.slimevr.context.createContext
import io.ktor.util.moveToByteArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import solarxr_protocol.MessageBundle
import solarxr_protocol.data_feed.DataFeedConfig
import solarxr_protocol.data_feed.DataFeedMessage
import solarxr_protocol.data_feed.DataFeedMessageHeader
import solarxr_protocol.data_feed.DataFeedUpdate
import solarxr_protocol.data_feed.StartDataFeed
import solarxr_protocol.data_feed.device_data.DeviceData
import solarxr_protocol.data_feed.tracker.TrackerData
import solarxr_protocol.datatypes.DeviceId
import solarxr_protocol.datatypes.TrackerId
import solarxr_protocol.datatypes.hardware_info.HardwareStatus
import solarxr_protocol.rpc.RpcMessage
import kotlin.reflect.KClass
import kotlin.time.measureTime
data class SolarXRConnectionState(
val dataFeedConfigs: List<DataFeedConfig>,
@@ -40,6 +25,8 @@ sealed interface SolarXRConnectionActions {
}
typealias SolarXRConnectionContext = Context<SolarXRConnectionState, SolarXRConnectionActions>
typealias SolarXRConnectionModule = CustomModule<SolarXRConnectionState, SolarXRConnectionActions, SolarXRConnection>
class PacketDispatcher<T : Any> {
val listeners = mutableMapOf<KClass<out T>, MutableList<suspend (T) -> Unit>>()
@@ -81,94 +68,6 @@ data class SolarXRConnection(
val send: suspend (ByteArray) -> Unit
)
data class SolarXRConnectionModule(
val reducer: ((SolarXRConnectionState, SolarXRConnectionActions) -> SolarXRConnectionState)? = null,
val observer: ((SolarXRConnection) -> Unit)? = null,
)
val DataFeedInitModule = SolarXRConnectionModule(
observer = { context ->
context.dataFeedDispatcher.on<StartDataFeed> { event ->
val datafeeds = event.dataFeeds ?: return@on
val state = context.context.state.value
state.datafeedTimers.forEach {
it.cancelAndJoin()
}
val timers = datafeeds.map { config ->
val minTime = config.minimumTimeSinceLast ?: return@map null
return@map context.context.scope.launch {
val fbb = FlatBufferBuilder(1024)
while (isActive) {
val timeTaken = measureTime {
fbb.clear()
val serverState = context.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 ->
DeviceData(
id = DeviceId(device.id.toUByte()),
hardwareStatus = HardwareStatus(
batteryVoltage = device.batteryVoltage,
batteryPctEstimate = device.batteryLevel.toUInt()
.toUByte(),
ping = device.ping?.toUShort()
),
trackers = trackers.filter { it.deviceId == device.id }
.map { tracker ->
TrackerData(
trackerId = TrackerId(
trackerNum = tracker.id.toUByte(),
deviceId = DeviceId(device.id.toUByte())
),
status = tracker.status
)
}
)
}
fbb.finish(
MessageBundle(
dataFeedMsgs = listOf(
DataFeedMessageHeader(
message = DataFeedUpdate(
devices = devices
)
)
)
).encode(fbb)
)
context.send(fbb.dataBuffer().moveToByteArray())
}
val remainingDelay =
(minTime.toLong() - timeTaken.inWholeMilliseconds).coerceAtLeast(
0
)
delay(remainingDelay)
}
}
}.filterNotNull()
context.context.dispatch(
SolarXRConnectionActions.SetConfig(
datafeeds,
timers = timers
)
)
timers.forEach { it.start() }
}
}
)
fun createSolarXRConnection(
serverContext: VRServer,
onSend: suspend (ByteArray) -> Unit,

View File

@@ -4,7 +4,11 @@ import dev.slimevr.AppLogger
import dev.slimevr.VRServer
import dev.slimevr.VRServerActions
import dev.slimevr.context.Context
import dev.slimevr.context.CustomModule
import dev.slimevr.context.createContext
import dev.slimevr.solarxr.SolarXRConnection
import dev.slimevr.solarxr.SolarXRConnectionActions
import dev.slimevr.solarxr.SolarXRConnectionState
import dev.slimevr.tracker.Device
import dev.slimevr.tracker.DeviceActions
import dev.slimevr.tracker.DeviceOrigin
@@ -50,6 +54,8 @@ sealed interface UDPConnectionActions {
}
typealias UDPConnectionContext = Context<UDPConnectionState, UDPConnectionActions>
typealias UDPConnectionModule = CustomModule<UDPConnectionState, UDPConnectionActions, UDPConnection>
data class UDPConnection(
val context: UDPConnectionContext,
@@ -60,12 +66,6 @@ data class UDPConnection(
val getTracker: (sensorId: Int) -> Tracker?,
)
data class UDPConnectionModule(
val reducer: ((UDPConnectionState, UDPConnectionActions) -> UDPConnectionState)? = null,
val observer: ((UDPConnection) -> Unit)? = null,
)
val PacketModule = UDPConnectionModule(
reducer = { s, a ->
when (a) {

View File

@@ -1,15 +1,11 @@
package dev.slimevr.tracker.udp
import dev.slimevr.VRServer
import dev.slimevr.VRServerContext
import dev.slimevr.config.ConfigContext
import dev.slimevr.config.AppConfig
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.BoundDatagramSocket
import io.ktor.network.sockets.Datagram
import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.aSocket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@@ -21,10 +17,10 @@ data class UDPTrackerServerState(
suspend fun createUDPTrackerServer(
serverContext: VRServer,
configContext: ConfigContext,
configContext: AppConfig,
): UDPTrackerServerState {
val state = UDPTrackerServerState(
port = configContext.state.value.settingsConfig.trackerPort,
port = configContext.settings.context.state.value.data.trackerPort,
connections = mutableMapOf(),
)

View File

@@ -1 +1,3 @@
/build
config/*
!config/README.md

View File

@@ -3,14 +3,16 @@
package dev.slimevr.desktop
import dev.slimevr.VRServer
import dev.slimevr.config.createConfig
import dev.slimevr.config.createAppConfig
import dev.slimevr.solarxr.createSolarXRWebsocketServer
import dev.slimevr.tracker.udp.createUDPTrackerServer
import io.eiren.util.resolveConfigDirectory
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main(args: Array<String>) = runBlocking {
val config = createConfig(this)
val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder")
val config = createAppConfig(this, configFolder = configFolder.toFile())
val server = VRServer.create(this)
launch {