feat: show live controller input

Route paired controller QoS data through the existing GIP protocol layer and
expose the latest gamepad report through the C API. Add a SwiftUI monitor for
buttons, d-pad, triggers, and stick positions without registering a native HID
device.

Co-Authored-By: openai/gpt-5.6-luna: GIP bridge and live input monitor
This commit is contained in:
portersky
2026-08-29 13:33:32 +02:00
parent 325923751c
commit 9c1b6493d8
7 changed files with 462 additions and 40 deletions
+148 -15
View File
@@ -24,16 +24,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
private struct ControllerSnapshot: Identifiable {
let id: String
let buttons: UInt16
let guideDown: Bool
let gipReady: Bool
let inputActive: Bool
let triggerLeft: UInt16
let triggerRight: UInt16
let stickLeftX: Int16
let stickLeftY: Int16
let stickRightX: Int16
let stickRightY: Int16
let sequence: UInt32
}
struct ContentView: View {
@State private var donglePresent = false
@State private var session: OpaquePointer? = nil
// Radio state polled from the background worker each tick; a change here
// is what re-renders the debug section.
@State private var radioState = XONE_STATE_IDLE
// Connected controllers (MAC strings), refreshed each tick.
@State private var controllers: [String] = []
// Connected controllers and their latest input snapshot.
@State private var controllers: [ControllerSnapshot] = []
private let timer = Timer.publish(every: 1.0, on: .main, in: .common)
private let timer = Timer.publish(every: 0.1, on: .main, in: .common)
.autoconnect()
var body: some View {
@@ -138,12 +153,32 @@ struct ContentView: View {
// Query the C API for connected controllers and store their MACs.
private func refreshControllers(_ session: OpaquePointer) {
let count = Int(xone_controller_count(session))
var list: [String] = []
var list: [ControllerSnapshot] = []
for i in 0..<count {
var buf = [CChar](repeating: 0, count: 18)
if xone_controller_mac(session, Int32(i), &buf, 18) == 0 {
list.append(String(cString: buf))
var macBuffer = [CChar](repeating: 0, count: 18)
guard xone_controller_mac(session, Int32(i), &macBuffer, 18) == 0 else {
continue
}
var state = xone_controller_state()
guard xone_controller_get_state(session, Int32(i), &state) == 0 else {
continue
}
list.append(ControllerSnapshot(
id: String(cString: macBuffer),
buttons: state.buttons,
guideDown: state.guide_down,
gipReady: state.gip_ready,
inputActive: state.input_active,
triggerLeft: state.trigger_left,
triggerRight: state.trigger_right,
stickLeftX: state.stick_left_x,
stickLeftY: state.stick_left_y,
stickRightX: state.stick_right_x,
stickRightY: state.stick_right_y,
sequence: state.sequence
))
}
controllers = list
}
@@ -159,24 +194,122 @@ struct ContentView: View {
}
private var controllersView: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
Text("Controllers")
.font(.headline)
if controllers.isEmpty {
Text("No controllers connected.")
.foregroundStyle(.secondary)
} else {
ForEach(controllers, id: \.self) { mac in
HStack {
Image(systemName: "gamecontroller")
.foregroundStyle(.secondary)
Text(mac)
.font(.system(.body, design: .monospaced))
}
ForEach(controllers) { controller in
controllerView(controller)
}
}
}
}
private func controllerView(_ controller: ControllerSnapshot) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "gamecontroller")
.foregroundStyle(.secondary)
Text(controller.id)
.font(.system(.body, design: .monospaced))
Spacer()
Text(controller.gipReady ? "ready" : "connecting")
.foregroundStyle(controller.gipReady ? .green : .orange)
}
HStack(spacing: 4) {
buttonIndicator("Guide", pressed: controller.guideDown)
buttonIndicator("A", pressed: isPressed(controller, 0x0010))
buttonIndicator("B", pressed: isPressed(controller, 0x0020))
buttonIndicator("X", pressed: isPressed(controller, 0x0040))
buttonIndicator("Y", pressed: isPressed(controller, 0x0080))
}
HStack(spacing: 4) {
buttonIndicator("LB", pressed: isPressed(controller, 0x1000))
buttonIndicator("RB", pressed: isPressed(controller, 0x2000))
buttonIndicator("LS", pressed: isPressed(controller, 0x4000))
buttonIndicator("RS", pressed: isPressed(controller, 0x8000))
buttonIndicator("", pressed: isPressed(controller, 0x0100))
buttonIndicator("", pressed: isPressed(controller, 0x0200))
buttonIndicator("", pressed: isPressed(controller, 0x0400))
buttonIndicator("", pressed: isPressed(controller, 0x0800))
}
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Left stick")
.foregroundStyle(.secondary)
signedAxis("X", controller.stickLeftX)
signedAxis("Y", controller.stickLeftY)
}
VStack(alignment: .leading, spacing: 4) {
Text("Right stick")
.foregroundStyle(.secondary)
signedAxis("X", controller.stickRightX)
signedAxis("Y", controller.stickRightY)
}
}
HStack(spacing: 12) {
triggerAxis("Left trigger", controller.triggerLeft)
triggerAxis("Right trigger", controller.triggerRight)
}
Text(controller.inputActive
? "Input #\(controller.sequence)"
: "Waiting for input")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func isPressed(_ controller: ControllerSnapshot, _ mask: UInt16) -> Bool {
(controller.buttons & mask) != 0
}
private func buttonIndicator(_ title: String, pressed: Bool) -> some View {
Text(title)
.font(.caption2)
.frame(minWidth: 28, minHeight: 22)
.padding(.horizontal, 2)
.background(pressed ? Color.green : Color.secondary.opacity(0.18))
.foregroundStyle(pressed ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
private func signedAxis(_ label: String, _ value: Int16) -> some View {
let normalized = Double(Int(value) + 32768) / 65535.0
return HStack(spacing: 4) {
Text(label)
.font(.caption2)
.frame(width: 12, alignment: .leading)
ProgressView(value: normalized)
.frame(width: 82)
Text("\(value)")
.font(.caption2.monospacedDigit())
.frame(width: 42, alignment: .trailing)
}
}
private func triggerAxis(_ label: String, _ value: UInt16) -> some View {
HStack(spacing: 4) {
Text(label)
.font(.caption2)
.frame(width: 76, alignment: .leading)
ProgressView(value: Double(value), total: 1023)
.frame(width: 70)
Text("\(value)")
.font(.caption2.monospacedDigit())
.frame(width: 32, alignment: .trailing)
}
}
}
// Entry point: SwiftUI App provides a static main() (top-level code lives in