feat: async C API and Swift radio state display

Split the dongle session into a fast probe (xone_open) and a background
start (xone_start) that loads the firmware and initializes the radio on
a worker thread, so the app UI never blocks. Add xone_state/xone_error
for progress display, and select the firmware image per product ID when
no path is given. The Swift app polls the state each second and shows
idle/starting/ready/error plus the firmware build or error string.

The session destructor joins the worker so quitting the app does not
terminate on a joinable thread. Note in the CLI and transport that the
recover/re-enumerate path should not be used yet: a crashed SIE drops
off the bus, while the MCU watchdog recovers it after ~90s of quiet.

Co-Authored-By: qwen3.8-27b@q2_k_xl: async C API, Swift state display, and exit-time worker join
This commit is contained in:
portersky
2026-08-17 19:57:40 +02:00
parent c9ea1a6919
commit b3e747b900
7 changed files with 174 additions and 30 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.8 LANGUAGES CXX Swift) project(xone_macos VERSION 0.1.9 LANGUAGES CXX Swift)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+27 -8
View File
@@ -20,19 +20,38 @@ const char *xone_version(void);
// TRUE if an Xbox Wireless Dongle is connected, FALSE otherwise. // TRUE if an Xbox Wireless Dongle is connected, FALSE otherwise.
bool xone_dongle_present(void); bool xone_dongle_present(void);
// Opaque handle to an open dongle session (probe + firmware load). // Opaque handle to an open dongle session. Create with xone_open(); only one
// Create with xone_open(); call the getters from the thread that opened // session may be open at a time. The getters are safe to call from any thread.
// it, and before xone_close(). Only one session may be open at a time.
typedef struct xone_dongle xone_dongle; typedef struct xone_dongle xone_dongle;
// Open a dongle session: probe, USB reset, and (if firmware_path is not // Session states reported by xone_state().
// NULL) load the firmware image from that file. Returns NULL if no dongle enum {
// is present or opening fails. XONE_STATE_IDLE = 0, // Probed; no firmware loaded yet
xone_dongle *xone_open(const char *firmware_path); XONE_STATE_STARTING, // Firmware load + radio init in progress
XONE_STATE_READY, // Radio initialized
XONE_STATE_ERROR // Last operation failed (see xone_error)
};
// Close the session and release the dongle. // Open a dongle session: probe and USB reset. Fast; returns NULL if no dongle
// is present or opening fails. The EFUSE getters (xone_pid/xone_chip_id/
// xone_mac_address) are valid immediately after this.
xone_dongle *xone_open(void);
// Kick off firmware load + radio init on a background thread. Non-blocking:
// returns 0 once started, -1 if the session is busy or already ready. If
// firmware_path is NULL, the image is selected per product ID
// (firmware/xone_dongle_<pid>.bin).
int xone_start(xone_dongle *d, const char *firmware_path);
// Close the session and release the dongle. Waits for any in-progress start.
void xone_close(xone_dongle *d); void xone_close(xone_dongle *d);
// Current session state (XONE_STATE_*).
int xone_state(const xone_dongle *d);
// Human-readable error from the last failed operation. Empty string if none.
const char *xone_error(const xone_dongle *d);
// Product ID of the connected dongle (e.g. 0x02E6). // Product ID of the connected dongle (e.g. 0x02E6).
uint16_t xone_pid(const xone_dongle *d); uint16_t xone_pid(const xone_dongle *d);
+5
View File
@@ -78,6 +78,11 @@ public:
// rescan). Host-side operation that works even when EP0 is wedged. Any // rescan). Host-side operation that works even when EP0 is wedged. Any
// existing transport handles are invalidated; probe() again for a fresh // existing transport handles are invalidated; probe() again for a fresh
// session. Returns 0 on success, -errno on failure. // session. Returns 0 on success, -errno on failure.
//
// Do not use yet: when the chip's SIE is crashed it cannot finish the
// host-side port reset handshake and the device drops off the bus
// entirely. A crashed chip recovers on its own via the MCU watchdog after
// ~90s of a quiet bus, so prefer waiting over re-enumeration.
auto re_enumerate() -> int; auto re_enumerate() -> int;
~transport(); ~transport();
+104 -15
View File
@@ -3,7 +3,11 @@
#include "app/xone_api.h" #include "app/xone_api.h"
#include <cstdio> #include <cstdio>
#include <cstring>
#include <memory> #include <memory>
#include <mutex>
#include <string>
#include <thread>
#include "common/log.hpp" #include "common/log.hpp"
#include "mt76/mt76.hpp" #include "mt76/mt76.hpp"
@@ -17,14 +21,39 @@
struct xone_dongle { struct xone_dongle {
std::unique_ptr<xone::usb::transport> transport; std::unique_ptr<xone::usb::transport> transport;
std::unique_ptr<xone::mt76::chip> chip; std::unique_ptr<xone::mt76::chip> chip;
// Guards state, firmware_build, and error (written by the start worker).
mutable std::mutex lock;
std::uint16_t pid = 0;
std::uint16_t chip_id = 0; std::uint16_t chip_id = 0;
char mac_address[18] = {}; char mac_address[18] = {};
int state = XONE_STATE_IDLE;
char firmware_build[64] = {};
char error[256] = {};
// Firmware load + radio init runs here so the UI thread never blocks.
std::thread worker;
~xone_dongle()
{
// The session is destroyed at process exit even without an explicit
// xone_close(); joining here avoids terminating on a joinable thread.
if (worker.joinable())
worker.join();
}
}; };
namespace { namespace {
// Single dongle session (the app only ever has one). // Single dongle session (the app only ever has one).
std::unique_ptr<xone_dongle> current_session; std::unique_ptr<xone_dongle> current_session;
std::mutex session_mutex; // guards current_session
// Select the firmware image for a product ID: firmware/xone_dongle_<pid>.bin.
auto firmware_path_for(std::uint16_t pid) -> std::string
{
char path[64];
std::snprintf(path, sizeof(path), "firmware/xone_dongle_%04x.bin", pid);
return path;
}
} // namespace } // namespace
@@ -38,8 +67,9 @@ extern "C" bool xone_dongle_present(void)
return xone::usb::dongle_present(); return xone::usb::dongle_present();
} }
extern "C" xone_dongle *xone_open(const char *firmware_path) extern "C" xone_dongle *xone_open(void)
{ {
std::lock_guard<std::mutex> guard(session_mutex);
if (current_session) if (current_session)
return nullptr; return nullptr;
@@ -51,34 +81,92 @@ extern "C" xone_dongle *xone_open(const char *firmware_path)
s->chip = std::make_unique<xone::mt76::chip>(*s->transport); s->chip = std::make_unique<xone::mt76::chip>(*s->transport);
// EFUSE reads work before and after the firmware load. // EFUSE reads work before and after the firmware load.
s->pid = s->transport->pid();
s->chip_id = s->chip->chip_id(); s->chip_id = s->chip->chip_id();
auto mac = s->chip->mac_address(); auto mac = s->chip->mac_address();
std::snprintf(s->mac_address, sizeof(s->mac_address), "%02x:%02x:%02x:%02x:%02x:%02x", std::snprintf(s->mac_address, sizeof(s->mac_address), "%02x:%02x:%02x:%02x:%02x:%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
if (firmware_path) {
int ret = s->chip->load_firmware(firmware_path);
if (ret != 0)
xone::log_msg(xone::log_level::warn, "api: firmware load failed (%d)", ret);
else if (auto err = s->chip->init_radio(); err != 0)
xone::log_msg(xone::log_level::warn,
"api: radio init failed (%d)", err);
}
current_session = std::move(s); current_session = std::move(s);
return current_session.get(); return current_session.get();
} }
extern "C" int xone_start(xone_dongle *d, const char *firmware_path)
{
if (!d)
return -1;
// Select the image per product ID when no explicit path is given.
std::string path = firmware_path ? std::string(firmware_path) : firmware_path_for(d->pid);
{
std::lock_guard<std::mutex> guard(d->lock);
if (d->state != XONE_STATE_IDLE && d->state != XONE_STATE_ERROR)
return -1; // busy or already ready
d->state = XONE_STATE_STARTING;
d->error[0] = '\0';
}
// A previous failed attempt may have left a finished worker behind (retry
// from error); join it so the thread slot is reusable.
if (d->worker.joinable())
d->worker.join();
d->worker = std::thread([d, path] {
xone::log_msg(xone::log_level::info, "api: start: loading firmware %s", path.c_str());
int err = d->chip->load_firmware(path.c_str());
bool loaded = (err == 0);
if (loaded) {
xone::log_msg(xone::log_level::info, "api: start: firmware ok, init radio");
err = d->chip->init_radio();
}
std::lock_guard<std::mutex> guard(d->lock);
if (!loaded) {
d->state = XONE_STATE_ERROR;
std::snprintf(d->error, sizeof(d->error), "firmware load failed (%d)", err);
xone::log_msg(xone::log_level::warn, "api: start: firmware load failed (%d)", err);
} else if (err != 0) {
d->state = XONE_STATE_ERROR;
std::snprintf(d->error, sizeof(d->error), "radio init failed (%d)", err);
xone::log_msg(xone::log_level::warn, "api: start: radio init failed (%d)", err);
} else {
std::strncpy(d->firmware_build, d->chip->firmware_build(),
sizeof(d->firmware_build) - 1);
d->state = XONE_STATE_READY;
xone::log_msg(xone::log_level::info, "api: start: radio ready");
}
});
return 0;
}
extern "C" void xone_close(xone_dongle *d) extern "C" void xone_close(xone_dongle *d)
{ {
if (d != current_session.get()) // Wait for the background work to finish before releasing the session.
return; if (d->worker.joinable())
current_session.reset(); d->worker.join();
std::lock_guard<std::mutex> guard(session_mutex);
if (d == current_session.get())
current_session.reset();
}
extern "C" int xone_state(const xone_dongle *d)
{
std::lock_guard<std::mutex> guard(d->lock);
return d->state;
}
extern "C" const char *xone_error(const xone_dongle *d)
{
std::lock_guard<std::mutex> guard(d->lock);
return d->error;
} }
extern "C" std::uint16_t xone_pid(const xone_dongle *d) extern "C" std::uint16_t xone_pid(const xone_dongle *d)
{ {
return d->transport->pid(); return d->pid; // set once at open, read-only afterwards
} }
extern "C" std::uint16_t xone_chip_id(const xone_dongle *d) extern "C" std::uint16_t xone_chip_id(const xone_dongle *d)
@@ -93,5 +181,6 @@ extern "C" const char *xone_mac_address(const xone_dongle *d)
extern "C" const char *xone_firmware_build(const xone_dongle *d) extern "C" const char *xone_firmware_build(const xone_dongle *d)
{ {
return d->chip->firmware_build(); std::lock_guard<std::mutex> guard(d->lock);
return d->firmware_build;
} }
+28 -2
View File
@@ -27,6 +27,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
struct ContentView: View { struct ContentView: View {
@State private var donglePresent = false @State private var donglePresent = false
@State private var session: OpaquePointer? = nil @State private var session: OpaquePointer? = nil
// Radio state polled from the background worker each tick; a change here
// is what re-renders the debug section.
@State private var radioState = XONE_STATE_IDLE
private let timer = Timer.publish(every: 1.0, on: .main, in: .common) private let timer = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect() .autoconnect()
@@ -69,10 +72,18 @@ struct ContentView: View {
.onReceive(timer) { _ in .onReceive(timer) { _ in
let present = xone_dongle_present() let present = xone_dongle_present()
if present && session == nil { if present && session == nil {
session = xone_open("firmware/xow_dongle.bin") // Probe is fast; firmware load + radio init run on a
// background thread so the UI never blocks.
if let s = xone_open() {
session = s
xone_start(s, nil)
radioState = Int(xone_state(s))
}
} else if !present, let s = session { } else if !present, let s = session {
xone_close(s) xone_close(s)
session = nil session = nil
} else if let s = session {
radioState = Int(xone_state(s))
} }
donglePresent = present donglePresent = present
} }
@@ -85,7 +96,12 @@ struct ContentView: View {
debugRow("PID", String(format: "0x%04X", xone_pid(session))) debugRow("PID", String(format: "0x%04X", xone_pid(session)))
debugRow("Chip ID", String(format: "0x%04X", xone_chip_id(session))) debugRow("Chip ID", String(format: "0x%04X", xone_chip_id(session)))
debugRow("MAC", String(cString: xone_mac_address(session))) debugRow("MAC", String(cString: xone_mac_address(session)))
debugRow("Firmware", firmwareBuildText(session)) debugRow("State", stateText(radioState))
if radioState == XONE_STATE_READY {
debugRow("Firmware", firmwareBuildText(session))
} else if radioState == XONE_STATE_ERROR {
debugRow("Error", String(cString: xone_error(session)))
}
} }
} }
@@ -99,6 +115,16 @@ struct ContentView: View {
} }
} }
private func stateText(_ state: Int) -> String {
switch state {
case XONE_STATE_IDLE: return "idle"
case XONE_STATE_STARTING: return "starting..."
case XONE_STATE_READY: return "ready"
case XONE_STATE_ERROR: return "error"
default: return "unknown"
}
}
private func firmwareBuildText(_ session: OpaquePointer) -> String { private func firmwareBuildText(_ session: OpaquePointer) -> String {
let build = String(cString: xone_firmware_build(session)) let build = String(cString: xone_firmware_build(session))
return build.isEmpty ? "not loaded" : build return build.isEmpty ? "not loaded" : build
+6 -4
View File
@@ -21,7 +21,7 @@ auto usage() -> int
"Usage: xone_cli <command>...\n" "Usage: xone_cli <command>...\n"
"\n" "\n"
"Commands (run in a single dongle session):\n" "Commands (run in a single dongle session):\n"
" recover Port reset + re-enumerate (first command)\n" " recover Port reset + re-enumerate (first command; do not use yet)\n"
" info Show dongle and chip state\n" " info Show dongle and chip state\n"
" firmware <path> Load the firmware image\n" " firmware <path> Load the firmware image\n"
" radio-init Initialize the radio\n" " radio-init Initialize the radio\n"
@@ -139,9 +139,11 @@ auto main(int argc, char **argv) -> int
std::fprintf(stderr, "\n"); std::fprintf(stderr, "\n");
}; };
// `recover` forces a hub port reset and re-enumeration. The transport is // `recover` forces a hub port reset and re-enumeration. Do not use it yet:
// dead afterwards, so probe again for the fresh device. It must be the // when the chip's SIE is crashed, it cannot complete the host-side port
// first command. // reset handshake and the device drops off the bus entirely. A crashed
// chip recovers on its own via the MCU watchdog after ~90s of a quiet
// bus, so just wait instead. It must be the first command.
int arg = 1; int arg = 1;
if (!std::strcmp(argv[1], "recover")) { if (!std::strcmp(argv[1], "recover")) {
auto current = xone::usb::transport::probe(on_frame, nullptr); auto current = xone::usb::transport::probe(on_frame, nullptr);
+3
View File
@@ -170,6 +170,9 @@ void transport::stop_pump()
auto transport::re_enumerate() -> int auto transport::re_enumerate() -> int
{ {
// Do not use yet: a crashed SIE cannot finish the host-side port reset
// handshake and drops off the bus. A crashed chip recovers on its own via
// the MCU watchdog after ~90s of a quiet bus, so prefer waiting.
// Stop the pump first so no completion callback touches the interface // Stop the pump first so no completion callback touches the interface
// references while the kernel tears them down. // references while the kernel tears them down.
stop_pump(); stop_pump();