Skip to content

Commit 543e319

Browse files
authored
HID support on Android (#1532)
2 parents b7ef70f + f03b300 commit 543e319

File tree

10 files changed

+962
-549
lines changed

10 files changed

+962
-549
lines changed

server/android/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools">
44

5+
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
6+
57
<uses-permission android:name="android.permission.INTERNET"/>
68
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
79
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -26,7 +28,11 @@
2628
<intent-filter>
2729
<action android:name="android.intent.action.MAIN" />
2830
<category android:name="android.intent.category.LAUNCHER" />
31+
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
2932
</intent-filter>
33+
34+
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
35+
android:resource="@xml/device_filter" />
3036
</activity>
3137

3238
<service

server/android/src/main/java/dev/slimevr/android/Main.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import androidx.appcompat.app.AppCompatActivity
88
import dev.slimevr.Keybinding
99
import dev.slimevr.VRServer
1010
import dev.slimevr.android.serial.AndroidSerialHandler
11+
import dev.slimevr.android.tracking.trackers.hid.AndroidHIDManager
12+
import dev.slimevr.tracking.trackers.Tracker
1113
import io.eiren.util.logging.LogManager
1214
import io.ktor.http.CacheControl
1315
import io.ktor.http.CacheControl.Visibility
@@ -60,6 +62,15 @@ fun main(activity: AppCompatActivity) {
6062
},
6163
)
6264
vrServer.start()
65+
66+
// Start service for USB HID trackers
67+
val androidHidManager = AndroidHIDManager(
68+
"Sensors HID service",
69+
{ tracker: Tracker -> vrServer.registerTracker(tracker) },
70+
activity,
71+
)
72+
androidHidManager.start()
73+
6374
Keybinding(vrServer)
6475
vrServer.join()
6576
LogManager.closeLogger()
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package dev.slimevr.android.tracking.trackers.hid
2+
3+
import android.hardware.usb.UsbConstants
4+
import android.hardware.usb.UsbDevice
5+
import android.hardware.usb.UsbDeviceConnection
6+
import android.hardware.usb.UsbEndpoint
7+
import android.hardware.usb.UsbInterface
8+
import android.hardware.usb.UsbManager
9+
import java.io.Closeable
10+
11+
/**
12+
* A wrapper over Android's [UsbDevice] for HID devices.
13+
*/
14+
class AndroidHIDDevice(hidDevice: UsbDevice, usbManager: UsbManager) : Closeable {
15+
16+
val deviceName = hidDevice.deviceName
17+
val serialNumber = hidDevice.serialNumber
18+
val manufacturerName = hidDevice.manufacturerName
19+
val productName = hidDevice.productName
20+
21+
val hidInterface: UsbInterface
22+
23+
val endpointIn: UsbEndpoint
24+
val endpointOut: UsbEndpoint?
25+
26+
val deviceConnection: UsbDeviceConnection
27+
28+
init {
29+
hidInterface = findHidInterface(hidDevice)!!
30+
31+
val (endpointIn, endpointOut) = findHidIO(hidInterface)
32+
this.endpointIn = endpointIn!!
33+
this.endpointOut = endpointOut
34+
35+
deviceConnection = usbManager.openDevice(hidDevice)!!
36+
37+
deviceConnection.claimInterface(hidInterface, true)
38+
}
39+
40+
override fun close() {
41+
deviceConnection.releaseInterface(hidInterface)
42+
deviceConnection.close()
43+
}
44+
45+
companion object {
46+
/**
47+
* Find the HID interface.
48+
*
49+
* @return
50+
* Return the HID interface if found, otherwise null.
51+
*/
52+
private fun findHidInterface(usbDevice: UsbDevice): UsbInterface? {
53+
val interfaceCount: Int = usbDevice.interfaceCount
54+
55+
for (interfaceIndex in 0 until interfaceCount) {
56+
val usbInterface = usbDevice.getInterface(interfaceIndex)
57+
58+
if (usbInterface.interfaceClass == UsbConstants.USB_CLASS_HID) {
59+
return usbInterface
60+
}
61+
}
62+
63+
return null
64+
}
65+
66+
/**
67+
* Find the HID endpoints.
68+
*
69+
* @return
70+
* Return the HID endpoints if found, otherwise null.
71+
*/
72+
private fun findHidIO(usbInterface: UsbInterface): Pair<UsbEndpoint?, UsbEndpoint?> {
73+
val endpointCount: Int = usbInterface.endpointCount
74+
75+
var usbEndpointIn: UsbEndpoint? = null
76+
var usbEndpointOut: UsbEndpoint? = null
77+
78+
for (endpointIndex in 0 until endpointCount) {
79+
val usbEndpoint = usbInterface.getEndpoint(endpointIndex)
80+
81+
if (usbEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) {
82+
if (usbEndpoint.direction == UsbConstants.USB_DIR_OUT) {
83+
usbEndpointOut = usbEndpoint
84+
} else {
85+
usbEndpointIn = usbEndpoint
86+
}
87+
}
88+
}
89+
90+
return Pair(usbEndpointIn, usbEndpointOut)
91+
}
92+
}
93+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package dev.slimevr.android.tracking.trackers.hid
2+
3+
import android.app.PendingIntent
4+
import android.content.BroadcastReceiver
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.content.IntentFilter
8+
import android.hardware.usb.UsbDevice
9+
import android.hardware.usb.UsbManager
10+
import androidx.core.content.ContextCompat
11+
import dev.slimevr.tracking.trackers.Device
12+
import dev.slimevr.tracking.trackers.Tracker
13+
import dev.slimevr.tracking.trackers.TrackerStatus
14+
import dev.slimevr.tracking.trackers.hid.HIDCommon
15+
import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_PID
16+
import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_VID
17+
import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.PACKET_SIZE
18+
import dev.slimevr.tracking.trackers.hid.HIDDevice
19+
import io.eiren.util.logging.LogManager
20+
import java.nio.ByteBuffer
21+
import java.util.function.Consumer
22+
23+
const val ACTION_USB_PERMISSION = "dev.slimevr.USB_PERMISSION"
24+
25+
/**
26+
* Handles Android USB Host HID dongles and receives tracker data from them.
27+
*/
28+
class AndroidHIDManager(
29+
name: String,
30+
private val trackersConsumer: Consumer<Tracker>,
31+
private val context: Context,
32+
) : Thread(name) {
33+
private val devices: MutableList<HIDDevice> = mutableListOf()
34+
private val devicesBySerial: MutableMap<String, MutableList<Int>> = HashMap()
35+
private val devicesByHID: MutableMap<UsbDevice, MutableList<Int>> = HashMap()
36+
private val connByHID: MutableMap<UsbDevice, AndroidHIDDevice> = HashMap()
37+
private val lastDataByHID: MutableMap<UsbDevice, Int> = HashMap()
38+
private val usbManager: UsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
39+
40+
val usbReceiver: BroadcastReceiver = object : BroadcastReceiver() {
41+
override fun onReceive(context: Context, intent: Intent) {
42+
when (intent.action) {
43+
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
44+
(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) as UsbDevice?)?.let {
45+
checkConfigureDevice(it, requestPermission = true)
46+
}
47+
}
48+
49+
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
50+
(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) as UsbDevice?)?.let {
51+
removeDevice(it)
52+
}
53+
}
54+
55+
ACTION_USB_PERMISSION -> {
56+
deviceEnumerate(false)
57+
}
58+
}
59+
}
60+
}
61+
62+
private fun proceedWithDeviceConfiguration(hidDevice: UsbDevice) {
63+
// This is the original logic from checkConfigureDevice after permission is confirmed
64+
LogManager.info("[TrackerServer] USB Permission granted for ${hidDevice.deviceName}. Proceeding with configuration.")
65+
66+
// Close any existing connection (do we still have one?)
67+
this.connByHID[hidDevice]?.close()
68+
// Open new HID connection with USB device
69+
this.connByHID[hidDevice] = AndroidHIDDevice(hidDevice, usbManager)
70+
71+
val serial = hidDevice.serialNumber ?: "Unknown USB Device ${hidDevice.deviceId}"
72+
this.devicesBySerial[serial]?.let {
73+
this.devicesByHID[hidDevice] = it
74+
synchronized(this.devices) {
75+
for (id in it) {
76+
val device = this.devices[id]
77+
for (value in device.trackers.values) {
78+
if (value.status == TrackerStatus.DISCONNECTED) value.status = TrackerStatus.OK
79+
}
80+
}
81+
}
82+
LogManager.info("[TrackerServer] Linked HID device reattached: $serial")
83+
return
84+
}
85+
86+
val list: MutableList<Int> = mutableListOf()
87+
this.devicesBySerial[serial] = list
88+
this.devicesByHID[hidDevice] = list
89+
this.lastDataByHID[hidDevice] = 0 // initialize last data received
90+
LogManager.info("[TrackerServer] (Probably) Compatible HID device detected: $serial")
91+
}
92+
93+
fun checkConfigureDevice(usbDevice: UsbDevice, requestPermission: Boolean = false) {
94+
if (usbDevice.vendorId == HID_TRACKER_RECEIVER_VID && usbDevice.productId == HID_TRACKER_RECEIVER_PID) {
95+
if (usbManager.hasPermission(usbDevice)) {
96+
LogManager.info("[TrackerServer] Already have permission for ${usbDevice.deviceName}")
97+
proceedWithDeviceConfiguration(usbDevice)
98+
} else if (requestPermission) {
99+
LogManager.info("[TrackerServer] Requesting permission for ${usbDevice.deviceName}")
100+
val permissionIntent = PendingIntent.getBroadcast(
101+
context,
102+
0,
103+
Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, // Explicitly set package
104+
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
105+
)
106+
usbManager.requestPermission(usbDevice, permissionIntent)
107+
}
108+
}
109+
}
110+
111+
private fun removeDevice(hidDevice: UsbDevice) {
112+
this.devicesByHID[hidDevice]?.let {
113+
synchronized(this.devices) {
114+
for (id in it) {
115+
val device = this.devices[id]
116+
for (value in device.trackers.values) {
117+
if (value.status == TrackerStatus.OK) {
118+
value.status =
119+
TrackerStatus.DISCONNECTED
120+
}
121+
}
122+
}
123+
}
124+
125+
this.devicesByHID.remove(hidDevice)
126+
127+
val oldConn = this.connByHID.remove(hidDevice)
128+
val serial = oldConn?.serialNumber ?: "Unknown"
129+
oldConn?.close()
130+
131+
LogManager.info("[TrackerServer] Linked HID device removed: $serial")
132+
}
133+
}
134+
135+
private fun dataRead() {
136+
synchronized(devicesByHID) {
137+
var devicesPresent = false
138+
var devicesDataReceived = false
139+
val q = intArrayOf(0, 0, 0, 0)
140+
val a = intArrayOf(0, 0, 0)
141+
val m = intArrayOf(0, 0, 0)
142+
for ((hidDevice, deviceList) in devicesByHID) {
143+
val dataReceived = ByteArray(64)
144+
val conn = connByHID[hidDevice]!!
145+
val dataRead = conn.deviceConnection.bulkTransfer(conn.endpointIn, dataReceived, dataReceived.size, 0)
146+
147+
// LogManager.info("[TrackerServer] HID data read ($dataRead bytes): ${dataReceived.contentToString()}")
148+
149+
devicesPresent = true // Even if the device has no data
150+
if (dataRead > 0) {
151+
// Process data
152+
// The data is always received as 64 bytes, this check no longer works
153+
if (dataRead % PACKET_SIZE != 0) {
154+
LogManager.info("[TrackerServer] Malformed HID packet, ignoring")
155+
continue // Don't continue with this data
156+
}
157+
devicesDataReceived = true // Data is received and is valid (not malformed)
158+
lastDataByHID[hidDevice] = 0 // reset last data received
159+
val packetCount = dataRead / PACKET_SIZE
160+
var i = 0
161+
while (i < packetCount * PACKET_SIZE) {
162+
// Common packet data
163+
val packetType = dataReceived[i].toUByte().toInt()
164+
val id = dataReceived[i + 1].toUByte().toInt()
165+
val deviceId = id
166+
167+
// Register device
168+
if (packetType == 255) { // device register packet from receiver
169+
val buffer = ByteBuffer.wrap(dataReceived, i + 2, 8)
170+
buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN)
171+
val addr = buffer.getLong() and 0xFFFFFFFFFFFF
172+
val deviceName = String.format("%012X", addr)
173+
HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, deviceName, deviceList) // register device
174+
// server wants tracker to be unique, so use combination of hid serial and full id
175+
i += PACKET_SIZE
176+
continue
177+
}
178+
179+
val device: HIDDevice? = HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, null, deviceList)
180+
if (device == null) { // not registered yet
181+
i += PACKET_SIZE
182+
continue
183+
}
184+
185+
HIDCommon.processPacket(dataReceived, i, packetType, device, q, a, m, trackersConsumer)
186+
i += PACKET_SIZE
187+
}
188+
// LogManager.info("[TrackerServer] HID received $packetCount tracker packets")
189+
} else {
190+
lastDataByHID[hidDevice] = lastDataByHID[hidDevice]!! + 1 // increment last data received
191+
}
192+
}
193+
if (!devicesPresent) {
194+
sleep(10) // No hid device, "empty loop" so sleep to save the poor cpu
195+
} else if (!devicesDataReceived) {
196+
sleep(1) // read has no timeout, no data also causes an "empty loop"
197+
}
198+
}
199+
}
200+
201+
private fun deviceEnumerate(requestPermission: Boolean = false) {
202+
val hidDeviceList: MutableList<UsbDevice> = usbManager.deviceList.values.filter {
203+
it.vendorId == HID_TRACKER_RECEIVER_VID && it.productId == HID_TRACKER_RECEIVER_PID
204+
}.toMutableList()
205+
synchronized(devicesByHID) {
206+
// Work on devicesByHid and add/remove as necessary
207+
val removeList: MutableList<UsbDevice> = devicesByHID.keys.toMutableList()
208+
removeList.removeAll(hidDeviceList)
209+
for (device in removeList) {
210+
removeDevice(device)
211+
}
212+
213+
hidDeviceList.removeAll(devicesByHID.keys) // addList
214+
for (device in hidDeviceList) {
215+
// This will handle permission check/request
216+
checkConfigureDevice(device, requestPermission)
217+
}
218+
}
219+
}
220+
221+
override fun run() {
222+
val intentFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)
223+
intentFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
224+
intentFilter.addAction(ACTION_USB_PERMISSION)
225+
226+
// Listen for USB device attach/detach
227+
ContextCompat.registerReceiver(
228+
context,
229+
usbReceiver,
230+
intentFilter,
231+
ContextCompat.RECEIVER_NOT_EXPORTED,
232+
)
233+
234+
// Enumerate existing devices
235+
deviceEnumerate(true)
236+
237+
// Data read loop
238+
while (true) {
239+
try {
240+
sleep(0) // Possible performance impact
241+
} catch (e: InterruptedException) {
242+
currentThread().interrupt()
243+
break
244+
}
245+
dataRead() // not in try catch?
246+
}
247+
}
248+
249+
fun getDevices(): List<Device> = devices
250+
251+
companion object {
252+
private const val resetSourceName = "TrackerServer"
253+
}
254+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<usb-device vendor-id="4617" product-id="30352" />
4+
</resources>

0 commit comments

Comments
 (0)