Skip to content

WiVRnEvdev.kt

src/jvmMain/kotlin/afield/media/browser/WiVRnEvdev.kt · 193 lines

@file:afield.runtime.completion.CompletionCategory("Internal")
package afield.media.browser

import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout.*
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path

internal enum class WiVRnDevice(val deviceName: String, val product: Int)

The two virtual devices created by WiVRn 26.6, not the PC's physical input devices.

internal enum class WiVRnDevice(val deviceName: String, val product: Int) {
    KEYBOARD("WiVRn Keyboard", 0x0840), MOUSE("WiVRn Mouse", 0x0839);

companion object

fun identify(name: String, bus: Int, vendor: Int, product: Int): WiVRnDevice?

        fun identify(name: String, bus: Int, vendor: Int, product: Int): WiVRnDevice? =
            entries.firstOrNull { bus == 3 && vendor == 0x4711 && product == it.product && name == it.deviceName }
    }
}

internal data class EvdevEvent(val micros: Long, val type: Int, val code: Int, val value: Int)

companion object

const val SIZE = 24 // Linux 64-bit timeval + u16 type/code + s32 value.

fun decode(buffer: ByteBuffer): EvdevEvent

        fun decode(buffer: ByteBuffer): EvdevEvent {
            require(buffer.remaining() >= SIZE)
            buffer.order(ByteOrder.nativeOrder())
            val seconds = buffer.long
            val micros = buffer.long
            return EvdevEvent(seconds * 1_000_000 + micros, buffer.short.toInt() and 0xffff,
                buffer.short.toInt() and 0xffff, buffer.int)
        }
    }
}

internal data class WiVRnDeviceEvent(val device: WiVRnDevice, val event: EvdevEvent)

internal interface WiVRnEventSource : AutoCloseable

Keyboard and mouse events in evdev form; WiVRnDevice names the channel, not necessarily the device.

val captured: Set<WiVRnDevice>

val problem: String?

val status: String? get() = null

The line shown in the VR toolbar, for sources that are not WiVRn's evdev devices.

fun poll(): List<WiVRnDeviceEvent>

fun takeResets(): Map<WiVRnDevice, Set<Int>>

internal interface EvdevSystem

Abstract only the syscalls, so selection, grabbing and disconnect paths can be tested.

fun open(path: Path): Int

fun identity(fd: Int): WiVRnDevice?

fun grab(fd: Int): Boolean

fun pressed(fd: Int): Set<Int>

fun read(fd: Int): List<EvdevEvent>

fun close(fd: Int)

internal class WiVRnDeviceReader

Discover by sysfs, verify the opened descriptor again, then grab exclusively or fail closed.

internal class WiVRnDeviceReader(
    private val sys: Path = Path.of("/sys/class/input"),
    private val dev: Path = Path.of("/dev/input"),
    private val io: EvdevSystem = LinuxEvdevSystem,
    private val clock: () -> Long = System::nanoTime,
) : WiVRnEventSource {
    private val devices = linkedMapOf<Path, Pair<WiVRnDevice, Int>>()
    private val resets = linkedMapOf<WiVRnDevice, Set<Int>>()
    private var nextScan = 0L

override val captured: Set<WiVRnDevice> get() = devices.values.map { it.first }.toSet()

override var problem: String?

    override var problem: String? = null
        private set

override fun poll(): List<WiVRnDeviceEvent>

body — 52 lines, from line 74
    override fun poll(): List<WiVRnDeviceEvent> {
        if (clock() >= nextScan) {
            nextScan = clock() + 1_000_000_000
            scan()
        }
        val result = ArrayList<WiVRnDeviceEvent>()
        for ((path, pair) in devices.toMap()) {
            try {
                val events = io.read(pair.second)
                check(events.none { it.type == 0 && it.code == 3 }) { "input overflow; releasing and resynchronizing" }
                result += events.map { WiVRnDeviceEvent(pair.first, it) }
            } catch (error: Exception) {
                io.close(pair.second)
                devices.remove(path)
                resets[pair.first] = emptySet()
                problem = "${pair.first.deviceName} disconnected: ${error.message}"
                nextScan = 0
            }
        }
        // Keyboard modifiers and mouse clicks can interleave within one graphics frame.
        return result.sortedBy { it.event.micros }
    }

    private fun scan() {
        if (!Files.isDirectory(sys)) return
        var issue: String? = null
        val paths = Files.list(sys).use { stream -> stream.filter { it.fileName.toString().matches(Regex("event[0-9]+")) }.toList() }
        for (path in paths) {
            val kind = runCatching {
                val d = path.resolve("device")
                fun hex(name: String) = Files.readString(d.resolve("id/$name")).trim().toInt(16)
                WiVRnDevice.identify(Files.readString(d.resolve("name")).trim(), hex("bustype"), hex("vendor"), hex("product"))
            }.getOrNull() ?: continue
            val node = dev.resolve(path.fileName)
            if (node in devices || kind in captured) continue
            var fd = -1
            try {
                fd = io.open(node)
                check(io.identity(fd) == kind) { "device identity changed while opening $node" }
                check(io.grab(fd)) { "exclusive input grab failed for $node" }
                val initial = io.pressed(fd)
                devices[node] = kind to fd
                resets[kind] = initial
                fd = -1
            } catch (error: Exception) {
                issue = "${kind.deviceName}: ${error.message}. Run tools/setup_wivrn_input.py --install as administrator."
            } finally {
                if (fd >= 0) io.close(fd)
            }
        }
        problem = issue
    }

override fun takeResets(): Map<WiVRnDevice, Set<Int>> = resets.toMap().also { resets.clear() }

override fun close()

    override fun close() {
        devices.values.forEach { io.close(it.second) }
        devices.clear()
    }
}

