From 1f54b75c5c817d3693ae0ef0a396e444471cedc5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:38:13 +0200 Subject: [PATCH 1/2] feat(client/apple): the phone's gyro can speak for a gyro-less pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in "Gyro from this device" (DefaultsKey.gyroFromDevice, off by default, iOS only): while player 1's forwarded controller reports no rotation rate of its own — no GCMotion, or the gravity-only motion an Xbox pad exposes — this device's IMU sources pad 0's wire motion instead. The rumble-on-device mirror's sibling, data flowing the other way: same session-scoped UserDefaults read, same hardware-gated settings rows, same pad-0 rule. DeviceGyro wraps CMDeviceMotion at the ~100 Hz CoreMotion ceiling on a dedicated serial queue (not main — the controller path's main-queue delivery is a known jitter source), converts with the shared GamepadWire constants, and rotates each sample from the device's portrait frame into the controller frame by interface orientation, so a phone clipped landscape yaws when the player yaws instead of rolling. The remap matrix is derived and pinned by DeviceGyroRemapTests. GamepadCapture owns engage/stand-down (reconcile, suspend/resume, stop), and suppresses pad 0's controller-motion forwarding while the mirror runs — two writers on one pad's motion state would fight, and the accel-only stream would stomp the mirror's gyro with zeros. Also fixes the stale-motion latch from the gyro sweep on the controller path: flush now parks motion at zero (keeping the last accel, so gravity doesn't become free-fall), and the mirror's stop sends the same closing zero. The host holds motion as state and re-emits it — a nonzero angular velocity left behind read as endless rotation for as long as an overlay (Control Center pull-down) kept the app inactive. --- .../Settings/GamepadSettingsView.swift | 17 ++ .../Settings/SettingsView+Sections.swift | 9 + .../Settings/SettingsView.swift | 1 + .../PunktfunkKit/Gamepad/DeviceGyro.swift | 201 ++++++++++++++++++ .../PunktfunkKit/Gamepad/GamepadCapture.swift | 92 ++++++-- .../PunktfunkShared/DefaultsKeys.swift | 8 + .../DeviceGyroRemapTests.swift | 62 ++++++ 7 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/DeviceGyroRemapTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index c23452cb..808ff0c9 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -85,6 +85,7 @@ struct GamepadSettingsView: View { #endif #if os(iOS) @AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false + @AppStorage(DefaultsKey.gyroFromDevice) private var gyroFromDevice = false #endif @ObservedObject private var gamepads = GamepadManager.shared /// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the @@ -649,6 +650,22 @@ struct GamepadSettingsView: View { value: $rumbleOnDevice), at: at + 1) } + // The phone-gyro mirror sits beside the rumble mirror: same clip-on-pad audience, + // opposite data direction. Hidden where the device has no motion hardware; engages + // in-session only while player 1's controller reports no rotation rate of its own. + if DeviceGyro.isAvailable, + let anchor = list.firstIndex(where: { $0.id == "deviceRumble" }) + ?? list.firstIndex(where: { $0.id == "padType" }) { + list.insert( + toggleRow( + id: "deviceGyro", tab: .controller, + icon: "gyroscope", + label: "Gyro from this device", + detail: "When the controller has no gyro, send this device's motion " + + "sensors as player 1's — for clip-on pads without one of their own.", + value: $gyroFromDevice), + at: anchor + 1) + } #endif return list + profileRows } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 93dbdf71..8ee5f380 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -712,6 +712,15 @@ extension SettingsView { Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice) } } + // The rumble mirror's sibling, data flowing the other way: hidden where the + // device has no motion hardware, engages only while the player-1 controller + // reports no rotation rate of its own. + if !inProfileScope, DeviceGyro.isAvailable { + described("When the controller has no gyro of its own, sends this device's " + + "motion sensors as player 1's — for clip-on pads without one.") { + Toggle("Gyro from this device", isOn: $gyroFromDevice) + } + } #endif #if !os(tvOS) if !inProfileScope { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 88128ee7..e93581f2 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -91,6 +91,7 @@ struct SettingsView: View { @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue @AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false + @AppStorage(DefaultsKey.gyroFromDevice) var gyroFromDevice = false // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // Width class decides the initial value: nil on iPhone (show the category list first), // General on iPad (a two-column layout should never open with an empty detail). diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift new file mode 100644 index 00000000..442c6542 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift @@ -0,0 +1,201 @@ +// The opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): when player 1's forwarded +// controller has no rotation sensor of its own, THIS device's IMU speaks for it on the wire's +// motion plane — for clip-on and third-party pads that ship without a gyro, where the phone +// body is rigidly attached to (or simply is) the thing in the player's hands. The sibling of +// `GamepadFeedback`'s rumble-on-device mirror, with the data flowing the other way. +// +// GamepadCapture owns the engage/stand-down decision (it knows the pad-0 slot and whether its +// controller reports a rotation rate); this class only turns CoreMotion on and off and converts +// samples. Two invariants it enforces itself: +// - one motion writer per pad: samples go out only between `start` and `stop`, and capture +// suppresses pad 0's controller-motion forwarding while this runs; +// - no stale rotation: `stop` sends a single zero-gyro sample after the last real one, so the +// host's virtual pad never keeps integrating an angular velocity this device stopped +// producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode). +// +// Samples are CMDeviceMotion (sensor-fused: bias-corrected rotation rate, gravity split from +// user acceleration) at the ~100 Hz CoreMotion ceiling — below a DualSense's 250 Hz, but the +// host's motion plane is event-driven, not cadence-locked, so a slower producer just means +// fewer samples. Units and axis semantics match `GamepadCapture.forwardMotion` exactly (the +// `GamepadWire` constants; accel = gravity + user acceleration — the same convention, so a +// future sign/scale correction lands in one place for both sources). The one thing the phone +// adds is a frame remap: CoreMotion reports in the device's portrait frame, while the wire +// wants the controller frame the player sees (x right, y up, z out of the screen), so each +// sample is rotated by the current interface orientation — a phone clipped landscape must yaw +// when the player yaws, not roll. + +#if os(iOS) +import CoreMotion +import Foundation +import UIKit + +/// Device-frame → controller-frame axis remap for one interface orientation. CoreMotion's +/// frame is fixed to the portrait device (+x right edge, +y top, +z out of the screen); the +/// controller frame keeps +z (the screen always faces the player) and rotates x/y so they +/// mean "player's right" and "player's up". Derived, like the wire scale constants — pinned +/// by `DeviceGyroRemapTests`, correctable in one place if on-glass says otherwise. +/// File-scope rather than nested so the sample thread can use it without actor isolation. +enum DeviceGyroRemap { + case identity + /// Upside-down portrait: both in-plane axes flip. + case flipped + /// Landscape, device top to the player's LEFT (interface `.landscapeRight`): + /// player-right = device-bottom, player-up = device-right. + case topLeft + /// Landscape, device top to the player's RIGHT (interface `.landscapeLeft`). + case topRight + + init(_ orientation: UIInterfaceOrientation) { + switch orientation { + case .portraitUpsideDown: self = .flipped + case .landscapeRight: self = .topLeft + case .landscapeLeft: self = .topRight + default: self = .identity + } + } + + /// Rotate one device-frame vector (rotation rate or acceleration — both transform the + /// same way under an in-plane rotation) into the controller frame. + func apply(x: Float, y: Float, z: Float) -> (x: Float, y: Float, z: Float) { + switch self { + case .identity: return (x, y, z) + case .flipped: return (-x, -y, z) + case .topLeft: return (-y, x, z) + case .topRight: return (y, -x, z) + } + } +} + +@MainActor +public final class DeviceGyro { + /// Whether this device can source motion at all — gates the settings rows (a device + /// without an IMU would make the toggle a silent no-op, the rumble mirror's rule). + /// One shared probe: Apple recommends a single `CMMotionManager` per app, and the + /// settings UI asking per-render must not allocate one each time. + public static let isAvailable: Bool = CMMotionManager().isDeviceMotionAvailable + + /// Everything the sample thread touches, behind one lock: the orientation remap (written + /// on main when the device rotates), the last converted accel, and whether a real sample + /// went out (so `stop` knows it owes the wire a zero). Kept off the actor deliberately — + /// `forward` runs on the delivery queue. + private final class SampleState: @unchecked Sendable { + let lock = NSLock() + var remap: DeviceGyroRemap = .identity + var sentSample = false + /// Re-sent with the closing zero-gyro sample so "rotation stopped" doesn't also + /// overwrite a plausible gravity vector with free-fall. + var lastAccel: (Int16, Int16, Int16) = (0, 0, 0) + } + + /// Ship one converted sample (wire pad 0). Must be thread-safe — invoked from the + /// delivery queue (`PunktfunkConnection.sendMotion` locks internally). + private let send: @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void + + private let motion = CMMotionManager() + /// Dedicated serial delivery queue — deliberately NOT main (the controller path's + /// main-queue delivery is a known jitter source; the mirror starts clean). + private let queue: OperationQueue = { + let q = OperationQueue() + q.name = "punktfunk.device-gyro" + q.maxConcurrentOperationCount = 1 + return q + }() + + private let state = SampleState() + private var orientationObserver: NSObjectProtocol? + + /// Whether the mirror is between `start` and `stop` — read by GamepadCapture to keep the + /// controller path off pad 0's motion while this runs. + public private(set) var isRunning = false + + public init( + send: @escaping @Sendable (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void + ) { + self.send = send + } + + /// Begin sourcing pad-0 motion from this device. Idempotent. + public func start() { + guard !isRunning, motion.isDeviceMotionAvailable else { return } + isRunning = true + updateRemap() + // Interface orientation only changes alongside a device-orientation notification, so + // this is the one signal needed; re-reading the scene keeps a rotation lock stable. + orientationObserver = NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.updateRemap() } + } + // CoreMotion's practical ceiling; requesting faster just clamps. + motion.deviceMotionUpdateInterval = 1.0 / 100.0 + motion.startDeviceMotionUpdates(to: queue) { [state, send] m, _ in + guard let m else { return } + Self.forward(m, state: state, send: send) + } + } + + /// Stop sourcing and, if anything was sent, park the host pad's rotation at zero. The + /// zero rides the same serial queue as the samples, so it is guaranteed last — without + /// blocking the caller. + public func stop() { + guard isRunning else { return } + isRunning = false + motion.stopDeviceMotionUpdates() + if let o = orientationObserver { + NotificationCenter.default.removeObserver(o) + orientationObserver = nil + } + queue.addOperation { [state, send] in + state.lock.lock() + let owed = state.sentSample + state.sentSample = false + let accel = state.lastAccel + state.lock.unlock() + if owed { send((0, 0, 0), accel) } + } + } + + private func updateRemap() { + let o = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.interfaceOrientation ?? .portrait + state.lock.lock() + state.remap = DeviceGyroRemap(o) + state.lock.unlock() + } + + /// Runs on the delivery queue: remap, scale, ship. + nonisolated private static func forward( + _ m: CMDeviceMotion, state: SampleState, + send: (_ gyro: (Int16, Int16, Int16), _ accel: (Int16, Int16, Int16)) -> Void + ) { + state.lock.lock() + let r = state.remap + state.lock.unlock() + let rot = r.apply( + x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z)) + // Same total-acceleration convention as GamepadCapture.forwardMotion. + let acc = r.apply( + x: Float(m.gravity.x + m.userAcceleration.x), + y: Float(m.gravity.y + m.userAcceleration.y), + z: Float(m.gravity.z + m.userAcceleration.z)) + let gs = GamepadWire.gyroLSBPerRadS + let as_ = GamepadWire.accelLSBPerG + let gyro = ( + GamepadWire.motionRaw(rot.x, scale: gs), + GamepadWire.motionRaw(rot.y, scale: gs), + GamepadWire.motionRaw(rot.z, scale: gs) + ) + let accel = ( + GamepadWire.motionRaw(acc.x, scale: as_), + GamepadWire.motionRaw(acc.y, scale: as_), + GamepadWire.motionRaw(acc.z, scale: as_) + ) + state.lock.lock() + state.lastAccel = accel + state.sentSample = true + state.lock.unlock() + send(gyro, accel) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 4ea7812b..0cb3b6db 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -67,6 +67,13 @@ public final class GamepadCapture { var axes: [Int32] = [0, 0, 0, 0, 0, 0] var fingerActive: [Bool] = [false, false] var lastMotionNs: UInt64 = 0 + /// A motion sample went out on this pad — `flush` then owes the wire a zero-gyro + /// sample: the host holds motion as STATE and re-emits it, so a nonzero angular + /// velocity left behind reads as endless rotation (the gyro-sweep latch). + var motionSent = false + /// The last accel sent, re-used by the flush zero so "rotation stopped" doesn't + /// also replace a plausible gravity vector with free-fall. + var lastAccel: (Int16, Int16, Int16) = (0, 0, 0) // Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to // this class's mask-diff model): a Select pressed ALONE is held out of the mask // until it resolves into a tap (delivered on release) or — past `guideHold` — a @@ -153,6 +160,15 @@ public final class GamepadCapture { /// everywhere but macOS). See `guideHold`. public let guideGesture: Bool + #if os(iOS) + /// Opt-in phone-gyro mirror (`DefaultsKey.gyroFromDevice`): while player 1's forwarded + /// controller has no rotation sensor, this device's IMU sources pad 0's motion instead — + /// for clip-on pads without a gyro. Session-scoped (the setting is read once here); nil + /// when off, unavailable, or forwarding is off (the mirror is wire-only, so with nothing + /// to send there is nothing to mirror). Engage/stand-down lives in `updateDeviceGyro`. + private let deviceGyro: DeviceGyro? + #endif + public init( connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true, systemForward: Bool = true, guideGesture: Bool = false @@ -162,6 +178,17 @@ public final class GamepadCapture { self.forwarding = forwarding self.systemForward = systemForward self.guideGesture = guideGesture + #if os(iOS) + if forwarding, DeviceGyro.isAvailable, + UserDefaults.standard.bool(forKey: DefaultsKey.gyroFromDevice) { + deviceGyro = DeviceGyro { [weak connection] gyro, accel in + // Thread-safe (sendMotion locks); pad 0 by the same rule as the rumble mirror. + connection?.sendMotion(pad: 0, gyro: gyro, accel: accel) + } + } else { + deviceGyro = nil + } + #endif } public func start() { @@ -187,6 +214,9 @@ public final class GamepadCapture { MainActor.assumeIsolated { self?.suspended = true self?.releaseAll() + // The mirror pauses with capture (its stop parks the host pad's rotation + // at zero — an overlay pull-down must not leave the game spinning). + self?.updateDeviceGyro() } }) observers.append(NotificationCenter.default.addObserver( @@ -199,11 +229,15 @@ public final class GamepadCapture { for slot in self.slots { if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) } } + self.updateDeviceGyro() } }) } public func stop() { + #if os(iOS) + deviceGyro?.stop() + #endif closeAllSlots() forwardedSub = nil observers.forEach { NotificationCenter.default.removeObserver($0) } @@ -224,6 +258,8 @@ public final class GamepadCapture { } // A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms. updateEscapeChord() + // Pad 0 may have changed hands — re-evaluate whether this device's IMU speaks for it. + updateDeviceGyro() } /// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its @@ -561,6 +597,13 @@ public final class GamepadCapture { private func forwardMotion(_ slot: Slot, _ m: GCMotion) { guard !suspended else { return } + #if os(iOS) + // While the phone-gyro mirror speaks for pad 0, the controller's own motion — + // necessarily rotation-less, that's the engage condition — stays off the wire: + // two writers on one pad's motion state would fight, and this accel-only stream + // would keep stomping the mirror's gyro with zeros. + if slot.pad == 0, deviceGyro?.isRunning == true { return } + #endif let now = DispatchTime.now().uptimeNanoseconds guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return } slot.lastMotionNs = now @@ -579,18 +622,35 @@ public final class GamepadCapture { } let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG - wire?.sendMotion( - pad: UInt8(slot.pad), - gyro: ( - GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs) - ), - accel: ( - GamepadWire.motionRaw(ax, scale: as_), - GamepadWire.motionRaw(ay, scale: as_), - GamepadWire.motionRaw(az, scale: as_) - )) + let gyro = ( + GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), + GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), + GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs) + ) + let accel = ( + GamepadWire.motionRaw(ax, scale: as_), + GamepadWire.motionRaw(ay, scale: as_), + GamepadWire.motionRaw(az, scale: as_) + ) + if wire != nil { + slot.motionSent = true + slot.lastAccel = accel + } + wire?.sendMotion(pad: UInt8(slot.pad), gyro: gyro, accel: accel) + } + + /// Engage or stand down the phone-gyro mirror: it speaks for pad 0 exactly while a + /// forwarded controller holds that index but can't rotate for itself — no `GCMotion`, + /// or a motion object without a rotation rate (gravity-only pads, e.g. an Xbox pad on + /// iOS). Re-evaluated on every reconcile and on suspend/resume; `DeviceGyro.stop` + /// parks the host pad's rotation at zero, so standing down never strands a spin. + private func updateDeviceGyro() { + #if os(iOS) + guard let gyro = deviceGyro else { return } + let pad0 = slots.first { $0.pad == 0 } + let wants = !suspended && pad0 != nil && pad0!.controller.motion?.hasRotationRate != true + if wants { gyro.start() } else { gyro.stop() } + #endif } /// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the @@ -634,6 +694,14 @@ public final class GamepadCapture { wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0) slot.fingerActive[f] = false } + // Motion is host-side STATE, re-emitted until replaced — a nonzero angular velocity + // left behind reads as endless rotation (the gyro-sweep latch: Control Center + // pull-down froze the last sample for as long as the overlay stayed up). Rest means + // zero rotation; the last accel is kept so gravity doesn't become free-fall. + if slot.motionSent { + slot.motionSent = false + wire?.sendMotion(pad: UInt8(slot.pad), gyro: (0, 0, 0), accel: slot.lastAccel) + } } /// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 79bb74f6..6a67b893 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -193,6 +193,14 @@ public enum DefaultsKey { /// once per session by `GamepadFeedback`. The toggle is shown only where the device actually /// has a haptic actuator (no iPad/Mac/TV). public static let rumbleOnDevice = "punktfunk.rumbleOnDevice" + /// Use this device's own gyroscope as player 1's motion when the forwarded controller has + /// none of its own — for clip-on and third-party pads without an IMU, where the device body + /// moves with the player's hands. The rumble mirror's sibling, data flowing the other way. + /// Off by default (opt-in); read once per session by `GamepadCapture`, whose `DeviceGyro` + /// mirror engages only while pad 0's controller reports no rotation rate (a real gyro pad + /// always wins). The toggle is shown only where the device has motion hardware + /// (`DeviceGyro.isAvailable`). + public static let gyroFromDevice = "punktfunk.gyroFromDevice" /// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire /// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…" /// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a diff --git a/clients/apple/Tests/PunktfunkKitTests/DeviceGyroRemapTests.swift b/clients/apple/Tests/PunktfunkKitTests/DeviceGyroRemapTests.swift new file mode 100644 index 00000000..bfaa2a8a --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/DeviceGyroRemapTests.swift @@ -0,0 +1,62 @@ +// Pins the phone-gyro mirror's device→controller frame remap (DeviceGyro.swift). The matrix is +// derived (like the wire scale constants), so these tests are the contract: if on-glass says an +// axis is wrong, fix the enum AND these expectations together. + +#if os(iOS) +import UIKit +import XCTest + +@testable import PunktfunkKit + +final class DeviceGyroRemapTests: XCTestCase { + /// A distinct vector per axis so a swapped or flipped component can't cancel out. + private let v: (x: Float, y: Float, z: Float) = (1, 2, 3) + + func testPortraitIsIdentity() { + let r = DeviceGyroRemap.identity.apply(x: v.x, y: v.y, z: v.z) + XCTAssertEqual([r.x, r.y, r.z], [1, 2, 3]) + } + + func testUpsideDownFlipsInPlane() { + let r = DeviceGyroRemap.flipped.apply(x: v.x, y: v.y, z: v.z) + XCTAssertEqual([r.x, r.y, r.z], [-1, -2, 3]) + } + + /// Device top to the player's LEFT: player-right = device-bottom (−y), player-up = + /// device-right (+x). z (out of the screen) never changes — the screen faces the player. + func testTopLeftLandscape() { + let r = DeviceGyroRemap.topLeft.apply(x: v.x, y: v.y, z: v.z) + XCTAssertEqual([r.x, r.y, r.z], [-2, 1, 3]) + } + + /// Device top to the player's RIGHT: player-right = device-top (+y), player-up = + /// device-left (−x). + func testTopRightLandscape() { + let r = DeviceGyroRemap.topRight.apply(x: v.x, y: v.y, z: v.z) + XCTAssertEqual([r.x, r.y, r.z], [2, -1, 3]) + } + + /// Interface orientation → remap: `.landscapeRight` means the Home edge is on the + /// player's right, i.e. the device top points LEFT (and vice versa). + func testOrientationMapping() { + XCTAssertEqual(DeviceGyroRemap(.portrait), .identity) + XCTAssertEqual(DeviceGyroRemap(.portraitUpsideDown), .flipped) + XCTAssertEqual(DeviceGyroRemap(.landscapeRight), .topLeft) + XCTAssertEqual(DeviceGyroRemap(.landscapeLeft), .topRight) + XCTAssertEqual(DeviceGyroRemap(.unknown), .identity) + } + + /// Every remap must stay a proper rotation (right-handed): x̂ × ŷ = ẑ after mapping. + func testHandednessPreserved() { + for remap in [DeviceGyroRemap.identity, .flipped, .topLeft, .topRight] { + let x = remap.apply(x: 1, y: 0, z: 0) + let y = remap.apply(x: 0, y: 1, z: 0) + // Cross product of the two mapped in-plane basis vectors. + let cross = ( + x: x.y * 0 - 0 * y.y, y: 0 * y.x - x.x * 0, z: x.x * y.y - x.y * y.x + ) + XCTAssertEqual(cross.z, 1, "left-handed remap: \(remap)") + } + } +} +#endif From 55aba3e936ee4aef5058eb1878c01336e3408e60 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 11:38:31 +0200 Subject: [PATCH 2/2] feat(client/android): the phone's gyro can speak for a gyro-less pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in "Gyro from this phone" (gyro_on_phone, off by default): this device's IMU sources wire pad 0's motion while that pad is a controller with no motion source of its own. On Android that gate is exact — the only pads that forward motion are the capture links (USB DualSense / SC2, claimed as ExternalPads), so the mirror stands down per sample whenever GamepadRouter.padHasOwnMotion(0) says a capture link holds the index, and sends nothing while pad 0 has no slot at all (motion never creates a host pad). "Rumble on this phone"'s sibling, data flowing the other way: same read-once-at-attach settings plumbing, same hardware-gated rows in the touch and controller settings (a TV box has no gyroscope to mirror from). DeviceGyro registers TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a dedicated HandlerThread with batching disabled (maxReportLatencyUs = 0 — batching is poison for gyro aim), converts with the wire contract shared with pf-client-core (rad/s → 20 LSB/°·s, m/s² → g → 10000 LSB/g; Android's accelerometer already reads specific force, the DualSense report's own convention), and rotates each sample from the natural-portrait sensor frame into the controller frame by display rotation — a phone clipped landscape yaws when the player yaws instead of rolling. The remap matrix and unit constants are pinned by DeviceGyroTest. A stand-down edge (capture link claims pad 0, or session teardown) sends one zero-gyro sample so the host's virtual pad never keeps integrating an angular velocity this device stopped producing — the gyro sweep's stale-rotation latch, avoided by construction here. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 24 ++- .../main/kotlin/io/unom/punktfunk/Settings.kt | 13 ++ .../io/unom/punktfunk/SettingsScreen.kt | 13 ++ .../kotlin/io/unom/punktfunk/StreamScreen.kt | 12 ++ .../io/unom/punktfunk/GamepadPaletteTest.kt | 8 +- .../unom/punktfunk/GamepadSettingsRowsTest.kt | 1 + .../io/unom/punktfunk/kit/DeviceGyro.kt | 179 ++++++++++++++++++ .../io/unom/punktfunk/kit/GamepadRouter.kt | 13 ++ .../io/unom/punktfunk/kit/DeviceGyroTest.kt | 53 ++++++ 9 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 7da32711..a9611574 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource +import io.unom.punktfunk.kit.DeviceGyro import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHostStore @@ -126,6 +127,8 @@ fun GamepadSettingsScreen( val context = LocalContext.current // Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto. val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } + // Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from. + val hasGyroscope = remember { DeviceGyro.available(context) } // Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`). val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null } @@ -159,7 +162,7 @@ fun GamepadSettingsScreen( // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard // interface remote-navigably. The strings branch on it. val tv = remember { isTvDevice(context) } - val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } // Which section is showing, and where each one's focus was when it was last left — a detour // into another tab shouldn't lose your place. @@ -445,12 +448,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick } /** Build the console settings rows from the current [Settings], writing through [update]. - * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the - * AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one - * tab at a time. */ + * [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this + * phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see + * `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, + hasGyroscope: Boolean, av1Capable: Boolean, update: (Settings) -> Unit, ): List { @@ -598,6 +602,18 @@ internal fun buildSettingsRows( } else { null }, + // The rumble mirror's sibling, data flowing the other way — needs a gyroscope to + // mirror FROM, which a TV box lacks. + if (hasGyroscope) { + toggle( + "phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone", + "When the controller has no gyro of its own, send this phone's motion " + + "sensors as controller 1's — for clip-on pads without one.", + s.gyroOnPhone, + ) { update(s.copy(gyroOnPhone = it)) } + } else { + null + }, ) + listOf( // NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has // nothing to do with this device's motor, and a TV box is where it matters most. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index ea6328d4..9b1048a1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -158,6 +158,16 @@ data class Settings( * toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op. */ val rumbleOnPhone: Boolean = false, + /** + * Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has + * none of its own — for clip-on gamepads without an IMU, where the phone body moves with the + * player's hands. The rumble mirror's sibling, data flowing the other way. Off by default; + * read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only + * when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture + * link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices + * without a gyroscope (TVs), where this would be a silent no-op. + */ + val gyroOnPhone: Boolean = false, /** * Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad) @@ -300,6 +310,7 @@ class SettingsStore(context: Context) { smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false), + gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false), sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true), dsCapture = prefs.getBoolean(K_DS_CAPTURE, true), padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true), @@ -340,6 +351,7 @@ class SettingsStore(context: Context) { .putInt(K_SMOOTH_BUFFER, s.smoothBuffer) .putBoolean(K_AUTO_WAKE, s.autoWakeEnabled) .putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone) + .putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone) .putBoolean(K_SC2_CAPTURE, s.sc2Capture) .putBoolean(K_DS_CAPTURE, s.dsCapture) .putBoolean(K_PAD_HAPTICS, s.padHaptics) @@ -390,6 +402,7 @@ class SettingsStore(context: Context) { const val K_SMOOTH_BUFFER = "smooth_buffer" const val K_AUTO_WAKE = "auto_wake_enabled" const val K_RUMBLE_ON_PHONE = "rumble_on_phone" + const val K_GYRO_ON_PHONE = "gyro_on_phone" const val K_SC2_CAPTURE = "sc2_capture" const val K_DS_CAPTURE = "ds_capture" const val K_PAD_HAPTICS = "pad_haptics" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index b150cca8..86bb00b9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +import io.unom.punktfunk.kit.DeviceGyro import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.security.KnownHostStore @@ -888,6 +889,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) }, ) } + // The rumble mirror's sibling, data flowing the other way: needs a gyroscope to + // mirror FROM — a TV box has none, so the row would be a silent no-op there. + val hasGyroscope = remember { DeviceGyro.available(context) } + if (hasGyroscope) { + ToggleRow( + title = "Gyro from this phone", + subtitle = "When the controller has no gyro, send this phone's motion " + + "sensors as controller 1's", + checked = s.gyroOnPhone, + onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) }, + ) + } // NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do // with rumbling this device's body, and the gate hid the toggle on exactly the machines // that most want it — TV boxes, where a Steam Controller 2 is the whole input story. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 600804b7..517662b5 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -67,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import io.unom.punktfunk.kit.DeviceGyro import io.unom.punktfunk.kit.DsCapture import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadRouter @@ -455,6 +456,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U router, deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null, ).also { it.start() } + // "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion + // while wire pad 0 is a controller without a gyro of its own — the rumble mirror's + // sibling, data flowing the other way. The mirror gates itself per sample (it stands + // down whenever a capture link — USB DualSense / SC2, pads with a real IMU — holds + // pad 0), so it composes with the captures below without coordination here. + val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) { + DeviceGyro(context, handle, router).also { it.start() } + } else { + null + } // Free a disconnected controller's rumble/lights bindings promptly (else the open lights // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. router.onSlotClosed = feedback::onDeviceRemoved @@ -587,6 +598,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U feedback.onHidRaw = null feedback.sink = null feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed + phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down) dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt index df662de4..bff22ff0 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -123,7 +123,9 @@ class GamepadPaletteTest { */ @Test fun everySettingsRowHasATab() { - val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {} + val rows = buildSettingsRows( + Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true, + ) {} assertTrue(rows.isNotEmpty()) assertEquals(rows.size, rows.map { it.id }.toSet().size) // Profiles is built separately (from the catalog), so no settings row claims it. @@ -137,7 +139,9 @@ class GamepadPaletteTest { @Test fun backgroundRowStepsTheSharedKey() { var s = Settings() - fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it } + fun rows() = buildSettingsRows( + s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false, + ) { s = it } fun palette() = rows().first { it.id == "palette" } assertEquals("violet", s.uiPalette) diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt index b9012c0f..2f47864d 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt @@ -24,6 +24,7 @@ class GamepadSettingsRowsTest { ): List = buildSettingsRows( Settings(gamepadForwarding = forwarding), hasBodyVibrator = true, + hasGyroscope = true, av1Capable = true, ) { sink += it } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt new file mode 100644 index 00000000..6f19cb14 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt @@ -0,0 +1,179 @@ +package io.unom.punktfunk.kit + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.view.Display +import android.view.Surface +import android.view.WindowManager +import kotlin.math.roundToInt + +/** + * The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a + * controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input + * motion plane — for clip-on and third-party pads that ship without a gyro, where the phone body + * is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s + * rumble-on-phone mirror with the data flowing the other way. + * + * On Android the only motion sources are the capture links (USB DualSense / SC2 — pads with a + * real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly + * [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing — + * two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot + * at all (motion never creates a host pad; a controller must have arrived first). + * + * Two properties this class enforces itself: + * - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) — + * sensor batching would trade the exact latency gyro aim exists to avoid; + * - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the + * host's virtual pad never keeps integrating an angular velocity this device stopped + * producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode). + * + * Units are the wire contract (mirrors `pf-client-core`'s constants): gyro rad/s → 20 LSB/°·s, + * accel m/s² → g → 10000 LSB/g. Android's accelerometer reads specific force (+1 g on the up + * axis at rest), which is the DualSense report's own convention — no sign flip. The one thing + * the phone adds is a frame remap: sensors report in the device's natural-portrait frame, while + * the wire wants the controller frame the player sees (x right, y up, z out of the screen), so + * each sample is rotated by the current display rotation — a phone clipped landscape must yaw + * when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`; + * correctable in one place if on-glass says otherwise. + */ +class DeviceGyro( + context: Context, + private val handle: Long, + private val router: GamepadRouter, +) : SensorEventListener { + + private val sensorManager: SensorManager? = + context.getSystemService(SensorManager::class.java) + + /** For the live rotation; null on contexts without a display association (then portrait). */ + private val display: Display? = runCatching { + if (Build.VERSION.SDK_INT >= 30) { + context.display + } else { + @Suppress("DEPRECATION") + context.getSystemService(WindowManager::class.java)?.defaultDisplay + } + }.getOrNull() + + private val thread = HandlerThread("pf-phone-gyro") + + /** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */ + private val lastAccel = intArrayOf(0, ACCEL_LSB_PER_G, 0) + + /** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */ + private var wasWriting = false + + /** Register the listeners; a device without a gyroscope makes this a no-op. */ + fun start() { + val sm = sensorManager ?: return + val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return + thread.start() + val h = Handler(thread.looper) + // ~200 Hz requested (the framework clamps to what the hardware offers), zero report + // latency: batching is poison for gyro aim. + sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h) + sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { + sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h) + } + } + + /** + * Unregister and join the sensor thread, then park the host pad's rotation at zero if this + * mirror was the live writer. Call BEFORE the router is released / the handle freed — + * teardown-ordered like the feedback threads. + */ + fun stop() { + sensorManager?.unregisterListener(this) + thread.quitSafely() + runCatching { thread.join() } + if (wasWriting) { + wasWriting = false + sendZero() + } + } + + override fun onSensorChanged(event: SensorEvent) { + val rotation = display?.rotation ?: Surface.ROTATION_0 + when (event.sensor.type) { + Sensor.TYPE_ACCELEROMETER -> { + val v = remap(rotation, event.values[0], event.values[1], event.values[2]) + for (i in 0..2) { + lastAccel[i] = (v[i] / GRAVITY * ACCEL_LSB_PER_G) + .roundToInt().coerceIn(-32768, 32767) + } + } + Sensor.TYPE_GYROSCOPE -> { + // The write gate, per sample: pad 0 must exist (motion never creates a pad) + // and must not be a capture link's (its own IMU is streaming). + val write = router.padPresent(0) && !router.padHasOwnMotion(0) + if (!write) { + // Stand-down edge: never leave the last angular velocity latched host-side. + if (wasWriting) { + wasWriting = false + sendZero() + } + return + } + wasWriting = true + val v = remap(rotation, event.values[0], event.values[1], event.values[2]) + NativeBridge.nativeSendPadMotion( + handle, 0, + (v[0] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), + (v[1] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), + (v[2] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), + lastAccel[0], lastAccel[1], lastAccel[2], + ) + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + /** Zero rotation, last-known accel — "at rest", not free-fall. */ + private fun sendZero() { + NativeBridge.nativeSendPadMotion( + handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2], + ) + } + + companion object { + /** Whether this device can source motion at all — gates the settings rows (a TV box + * without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */ + fun available(context: Context): Boolean = + context.getSystemService(SensorManager::class.java) + ?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null + + /** ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz). */ + private const val SAMPLING_PERIOD_US = 5000 + + /** The wire contract (pf-client-core `GYRO_LSB_PER_RAD_S`): 20 LSB/°·s from rad/s. */ + const val GYRO_LSB_PER_RAD_S = 20f * 180f / Math.PI.toFloat() + + /** The wire contract (pf-client-core `ACCEL_LSB_PER_G`). */ + const val ACCEL_LSB_PER_G = 10_000 + + /** pf-client-core's `G`. */ + const val GRAVITY = 9.80665f + + /** + * Rotate one device-frame vector (rotation rate or acceleration — both transform the + * same way under an in-plane rotation) into the controller frame for [rotation] + * ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge, + * +y top, +z out of the screen); the controller frame keeps +z (the screen always faces + * the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 = + * the device physically turned counter-clockwise, top to the player's LEFT. + */ + fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) { + Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x + Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = −x + Surface.ROTATION_180 -> floatArrayOf(-x, -y, z) + else -> floatArrayOf(x, y, z) + } + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 7fe1f02d..8eaa9cd2 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -320,6 +320,19 @@ class GamepadRouter( return null } + /** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */ + fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad } + + /** + * Whether wire pad [pad] is held by a capture-link slot ([ExternalPad] — USB DualSense / + * SC2), whose motion arrives from the pad's OWN IMU. The phone-gyro mirror stands down for + * those: two motion writers on one wire pad would fight. Synthetic ids are negative + * ([EXTERNAL_ID_BASE]); real [InputDevice] ids are positive. Read from the phone-gyro thread + * (the slot table is concurrent). + */ + fun padHasOwnMotion(pad: Int): Boolean = + slots.any { (id, slot) -> slot.index == pad && id < 0 } + /** * A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam * Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt new file mode 100644 index 00000000..b7468a82 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt @@ -0,0 +1,53 @@ +package io.unom.punktfunk.kit + +import android.view.Surface +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the phone-gyro mirror's device→controller frame remap and its wire-unit constants + * ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is + * plain math. The matrix is derived (like the wire scale constants) — if on-glass says an axis + * is wrong, fix [DeviceGyro.remap] AND these expectations together. + * Run: `./gradlew :kit:testDebugUnitTest`. + */ +class DeviceGyroTest { + /** A distinct value per axis so a swapped or flipped component can't cancel out. */ + private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList() + + @Test + fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0)) + + @Test + fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180)) + + /** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT: + * player-right = device-bottom (−y), player-up = device-right (+x); z never changes. */ + @Test + fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90)) + + /** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = −x. */ + @Test + fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270)) + + /** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */ + @Test + fun handednessPreserved() { + for (r in listOf( + Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270, + )) { + val x = DeviceGyro.remap(r, 1f, 0f, 0f) + val y = DeviceGyro.remap(r, 0f, 1f, 0f) + assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f) + } + } + + /** The wire contract, shared with pf-client-core / the Swift client: 20 LSB/°·s means + * 1 rad/s ⇒ ~1145.9 raw; 1 g ⇒ 10000 raw. */ + @Test + fun wireUnitConstants() { + assertEquals(20f * 180f / Math.PI.toFloat(), DeviceGyro.GYRO_LSB_PER_RAD_S, 0f) + assertEquals(1145.9156f, DeviceGyro.GYRO_LSB_PER_RAD_S, 0.001f) + assertEquals(10_000, DeviceGyro.ACCEL_LSB_PER_G) + } +}