mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Basic HID support
This commit is contained in:
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 ->
|
||||
|
||||
290
server/core/src/main/java/dev/slimevr/hid/module.kt
Normal file
290
server/core/src/main/java/dev/slimevr/hid/module.kt
Normal 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
|
||||
}
|
||||
203
server/core/src/main/java/dev/slimevr/hid/packets.kt
Normal file
203
server/core/src/main/java/dev/slimevr/hid/packets.kt
Normal 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) }
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.slimevr.tracker.udp
|
||||
package dev.slimevr.udp
|
||||
|
||||
import dev.slimevr.EventDispatcher
|
||||
import io.github.axisangles.ktmath.Quaternion
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.slimevr.tracker.udp
|
||||
package dev.slimevr.udp
|
||||
|
||||
import dev.slimevr.AppLogger
|
||||
import dev.slimevr.VRServer
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
119
server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt
Normal file
119
server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user