internal object LinuxEvdevSystem : EvdevSystem

No helper process, global input group or native build: libc through the JDK's FFM API.

internal object LinuxEvdevSystem : EvdevSystem {
    private val linker by lazy { Linker.nativeLinker() }
    private fun call(name: String, result: java.lang.foreign.MemoryLayout, vararg args: java.lang.foreign.MemoryLayout) =
        linker.downcallHandle(linker.defaultLookup().find(name).orElseThrow(), FunctionDescriptor.of(result, *args))
    private val open by lazy { call("open", JAVA_INT, ADDRESS, JAVA_INT) }
    private val close by lazy { call("close", JAVA_INT, JAVA_INT) }
    private val read by lazy { call("read", JAVA_LONG, JAVA_INT, ADDRESS, JAVA_LONG) }
    private val errno by lazy { call("__errno_location", ADDRESS) }
    private val ioctlPointer by lazy {
        linker.downcallHandle(linker.defaultLookup().find("ioctl").orElseThrow(),
            FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_LONG, ADDRESS), Linker.Option.firstVariadicArg(2))
    }
    private val ioctlLong by lazy {
        linker.downcallHandle(linker.defaultLookup().find("ioctl").orElseThrow(),
            FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_LONG, JAVA_LONG), Linker.Option.firstVariadicArg(2))
    }
    private fun errno(): Int = (errno.invokeExact() as MemorySegment).reinterpret(4).get(JAVA_INT, 0)

override fun open(path: Path): Int

    override fun open(path: Path): Int = Arena.ofConfined().use { arena ->
        check(System.getProperty("os.name").startsWith("Linux") && ADDRESS.byteSize() == 8L)
        val fd = open.invokeExact(arena.allocateFrom(path.toString()), 0x800 or 0x80000) as Int // NONBLOCK | CLOEXEC, read-only
        check(fd >= 0) { "cannot read $path (errno ${errno()})" }
        fd
    }

override fun identity(fd: Int): WiVRnDevice?

    override fun identity(fd: Int): WiVRnDevice? = Arena.ofConfined().use { arena ->
        val id = arena.allocate(8, 2)
        val name = arena.allocate(256)
        check((ioctlPointer.invokeExact(fd, 0x80084502L, id) as Int) >= 0)
        check((ioctlPointer.invokeExact(fd, 0x81004506L, name) as Int) >= 0)
        WiVRnDevice.identify(name.getString(0), id.get(JAVA_SHORT, 0).toInt() and 0xffff,
            id.get(JAVA_SHORT, 2).toInt() and 0xffff, id.get(JAVA_SHORT, 4).toInt() and 0xffff)
    }

override fun grab(fd: Int): Boolean = (ioctlLong.invokeExact(fd, 0x40044590L, 1L) as Int) == 0

override fun pressed(fd: Int): Set<Int>

    override fun pressed(fd: Int): Set<Int> = Arena.ofConfined().use { arena ->
        val bits = arena.allocate(96)
        check((ioctlPointer.invokeExact(fd, 0x80604518L, bits) as Int) >= 0)
        (0 until 768).filter { (bits.get(JAVA_BYTE, (it / 8).toLong()).toInt() and (1 shl (it % 8))) != 0 }.toSet()
    }

override fun read(fd: Int): List<EvdevEvent>

    override fun read(fd: Int): List<EvdevEvent> = Arena.ofConfined().use { arena ->
        val data = arena.allocate((EvdevEvent.SIZE * 256).toLong(), 8)
        val count = read.invokeExact(fd, data, data.byteSize()) as Long
        if (count < 0) {
            val error = errno()
            if (error == 11) return emptyList() // EAGAIN
            throw IllegalStateException("evdev read failed (errno $error)")
        }
        check(count > 0 && count % EvdevEvent.SIZE == 0L) { "evdev stream ended or returned a partial event" }
        val bytes = data.asSlice(0, count).asByteBuffer()
        buildList { while (bytes.hasRemaining()) add(EvdevEvent.decode(bytes)) }
    }

override fun close(fd: Int) { close.invokeExact(fd) as Int }