feat: battery indicator and rumble test button

Plumb controller battery type/level from the GIP status packets
through the C API into the card header (SF Symbol battery icon).
Add a per-controller Rumble button wired to a new
xone_controller_rumble() entry point; it validates its arguments
and returns -ENOSYS until host to controller TX is implemented.

Co-Authored-By: qwen3.8-27b@q3_k_xl: battery + rumble button UI
This commit is contained in:
portersky
2026-08-29 15:13:18 +02:00
parent 8d6672f43d
commit 416507ed53
7 changed files with 80 additions and 4 deletions
+3 -1
View File
@@ -43,7 +43,9 @@ struct ContentView: View {
} else {
LazyVGrid(columns: cardColumns, alignment: .leading, spacing: 16) {
ForEach(store.controllers) { controller in
ControllerCardView(controller: controller)
ControllerCardView(controller: controller) {
store.testRumble(controller)
}
}
}
}
+8
View File
@@ -8,6 +8,7 @@ import SwiftUI
struct ControllerCardView: View {
let controller: ControllerInput
var onRumble: () -> Void = {}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -33,6 +34,13 @@ struct ControllerCardView: View {
Text(controller.id)
.font(.system(.callout, design: .monospaced))
Spacer()
if let symbol = controller.batterySymbol {
Image(systemName: symbol)
.font(.caption)
}
Button("Rumble") { onRumble() }
.controlSize(.small)
.disabled(!controller.gipReady)
Text(controller.gipReady ? "ready" : "connecting")
.font(.caption.weight(.medium))
.foregroundStyle(controller.gipReady ? .green : .orange)
+13
View File
@@ -32,10 +32,23 @@ struct ControllerInput: Identifiable {
let stickRightX: Int16
let stickRightY: Int16
let sequence: UInt32
let batteryType: UInt8
let batteryLevel: UInt8
func isPressed(_ button: XboxButton) -> Bool {
(buttons & button.mask) != 0
}
// SF Symbol for the battery level; nil when there is no battery info.
var batterySymbol: String? {
guard batteryType != 0 else { return nil }
switch batteryLevel {
case 0: return "battery.25"
case 1: return "battery.50"
case 2: return "battery.75"
default: return "battery.100"
}
}
}
// Standard GIP gamepad buttons and their bit masks. Guide is a separate flag
+14 -1
View File
@@ -33,6 +33,17 @@ final class XoneStore: ObservableObject {
xone_set_pairing(s, pairingActive)
}
// Rumble test seam: the C API returns -ENOSYS until host to controller
// TX is implemented.
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")
}
}
private func poll() {
let present = xone_dongle_present()
donglePresent = present
@@ -97,7 +108,9 @@ final class XoneStore: ObservableObject {
stickLeftY: raw.stick_left_y,
stickRightX: raw.stick_right_x,
stickRightY: raw.stick_right_y,
sequence: raw.sequence
sequence: raw.sequence,
batteryType: raw.battery_type,
batteryLevel: raw.battery_level
))
}
return list
+31
View File
@@ -4,6 +4,7 @@
#include <array>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <memory>
@@ -59,6 +60,8 @@ public:
auto on_client_added(xone::gip::client&) -> void override;
auto on_client_removed(std::uint8_t) -> void override;
auto on_guide_button(xone::gip::client&, bool down) -> void override;
auto on_battery(xone::gip::client&, std::uint8_t type, std::uint8_t level)
-> void override;
auto on_input(xone::gip::client&, std::span<std::uint8_t const> data)
-> void override;
@@ -83,6 +86,9 @@ struct controller {
bool input_active = false;
bool guide_down = false;
bool gip_ready = false;
// From periodic GIP status packets (GIP_BATT_TYPE_*/LEVEL_*).
std::uint8_t battery_type = 0;
std::uint8_t battery_level = 0;
// Declared in dependency order so adapter is destroyed before its bridge.
std::unique_ptr<controller_transport> gip_transport;
@@ -201,6 +207,14 @@ auto controller_listener::on_guide_button(xone::gip::client&, bool down)
m_controller.guide_down = down;
}
auto controller_listener::on_battery(xone::gip::client&, std::uint8_t type,
std::uint8_t level) -> void
{
std::lock_guard<std::mutex> guard(m_dongle.lock);
m_controller.battery_type = type;
m_controller.battery_level = level;
}
auto controller_listener::on_input(
xone::gip::client&, std::span<std::uint8_t const> data) -> void
{
@@ -695,6 +709,8 @@ extern "C" int xone_controller_get_state(
state->stick_right_x = c.stick_right_x;
state->stick_right_y = c.stick_right_y;
state->sequence = c.input_sequence;
state->battery_type = c.battery_type;
state->battery_level = c.battery_level;
return 0;
}
@@ -704,3 +720,18 @@ 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)
{
if (!d)
return -1;
std::lock_guard<std::mutex> guard(d->lock);
if (index < 0 || index >= static_cast<int>(d->controllers.size()))
return -1;
return -ENOSYS;
}