From 0f6c985f7f4dbeb857e927d6c0ead0e120dd5a9a Mon Sep 17 00:00:00 2001 From: portersky Date: Sat, 29 Aug 2026 15:19:14 +0200 Subject: [PATCH] feat: implement rumble TX over the GIP data path Port the rumble packet from upstream driver/gamepad.c: a 9-byte GIP_CMD_RUMBLE payload (motors R|L|RT|LT, intensities scaled to the 0..100 GIP range, duration/repeat constants). xone_controller_rumble now sends it via client::send_rumble; the app button pulses both motors for 0.6s then stops them. Completes the host to controller TX remainder of Phase 3 (the dongle LED is already driven at chip level for pairing and client lifecycle). Co-Authored-By: qwen3.8-27b@q3_k_xl: implemented rumble TX --- CMakeLists.txt | 2 +- include/app/xone_api.h | 11 ++++++----- src/app/XoneStore.swift | 17 ++++++++++++---- src/app/api.cpp | 43 +++++++++++++++++++++++++++++++++++------ 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b977ac9..f50c206 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ if(NOT CMAKE_GENERATOR MATCHES "^(Ninja|Xcode)$") endif() cmake_minimum_required(VERSION 3.21) -project(xone_macos VERSION 0.1.26 LANGUAGES CXX Swift) +project(xone_macos VERSION 0.1.27 LANGUAGES CXX Swift) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) diff --git a/include/app/xone_api.h b/include/app/xone_api.h index 48b6cda..203a0d6 100644 --- a/include/app/xone_api.h +++ b/include/app/xone_api.h @@ -100,11 +100,12 @@ int xone_controller_get_state(xone_dongle const *d, int index, // Enter or leave pairing mode (beacon pairing flag + LED blink). void xone_set_pairing(xone_dongle *d, bool enable); -// Trigger a rumble test on the controller at index. Returns 0 on success, -// -1 for an invalid session or index. Not implemented yet: host to -// controller TX (GIP_CMD_RUMBLE payload) is pending, so this currently -// returns -ENOSYS. -int xone_controller_rumble(xone_dongle *d, int index); +// Send a rumble packet to the controller at index (host to controller TX +// over the GIP data path). Intensities use a 0..255 scale; passing zero for +// both stops the motors. Returns 0 on success, -1 for an invalid session or +// index, or if the client is not ready. +int xone_controller_rumble(xone_dongle *d, int index, uint8_t left, + uint8_t right); #ifdef __cplusplus } diff --git a/src/app/XoneStore.swift b/src/app/XoneStore.swift index 648696e..7ff2964 100644 --- a/src/app/XoneStore.swift +++ b/src/app/XoneStore.swift @@ -33,14 +33,23 @@ final class XoneStore: ObservableObject { xone_set_pairing(s, pairingActive) } - // Rumble test seam: the C API returns -ENOSYS until host to controller - // TX is implemented. + // Rumble test: a full-intensity pulse. The motors keep spinning until a + // zero-intensity packet is sent, so schedule the stop; the session is + // re-validated then because it may have closed in the meantime. func testRumble(_ controller: ControllerInput) { guard let s = session, let index = controllers.firstIndex(where: { $0.id == controller.id }) else { return } - if xone_controller_rumble(s, Int32(index)) != 0 { - NSLog("xone: rumble TX not implemented yet") + if xone_controller_rumble(s, Int32(index), 255, 255) != 0 { + NSLog("xone: rumble start failed") + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self = self, + let s = self.session, + let i = self.controllers.firstIndex(where: { $0.id == controller.id }) + else { return } + xone_controller_rumble(s, Int32(i), 0, 0) } } diff --git a/src/app/api.cpp b/src/app/api.cpp index 698e37e..4d24df8 100644 --- a/src/app/api.cpp +++ b/src/app/api.cpp @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -156,6 +155,17 @@ std::mutex session_mutex; // guards current_session // 802.11 management frame header length (three addresses, no extensions). constexpr std::size_t k_mgmt_hdr_len = 24; +// GIP rumble packet constants, port of the gip_gamepad definitions in +// upstream driver/gamepad.c. +constexpr std::uint8_t k_gp_motor_all = 0x0f; // R | L | RT | LT +constexpr std::uint8_t k_gp_rumble_max = 100; + +// Map a 0..255 intensity onto the GIP motor range (0..k_gp_rumble_max). +auto scale_rumble(std::uint8_t value) -> std::uint8_t +{ + return static_cast((value * k_gp_rumble_max + 127) / 255); +} + // Upstream xone loads one common firmware image for every dongle PID. auto firmware_path_for(std::uint16_t) -> std::string { @@ -721,10 +731,12 @@ extern "C" void xone_set_pairing(xone_dongle *d, bool enable) d->set_pairing(enable); } -// Rumble test seam: host to controller TX is not implemented yet (the -// GIP_CMD_RUMBLE payload format is undefined), so this only validates its -// arguments for now. -extern "C" int xone_controller_rumble(xone_dongle *d, int index) +// Send a rumble packet to the controller at index (port of the rumble path +// in upstream driver/gamepad.c). Intensities use a 0..255 scale mapped onto +// the GIP motor range; zero for both stops the motors, which otherwise keep +// spinning until told. +extern "C" int xone_controller_rumble(xone_dongle *d, int index, uint8_t left, + uint8_t right) { if (!d) return -1; @@ -733,5 +745,24 @@ extern "C" int xone_controller_rumble(xone_dongle *d, int index) if (index < 0 || index >= static_cast(d->controllers.size())) return -1; - return -ENOSYS; + auto& c = *d->controllers[index]; + if (!c.gip_ready || !c.gip_adapter) + return -1; + + auto* client = c.gip_adapter->get_client(0); + if (!client) + return -1; + + // gip_gamepad_pkt_rumble (packed, 9 bytes). + std::uint8_t pkt[9] = { + 0x00, // unknown + k_gp_motor_all, // motors: R | L | RT | LT + 0x00, 0x00, // left/right trigger (unused) + scale_rumble(left), + scale_rumble(right), + 0xff, // duration (constant, as in upstream) + 0x00, // delay + 0xeb, // repeat (constant, as in upstream) + }; + return client->send_rumble(pkt); }