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
This commit is contained in:
portersky
2026-08-29 15:19:14 +02:00
parent 416507ed53
commit 0f6c985f7f
4 changed files with 57 additions and 16 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ if(NOT CMAKE_GENERATOR MATCHES "^(Ninja|Xcode)$")
endif() endif()
cmake_minimum_required(VERSION 3.21) 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) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+6 -5
View File
@@ -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). // Enter or leave pairing mode (beacon pairing flag + LED blink).
void xone_set_pairing(xone_dongle *d, bool enable); void xone_set_pairing(xone_dongle *d, bool enable);
// Trigger a rumble test on the controller at index. Returns 0 on success, // Send a rumble packet to the controller at index (host to controller TX
// -1 for an invalid session or index. Not implemented yet: host to // over the GIP data path). Intensities use a 0..255 scale; passing zero for
// controller TX (GIP_CMD_RUMBLE payload) is pending, so this currently // both stops the motors. Returns 0 on success, -1 for an invalid session or
// returns -ENOSYS. // index, or if the client is not ready.
int xone_controller_rumble(xone_dongle *d, int index); int xone_controller_rumble(xone_dongle *d, int index, uint8_t left,
uint8_t right);
#ifdef __cplusplus #ifdef __cplusplus
} }
+13 -4
View File
@@ -33,14 +33,23 @@ final class XoneStore: ObservableObject {
xone_set_pairing(s, pairingActive) xone_set_pairing(s, pairingActive)
} }
// Rumble test seam: the C API returns -ENOSYS until host to controller // Rumble test: a full-intensity pulse. The motors keep spinning until a
// TX is implemented. // 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) { func testRumble(_ controller: ControllerInput) {
guard let s = session, guard let s = session,
let index = controllers.firstIndex(where: { $0.id == controller.id }) let index = controllers.firstIndex(where: { $0.id == controller.id })
else { return } else { return }
if xone_controller_rumble(s, Int32(index)) != 0 { if xone_controller_rumble(s, Int32(index), 255, 255) != 0 {
NSLog("xone: rumble TX not implemented yet") 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)
} }
} }
+37 -6
View File
@@ -4,7 +4,6 @@
#include <array> #include <array>
#include <atomic> #include <atomic>
#include <cerrno>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@@ -156,6 +155,17 @@ std::mutex session_mutex; // guards current_session
// 802.11 management frame header length (three addresses, no extensions). // 802.11 management frame header length (three addresses, no extensions).
constexpr std::size_t k_mgmt_hdr_len = 24; 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<std::uint8_t>((value * k_gp_rumble_max + 127) / 255);
}
// Upstream xone loads one common firmware image for every dongle PID. // Upstream xone loads one common firmware image for every dongle PID.
auto firmware_path_for(std::uint16_t) -> std::string 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); d->set_pairing(enable);
} }
// Rumble test seam: host to controller TX is not implemented yet (the // Send a rumble packet to the controller at index (port of the rumble path
// GIP_CMD_RUMBLE payload format is undefined), so this only validates its // in upstream driver/gamepad.c). Intensities use a 0..255 scale mapped onto
// arguments for now. // the GIP motor range; zero for both stops the motors, which otherwise keep
extern "C" int xone_controller_rumble(xone_dongle *d, int index) // spinning until told.
extern "C" int xone_controller_rumble(xone_dongle *d, int index, uint8_t left,
uint8_t right)
{ {
if (!d) if (!d)
return -1; return -1;
@@ -733,5 +745,24 @@ extern "C" int xone_controller_rumble(xone_dongle *d, int index)
if (index < 0 || index >= static_cast<int>(d->controllers.size())) if (index < 0 || index >= static_cast<int>(d->controllers.size()))
return -1; 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);
} }