IPC interface for linux (tested) and windows (untested) - Solarxr / driver

This commit is contained in:
loucass003
2026-03-24 01:21:47 +01:00
parent a42ed79003
commit af4665e7c9
12 changed files with 446 additions and 8 deletions

View File

@@ -20,3 +20,4 @@ buildconfigVersion=6.0.7
# We should probably stop using grgit, see:
# https://andrewoberstar.com/posts/2024-04-02-dont-commit-to-grgit/
grgitVersion=5.3.3
wireVersion=5.3.1

View File

@@ -41,6 +41,7 @@ data class SettingsState(
)
sealed interface SettingsActions {
data class Update(val transform: SettingsState.() -> SettingsState) : SettingsActions
data class LoadProfile(val newState: SettingsState) : SettingsActions
}
@@ -56,7 +57,9 @@ data class Settings(
val DefaultSettingsModule = SettingsModule(
reducer = { s, a ->
when (a) {
is SettingsActions.Update -> a.transform(s)
is SettingsActions.LoadProfile -> a.newState
else -> s
}
},
)
@@ -98,4 +101,4 @@ suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String)
}
return Settings(context, configDir = settingsDir.toString(), swap)
}
}

View File

@@ -3,6 +3,7 @@ package dev.slimevr.config
import dev.slimevr.context.Context
import dev.slimevr.context.CustomModule
import dev.slimevr.context.createContext
import dev.slimevr.tracker.DeviceActions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
@@ -57,6 +58,7 @@ data class UserConfig(
val DefaultUserModule = UserConfigModule(
reducer = { s, a ->
when (a) {
is UserConfigActions.Update -> a.transform(s)
is UserConfigActions.LoadProfile -> a.newState
else -> s
}

View File

@@ -1,4 +1,4 @@
package io.eiren.util
package dev.slimevr
import java.io.File
import java.nio.file.Path

View File

@@ -8,13 +8,9 @@ import io.ktor.server.netty.Netty
import io.ktor.server.routing.routing
import io.ktor.server.websocket.*
import io.ktor.websocket.Frame
import io.ktor.websocket.readBytes
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import solarxr_protocol.MessageBundle
import solarxr_protocol.rpc.ResetRequest
import java.nio.ByteBuffer
import kotlin.reflect.KClass
const val SOLARXR_PORT = 21110
@@ -27,10 +23,16 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) {
context.dataFeedDispatcher.emit(msg)
}
// FIXME: temporary test
context.rpcDispatcher.on<ResetRequest> {
println("RESET $it")
}
messageBundle.rpcMsgs?.forEach {
val msg = it.message ?: return;
context.rpcDispatcher.emit(msg)
}
}

View File

@@ -14,6 +14,7 @@ plugins {
id("com.gradleup.shadow")
id("com.github.gmazzo.buildconfig")
id("org.ajoberstar.grgit")
id("com.squareup.wire")
}
kotlin {
@@ -54,6 +55,23 @@ allprojects {
}
}
val downloadDriverProto by tasks.registering {
val protoFile = layout.buildDirectory.file("proto/ProtobufMessages.proto")
outputs.file(protoFile)
doLast {
val url = "https://raw.githubusercontent.com/SlimeVR/SlimeVR-OpenVR-Driver/main/src/bridge/ProtobufMessages.proto"
protoFile.get().asFile.parentFile.mkdirs()
uri(url).toURL().openStream().use { it.copyTo(protoFile.get().asFile.outputStream()) }
}
}
wire {
sourcePath {
srcDir(downloadDriverProto.map { layout.buildDirectory.dir("proto") })
}
kotlin { }
}
dependencies {
implementation(project(":server:core"))
implementation(project(":solarxr-protocol"))

View File

@@ -4,9 +4,10 @@ package dev.slimevr.desktop
import dev.slimevr.VRServer
import dev.slimevr.config.createAppConfig
import dev.slimevr.desktop.ipc.createIpcServers
import dev.slimevr.solarxr.createSolarXRWebsocketServer
import dev.slimevr.tracker.udp.createUDPTrackerServer
import io.eiren.util.resolveConfigDirectory
import dev.slimevr.resolveConfigDirectory
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -21,5 +22,6 @@ fun main(args: Array<String>) = runBlocking {
launch {
createSolarXRWebsocketServer(server)
}
createIpcServers(server)
Unit
}

View File

@@ -0,0 +1,31 @@
package dev.slimevr.desktop.ipc
import dev.slimevr.VRServer
import dev.slimevr.CURRENT_PLATFORM
import dev.slimevr.Platform
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
const val DRIVER_SOCKET_NAME = "SlimeVRDriver"
const val FEEDER_SOCKET_NAME = "SlimeVRInput"
const val SOLARXR_SOCKET_NAME = "SlimeVRRpc"
const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver"
const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput"
const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc"
suspend fun createIpcServers(server: VRServer) = coroutineScope {
when (CURRENT_PLATFORM) {
Platform.LINUX, Platform.OSX -> {
launch { createUnixDriverSocket(server) }
launch { createUnixFeederSocket(server) }
launch { createUnixSolarXRSocket(server) }
}
Platform.WINDOWS -> {
launch { createWindowsDriverPipe(server) }
launch { createWindowsFeederPipe(server) }
launch { createWindowsSolarXRPipe(server) }
}
else -> Unit
}
}

View File

@@ -0,0 +1,95 @@
package dev.slimevr.desktop.ipc
import dev.slimevr.VRServer
import dev.slimevr.getSocketDirectory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.StandardProtocolFamily
import java.net.UnixDomainSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.ServerSocketChannel
import java.nio.channels.SocketChannel
import kotlin.io.path.Path
suspend fun createUnixDriverSocket(server: VRServer) =
acceptUnixClients(DRIVER_SOCKET_NAME) { channel ->
handleDriverConnection(
server = server,
messages = readFramedMessages(channel),
send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } },
)
}
suspend fun createUnixFeederSocket(server: VRServer) =
acceptUnixClients(FEEDER_SOCKET_NAME) { channel ->
handleFeederConnection(
server = server,
messages = readFramedMessages(channel),
send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } },
)
}
suspend fun createUnixSolarXRSocket(server: VRServer) =
acceptUnixClients(SOLARXR_SOCKET_NAME) { channel ->
handleSolarXRConnection(
server = server,
messages = readFramedMessages(channel),
send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } },
)
}
private fun isSocketInUse(socketPath: String): Boolean = try {
SocketChannel.open(StandardProtocolFamily.UNIX).use {
it.connect(UnixDomainSocketAddress.of(socketPath))
true
}
} catch (_: Exception) { false }
// Length field is LE u32 and includes the 4-byte header itself
private fun readFramedMessages(channel: SocketChannel) = flow {
val lenBuf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN)
while (true) {
lenBuf.clear()
if (channel.read(lenBuf) == -1) break
lenBuf.flip()
val dataBuf = ByteBuffer.allocate(lenBuf.int - 4)
while (dataBuf.hasRemaining()) {
if (channel.read(dataBuf) == -1) break
}
emit(dataBuf.array())
}
}.flowOn(Dispatchers.IO)
private fun writeFramed(channel: SocketChannel, bytes: ByteArray) {
val header = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(bytes.size + 4).flip()
channel.write(arrayOf(header, ByteBuffer.wrap(bytes)))
}
private suspend fun acceptUnixClients(
name: String,
handle: suspend (SocketChannel) -> Unit,
) = withContext(Dispatchers.IO) {
val path = Path(getSocketDirectory(), name)
val file = path.toFile()
if (file.exists()) {
check(!isSocketInUse(path.toString())) {
"$name socket is already in use by another process"
}
file.delete()
}
file.deleteOnExit()
ServerSocketChannel.open(StandardProtocolFamily.UNIX).use { server ->
server.bind(UnixDomainSocketAddress.of(path))
while (isActive) {
val client = server.accept()
launch { handle(client) }
}
}
}

