mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Trying to get the basic shape of the state system out. Testing it with the udp part of the server to confirm the implementation choices
This commit is contained in:
@@ -12,8 +12,10 @@
|
||||
|
||||
perSystem = { pkgs, ... }:
|
||||
let
|
||||
java = pkgs.jdk24;
|
||||
|
||||
runtimeLibs = pkgs: (with pkgs; [
|
||||
jdk22
|
||||
java
|
||||
|
||||
alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat
|
||||
gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify
|
||||
@@ -33,8 +35,8 @@
|
||||
name = "slimevr-env";
|
||||
targetPkgs = runtimeLibs;
|
||||
profile = ''
|
||||
export JAVA_HOME=${pkgs.jdk22}
|
||||
export PATH="${pkgs.jdk22}/bin:$PATH"
|
||||
export JAVA_HOME=${java}
|
||||
export PATH="${java}/bin:$PATH"
|
||||
|
||||
# Tell electron-builder to use system tools instead of downloading them
|
||||
export USE_SYSTEM_FPM=true
|
||||
|
||||
@@ -22,12 +22,12 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_22
|
||||
targetCompatibility = JavaVersion.VERSION_22
|
||||
sourceCompatibility = JavaVersion.VERSION_24
|
||||
targetCompatibility = JavaVersion.VERSION_24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ configure<com.diffplug.gradle.spotless.SpotlessExtension> {
|
||||
",dev.slimevr.tracking.trackers.*,dev.slimevr.desktop.platform.ProtobufMessages.*" +
|
||||
",solarxr_protocol.rpc.*,kotlinx.coroutines.*,com.illposed.osc.*,android.app.*",
|
||||
"ij_kotlin_allow_trailing_comma" to true,
|
||||
"ktlint_standard_filename" to "disabled",
|
||||
)
|
||||
val ktlintVersion = "1.8.0"
|
||||
kotlinGradle {
|
||||
|
||||
@@ -16,17 +16,17 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_22)
|
||||
jvmTarget.set(JvmTarget.JVM_24)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("com.mayakapps.kache:kache:2.1.1")
|
||||
implementation("io.ktor:ktor-network:3.0.0")
|
||||
implementation("io.ktor:ktor-utils:3.0.3")
|
||||
|
||||
api("com.github.loucass003:EspflashKotlin:v0.11.0")
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package dev.slimevr
|
||||
|
||||
class VRServer {
|
||||
}
|
||||
67
server/core/src/main/java/dev/slimevr/config/config.kt
Normal file
67
server/core/src/main/java/dev/slimevr/config/config.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package dev.slimevr.config
|
||||
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.BasicModule
|
||||
import dev.slimevr.context.createContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserConfigState(
|
||||
val userHeight: Float = 1.6f,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsConfigState(
|
||||
val trackerPort: Int = 6969,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GlobalConfigState(
|
||||
val selectedUserProfile: String = "default",
|
||||
val selectedSettingsProfile: String = "default",
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
typealias ConfigContext = Context<ConfigState, ConfigAction>
|
||||
typealias ConfigModule = BasicModule<ConfigState, ConfigAction>
|
||||
|
||||
val ConfigModuleTest = ConfigModule(
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun createConfig(scope: CoroutineScope): ConfigContext {
|
||||
|
||||
val modules = listOf(ConfigModuleTest)
|
||||
|
||||
val context = createContext(
|
||||
initialState = ConfigState(),
|
||||
reducers = modules.map { it.reducer },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
modules.map { it.observer }.forEach { it?.invoke(context) }
|
||||
|
||||
context.dispatch(ConfigAction.ChangeProfile(settingsProfile = "Test"))
|
||||
|
||||
return context
|
||||
}
|
||||
44
server/core/src/main/java/dev/slimevr/context/context.kt
Normal file
44
server/core/src/main/java/dev/slimevr/context/context.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package dev.slimevr.context
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class BasicModule<S, A>(
|
||||
val reducer: (S, A) -> S,
|
||||
val observer: ((Context<S, A>) -> Unit)? = null,
|
||||
)
|
||||
|
||||
data class Context<S, in A>(
|
||||
val state: StateFlow<S>,
|
||||
val dispatch: suspend (A) -> Unit,
|
||||
val dispatchAll: suspend (List<A>) -> Unit,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
fun <S, A> createContext(
|
||||
initialState: S,
|
||||
scope: CoroutineScope,
|
||||
reducers: List<(S, A) -> S>,
|
||||
): Context<S, A> {
|
||||
val mutableStateFlow = MutableStateFlow(initialState)
|
||||
|
||||
val applyAction: (S, A) -> S = { currentState, action ->
|
||||
reducers.fold(currentState) { s, reducer -> reducer(s, action) }
|
||||
}
|
||||
|
||||
val dispatch: suspend (A) -> Unit = { action ->
|
||||
mutableStateFlow.update { applyAction(it, action) }
|
||||
}
|
||||
|
||||
val dispatchAll: suspend (List<A>) -> Unit = { actions ->
|
||||
mutableStateFlow.update { currentState ->
|
||||
actions.fold(currentState) { s, action -> applyAction(s, action) }
|
||||
}
|
||||
}
|
||||
val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope)
|
||||
|
||||
return context
|
||||
}
|
||||
18
server/core/src/main/java/dev/slimevr/tracker/tracker.kt
Normal file
18
server/core/src/main/java/dev/slimevr/tracker/tracker.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package dev.slimevr.tracker
|
||||
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.BasicModule
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
|
||||
data class TrackerState(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val rawRotation: Quaternion,
|
||||
)
|
||||
|
||||
sealed interface TrackerActions {
|
||||
data class UpdateRotation(val rotation: Quaternion)
|
||||
}
|
||||
|
||||
typealias TrackerContext = Context<TrackerState, TrackerActions>
|
||||
typealias TrackerModule = BasicModule<TrackerState, TrackerActions>
|
||||
138
server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt
Normal file
138
server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package dev.slimevr.tracker.udp
|
||||
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.createContext
|
||||
import io.ktor.network.sockets.BoundDatagramSocket
|
||||
import io.ktor.network.sockets.Datagram
|
||||
import io.ktor.network.sockets.InetSocketAddress
|
||||
import io.ktor.utils.io.core.buildPacket
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LastPing(
|
||||
val id: Int,
|
||||
val startTime: Long,
|
||||
val duration: Long,
|
||||
)
|
||||
|
||||
data class UDPConnectionState(
|
||||
val id: String,
|
||||
val lastPacket: Long,
|
||||
val lastPacketNum: Int,
|
||||
val lastPing: LastPing,
|
||||
val didHandshake: Boolean,
|
||||
val address: String,
|
||||
val port: Int,
|
||||
)
|
||||
|
||||
sealed interface UDPConnectionActions {
|
||||
data class StartPing(val startTime: Long): UDPConnectionActions
|
||||
data class ReceivedPong(val id: Int, val duration: Long): UDPConnectionActions
|
||||
}
|
||||
|
||||
typealias UDPConnectionContext = Context<UDPConnectionState, UDPConnectionActions>
|
||||
|
||||
data class UDPConnection(
|
||||
val context: UDPConnectionContext,
|
||||
val packetEvents: PacketDispatcher,
|
||||
val send: (Packet) -> Unit
|
||||
)
|
||||
|
||||
data class UDPConnectionModule(
|
||||
val reducer: (UDPConnectionState, UDPConnectionActions) -> UDPConnectionState,
|
||||
val observer: ((UDPConnection) -> Unit)? = null,
|
||||
)
|
||||
|
||||
val PingModule = UDPConnectionModule(
|
||||
reducer = { s, a ->
|
||||
when (a) {
|
||||
is UDPConnectionActions.StartPing -> {
|
||||
s.copy(lastPing = s.lastPing.copy(startTime = a.startTime))
|
||||
}
|
||||
is UDPConnectionActions.ReceivedPong -> {
|
||||
s.copy(lastPing = s.lastPing.copy(duration = a.duration, id = a.id))
|
||||
}
|
||||
else -> s
|
||||
}
|
||||
},
|
||||
observer = {
|
||||
// Send the ping every 1s
|
||||
it.context.scope.launch {
|
||||
while (isActive) {
|
||||
val state = it.context.state.value
|
||||
if (state.didHandshake) {
|
||||
it.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis()))
|
||||
it.send(PingPong(state.lastPacketNum + 1))
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// listen for the pong
|
||||
it.packetEvents.on<PingPong> { packet ->
|
||||
val state = it.context.state.value
|
||||
if (packet.pingId != state.lastPing.id + 1) {
|
||||
println("Ping ID does not match, ignoring")
|
||||
return@on
|
||||
}
|
||||
|
||||
val ping = System.currentTimeMillis() - state.lastPing.startTime;
|
||||
|
||||
it.context.scope.launch {
|
||||
it.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.pingId, duration = ping))
|
||||
|
||||
// TODO update the device ping delay
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
fun createUDPConnectionContext(
|
||||
id: String,
|
||||
socket: BoundDatagramSocket,
|
||||
remoteAddress: InetSocketAddress,
|
||||
scope: CoroutineScope
|
||||
): UDPConnection {
|
||||
|
||||
val modules = listOf(PingModule)
|
||||
|
||||
val context = createContext(
|
||||
initialState = UDPConnectionState(
|
||||
id = id,
|
||||
lastPacket = System.currentTimeMillis(),
|
||||
lastPacketNum = 0,
|
||||
lastPing = LastPing(id = 0, duration = 0, startTime = 0),
|
||||
didHandshake = false,
|
||||
address = remoteAddress.hostname,
|
||||
port = remoteAddress.port,
|
||||
),
|
||||
reducers = modules.map { it.reducer },
|
||||
scope = scope
|
||||
)
|
||||
|
||||
val dispatcher = PacketDispatcher()
|
||||
|
||||
val sendFunc = { packet: Packet ->
|
||||
scope.launch {
|
||||
val bytePacket = buildPacket {
|
||||
writePacket(this, packet)
|
||||
}
|
||||
socket.send(Datagram(bytePacket, remoteAddress))
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
val conn = UDPConnection(
|
||||
context,
|
||||
dispatcher,
|
||||
send = sendFunc
|
||||
)
|
||||
|
||||
modules.map { it.observer }.forEach { it?.invoke(conn) }
|
||||
|
||||
return conn
|
||||
}
|
||||
187
server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt
Normal file
187
server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt
Normal file
@@ -0,0 +1,187 @@
|
||||
package dev.slimevr.tracker.udp
|
||||
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
import io.github.axisangles.ktmath.Vector3
|
||||
import io.ktor.utils.io.core.remaining
|
||||
import kotlinx.io.*
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
// ── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
enum class PacketType(val id: Int) {
|
||||
HEARTBEAT(0), ROTATION(1), HANDSHAKE(3), ACCEL(4), PING_PONG(10),
|
||||
SERIAL(11), BATTERY_LEVEL(12), TAP(13), ERROR(14), SENSOR_INFO(15),
|
||||
ROTATION_2(16), ROTATION_DATA(17), MAGNETOMETER_ACCURACY(18),
|
||||
SIGNAL_STRENGTH(19), TEMPERATURE(20), USER_ACTION(21), PROTOCOL_CHANGE(200);
|
||||
|
||||
companion object {
|
||||
private val map = entries.associateBy { it.id }
|
||||
fun fromId(id: Int) = map[id]
|
||||
}
|
||||
}
|
||||
|
||||
enum class UserAction(val id: Int) {
|
||||
RESET_FULL(2), RESET_YAW(3), RESET_MOUNTING(4), PAUSE_TRACKING(5);
|
||||
companion object {
|
||||
private val map = entries.associateBy { it.id }
|
||||
fun fromId(id: Int) = map[id]
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Packet
|
||||
sealed interface SensorSpecificPacket : Packet { val sensorId: Int }
|
||||
sealed interface RotationPacket : SensorSpecificPacket { val rotation: Quaternion }
|
||||
|
||||
data object Heartbeat : Packet
|
||||
data class Handshake(val board: Int = 0, val imu: Int = 0, val mcu: Int = 0, val pVer: Int = 0, val firmware: String? = null, val mac: String? = null) : Packet
|
||||
data class Rotation(override val sensorId: Int = 0, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket
|
||||
data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket
|
||||
data class PingPong(val pingId: Int = 0) : Packet
|
||||
data class Serial(val serial: String = "") : Packet
|
||||
data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : Packet
|
||||
data class Tap(override val sensorId: Int = 0, val tap: Int = 0) : SensorSpecificPacket
|
||||
data class Error(override val sensorId: Int = 0, val errorNumber: Int = 0) : SensorSpecificPacket
|
||||
data class SensorInfo(override val sensorId: Int = 0, val status: Int = 0, val type: Int = 0) : SensorSpecificPacket
|
||||
data class Rotation2(override val sensorId: Int = 1, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket
|
||||
data class RotationData(override val sensorId: Int = 0, val dataType: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY, val calibration: Int = 0) : SensorSpecificPacket
|
||||
data class MagnetometerAccuracy(override val sensorId: Int = 0, val accuracy: Float = 0f) : SensorSpecificPacket
|
||||
data class SignalStrength(override val sensorId: Int = 0, val signal: Int = 0) : SensorSpecificPacket
|
||||
data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : SensorSpecificPacket
|
||||
data class UserActionPacket(val action: UserAction? = null) : Packet
|
||||
data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : Packet
|
||||
|
||||
private fun Source.readU8() = readByte().toInt() and 0xFF
|
||||
private fun Sink.writeU8(v: Int) = writeByte(v.toByte())
|
||||
|
||||
private fun Source.readSafeFloat() = readFloat().let { if (it.isNaN()) 0f else it }
|
||||
private fun Source.readSafeQuat() = Quaternion(readFloat(), readFloat(), readFloat(), readFloat()).let {
|
||||
if (it.x.isNaN() || (it.x == 0f && it.y == 0f && it.z == 0f && it.w == 0f)) Quaternion.IDENTITY else it
|
||||
}
|
||||
|
||||
private fun Sink.writeQuat(q: Quaternion) {
|
||||
writeFloat(q.x); writeFloat(q.y); writeFloat(q.z); writeFloat(q.w)
|
||||
}
|
||||
|
||||
private fun Source.readStr(len: Int) = buildString {
|
||||
repeat(len) { readByte().toInt().takeIf { it != 0 }?.let { append(it.toChar()) } }
|
||||
}
|
||||
|
||||
object PacketCodec {
|
||||
fun read(type: PacketType, src: Source): Packet = when (type) {
|
||||
PacketType.HEARTBEAT -> Heartbeat
|
||||
PacketType.HANDSHAKE -> with(src) {
|
||||
if (exhausted()) return Handshake()
|
||||
val b = if (remaining >= 4) readInt() else 0
|
||||
val i = if (remaining >= 4) readInt() else 0
|
||||
val m = if (remaining >= 4) readInt() else 0
|
||||
if (remaining >= 12) skip(12)
|
||||
val p = if (remaining >= 4) readInt() else 0
|
||||
val f = if (remaining >= 1) readStr(readByte().toInt()) else null
|
||||
val mac = if (remaining >= 6) readByteArray(6).joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } else null
|
||||
Handshake(b, i, m, p, f, mac)
|
||||
}
|
||||
PacketType.ROTATION -> Rotation(rotation = src.readSafeQuat())
|
||||
PacketType.ACCEL -> Accel(Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), if (!src.exhausted()) src.readU8() else 0)
|
||||
PacketType.PING_PONG -> PingPong(src.readInt())
|
||||
PacketType.SERIAL -> Serial(src.readStr(src.readInt()))
|
||||
PacketType.BATTERY_LEVEL -> src.readSafeFloat().let { f ->
|
||||
if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f)
|
||||
}
|
||||
PacketType.TAP -> Tap(src.readU8(), src.readU8())
|
||||
PacketType.ERROR -> Error(src.readU8(), src.readU8())
|
||||
PacketType.SENSOR_INFO -> SensorInfo(src.readU8(), src.readU8(), if (!src.exhausted()) src.readU8() else 0)
|
||||
PacketType.ROTATION_2 -> Rotation2(rotation = src.readSafeQuat())
|
||||
PacketType.ROTATION_DATA -> RotationData(src.readU8(), src.readU8(), src.readSafeQuat(), src.readU8())
|
||||
PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy(src.readU8(), src.readSafeFloat())
|
||||
PacketType.SIGNAL_STRENGTH -> SignalStrength(src.readU8(), src.readByte().toInt())
|
||||
PacketType.TEMPERATURE -> Temperature(src.readU8(), src.readSafeFloat())
|
||||
PacketType.USER_ACTION -> UserActionPacket(UserAction.fromId(src.readU8()))
|
||||
PacketType.PROTOCOL_CHANGE -> ProtocolChange(src.readU8(), src.readU8())
|
||||
}
|
||||
|
||||
fun write(dst: Sink, packet: Packet) = when (packet) {
|
||||
is Heartbeat -> {}
|
||||
is Handshake -> {
|
||||
dst.writeU8(PacketType.HANDSHAKE.id)
|
||||
dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
is Rotation -> dst.writeQuat(packet.rotation)
|
||||
is Accel -> { dst.writeFloat(packet.acceleration.x); dst.writeFloat(packet.acceleration.y); dst.writeFloat(packet.acceleration.z) }
|
||||
is PingPong -> dst.writeInt(packet.pingId)
|
||||
is Serial -> { dst.writeInt(packet.serial.length); packet.serial.forEach { dst.writeU8(it.code) } }
|
||||
is BatteryLevel -> { dst.writeFloat(packet.voltage); dst.writeFloat(packet.level) }
|
||||
is Tap -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.tap) }
|
||||
is Error -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.errorNumber) }
|
||||
is SensorInfo -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.status); dst.writeU8(packet.type) }
|
||||
is Rotation2 -> dst.writeQuat(packet.rotation)
|
||||
is RotationData -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.dataType); dst.writeQuat(packet.rotation); dst.writeU8(packet.calibration) }
|
||||
is MagnetometerAccuracy -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.accuracy) }
|
||||
is SignalStrength -> { dst.writeU8(packet.sensorId); dst.writeByte(packet.signal.toByte()) }
|
||||
is Temperature -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.temp) }
|
||||
is UserActionPacket -> dst.writeU8(packet.action?.id ?: 0)
|
||||
is ProtocolChange -> { dst.writeU8(packet.targetProtocol); dst.writeU8(packet.targetVersion) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry Points ────────────────────────────────────────────────────────────
|
||||
|
||||
fun readPacket(src: Source): Packet? {
|
||||
if (src.exhausted()) return null
|
||||
val type = PacketType.fromId(src.readInt()) ?: return null
|
||||
if (type != PacketType.HANDSHAKE) src.skip(8) // Skip sequence number
|
||||
return PacketCodec.read(type, src)
|
||||
}
|
||||
|
||||
fun writePacket(dst: Sink, packet: Packet) {
|
||||
val type = when(packet) {
|
||||
is Heartbeat -> PacketType.HEARTBEAT
|
||||
is Handshake -> PacketType.HANDSHAKE
|
||||
is Rotation -> PacketType.ROTATION
|
||||
is Accel -> PacketType.ACCEL
|
||||
is PingPong -> PacketType.PING_PONG
|
||||
is Serial -> PacketType.SERIAL
|
||||
is BatteryLevel -> PacketType.BATTERY_LEVEL
|
||||
is Tap -> PacketType.TAP
|
||||
is Error -> PacketType.ERROR
|
||||
is SensorInfo -> PacketType.SENSOR_INFO
|
||||
is Rotation2 -> PacketType.ROTATION_2
|
||||
is RotationData -> PacketType.ROTATION_DATA
|
||||
is MagnetometerAccuracy -> PacketType.MAGNETOMETER_ACCURACY
|
||||
is SignalStrength -> PacketType.SIGNAL_STRENGTH
|
||||
is Temperature -> PacketType.TEMPERATURE
|
||||
is UserActionPacket -> PacketType.USER_ACTION
|
||||
is ProtocolChange -> PacketType.PROTOCOL_CHANGE
|
||||
}
|
||||
|
||||
if (type != PacketType.HANDSHAKE) {
|
||||
dst.writeInt(type.id)
|
||||
dst.writeLong(type.id.toLong()) // Sequence number placeholder
|
||||
}
|
||||
PacketCodec.write(dst, packet)
|
||||
}
|
||||
|
||||
|
||||
class PacketDispatcher {
|
||||
val listeners = mutableMapOf<KClass<out Packet>, MutableList<(Packet) -> Unit>>()
|
||||
|
||||
/**
|
||||
* Listen for a specific packet type.
|
||||
* Usage: dispatcher.on<Rotation> { packet -> println(packet.rotation) }
|
||||
*/
|
||||
inline fun <reified T : Packet> on(crossinline callback: (T) -> Unit) {
|
||||
val list = listeners.getOrPut(T::class) { mutableListOf() }
|
||||
synchronized(list) {
|
||||
list.add { callback(it as T) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a packet to all registered listeners for its type.
|
||||
*/
|
||||
fun emit(packet: Packet) {
|
||||
val list = listeners[packet::class] ?: return
|
||||
synchronized(list) {
|
||||
list.forEach { it(packet) }
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/core/src/main/java/dev/slimevr/tracker/udp/server.kt
Normal file
86
server/core/src/main/java/dev/slimevr/tracker/udp/server.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package dev.slimevr.tracker.udp
|
||||
|
||||
import dev.slimevr.VRServerContext
|
||||
import dev.slimevr.config.ConfigContext
|
||||
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
|
||||
|
||||
data class UDPTrackerServerState(
|
||||
val port: Int,
|
||||
val connections: MutableMap<String, UDPConnection>
|
||||
)
|
||||
|
||||
suspend fun processPacket(
|
||||
socket: BoundDatagramSocket,
|
||||
datagram: Datagram,
|
||||
workerId: Int
|
||||
) {
|
||||
|
||||
|
||||
}
|
||||
|
||||
const val PACKET_WORKERS = 4
|
||||
|
||||
suspend fun createUDPTrackerServer(
|
||||
serverContext: VRServerContext,
|
||||
configContext: ConfigContext
|
||||
): UDPTrackerServerState {
|
||||
val state = UDPTrackerServerState(
|
||||
port = configContext.state.value.settingsConfig.trackerPort,
|
||||
connections = mutableMapOf()
|
||||
)
|
||||
|
||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||
val serverSocket =
|
||||
aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", state.port))
|
||||
val packetChannel = Channel<Datagram>(capacity = Channel.BUFFERED)
|
||||
|
||||
supervisorScope {
|
||||
launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
val datagram = serverSocket.receive()
|
||||
packetChannel.send(datagram)
|
||||
}
|
||||
}
|
||||
|
||||
repeat(PACKET_WORKERS) { workerId ->
|
||||
launch(Dispatchers.Default) {
|
||||
for (datagram in packetChannel) {
|
||||
val packet = readPacket(datagram.packet)
|
||||
if (packet == null) {
|
||||
println("null packet")
|
||||
continue
|
||||
}
|
||||
|
||||
val address = datagram.address as InetSocketAddress
|
||||
val connContext = state.connections[address.hostname]
|
||||
|
||||
if (connContext !== null)
|
||||
connContext.packetEvents.emit(packet = packet)
|
||||
else {
|
||||
val newContext = createUDPConnectionContext(
|
||||
id = address.hostname,
|
||||
remoteAddress = address,
|
||||
socket = serverSocket,
|
||||
scope = this
|
||||
)
|
||||
|
||||
state.connections[address.hostname] = newContext
|
||||
newContext.packetEvents.emit(packet = packet)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
54
server/core/src/main/java/dev/slimevr/vrserver.kt
Normal file
54
server/core/src/main/java/dev/slimevr/vrserver.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package dev.slimevr
|
||||
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.BasicModule
|
||||
import dev.slimevr.context.createContext
|
||||
import dev.slimevr.tracker.TrackerContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
data class VRServerState(
|
||||
val trackers: Map<Int, TrackerContext>,
|
||||
)
|
||||
|
||||
sealed interface VRServerActions {
|
||||
data class NewTracker(val trackerId: Int, val context: TrackerContext) : VRServerActions
|
||||
}
|
||||
|
||||
typealias VRServerContext = Context<VRServerState, VRServerActions>
|
||||
typealias VRServerModule = BasicModule<VRServerState, VRServerActions>
|
||||
|
||||
val TestModule = VRServerModule(
|
||||
reducer = { s, a ->
|
||||
when (a) {
|
||||
is VRServerActions.NewTracker -> s.copy(
|
||||
trackers = s.trackers + (a.trackerId to a.context),
|
||||
)
|
||||
}
|
||||
},
|
||||
observer = { context ->
|
||||
context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach {
|
||||
println("tracker list size changed")
|
||||
}.launchIn(context.scope)
|
||||
},
|
||||
)
|
||||
|
||||
fun createVRServer(scope: CoroutineScope): VRServerContext {
|
||||
val server = VRServerState(
|
||||
trackers = mapOf(),
|
||||
)
|
||||
|
||||
val modules = listOf(TestModule)
|
||||
|
||||
val context = createContext(
|
||||
initialState = server,
|
||||
reducers = modules.map { it.reducer },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
modules.map { it.observer }.forEach { it?.invoke(context) }
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -18,17 +18,17 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(22))
|
||||
languageVersion.set(JavaLanguageVersion.of(24))
|
||||
}
|
||||
}
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_22)
|
||||
jvmTarget.set(JvmTarget.JVM_24)
|
||||
freeCompilerArgs.set(listOf("-Xvalue-classes"))
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
|
||||
implementation("net.java.dev.jna:jna:5.+")
|
||||
implementation("net.java.dev.jna:jna-platform:5.+")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("com.fazecast:jSerialComm:2.11.3") {
|
||||
exclude(group = "com.fazecast", module = "android")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
package dev.slimevr.desktop
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
println("Hello world!")
|
||||
import dev.slimevr.config.createConfig
|
||||
import dev.slimevr.createVRServer
|
||||
import dev.slimevr.tracker.udp.createUDPTrackerServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
fun main(args: Array<String>) = runBlocking {
|
||||
val config = createConfig(this)
|
||||
val server = createVRServer(this)
|
||||
|
||||
val udpServer = createUDPTrackerServer(server, config)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user