From ea92fb4c0198bfad2f5a62a8a069a49703f2a670 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 22:41:47 +0100 Subject: [PATCH] Basic HID support --- server/README.md | 1 + .../{tracker/device.kt => device/module.kt} | 5 +- .../src/main/java/dev/slimevr/firmware/ota.kt | 7 +- .../src/main/java/dev/slimevr/hid/module.kt | 290 ++++++++++++++++++ .../src/main/java/dev/slimevr/hid/packets.kt | 203 ++++++++++++ .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/datafeed.kt | 3 +- .../slimevr/tracker/{tracker.kt => module.kt} | 2 +- .../slimevr/{tracker => }/udp/connection.kt | 11 +- .../dev/slimevr/{tracker => }/udp/packets.kt | 2 +- .../dev/slimevr/{tracker => }/udp/server.kt | 2 +- .../src/main/java/dev/slimevr/vrserver.kt | 2 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 4 +- server/desktop/build.gradle.kts | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 6 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 119 +++++++ .../java/dev/slimevr/desktop/ipc/protocol.kt | 9 +- 17 files changed, 639 insertions(+), 29 deletions(-) rename server/core/src/main/java/dev/slimevr/{tracker/device.kt => device/module.kt} (93%) create mode 100644 server/core/src/main/java/dev/slimevr/hid/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/hid/packets.kt rename server/core/src/main/java/dev/slimevr/tracker/{tracker.kt => module.kt} (97%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/connection.kt (98%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/packets.kt (99%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/server.kt (98%) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt diff --git a/server/README.md b/server/README.md index 4c08ab0cb..58001d44c 100644 --- a/server/README.md +++ b/server/README.md @@ -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 | --- diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/device/module.kt similarity index 93% rename from server/core/src/main/java/dev/slimevr/tracker/device.kt rename to server/core/src/main/java/dev/slimevr/device/module.kt index 4d8a3058c..08fdf3771 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -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 { diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 7d8a72be5..d83077cf8 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -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 -> diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt new file mode 100644 index 000000000..fe8c90a01 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -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, +) + +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 +typealias HIDReceiverBehaviour = CustomBehaviour +typealias HIDPacketDispatcher = EventDispatcher + +@Suppress("UNCHECKED_CAST") +inline fun 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 { 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 { 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 { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { 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 { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { + copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) + }, + ) + } + + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(signalStrength = packet.rssi) }, + ) + } + }, +) + +val HIDStatusBehaviour = HIDReceiverBehaviour( + observer = { receiver -> + receiver.packetEvents.onPacket { 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, + 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 +} diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt new file mode 100644 index 000000000..112260781 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -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 { + if (data.size % HID_PACKET_SIZE != 0) return emptyList() + return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } +} diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index b04523cea..44205efff 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -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") diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index b0cc68181..4fce6a38e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -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 diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt similarity index 97% rename from server/core/src/main/java/dev/slimevr/tracker/tracker.kt rename to server/core/src/main/java/dev/slimevr/tracker/module.kt index 43ca09494..09e38f1df 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -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) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt similarity index 98% rename from server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt rename to server/core/src/main/java/dev/slimevr/udp/connection.kt index 02d92c8ea..1d3cc00a0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -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 diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt similarity index 99% rename from server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt rename to server/core/src/main/java/dev/slimevr/udp/packets.kt index f6d73dabd..67e21ab2d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -1,4 +1,4 @@ -package dev.slimevr.tracker.udp +package dev.slimevr.udp import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt similarity index 98% rename from server/core/src/main/java/dev/slimevr/tracker/udp/server.kt rename to server/core/src/main/java/dev/slimevr/udp/server.kt index 268bd8f2d..649d14549 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -1,4 +1,4 @@ -package dev.slimevr.tracker.udp +package dev.slimevr.udp import dev.slimevr.AppLogger import dev.slimevr.VRServer diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 2a50b6741..77df62869 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -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 diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 02291d07b..709cd302a 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -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 diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index db22322f9..f1e38e8a7 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -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 { 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 42e388aad..442eb157c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -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) = runBlocking { launch { createIpcServers(server) } + launch { + createDesktopHIDManager(server, this) + } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt new file mode 100644 index 000000000..ea4c9ce17 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -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 { + hidServices // ensure native lib is loaded + val root = HidApi.enumerateDevices(0, 0) ?: return emptyMap() + val result = mutableMapOf() + 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() + + 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) + } + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index e59c3afb4..38e758f0e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -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