forked from unom/punktfunk
G14/G16 leg 3. This supersedes the nominal constant0e40b374shipped, which was always labelled a stopgap. Measured on glass 2026-08-07: a DualSense over USB into an Android phone, streaming to a Linux host, flat and face up, arrived as |accel| = 0.811 g where 1.000 was owed. The parse forwarded the pad's raw i16s verbatim, and raw device units are not wire units.0e40b374rescaled acceleration by the nominal 10000/8192 and deliberately left gyro alone, because a constant provably cannot fix gyro: the same still average showed this unit's accel calibration is near-identity (~1% off) while its gyro's emphatically is not — a near-identity gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. That scale is per unit, and the only thing that knows it is the pad. So the client now asks. HidUsbLink grows a GET_REPORT path — EP0, the exact mirror of the SET_REPORT it already had — and DsCapture reads the pad's IMU calibration feature report ONCE, while claiming it: 0x05 / 41 B on a DualSense or Edge, 0x02 / 37 B on a USB DualShock 4. DsDevice.MotionCal then applies hid-playstation's own arithmetic per axis, which is the same math the host's contract test (crates/pf-inject/tests/motion_contract.rs, SonyImuCalibration) reads from the other end: gyro raw × speed_2x × 20 / (|plus−bias| + |minus−bias|), accel (raw − (plus − range/2)) × 20000 / range. Long arithmetic, because the gyro multiplier overflows an Int, and clamped, because both are >1 multipliers and a full-scale flick would otherwise wrap the i16 into a motion in the opposite direction. Reading the blob also removes acceleration's residual ~1% factory bias that the nominal constant left behind. Once at claim and never per report. EP0 is independent of the interrupt endpoints so the read is safe alongside the reader thread, but a blocking control transfer in the report path would wreck capture latency, and the calibration is fixed for the life of the connection anyway. The capture logs the derived resolutions, which is the discriminator for whether a blob was read at all: a real pad declares ≈16 LSB per deg/s, the fallback reads back as exactly 20. A pad that refuses, answers short, or declares zeroes (a clone, a broken unit) keeps today's behaviour per axis — nominal accel, gyro straight through. Nothing here ever zeroes motion: slightly mis-scaled beats silent. Not covered. The axis frame is still untouched: this leg puts gravity on Y where the Apple leg put it on Z, so at least one client's frame is wrong, and settling it needs the bare-metal Linux reference reading G16 step 1 calls for. Rescaling is frame-independent, so it stands however that resolves — remapping is not, so it stays out. Bluetooth's grouped plus/minus layout is not implemented either: this path is USB-only by construction (Android exposes no raw path to a Classic pad), and a half-used generalisation would be a latent bug rather than a feature. Gate: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` green, 16 DsDeviceTest cases run 0 failed, and the five new ones were confirmed present in the JUnit XML rather than merely compiled. Non-vacuity checked by mutation — perturbing the gyro conversion fails 6 tests, including all four new ones that assert a number. On-glass re-verification owed, on the rig that measured the defect (DualSense → USB → phone → 192.168.1.21): at rest |a| = 1.00 g exactly via ~/gyroscope.py, and a nominal 90 deg yaw integrating to ~90 deg via ~/integrate.py — the same 90 deg that read ~62.7 deg before this change.