diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/config.kt index cdfdffd87..5a971cc66 100644 --- a/server/core/src/main/java/dev/slimevr/config/config.kt +++ b/server/core/src/main/java/dev/slimevr/config/config.kt @@ -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 -typealias ConfigModule = BasicModule +typealias GlobalConfigContext = Context +typealias GlobalConfigModule = BasicModule -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, + ) } diff --git a/server/core/src/main/java/dev/slimevr/config/configio.kt b/server/core/src/main/java/dev/slimevr/config/configio.kt new file mode 100644 index 000000000..f2aba7607 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/configio.kt @@ -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 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 launchAutosave( + scope: CoroutineScope, + state: StateFlow, + 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) +} diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt new file mode 100644 index 000000000..a85f5533b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -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 +typealias SettingsModule = CustomModule + +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) +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt new file mode 100644 index 000000000..ead6936ad --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -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 +typealias UserConfigModule = CustomModule + +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) +} diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 73cf82ebd..3a208beab 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -6,10 +6,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +interface Module { + val reducer: ((S, A) -> S)? + val observer: ((C) -> Unit)? +} + data class BasicModule( - val reducer: ((S, A) -> S)? = null, - val observer: ((Context) -> Unit)? = null, -) + override val reducer: ((S, A) -> S)? = null, + override val observer: ((Context) -> Unit)? = null, +) : Module> + +data class CustomModule( + override val reducer: ((S, A) -> S)? = null, + override val observer: ((C) -> Unit)? = null, +) : Module data class Context( val state: StateFlow, diff --git a/server/core/src/main/java/dev/slimevr/platform.kt b/server/core/src/main/java/dev/slimevr/platform.kt new file mode 100644 index 000000000..67ba9f0b7 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/platform.kt @@ -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 + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt new file mode 100644 index 000000000..5a01f49f6 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt @@ -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 +) { + val globalRotation: Quaternion + get() = localRotation //FIXME: do maths LMAO +} + +data class SkeletonState( + val bones: Map, + val rootBone: Bone, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt new file mode 100644 index 000000000..59246f031 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -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, + 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 { 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 { 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()) + } + } +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index 42c1fec33..b8817736a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -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, @@ -40,6 +25,8 @@ sealed interface SolarXRConnectionActions { } typealias SolarXRConnectionContext = Context +typealias SolarXRConnectionModule = CustomModule + class PacketDispatcher { val listeners = mutableMapOf, MutableList 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 { 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, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 1bc589623..94dc62573 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -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 +typealias UDPConnectionModule = CustomModule + 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) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index 5c6db8243..49fd00783 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -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(), ) diff --git a/server/desktop/.gitignore b/server/desktop/.gitignore index 796b96d1c..d18cf6eed 100644 --- a/server/desktop/.gitignore +++ b/server/desktop/.gitignore @@ -1 +1,3 @@ /build +config/* +!config/README.md diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt index ba14c6999..573491a36 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -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) = 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 { diff --git a/solarxr-protocol b/solarxr-protocol index 84fc97ff3..f784b4bb0 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 84fc97ff306f2d60e8f4f34ac6d2374d2587bcfc +Subproject commit f784b4bb034b1c588e9e0cefdd909c7ee5844dfc