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:
+1
-1
@@ -6,7 +6,7 @@ if(NOT CMAKE_GENERATOR MATCHES "^(Ninja|Xcode)$")
|
||||
endif()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
+27
-8
@@ -20,19 +20,38 @@ const char *xone_version(void);
|
||||
// TRUE if an Xbox Wireless Dongle is connected, FALSE otherwise.
|
||||
bool xone_dongle_present(void);
|
||||
|
||||
// Opaque handle to an open dongle session (probe + firmware load).
|
||||
// Create with xone_open(); call the getters from the thread that opened
|
||||
// it, and before xone_close(). Only one session may be open at a time.
|
||||
// Opaque handle to an open dongle session. Create with xone_open(); only one
|
||||
// session may be open at a time. The getters are safe to call from any thread.
|
||||
typedef struct xone_dongle xone_dongle;
|
||||
|
||||
// Open a dongle session: probe, USB reset, and (if firmware_path is not
|
||||
// NULL) load the firmware image from that file. Returns NULL if no dongle
|
||||
// is present or opening fails.
|
||||
xone_dongle *xone_open(const char *firmware_path);
|
||||
// Session states reported by xone_state().
|
||||
enum {
|
||||
XONE_STATE_IDLE = 0, // Probed; no firmware loaded yet
|
||||
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);
|
||||
|
||||
// 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).
|
||||
uint16_t xone_pid(const xone_dongle *d);
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ public:
|
||||
// rescan). Host-side operation that works even when EP0 is wedged. Any
|
||||
// existing transport handles are invalidated; probe() again for a fresh
|
||||
// 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;
|
||||
|
||||
~transport();
|
||||
|
||||
+104
-15
@@ -3,7 +3,11 @@
|
||||
#include "app/xone_api.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "common/log.hpp"
|
||||
#include "mt76/mt76.hpp"
|
||||
@@ -17,14 +21,39 @@
|
||||
struct xone_dongle {
|
||||
std::unique_ptr<xone::usb::transport> transport;
|
||||
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;
|
||||
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 {
|
||||
|
||||
// Single dongle session (the app only ever has one).
|
||||
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
|
||||
|
||||
@@ -38,8 +67,9 @@ extern "C" bool xone_dongle_present(void)
|
||||
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)
|
||||
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);
|
||||
|
||||
// EFUSE reads work before and after the firmware load.
|
||||
s->pid = s->transport->pid();
|
||||
s->chip_id = s->chip->chip_id();
|
||||
auto mac = s->chip->mac_address();
|
||||
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]);
|
||||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
if (d != current_session.get())
|
||||
return;
|
||||
current_session.reset();
|
||||
// Wait for the background work to finish before releasing the session.
|
||||
if (d->worker.joinable())
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -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)
|
||||
{
|
||||
return d->chip->firmware_build();
|
||||
std::lock_guard<std::mutex> guard(d->lock);
|
||||
return d->firmware_build;
|
||||
}
|
||||
|
||||
+28
-2
@@ -27,6 +27,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
struct ContentView: View {
|
||||
@State private var donglePresent = false
|
||||
@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)
|
||||
.autoconnect()
|
||||
@@ -69,10 +72,18 @@ struct ContentView: View {
|
||||
.onReceive(timer) { _ in
|
||||
let present = xone_dongle_present()
|
||||
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 {
|
||||
xone_close(s)
|
||||
session = nil
|
||||
} else if let s = session {
|
||||
radioState = Int(xone_state(s))
|
||||
}
|
||||
donglePresent = present
|
||||
}
|
||||
@@ -85,7 +96,12 @@ struct ContentView: View {
|
||||
debugRow("PID", String(format: "0x%04X", xone_pid(session)))
|
||||
debugRow("Chip ID", String(format: "0x%04X", xone_chip_id(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 {
|
||||
let build = String(cString: xone_firmware_build(session))
|
||||
return build.isEmpty ? "not loaded" : build
|
||||
|
||||
+6
-4
@@ -21,7 +21,7 @@ auto usage() -> int
|
||||
"Usage: xone_cli <command>...\n"
|
||||
"\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"
|
||||
" firmware <path> Load the firmware image\n"
|
||||
" radio-init Initialize the radio\n"
|
||||
@@ -139,9 +139,11 @@ auto main(int argc, char **argv) -> int
|
||||
std::fprintf(stderr, "\n");
|
||||
};
|
||||
|
||||
// `recover` forces a hub port reset and re-enumeration. The transport is
|
||||
// dead afterwards, so probe again for the fresh device. It must be the
|
||||
// first command.
|
||||
// `recover` forces a hub port reset and re-enumeration. Do not use it yet:
|
||||
// when the chip's SIE is crashed, it cannot complete 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 just wait instead. It must be the first command.
|
||||
int arg = 1;
|
||||
if (!std::strcmp(argv[1], "recover")) {
|
||||
auto current = xone::usb::transport::probe(on_frame, nullptr);
|
||||
|
||||
@@ -170,6 +170,9 @@ void transport::stop_pump()
|
||||
|
||||
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
|
||||
// references while the kernel tears them down.
|
||||
stop_pump();
|
||||
|
||||
Reference in New Issue
Block a user