Basic HID support

This commit is contained in:
loucass003
2026-03-26 22:41:47 +01:00
parent fc476a1683
commit ea92fb4c01
17 changed files with 639 additions and 29 deletions

View File

@@ -245,6 +245,7 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor
| `tracker/udp/` | Everything specific to the SlimeVR UDP wire protocol |
| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling |
| `config/` | JSON config read/write with autosave; no business logic |
| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol |
---

View File

@@ -1,17 +1,14 @@
package dev.slimevr.tracker
package dev.slimevr.device
import dev.slimevr.AppLogger
import dev.slimevr.VRServer
import dev.slimevr.context.BasicBehaviour
import dev.slimevr.context.Context
import dev.slimevr.context.createContext
import io.ktor.http.HttpProtocolVersion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import solarxr_protocol.datatypes.TrackerStatus
import solarxr_protocol.datatypes.hardware_info.BoardType
import solarxr_protocol.datatypes.hardware_info.ImuType
import solarxr_protocol.datatypes.hardware_info.McuType
enum class DeviceOrigin {

View File

@@ -12,7 +12,6 @@ import io.ktor.utils.io.core.writeFully
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -158,9 +157,9 @@ suspend fun doOtaFlash(
onStatus(FirmwareUpdateStatus.REBOOTING, 0)
// Wait for the device to come back online after reboot.
// flatMapLatest switches to the matched device's own state flow so that
// status changes (which don't emit a new VRServerState) are also observed.
@OptIn(ExperimentalCoroutinesApi::class)
// which don't emit a new VRServerState, are also observed.
// flatMapLatest switches to the matched device's own state flow so that status changes,
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val connected = withTimeoutOrNull(60_000) {
server.context.state
.flatMapLatest { state ->

View File

@@ -0,0 +1,290 @@
package dev.slimevr.hid
import dev.slimevr.AppLogger
import dev.slimevr.EventDispatcher
import dev.slimevr.VRServer
import dev.slimevr.VRServerActions
import dev.slimevr.context.Context
import dev.slimevr.context.CustomBehaviour
import dev.slimevr.context.createContext
import dev.slimevr.device.Device
import dev.slimevr.device.DeviceActions
import dev.slimevr.device.DeviceOrigin
import dev.slimevr.device.createDevice
import dev.slimevr.tracker.Tracker
import dev.slimevr.tracker.TrackerActions
import dev.slimevr.tracker.createTracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import solarxr_protocol.datatypes.TrackerStatus
const val HID_TRACKER_RECEIVER_VID = 0x1209
const val HID_TRACKER_RECEIVER_PID = 0x7690
const val HID_TRACKER_PID = 0x7692
// --- State and actions ---
data class HIDTrackerRecord(
val hidId: Int,
val address: String,
val deviceId: Int,
val trackerId: Int?,
)
data class HIDReceiverState(
val serialNumber: String,
val trackers: Map<Int, HIDTrackerRecord>,
)
sealed interface HIDReceiverActions {
data class DeviceRegistered(val hidId: Int, val address: String, val deviceId: Int) : HIDReceiverActions
data class TrackerRegistered(val hidId: Int, val trackerId: Int) : HIDReceiverActions
}
typealias HIDReceiverContext = Context<HIDReceiverState, HIDReceiverActions>
typealias HIDReceiverBehaviour = CustomBehaviour<HIDReceiverState, HIDReceiverActions, HIDReceiver>
typealias HIDPacketDispatcher = EventDispatcher<HIDPacket>
@Suppress("UNCHECKED_CAST")
inline fun <reified T : HIDPacket> HIDPacketDispatcher.onPacket(crossinline callback: suspend (T) -> Unit) {
register(T::class) { callback(it as T) }
}
val HIDRegistrationBehaviour = HIDReceiverBehaviour(
reducer = { s, a ->
when (a) {
is HIDReceiverActions.DeviceRegistered -> s.copy(
trackers = s.trackers + (a.hidId to HIDTrackerRecord(
hidId = a.hidId,
address = a.address,
deviceId = a.deviceId,
trackerId = null,
)),
)
else -> s
}
},
observer = { receiver ->
receiver.packetEvents.onPacket<HIDDeviceRegister> { packet ->
val state = receiver.context.state.value
val existing = state.trackers[packet.hidId]
if (existing != null) return@onPacket
val existingDevice = receiver.serverContext.context.state.value.devices.values
.find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID }
if (existingDevice != null) {
receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id))
AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})")
return@onPacket
}
val deviceId = receiver.serverContext.nextHandle()
val device = createDevice(
scope = receiver.serverContext.context.scope,
id = deviceId,
address = packet.address,
macAddress = packet.address,
origin = DeviceOrigin.HID,
protocolVersion = 0,
serverContext = receiver.serverContext,
)
receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device))
receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId))
AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})")
}
},
)
val HIDDeviceInfoBehaviour = HIDReceiverBehaviour(
reducer = { s, a ->
when (a) {
is HIDReceiverActions.TrackerRegistered -> {
val existing = s.trackers[a.hidId] ?: return@HIDReceiverBehaviour s
s.copy(trackers = s.trackers + (a.hidId to existing.copy(trackerId = a.trackerId)))
}
else -> s
}
},
observer = { receiver ->
receiver.packetEvents.onPacket<HIDDeviceInfo> { packet ->
val device = receiver.getDevice(packet.hidId) ?: return@onPacket
device.context.dispatch(
DeviceActions.Update {
copy(
boardType = packet.boardType,
mcuType = packet.mcuType,
firmware = packet.firmware,
batteryLevel = packet.batteryLevel,
batteryVoltage = packet.batteryVoltage,
signalStrength = packet.rssi,
)
},
)
val tracker = receiver.getTracker(packet.hidId)
if (tracker == null) {
val deviceState = device.context.state.value
val existingTracker = receiver.serverContext.context.state.value.trackers.values
.find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID }
if (existingTracker != null) {
receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id))
existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) })
} else {
val trackerId = receiver.serverContext.nextHandle()
val newTracker = createTracker(
scope = receiver.serverContext.context.scope,
id = trackerId,
deviceId = deviceState.id,
sensorType = packet.imuType,
hardwareId = deviceState.address,
origin = DeviceOrigin.HID,
serverContext = receiver.serverContext,
)
receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker))
receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId))
AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})")
}
device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) })
} else {
tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) })
}
}
},
)
val HIDRotationBehaviour = HIDReceiverBehaviour(
observer = { receiver ->
receiver.packetEvents.onPacket<HIDRotation> { packet ->
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
}
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
}
receiver.packetEvents.onPacket<HIDRotationMag> { packet ->
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
}
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket
tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) })
}
},
)
val HIDBatteryBehaviour = HIDReceiverBehaviour(
observer = { receiver ->
receiver.packetEvents.onPacket<HIDRotationBattery> { packet ->
receiver.getDevice(packet.hidId)?.context?.dispatch(
DeviceActions.Update {
copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi)
},
)
}
receiver.packetEvents.onPacket<HIDRotationButton> { packet ->
receiver.getDevice(packet.hidId)?.context?.dispatch(
DeviceActions.Update { copy(signalStrength = packet.rssi) },
)
}
},
)
val HIDStatusBehaviour = HIDReceiverBehaviour(
observer = { receiver ->
receiver.packetEvents.onPacket<HIDStatus> { packet ->
if (receiver.getTracker(packet.hidId) == null) return@onPacket
receiver.getDevice(packet.hidId)?.context?.dispatch(
DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) },
)
}
},
)
data class HIDReceiver(
val context: HIDReceiverContext,
val serverContext: VRServer,
val packetEvents: HIDPacketDispatcher,
) {
fun getDevice(hidId: Int): Device? {
val record = context.state.value.trackers[hidId] ?: return null
return serverContext.getDevice(record.deviceId)
}
fun getTracker(hidId: Int): Tracker? {
val record = context.state.value.trackers[hidId] ?: return null
val trackerId = record.trackerId ?: return null
return serverContext.getTracker(trackerId)
}
}
fun createHIDReceiver(
serialNumber: String,
data: Flow<ByteArray>,
serverContext: VRServer,
scope: CoroutineScope,
): HIDReceiver {
val behaviours = listOf(
HIDRegistrationBehaviour,
HIDDeviceInfoBehaviour,
HIDRotationBehaviour,
HIDBatteryBehaviour,
HIDStatusBehaviour,
)
val context = createContext(
initialState = HIDReceiverState(
serialNumber = serialNumber,
trackers = mapOf(),
),
reducers = behaviours.map { it.reducer },
scope = scope,
)
val dispatcher = HIDPacketDispatcher()
val receiver = HIDReceiver(
context = context,
serverContext = serverContext,
packetEvents = dispatcher,
)
behaviours.map { it.observer }.forEach { it?.invoke(receiver) }
data
.onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } }
.launchIn(scope)
scope.launch {
try {
awaitCancellation()
} finally {
withContext(NonCancellable) {
for (record in context.state.value.trackers.values) {
serverContext.getDevice(record.deviceId)?.context?.dispatch(
DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) },
)
}
}
}
}
return receiver
}

