diff --git a/CMakeLists.txt b/CMakeLists.txt index 63d55f9..93e47b5 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.8 LANGUAGES CXX Swift) +project(xone_macos VERSION 0.1.9 LANGUAGES CXX Swift) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) diff --git a/include/app/xone_api.h b/include/app/xone_api.h index 6381664..dd3bdc2 100644 --- a/include/app/xone_api.h +++ b/include/app/xone_api.h @@ -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_.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); diff --git a/include/usb/usb_transport.hpp b/include/usb/usb_transport.hpp index 73e6c00..93b18f6 100644 --- a/include/usb/usb_transport.hpp +++ b/include/usb/usb_transport.hpp @@ -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(); diff --git a/src/app/api.cpp b/src/app/api.cpp index 50954ee..dcc1a88 100644 --- a/src/app/api.cpp +++ b/src/app/api.cpp @@ -3,7 +3,11 @@ #include "app/xone_api.h" #include +#include #include +#include +#include +#include #include "common/log.hpp" #include "mt76/mt76.hpp" @@ -17,14 +21,39 @@ struct xone_dongle { std::unique_ptr transport; std::unique_ptr 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 current_session; +std::mutex session_mutex; // guards current_session + +// Select the firmware image for a product ID: firmware/xone_dongle_.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 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(*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 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 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 guard(session_mutex); + if (d == current_session.get()) + current_session.reset(); +} + +extern "C" int xone_state(const xone_dongle *d) +{ + std::lock_guard guard(d->lock); + return d->state; +} + +extern "C" const char *xone_error(const xone_dongle *d) +{ + std::lock_guard 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 guard(d->lock); + return d->firmware_build; } diff --git a/src/app/main.swift b/src/app/main.swift index a3f9c9c..a3f5e21 100644 --- a/src/app/main.swift +++ b/src/app/main.swift @@ -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 diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 0256844..523bcd3 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -21,7 +21,7 @@ auto usage() -> int "Usage: xone_cli ...\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 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); diff --git a/src/usb/usb_transport.cpp b/src/usb/usb_transport.cpp index d478b68..e23daf8 100644 --- a/src/usb/usb_transport.cpp +++ b/src/usb/usb_transport.cpp @@ -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();