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:
+1
-1
@@ -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.25 LANGUAGES CXX Swift)
|
project(xone_macos VERSION 0.1.26 LANGUAGES CXX Swift)
|
||||||
|
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -74,7 +74,8 @@ int xone_controller_mac(xone_dongle const *d, int index, char *buf, int len);
|
|||||||
|
|
||||||
// Snapshot of the latest standard Xbox controller input report. Stick values
|
// Snapshot of the latest standard Xbox controller input report. Stick values
|
||||||
// are signed 16-bit values; trigger values are unsigned 10-bit values. The
|
// are signed 16-bit values; trigger values are unsigned 10-bit values. The
|
||||||
// buttons field uses the standard GIP gamepad bit assignments.
|
// buttons field uses the standard GIP gamepad bit assignments. Battery fields
|
||||||
|
// come from periodic GIP status packets (battery_type is 0 when unknown).
|
||||||
struct xone_controller_state {
|
struct xone_controller_state {
|
||||||
uint16_t buttons;
|
uint16_t buttons;
|
||||||
bool guide_down;
|
bool guide_down;
|
||||||
@@ -87,6 +88,8 @@ struct xone_controller_state {
|
|||||||
int16_t stick_right_x;
|
int16_t stick_right_x;
|
||||||
int16_t stick_right_y;
|
int16_t stick_right_y;
|
||||||
uint32_t sequence;
|
uint32_t sequence;
|
||||||
|
uint8_t battery_type;
|
||||||
|
uint8_t battery_level;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy the latest input state for the controller at index. Returns 0 on
|
// Copy the latest input state for the controller at index. Returns 0 on
|
||||||
@@ -97,6 +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,
|
||||||
|
// -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);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
LazyVGrid(columns: cardColumns, alignment: .leading, spacing: 16) {
|
LazyVGrid(columns: cardColumns, alignment: .leading, spacing: 16) {
|
||||||
ForEach(store.controllers) { controller in
|
ForEach(store.controllers) { controller in
|
||||||
ControllerCardView(controller: controller)
|
ControllerCardView(controller: controller) {
|
||||||
|
store.testRumble(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ControllerCardView: View {
|
struct ControllerCardView: View {
|
||||||
let controller: ControllerInput
|
let controller: ControllerInput
|
||||||
|
var onRumble: () -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -33,6 +34,13 @@ struct ControllerCardView: View {
|
|||||||
Text(controller.id)
|
Text(controller.id)
|
||||||
.font(.system(.callout, design: .monospaced))
|
.font(.system(.callout, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if let symbol = controller.batterySymbol {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Button("Rumble") { onRumble() }
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(!controller.gipReady)
|
||||||
Text(controller.gipReady ? "ready" : "connecting")
|
Text(controller.gipReady ? "ready" : "connecting")
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(controller.gipReady ? .green : .orange)
|
.foregroundStyle(controller.gipReady ? .green : .orange)
|
||||||
|
|||||||
@@ -32,10 +32,23 @@ struct ControllerInput: Identifiable {
|
|||||||
let stickRightX: Int16
|
let stickRightX: Int16
|
||||||
let stickRightY: Int16
|
let stickRightY: Int16
|
||||||
let sequence: UInt32
|
let sequence: UInt32
|
||||||
|
let batteryType: UInt8
|
||||||
|
let batteryLevel: UInt8
|
||||||
|
|
||||||
func isPressed(_ button: XboxButton) -> Bool {
|
func isPressed(_ button: XboxButton) -> Bool {
|
||||||
(buttons & button.mask) != 0
|
(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
|
// Standard GIP gamepad buttons and their bit masks. Guide is a separate flag
|
||||||
|
|||||||
+14
-1
@@ -33,6 +33,17 @@ 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
|
||||||
|
// 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() {
|
private func poll() {
|
||||||
let present = xone_dongle_present()
|
let present = xone_dongle_present()
|
||||||
donglePresent = present
|
donglePresent = present
|
||||||
@@ -97,7 +108,9 @@ final class XoneStore: ObservableObject {
|
|||||||
stickLeftY: raw.stick_left_y,
|
stickLeftY: raw.stick_left_y,
|
||||||
stickRightX: raw.stick_right_x,
|
stickRightX: raw.stick_right_x,
|
||||||
stickRightY: raw.stick_right_y,
|
stickRightY: raw.stick_right_y,
|
||||||
sequence: raw.sequence
|
sequence: raw.sequence,
|
||||||
|
batteryType: raw.battery_type,
|
||||||
|
batteryLevel: raw.battery_level
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <cerrno>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -59,6 +60,8 @@ public:
|
|||||||
auto on_client_added(xone::gip::client&) -> void override;
|
auto on_client_added(xone::gip::client&) -> void override;
|
||||||
auto on_client_removed(std::uint8_t) -> void override;
|
auto on_client_removed(std::uint8_t) -> void override;
|
||||||
auto on_guide_button(xone::gip::client&, bool down) -> 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)
|
auto on_input(xone::gip::client&, std::span<std::uint8_t const> data)
|
||||||
-> void override;
|
-> void override;
|
||||||
|
|
||||||
@@ -83,6 +86,9 @@ struct controller {
|
|||||||
bool input_active = false;
|
bool input_active = false;
|
||||||
bool guide_down = false;
|
bool guide_down = false;
|
||||||
bool gip_ready = 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.
|
// Declared in dependency order so adapter is destroyed before its bridge.
|
||||||
std::unique_ptr<controller_transport> gip_transport;
|
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;
|
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(
|
auto controller_listener::on_input(
|
||||||
xone::gip::client&, std::span<std::uint8_t const> data) -> void
|
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_x = c.stick_right_x;
|
||||||
state->stick_right_y = c.stick_right_y;
|
state->stick_right_y = c.stick_right_y;
|
||||||
state->sequence = c.input_sequence;
|
state->sequence = c.input_sequence;
|
||||||
|
state->battery_type = c.battery_type;
|
||||||
|
state->battery_level = c.battery_level;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,3 +720,18 @@ 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
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user