mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Basic config system
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
92
server/core/src/main/java/dev/slimevr/config/configio.kt
Normal file
92
server/core/src/main/java/dev/slimevr/config/configio.kt
Normal 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)
|
||||
}
|
||||
101
server/core/src/main/java/dev/slimevr/config/settings.kt
Normal file
101
server/core/src/main/java/dev/slimevr/config/settings.kt
Normal 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)
|
||||
}
|
||||
103
server/core/src/main/java/dev/slimevr/config/user.kt
Normal file
103
server/core/src/main/java/dev/slimevr/config/user.kt
Normal 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)
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
90
server/core/src/main/java/dev/slimevr/platform.kt
Normal file
90
server/core/src/main/java/dev/slimevr/platform.kt
Normal 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
|
||||
}
|
||||
}
|
||||
20
server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt
Normal file
20
server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt
Normal 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,
|
||||
)
|
||||
145
server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt
Normal file
145
server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
2
server/desktop/.gitignore
vendored
2
server/desktop/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/build
|
||||
config/*
|
||||
!config/README.md
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Submodule solarxr-protocol updated: 84fc97ff30...f784b4bb03
Reference in New Issue
Block a user