View File

@@ -0,0 +1,156 @@
package dev.slimevr.desktop.ipc
import dev.slimevr.VRServer
import dev.slimevr.VRServerActions
import dev.slimevr.desktop.platform.Position
import dev.slimevr.desktop.platform.ProtobufMessage
import dev.slimevr.desktop.platform.TrackerAdded
import dev.slimevr.desktop.platform.Version
import dev.slimevr.solarxr.createSolarXRConnection
import dev.slimevr.solarxr.onSolarXRMessage
import dev.slimevr.tracker.DeviceOrigin
import dev.slimevr.tracker.TrackerActions
import dev.slimevr.tracker.createDevice
import dev.slimevr.tracker.createTracker
import io.github.axisangles.ktmath.Quaternion
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import solarxr_protocol.datatypes.hardware_info.ImuType
import java.nio.ByteBuffer
const val PROTOCOL_VERSION = 5
suspend fun handleDriverConnection(
server: VRServer,
messages: Flow<ByteArray>,
send: suspend (ByteArray) -> Unit,
) = coroutineScope {
val sendMutex = Mutex()
suspend fun sendMsg(msg: ProtobufMessage) = sendMutex.withLock {
send(ProtobufMessage.ADAPTER.encode(msg))
}
sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))
val subscribedTrackers = mutableSetOf<Int>()
val observerJob = launch {
server.context.state.collect { state ->
state.trackers.values.forEach { tracker ->
val trackerState = tracker.context.state.value
if (trackerState.origin == DeviceOrigin.DRIVER) return@forEach
if (subscribedTrackers.add(trackerState.id)) {
sendMsg(
ProtobufMessage(
tracker_added = TrackerAdded(
tracker_id = trackerState.id,
tracker_serial = trackerState.hardwareId,
tracker_name = trackerState.customName ?: trackerState.name,
),
),
)
launch {
tracker.context.state.collect { ts ->
sendMsg(
ProtobufMessage(
position = Position(
tracker_id = ts.id,
qx = ts.rawRotation.x,
qy = ts.rawRotation.y,
qz = ts.rawRotation.z,
qw = ts.rawRotation.w,
),
),
)
}
}
}
}
}
}
try {
messages.collect { bytes ->
val msg = ProtobufMessage.ADAPTER.decode(bytes)
msg.user_action?.let {
// TODO: dispatch user actions (reset, etc.) to VRServer
}
msg.version?.let {
// TODO: store remote protocol version if needed
}
}
} finally {
observerJob.cancel()
}
}
suspend fun handleFeederConnection(
server: VRServer,
messages: Flow<ByteArray>,
send: suspend (ByteArray) -> Unit,
) = coroutineScope {
send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))))
messages.collect { bytes ->
val msg = ProtobufMessage.ADAPTER.decode(bytes)
if (msg.tracker_added != null) {
val deviceId = server.nextHandle()
val device = createDevice(
scope = this,
id = deviceId,
address = msg.tracker_added.tracker_serial,
origin = DeviceOrigin.FEEDER,
serverContext = server,
)
server.context.dispatch(VRServerActions.NewDevice(deviceId, device))
val trackerId = server.nextHandle()
val tracker = createTracker(
scope = this,
id = trackerId,
deviceId = deviceId,
sensorType = ImuType.MPU9250,
hardwareId = msg.tracker_added.tracker_serial,
origin = DeviceOrigin.FEEDER,
serverContext = server,
)
server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker))
}
if (msg.position != null) {
server.getTracker(msg.position.tracker_id)?.context?.dispatch(
TrackerActions.Update {
copy(
rawRotation = Quaternion(
w = msg.position.qw,
x = msg.position.qx,
y = msg.position.qy,
z = msg.position.qz,
),
)
},
)
}
}
}
suspend fun handleSolarXRConnection(
server: VRServer,
messages: Flow<ByteArray>,
send: suspend (ByteArray) -> Unit,
) = coroutineScope {
val connection = createSolarXRConnection(
serverContext = server,
scope = this,
onSend = send,
)
messages.collect { bytes ->
onSolarXRMessage(ByteBuffer.wrap(bytes), connection)
}
}

