switch-pico/tools/Test-SwitchPicoMac.swift

299 lines
9.8 KiB
Swift
Executable file

#!/usr/bin/env swift
import Darwin
import Dispatch
import Foundation
import IOKit.hid
private let vendorID = 0xCAFE
private let productID = 0x4021
private let genericDesktopPage = 0x01
private let simulationControlsPage = 0x02
private let gamePadUsage = 0x05
private let buttonPage = 0x09
private let expectedInterfaceCount = 4
private func writeError(_ message: String) {
FileHandle.standardError.write(Data((message + "\n").utf8))
}
private func ioReturnDescription(_ result: IOReturn) -> String {
String(format: "0x%08X", UInt32(bitPattern: Int32(result)))
}
private func printInputMonitoringHint() {
writeError(
"If macOS denied HID access, enable your terminal (or the app launching Swift) in "
+ "System Settings > Privacy & Security > Input Monitoring, then rerun the command."
)
}
private func numberProperty(_ device: IOHIDDevice, keys: [String]) -> Int? {
for key in keys {
if let number = IOHIDDeviceGetProperty(device, key as CFString) as? NSNumber {
return number.intValue
}
}
return nil
}
private func deviceKey(_ device: IOHIDDevice) -> ObjectIdentifier {
ObjectIdentifier(device)
}
private struct DeviceIdentity {
let interface: String
let location: String
init(_ device: IOHIDDevice) {
if let value = numberProperty(device, keys: ["InterfaceID", "InterfaceNumber", "bInterfaceNumber"]) {
interface = String(value)
} else {
interface = "unknown"
}
if let value = numberProperty(device, keys: ["LocationID", "locationID"]) {
location = String(format: "0x%08X", UInt32(truncatingIfNeeded: value))
} else {
location = "unknown"
}
}
var prefix: String {
"[interface=\(interface) location=\(location)]"
}
}
private final class DeviceState {
let device: IOHIDDevice
let identity: DeviceIdentity
var lastValues: [Int: CFIndex] = [:]
init(device: IOHIDDevice) {
self.device = device
identity = DeviceIdentity(device)
}
}
private final class HIDMonitor {
private let manager: IOHIDManager
private let runLoop: CFRunLoop
private var devices: [ObjectIdentifier: DeviceState] = [:]
private var interruptSource: DispatchSourceSignal?
private var didPrintPermissionHint = false
private var isCleanedUp = false
init(manager: IOHIDManager, runLoop: CFRunLoop) {
self.manager = manager
self.runLoop = runLoop
}
func start() -> IOReturn {
let context = Unmanaged.passUnretained(self).toOpaque()
let matching: [String: Any] = [
kIOHIDVendorIDKey as String: vendorID,
kIOHIDProductIDKey as String: productID,
kIOHIDDeviceUsagePageKey as String: genericDesktopPage,
kIOHIDDeviceUsageKey as String: gamePadUsage,
]
IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary)
IOHIDManagerRegisterDeviceMatchingCallback(manager, deviceMatchedCallback, context)
IOHIDManagerRegisterDeviceRemovalCallback(manager, deviceRemovedCallback, context)
IOHIDManagerRegisterInputValueCallback(manager, inputValueCallback, context)
IOHIDManagerScheduleWithRunLoop(manager, runLoop, CFRunLoopMode.defaultMode.rawValue)
let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
guard result == kIOReturnSuccess else {
writeError("Could not open the IOHID manager (\(ioReturnDescription(result))).")
printPermissionHintOnce()
return result
}
let matchedDeviceCount: Int
if let matchedDevices = IOHIDManagerCopyDevices(manager) {
matchedDeviceCount = CFSetGetCount(matchedDevices)
} else {
matchedDeviceCount = 0
}
if matchedDeviceCount == 0 {
print(
"No CAFE:4021 Generic Desktop Game Pad interfaces are connected; "
+ "waiting for hot-plug. Press Ctrl-C to stop."
)
} else {
print(
"Monitoring \(matchedDeviceCount)/\(expectedInterfaceCount) CAFE:4021 "
+ "raw Game Pad interfaces. Press Ctrl-C to stop."
)
}
signal(SIGINT, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global(qos: .userInitiated))
source.setEventHandler { [weak self] in
guard let self else { return }
CFRunLoopStop(self.runLoop)
}
source.resume()
interruptSource = source
return result
}
func deviceMatched(_ device: IOHIDDevice, result: IOReturn) {
guard result == kIOReturnSuccess else {
writeError("Device matching callback failed (\(ioReturnDescription(result))).")
return
}
let key = deviceKey(device)
guard devices[key] == nil else { return }
let identity = DeviceIdentity(device)
let openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone))
guard openResult == kIOReturnSuccess else {
writeError("\(identity.prefix) Could not open HID interface (\(ioReturnDescription(openResult))).")
printPermissionHintOnce()
return
}
devices[key] = DeviceState(device: device)
print("\(identity.prefix) attached (\(devices.count)/\(expectedInterfaceCount) Game Pad interfaces open)")
}
func deviceRemoved(_ device: IOHIDDevice, result: IOReturn) {
let key = deviceKey(device)
let identity = devices.removeValue(forKey: key)?.identity ?? DeviceIdentity(device)
IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone))
if result == kIOReturnSuccess {
print("\(identity.prefix) removed (\(devices.count)/\(expectedInterfaceCount) Game Pad interfaces open)")
} else {
writeError("\(identity.prefix) removal callback failed (\(ioReturnDescription(result))).")
}
}
func inputValue(_ value: IOHIDValue, result: IOReturn) {
guard result == kIOReturnSuccess else {
writeError("Input callback failed (\(ioReturnDescription(result))).")
return
}
let element = IOHIDValueGetElement(value)
let usagePage = IOHIDElementGetUsagePage(element)
let usage = IOHIDElementGetUsage(element)
guard let name = usageName(page: usagePage, usage: usage) else { return }
let device = IOHIDElementGetDevice(element)
let key = deviceKey(device)
guard let state = devices[key] else { return }
let integerValue = IOHIDValueGetIntegerValue(value)
let elementCookie = Int(IOHIDElementGetCookie(element))
guard state.lastValues[elementCookie] != integerValue else { return }
state.lastValues[elementCookie] = integerValue
let logicalMin = IOHIDElementGetLogicalMin(element)
let logicalMax = IOHIDElementGetLogicalMax(element)
print(
"\(state.identity.prefix) \(name) "
+ "page=\(hexUsage(usagePage)) usage=\(hexUsage(usage)) "
+ "logical=\(logicalMin)...\(logicalMax) value=\(integerValue)"
)
}
func cleanup() {
guard !isCleanedUp else { return }
isCleanedUp = true
interruptSource?.cancel()
interruptSource = nil
for state in devices.values {
print("\(state.identity.prefix) closing")
IOHIDDeviceClose(state.device, IOOptionBits(kIOHIDOptionsTypeNone))
}
devices.removeAll()
IOHIDManagerUnscheduleFromRunLoop(manager, runLoop, CFRunLoopMode.defaultMode.rawValue)
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
private func printPermissionHintOnce() {
guard !didPrintPermissionHint else { return }
didPrintPermissionHint = true
printInputMonitoringHint()
}
}
private func usageName(page: UInt32, usage: UInt32) -> String? {
if page == UInt32(genericDesktopPage) {
switch usage {
case 0x30: return "X"
case 0x31: return "Y"
case 0x32: return "Z"
case 0x33: return "Rx"
case 0x39: return "Hat"
default: return nil
}
}
if page == UInt32(simulationControlsPage) {
switch usage {
case 0xc5: return "LeftBrake"
case 0xc4: return "RightAccelerator"
default: return nil
}
}
if page == UInt32(buttonPage) {
return "Button \(usage)"
}
return nil
}
private func hexUsage(_ value: UInt32) -> String {
String(format: "0x%02X", value)
}
private func monitor(from context: UnsafeMutableRawPointer?) -> HIDMonitor? {
guard let context else { return nil }
return Unmanaged<HIDMonitor>.fromOpaque(context).takeUnretainedValue()
}
private func deviceMatchedCallback(
context: UnsafeMutableRawPointer?,
result: IOReturn,
sender: UnsafeMutableRawPointer?,
device: IOHIDDevice
) {
_ = sender
monitor(from: context)?.deviceMatched(device, result: result)
}
private func deviceRemovedCallback(
context: UnsafeMutableRawPointer?,
result: IOReturn,
sender: UnsafeMutableRawPointer?,
device: IOHIDDevice
) {
_ = sender
monitor(from: context)?.deviceRemoved(device, result: result)
}
private func inputValueCallback(
context: UnsafeMutableRawPointer?,
result: IOReturn,
sender: UnsafeMutableRawPointer?,
value: IOHIDValue
) {
_ = sender
monitor(from: context)?.inputValue(value, result: result)
}
let manager: IOHIDManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
private let monitorInstance = HIDMonitor(manager: manager, runLoop: CFRunLoopGetCurrent())
let openResult = monitorInstance.start()
if openResult != kIOReturnSuccess {
monitorInstance.cleanup()
exit(EXIT_FAILURE)
}
CFRunLoopRun()
monitorInstance.cleanup()
print("Stopped.")