diff --git a/CMakeLists.txt b/CMakeLists.txt index b7bf202..b977ac9 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.25 LANGUAGES CXX Swift) +project(xone_macos VERSION 0.1.26 LANGUAGES CXX Swift) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) diff --git a/include/app/xone_api.h b/include/app/xone_api.h index ace592d..48b6cda 100644 --- a/include/app/xone_api.h +++ b/include/app/xone_api.h @@ -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 // 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 { uint16_t buttons; bool guide_down; @@ -87,6 +88,8 @@ struct xone_controller_state { int16_t stick_right_x; int16_t stick_right_y; uint32_t sequence; + uint8_t battery_type; + uint8_t battery_level; }; // 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). 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 } #endif diff --git a/src/app/ContentView.swift b/src/app/ContentView.swift index b605681..ddcd11d 100644 --- a/src/app/ContentView.swift +++ b/src/app/ContentView.swift @@ -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) + } } } } diff --git a/src/app/ControllerCardView.swift b/src/app/ControllerCardView.swift index 0ce2540..b89097c 100644 --- a/src/app/ControllerCardView.swift +++ b/src/app/ControllerCardView.swift @@ -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) diff --git a/src/app/XoneModels.swift b/src/app/XoneModels.swift index deb015f..e25c92c 100644 --- a/src/app/XoneModels.swift +++ b/src/app/XoneModels.swift @@ -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 diff --git a/src/app/XoneStore.swift b/src/app/XoneStore.swift index 8b4f149..648696e 100644 --- a/src/app/XoneStore.swift +++ b/src/app/XoneStore.swift @@ -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 diff --git a/src/app/api.cpp b/src/app/api.cpp index 14e2eca..698e37e 100644 --- a/src/app/api.cpp +++ b/src/app/api.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -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 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 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 guard(m_dongle.lock); + m_controller.battery_type = type; + m_controller.battery_level = level; +} + auto controller_listener::on_input( xone::gip::client&, std::span 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 guard(d->lock); + if (index < 0 || index >= static_cast(d->controllers.size())) + return -1; + + return -ENOSYS; +}