From 61c752e91ea6e456c64d9d04a5d7c1943f3cc98e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 11:21:42 +0200 Subject: [PATCH] feat(gamepad): Apple + Android pickers declare DualSense Edge / Switch Pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 0.4 for the N1/N2 backends (SDL landed with them): - Apple: GamepadType grows dualSenseEdge=7 / switchPro=8 (wire-byte parity + name parsing). padKind splits the Edge out of the shared GCDualSenseGamepad subclass by product category, and resolves Switch Pro / a paired Joy-Con set by category (GameController has no Nintendo subclass; single Joy-Cons stay on the Xbox 360 fallback — half a pad). The DualSense-only gates (adaptive-trigger feedback, player LEDs, the touchpad+motion rich capture) now include the Edge — same surfaces. Paddle CAPTURE stays gated on G22 (needs a real pad to pin the paddleButton1..4 correspondence); the declared identity is right meanwhile. swift build + 124 tests green. - Android: PREF_DUALSENSEEDGE/PREF_SWITCHPRO wire bytes; the Sony PID table splits 0x0DF2 (Edge) out of DualSense; Nintendo 057E:2009 declares Switch Pro; ControllersScreen labels the new kinds. :kit/:app Kotlin compile green (-PskipRustBuild). Co-Authored-By: Claude Fable 5 --- .../io/unom/punktfunk/ControllersScreen.kt | 2 + .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 15 ++++++- .../Connection/PunktfunkConnection.swift | 11 +++++ .../PunktfunkKit/Gamepad/GamepadManager.swift | 41 ++++++++++++------- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 02a68d66..e5a9169b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -387,6 +387,8 @@ private fun prefLabel(pref: Int): String = when (pref) { Gamepad.PREF_DUALSHOCK4 -> "DualShock 4" Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller" Gamepad.PREF_STEAMDECK -> "Steam Deck" + Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge" + Gamepad.PREF_SWITCHPRO -> "Switch Pro" else -> "Automatic" } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 08c0a431..1fc05171 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -52,6 +52,8 @@ object Gamepad { const val PREF_DUALSHOCK4 = 4 const val PREF_STEAMCONTROLLER = 5 const val PREF_STEAMDECK = 6 + const val PREF_DUALSENSEEDGE = 7 + const val PREF_SWITCHPRO = 8 // USB vendor ids of the controllers we can identify by VID/PID. private const val VID_SONY = 0x054C @@ -59,10 +61,17 @@ object Gamepad { private const val VID_VALVE = 0x28DE private const val VID_NINTENDO = 0x057E - // Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. - private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) + // Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct + // host pad types — the Edge's back paddles get native slots on the virtual Edge (Android + // forwards no paddle input yet, but the identity + rich planes match the physical pad). + private val PID_DUALSENSE = setOf(0x0CE6) + private val PID_DUALSENSEEDGE = setOf(0x0DF2) private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) + // Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct + // glyphs + positional layout). + private val PID_SWITCHPRO = setOf(0x2009) + // Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) / // dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles / // trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard @@ -91,10 +100,12 @@ object Gamepad { val pid = dev.productId return when { vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE + vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4 vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER + vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO else -> PREF_XBOX360 } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 6b8d3f69..4bd6084a 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -188,6 +188,14 @@ public final class PunktfunkConnection { // exist so the resolved type round-trips and name parsing matches the host. case steamController = 5 case steamDeck = 6 + /// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn + /// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own + /// product category; paddle CAPTURE is still gated on G22, but the declared identity + + /// rich planes match the physical pad. + case dualSenseEdge = 7 + /// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo + /// glyphs + positional layout on the host side. + case switchPro = 8 /// Loose name parsing for env/dev hooks, mirroring the host's /// `GamepadPref::from_name`. @@ -200,6 +208,9 @@ public final class PunktfunkConnection { case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 case "steamdeck", "steam-deck", "deck": self = .steamDeck case "steamcontroller", "steam-controller", "steamcon": self = .steamController + case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge + case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller": + self = .switchPro default: return nil } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index d52c6b20..41b81234 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject { public let hasHaptics: Bool public let hasMotion: Bool public let hasAdaptiveTriggers: Bool - /// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers, - /// player LEDs) and the PlayStation glyph in Settings. - public var isDualSense: Bool { kind == .dualSense } - /// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates + /// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the + /// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph + /// in Settings. + public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge } + /// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates /// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC). public var hasTouchpadAndMotion: Bool { - kind == .dualSense || kind == .dualShock4 + kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4 } /// 0...1, nil when the controller doesn't report a battery (e.g. wired). public let batteryLevel: Float? @@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject { private static func describe(_ c: GCController, id: String) -> DiscoveredController { let extended = c.extendedGamepad - let kind = padKind(extended) + let kind = padKind(extended, productCategory: c.productCategory) return DiscoveredController( id: id, name: c.vendorName ?? c.productCategory, @@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject { hasLight: c.light != nil, hasHaptics: c.haptics != nil, hasMotion: c.motion != nil, - // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the - // DualShock 4 has none. - hasAdaptiveTriggers: kind == .dualSense, + // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the + // Edge included); the DualShock 4 has none. + hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge, batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, isCharging: c.battery?.batteryState == .charging, controller: c) } /// Resolve a physical controller's matching virtual-pad type from its GameController - /// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then - /// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent - /// profile also falls back to `.xbox360` (it's never forwarded anyway). + /// subclass (+ the product-category string where the subclass is shared). Detection order + /// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a + /// `GCDualSenseGamepad` too — its distinct product category splits it out), then + /// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no + /// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360` + /// (it's never forwarded anyway). private static func padKind( - _ extended: GCExtendedGamepad? + _ extended: GCExtendedGamepad?, + productCategory: String ) -> PunktfunkConnection.GamepadType { guard let extended else { return .xbox360 } + let category = productCategory.lowercased() // Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version // here, so no `@available` guard is needed — matching the unguarded // `GCDualSenseGamepad` use elsewhere in the package. - if extended is GCDualSenseGamepad { return .dualSense } + if extended is GCDualSenseGamepad { + return category.contains("edge") ? .dualSenseEdge : .dualSense + } if extended is GCDualShockGamepad { return .dualShock4 } if extended is GCXboxGamepad { return .xboxOne } + // Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single + // Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad. + if category.contains("switch pro") || category.contains("joy-con (l/r)") { + return .switchPro + } return .xbox360 } }