View File

@@ -0,0 +1,203 @@
package dev.slimevr.hid
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector
import io.github.axisangles.ktmath.Vector3
import solarxr_protocol.datatypes.TrackerStatus
import solarxr_protocol.datatypes.hardware_info.BoardType
import solarxr_protocol.datatypes.hardware_info.ImuType
import solarxr_protocol.datatypes.hardware_info.McuType
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
private const val HID_PACKET_SIZE = 16
private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f)
sealed interface HIDPacket {
val hidId: Int
}
/** Receiver associates a wireless tracker ID with its 6-byte address (type 255). */
data class HIDDeviceRegister(override val hidId: Int, val address: String) : HIDPacket
/** Board/MCU/firmware/battery + IMU type for tracker registration (type 0). */
data class HIDDeviceInfo(
override val hidId: Int,
val imuType: ImuType,
val boardType: BoardType,
val mcuType: McuType,
val firmware: String,
val batteryLevel: Float,
val batteryVoltage: Float,
val rssi: Int,
) : HIDPacket
/** Full-precision Q15 quaternion + Q7 acceleration (type 1). */
data class HIDRotation(
override val hidId: Int,
val rotation: Quaternion,
val acceleration: Vector3,
) : HIDPacket
/** Compact exp-map quaternion + Q7 acceleration + battery level + rssi (type 2). */
data class HIDRotationBattery(
override val hidId: Int,
val rotation: Quaternion,
val acceleration: Vector3,
val batteryLevel: Float,
val batteryVoltage: Float,
val rssi: Int,
) : HIDPacket
/** Tracker status report + rssi (type 3). */
data class HIDStatus(
override val hidId: Int,
val status: TrackerStatus,
val rssi: Int,
) : HIDPacket
/** Full-precision Q15 quaternion + Q10 magnetometer (type 4). */
data class HIDRotationMag(
override val hidId: Int,
val rotation: Quaternion,
val magnetometer: Vector3,
) : HIDPacket
/** Button state + compact exp-map quaternion + Q7 acceleration + rssi (type 7). */
data class HIDRotationButton(
override val hidId: Int,
val button: Int,
val rotation: Quaternion,
val acceleration: Vector3,
val rssi: Int,
) : HIDPacket
private fun readLE16Signed(data: ByteArray, offset: Int): Int =
data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt()
private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion {
val scale = 1f / 32768f
val x = readLE16Signed(data, offset).toShort().toFloat() * scale
val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale
val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale
val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale
return AXES_OFFSET * Quaternion(w, x, y, z)
}
private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion {
val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt()
val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f
val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f
val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f
val d = vx * vx + vy * vy + vz * vz
val invSqrtD = 1f / sqrt(d + 1e-6f)
val a = (PI.toFloat() / 2f) * d * invSqrtD
val s = sin(a)
val k = s * invSqrtD
return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz)
}
private fun decodeAccel(data: ByteArray, offset: Int): Vector3 {
val scale = 1f / 128f
return Vector3(
readLE16Signed(data, offset).toShort().toFloat() * scale,
readLE16Signed(data, offset + 2).toShort().toFloat() * scale,
readLE16Signed(data, offset + 4).toShort().toFloat() * scale,
)
}
private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat()
private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f
private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? {
val packetType = data[i].toUByte().toInt()
val hidId = data[i + 1].toUByte().toInt()
return when (packetType) {
255 -> {
val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL
HIDDeviceRegister(hidId, "%012X".format(addr))
}
0 -> {
val batt = data[i + 2].toUByte().toInt()
val battV = data[i + 3].toUByte().toInt()
val brdId = data[i + 5].toUByte().toInt()
val mcuId = data[i + 6].toUByte().toInt()
val imuId = data[i + 8].toUByte().toInt()
val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt()
val fwMajor = data[i + 12].toUByte().toInt()
val fwMinor = data[i + 13].toUByte().toInt()
val fwPatch = data[i + 14].toUByte().toInt()
val rssi = data[i + 15].toUByte().toInt()
val fwYear = 2020 + (fwDate shr 9 and 127)
val fwMonth = fwDate shr 5 and 15
val fwDay = fwDate and 31
HIDDeviceInfo(
hidId = hidId,
imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other,
boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN,
mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other,
firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch),
batteryLevel = decodeBattery(batt),
batteryVoltage = decodeBatteryVoltage(battV),
rssi = -rssi,
)
}
1 -> HIDRotation(
hidId = hidId,
rotation = decodeQ15Quat(data, i + 2),
acceleration = decodeAccel(data, i + 10),
)
2 -> HIDRotationBattery(
hidId = hidId,
rotation = decodeExpMapQuat(data, i + 5),
acceleration = decodeAccel(data, i + 9),
batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()),
batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()),
rssi = -data[i + 15].toUByte().toInt(),
)
3 -> HIDStatus(
hidId = hidId,
status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK,
rssi = -data[i + 15].toUByte().toInt(),
)
4 -> {
val scaleMag = 1000f / 1024f
HIDRotationMag(
hidId = hidId,
rotation = decodeQ15Quat(data, i + 2),
magnetometer = Vector3(
readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag,
readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag,
readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag,
),
)
}
7 -> HIDRotationButton(
hidId = hidId,
button = data[i + 2].toUByte().toInt(),
rotation = decodeExpMapQuat(data, i + 5),
acceleration = decodeAccel(data, i + 9),
rssi = -data[i + 15].toUByte().toInt(),
)
else -> null
}
}
fun parseHIDPackets(data: ByteArray): List<HIDPacket> {
if (data.size % HID_PACKET_SIZE != 0) return emptyList()
return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) }
}