View File

@@ -0,0 +1,126 @@
package dev.slimevr.desktop.ipc
import com.sun.jna.platform.win32.Advapi32
import com.sun.jna.platform.win32.Kernel32
import com.sun.jna.platform.win32.WinBase
import com.sun.jna.platform.win32.WinError
import com.sun.jna.platform.win32.WinNT
import com.sun.jna.ptr.IntByReference
import dev.slimevr.VRServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.nio.ByteOrder
private val k32 = Kernel32.INSTANCE
private val adv32 = Advapi32.INSTANCE
suspend fun createWindowsDriverPipe(server: VRServer) =
acceptWindowsClients(DRIVER_PIPE) { handle ->
handleDriverConnection(
server = server,
messages = readFramedMessages(handle),
send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } },
)
}
suspend fun createWindowsFeederPipe(server: VRServer) =
acceptWindowsClients(FEEDER_PIPE) { handle ->
handleFeederConnection(
server = server,
messages = readFramedMessages(handle),
send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } },
)
}
suspend fun createWindowsSolarXRPipe(server: VRServer) =
acceptWindowsClients(SOLARXR_PIPE) { handle ->
handleSolarXRConnection(
server = server,
messages = readFramedMessages(handle),
send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } },
)
}
// Length field is LE u32 and includes the 4-byte header itself
private fun readFramedMessages(handle: WinNT.HANDLE) = flow {
val lenBuf = ByteArray(4)
while (true) {
if (!readExact(handle, lenBuf, 4)) break
val totalLen = ByteBuffer.wrap(lenBuf).order(ByteOrder.LITTLE_ENDIAN).int
val dataBuf = ByteArray(totalLen - 4)
if (!readExact(handle, dataBuf, totalLen - 4)) break
emit(dataBuf)
}
}.flowOn(Dispatchers.IO)
private fun readExact(handle: WinNT.HANDLE, buf: ByteArray, len: Int): Boolean {
var offset = 0
val bytesRead = IntByReference()
while (offset < len) {
val ok = k32.ReadFile(handle, buf, len - offset, bytesRead, null)
if (!ok || bytesRead.value == 0) return false
offset += bytesRead.value
}
return true
}
private fun writeFramedPipe(handle: WinNT.HANDLE, bytes: ByteArray) {
val buf = ByteArray(bytes.size + 4)
ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putInt(bytes.size + 4)
bytes.copyInto(buf, destinationOffset = 4)
k32.WriteFile(handle, buf, buf.size, IntByReference(), null)
}
private fun createSecurePipe(pipeName: String): WinNT.HANDLE {
// Null DACL allows any process (including SteamVR driver) to connect
val descriptor = WinNT.SECURITY_DESCRIPTOR(64 * 1024)
adv32.InitializeSecurityDescriptor(descriptor, WinNT.SECURITY_DESCRIPTOR_REVISION)
adv32.SetSecurityDescriptorDacl(descriptor, true, null, false)
val attributes = WinBase.SECURITY_ATTRIBUTES()
attributes.lpSecurityDescriptor = descriptor.pointer
attributes.bInheritHandle = false
val pipe = k32.CreateNamedPipe(
pipeName,
WinBase.PIPE_ACCESS_DUPLEX,
WinBase.PIPE_TYPE_BYTE or WinBase.PIPE_READMODE_BYTE or WinBase.PIPE_WAIT,
WinBase.PIPE_UNLIMITED_INSTANCES,
65536, 65536, 0, attributes,
)
check(pipe != WinNT.INVALID_HANDLE_VALUE) {
"CreateNamedPipe failed for $pipeName: ${k32.GetLastError()}"
}
return pipe
}
private suspend fun acceptWindowsClients(
pipeName: String,
handle: suspend (WinNT.HANDLE) -> Unit,
) = withContext(Dispatchers.IO) {
while (isActive) {
val pipe = createSecurePipe(pipeName)
val ok = k32.ConnectNamedPipe(pipe, null)
val err = k32.GetLastError()
if (!ok && err != WinError.ERROR_PIPE_CONNECTED) {
k32.CloseHandle(pipe)
continue
}
launch {
try {
handle(pipe)
} finally {
k32.DisconnectNamedPipe(pipe)
k32.CloseHandle(pipe)
}
}
}
}

View File

@@ -21,6 +21,7 @@ pluginManagement {
val shadowJarVersion: String by settings
val buildconfigVersion: String by settings
val grgitVersion: String by settings
val wireVersion: String by settings
plugins {
kotlin("plugin.serialization") version kotlinVersion
kotlin("jvm") version kotlinVersion
@@ -29,6 +30,7 @@ pluginManagement {
id("com.gradleup.shadow") version shadowJarVersion
id("com.github.gmazzo.buildconfig") version buildconfigVersion
id("org.ajoberstar.grgit") version grgitVersion
id("com.squareup.wire") version wireVersion
}
}