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:
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user