View File

@@ -11,6 +11,7 @@ object AppLogger {
val device = logger("Device")
val udp = logger("UDPConnection")
val solarxr = logger("SolarXR")
val hid = logger("HID")
val serial = logger("Serial")
val firmware = logger("Firmware")

View File

@@ -2,7 +2,7 @@ package dev.slimevr.solarxr
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.VRServer
import dev.slimevr.tracker.DeviceState
import dev.slimevr.device.DeviceState
import dev.slimevr.tracker.TrackerState
import io.ktor.util.moveToByteArray
import kotlinx.coroutines.cancelAndJoin
@@ -22,7 +22,6 @@ import solarxr_protocol.data_feed.tracker.TrackerInfo
import solarxr_protocol.datatypes.DeviceId
import solarxr_protocol.datatypes.Ipv4Address
import solarxr_protocol.datatypes.TrackerId
import solarxr_protocol.datatypes.hardware_info.HardwareAddress
import solarxr_protocol.datatypes.hardware_info.HardwareInfo
import solarxr_protocol.datatypes.hardware_info.HardwareStatus
import solarxr_protocol.datatypes.math.Quat

View File

@@ -4,12 +4,12 @@ import dev.slimevr.VRServer
import dev.slimevr.context.BasicBehaviour
import dev.slimevr.context.Context
import dev.slimevr.context.createContext
import dev.slimevr.device.DeviceOrigin
import io.github.axisangles.ktmath.Quaternion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import solarxr_protocol.datatypes.BodyPart
import solarxr_protocol.datatypes.TrackerStatus
import solarxr_protocol.datatypes.hardware_info.ImuType
data class TrackerIdNum(val id: Int, val trackerNum: Int)

View File

@@ -1,6 +1,5 @@
package dev.slimevr.tracker.udp
package dev.slimevr.udp
import dev.llelievr.espflashkotlin.Packet
import dev.slimevr.AppLogger
import dev.slimevr.EventDispatcher
import dev.slimevr.VRServer
@@ -8,13 +7,13 @@ import dev.slimevr.VRServerActions
import dev.slimevr.context.Context
import dev.slimevr.context.CustomBehaviour
import dev.slimevr.context.createContext
import dev.slimevr.tracker.Device
import dev.slimevr.tracker.DeviceActions
import dev.slimevr.tracker.DeviceOrigin
import dev.slimevr.device.Device
import dev.slimevr.device.DeviceActions
import dev.slimevr.device.DeviceOrigin
import dev.slimevr.tracker.Tracker
import dev.slimevr.tracker.TrackerActions
import dev.slimevr.tracker.TrackerIdNum
import dev.slimevr.tracker.createDevice
import dev.slimevr.device.createDevice
import dev.slimevr.tracker.createTracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

View File

@@ -1,4 +1,4 @@
package dev.slimevr.tracker.udp
package dev.slimevr.udp
import dev.slimevr.EventDispatcher
import io.github.axisangles.ktmath.Quaternion

View File

@@ -1,4 +1,4 @@
package dev.slimevr.tracker.udp
package dev.slimevr.udp
import dev.slimevr.AppLogger
import dev.slimevr.VRServer

View File

@@ -5,7 +5,7 @@ import dev.slimevr.context.CustomBehaviour
import dev.slimevr.context.createContext
import dev.slimevr.firmware.FirmwareManager
import dev.slimevr.serial.SerialServer
import dev.slimevr.tracker.Device
import dev.slimevr.device.Device
import dev.slimevr.tracker.Tracker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChangedBy

View File

@@ -6,8 +6,8 @@ import dev.slimevr.VRServerActions
import dev.slimevr.serial.SerialPortHandle
import dev.slimevr.serial.SerialPortInfo
import dev.slimevr.serial.SerialServer
import dev.slimevr.tracker.DeviceOrigin
import dev.slimevr.tracker.createDevice
import dev.slimevr.device.DeviceOrigin
import dev.slimevr.device.createDevice
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

View File

@@ -83,6 +83,7 @@ dependencies {
exclude(group = "com.fazecast", module = "android")
}
implementation("org.hid4java:hid4java:0.8.0")
implementation("io.klogging:klogging:0.11.7")
}
tasks.shadowJar {

View File

@@ -4,12 +4,13 @@ package dev.slimevr.desktop
import dev.slimevr.VRServer
import dev.slimevr.config.createAppConfig
import dev.slimevr.desktop.hid.createDesktopHIDManager
import dev.slimevr.desktop.ipc.createIpcServers
import dev.slimevr.desktop.serial.createDesktopSerialServer
import dev.slimevr.firmware.createFirmwareManager
import dev.slimevr.resolveConfigDirectory
import dev.slimevr.solarxr.createSolarXRWebsocketServer
import dev.slimevr.tracker.udp.createUDPTrackerServer
import dev.slimevr.udp.createUDPTrackerServer
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -29,5 +30,8 @@ fun main(args: Array<String>) = runBlocking {
launch {
createIpcServers(server)
}
launch {
createDesktopHIDManager(server, this)
}
Unit
}

View File

@@ -0,0 +1,119 @@
package dev.slimevr.desktop.hid
import dev.slimevr.AppLogger
import dev.slimevr.VRServer
import dev.slimevr.hid.HID_TRACKER_PID
import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID
import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID
import dev.slimevr.hid.HIDReceiver
import dev.slimevr.hid.createHIDReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.hid4java.HidDevice
import org.hid4java.HidManager
import org.hid4java.HidServicesSpecification
import org.hid4java.jna.HidApi
import org.hid4java.jna.HidDeviceInfoStructure
private const val HID_POLL_INTERVAL_MS = 3000L
private fun isCompatibleDevice(vid: Int, pid: Int) =
vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID)
private val hidSpec = HidServicesSpecification().apply { isAutoStart = false }
// Initialize the native HID library. Must be called before enumerateDevices.
private val hidServices by lazy { HidManager.getHidServices(hidSpec) }
private fun enumerateCompatibleDevices(): Map<String, HidDevice> {
hidServices // ensure native lib is loaded
val root = HidApi.enumerateDevices(0, 0) ?: return emptyMap()
val result = mutableMapOf<String, HidDevice>()
var info: HidDeviceInfoStructure? = root
while (info != null) {
if (isCompatibleDevice(info.vendor_id.toInt(), info.product_id.toInt())) {
val device = HidDevice(info, null, hidSpec)
// Use path as key, unique per physical device, available without opening
result[info.path] = device
}
info = info.next()
}
HidApi.freeEnumeration(root)
return result
}
private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver)
fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) {
val active = mutableMapOf<String, ActiveReceiver>()
scope.launch {
while (isActive) {
val found = withContext(Dispatchers.IO) {
try { enumerateCompatibleDevices() } catch (_: Exception) { emptyMap() }
}
// Devices no longer present + jobs that exited on their own (read error)
val toRemove = (active.keys - found.keys) +
active.entries.filter { !it.value.job.isActive }.map { it.key }
for (path in toRemove) {
val entry = active.remove(path) ?: continue
entry.job.cancel()
entry.job.join()
AppLogger.hid.info("HID device removed: $path")
}
// Open newly detected devices
for ((path, hidDevice) in found) {
if (path in active) continue
if (!hidDevice.open()) {
AppLogger.hid.warn("Failed to open HID device: $path")
continue
}
val serial = hidDevice.serialNumber ?: path
AppLogger.hid.info("HID device detected: $serial")
val deviceJob = Job(scope.coroutineContext[Job])
val deviceScope = CoroutineScope(scope.coroutineContext + deviceJob)
val dataFlow = channelFlow {
try {
while (isActive) {
val data = withContext(Dispatchers.IO) {
try { hidDevice.readAll(0) } catch (_: Exception) { null }
}
when {
data == null -> return@channelFlow // read error, device gone
data.isNotEmpty() -> send(data)
else -> delay(1) // no data yet, yield without busy-spinning
}
}
} finally {
withContext(NonCancellable + Dispatchers.IO) { hidDevice.close() }
}
}
val receiver = createHIDReceiver(
serialNumber = serial,
data = dataFlow,
serverContext = serverContext,
scope = deviceScope,
)
deviceJob.complete()
active[path] = ActiveReceiver(deviceJob, receiver)
}
delay(HID_POLL_INTERVAL_MS)
}
}
}

View File

@@ -8,17 +8,14 @@ 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.DeviceActions
import dev.slimevr.tracker.DeviceOrigin
import dev.slimevr.device.DeviceActions
import dev.slimevr.device.DeviceOrigin
import dev.slimevr.tracker.TrackerActions
import dev.slimevr.tracker.createDevice
import dev.slimevr.device.createDevice
import dev.slimevr.tracker.createTracker
import io.github.axisangles.ktmath.Quaternion
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock