feat: add IOKit-based USB transport

Replace the stub probe with a real IOKit/IOUSBFamily transport:
discovery by VID/PID, device and interface connections, vendor
control requests on EP0, bulk writes to EP 0x04 OUT, and an async
read pump (EP 0x05 IN / EP 0x04 IN) on a dedicated CFRunLoop
thread with disconnect notification.

Add the test_usb suite, link IOKit/CoreFoundation into xone_usb,
and update the README investigation checklist.

Co-Authored-By: qwen3.8-27b@q2_k_xl: implemented USB transport layer
This commit is contained in:
portersky
2026-08-17 15:52:22 +02:00
parent 5a102bb511
commit 4f0f1ff75d
7 changed files with 690 additions and 23 deletions
+1 -1
View File
@@ -15,5 +15,5 @@ extern "C" const char *xone_version(void)
extern "C" bool xone_dongle_present(void)
{
return xone::usb::probe();
return xone::usb::dongle_present();
}
+516 -4
View File
@@ -1,14 +1,526 @@
// USB transport (IOKit / IOUSBFamily)
// TODO(phase 2): port from medusalix/xone transport/dongle.c + mt76.c USB calls.
// Port of medusalix/xone transport/dongle.c + the USB calls in mt76.c.
#include "usb/usb_transport.hpp"
#include <cerrno>
#include <condition_variable>
#include <cstring>
#include <mutex>
#include <optional>
#include <thread>
#include <vector>
#include <CoreFoundation/CoreFoundation.h>
#include <IOKit/IOCFPlugIn.h>
#include <IOKit/IOKitLib.h>
#include <IOKit/usb/IOUSBLib.h>
#include "common/log.hpp"
namespace xone::usb {
auto probe() -> bool
// Outstanding async reads per IN endpoint. The kernel-side driver uses 12
// URBs; user-space resubmits synchronously in the completion handler, so a
// small pool is enough to keep the pipes busy.
constexpr std::size_t num_in_reads = 4;
namespace {
auto vid_match_dict() -> CFMutableDictionaryRef
{
// Not implemented yet: no IOKit device matching in place.
return false;
CFMutableDictionaryRef match = IOServiceMatching("IOUSBDevice");
SInt32 vid_value = static_cast<SInt32>(vid);
CFNumberRef number = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &vid_value);
CFDictionaryAddValue(match, CFSTR(kUSBVendorString), number);
CFRelease(number);
return match;
}
auto is_supported_pid(std::uint16_t pid) -> bool
{
return pid == pid_old_dongle || pid == pid_new_dongle
|| pid == pid_builtin_asus_lenovo || pid == pid_surface_book_2;
}
// Read the "kUSBProductString" property (a CFNumber) of a USB device service.
// Returns 0 if the property is missing or unreadable.
auto read_pid(io_service_t service) -> std::uint16_t
{
CFMutableDictionaryRef props = nullptr;
if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) != kIOReturnSuccess
|| !props)
return 0;
auto *number = static_cast<CFNumberRef>(CFDictionaryGetValue(props, CFSTR(kUSBProductString)));
std::uint16_t pid = 0;
if (number) {
SInt32 value = 0;
if (CFNumberGetValue(number, kCFNumberSInt32Type, &value))
pid = static_cast<std::uint16_t>(value);
}
CFRelease(props);
return pid;
}
} // namespace
struct transport::read_slot {
transport *owner = nullptr;
std::uint8_t ep = 0;
IOUSBInterfaceInterface190 *iface = nullptr;
UInt8 pipe_ref = 0;
std::vector<std::uint8_t> buf;
};
struct transport::state {
std::uint16_t pid = 0;
// Device connection (port of the usb_device + usb_interface binding).
// Plug-in interface references are double pointers; QI'd interfaces are
// single struct pointers.
io_service_t service = 0;
IOCFPlugInInterface **dev_iodev = nullptr;
IOUSBDeviceInterface197 *dev = nullptr;
bool dev_opened = false;
struct iface_conn {
IOCFPlugInInterface **iodev = nullptr;
IOUSBInterfaceInterface190 *iface = nullptr;
};
std::vector<iface_conn> ifaces;
struct pipe_ref {
IOUSBInterfaceInterface190 *iface = nullptr;
UInt8 ref = 0;
};
std::optional<pipe_ref> in_cmd_pipe;
std::optional<pipe_ref> in_wlan_pipe;
std::optional<pipe_ref> out_pipe;
// One buffer per outstanding async read. Slots are never moved after the
// initial ReadPipeAsync submissions (the refcon is their address).
std::vector<read_slot> slots;
// Async completion dispatch (one CFRunLoop on a dedicated thread).
std::vector<CFRunLoopSourceRef> sources;
IONotificationPortRef notify_port = 0;
io_iterator_t termination_iter = 0;
bool thread_started = false;
std::thread thread;
bool loop_ready = false;
CFRunLoopRef runloop = nullptr;
std::mutex lock;
std::condition_variable cv;
bool stopping = false;
int in_flight = 0;
bool disconnected_notified = false;
frame_callback frames;
disconnect_callback disconnected;
};
auto transport::probe(frame_callback frames, disconnect_callback disconnected) -> std::unique_ptr<transport>
{
// make_unique cannot call the private constructor from outside the class.
auto t = std::unique_ptr<transport>(new transport());
if (!t->open(std::move(frames), std::move(disconnected)))
return nullptr;
return t;
}
transport::~transport()
{
if (!state_)
return;
// Stop generating completions, then wait for the in-flight ones to drain.
{
std::lock_guard<std::mutex> lock(state_->lock);
state_->stopping = true;
}
for (auto &slot : state_->slots)
slot.iface->AbortPipe(slot.iface, slot.pipe_ref);
if (state_->thread_started) {
CFRunLoopRef runloop = nullptr;
{
std::unique_lock<std::mutex> lock(state_->lock);
state_->cv.wait(lock, [this] { return state_->loop_ready && state_->in_flight == 0; });
runloop = state_->runloop;
}
// The reader thread is joined below; no callback can run after this.
CFRunLoopStop(runloop);
state_->thread.join();
}
// Release the termination watch and async event sources.
if (state_->termination_iter)
IOObjectRelease(state_->termination_iter);
if (state_->notify_port)
IONotificationPortDestroy(state_->notify_port);
for (auto *source : state_->sources)
CFRelease(source);
// Close the interfaces and their endpoint pipes.
for (auto &conn : state_->ifaces) {
conn.iface->USBInterfaceClose(conn.iface);
conn.iface->Release(conn.iface);
IODestroyPlugInInterface(conn.iodev);
}
// Close the device connection.
if (state_->dev) {
if (state_->dev_opened)
state_->dev->USBDeviceClose(state_->dev);
state_->dev->Release(state_->dev);
}
if (state_->dev_iodev)
IODestroyPlugInInterface(state_->dev_iodev);
if (state_->service)
IOObjectRelease(state_->service);
}
auto transport::open(frame_callback frames, disconnect_callback disconnected) -> bool
{
state_ = std::make_unique<state>();
state_->frames = std::move(frames);
state_->disconnected = std::move(disconnected);
// Find the dongle service (VID match, filter by PID).
io_iterator_t iter = 0;
kern_return_t kr = IOServiceGetMatchingServices(0, vid_match_dict(), &iter);
if (kr != kIOReturnSuccess)
return false;
bool found = false;
while (true) {
io_service_t service = IOIteratorNext(iter);
if (!service)
break;
std::uint16_t pid = read_pid(service);
if (is_supported_pid(pid)) {
state_->service = service;
state_->pid = pid;
found = true;
} else {
IOObjectRelease(service);
}
}
IOObjectRelease(iter);
if (!found)
return false;
// Open the device connection.
SInt32 score = 0;
kr = IOCreatePlugInInterfaceForService(state_->service, kIOUSBDeviceUserClientTypeID,
kIOCFPlugInInterfaceID, &state_->dev_iodev, &score);
if (kr != kIOReturnSuccess) {
xone::log_msg(log_level::error, "usb: create device interface failed (%d)", kr);
return false;
}
auto *dev = static_cast<IOUSBDeviceInterface197 *>(nullptr);
HRESULT hr = (*state_->dev_iodev)->QueryInterface(state_->dev_iodev,
CFUUIDGetUUIDBytes(kIOUSBDeviceInterfaceID197),
reinterpret_cast<void **>(&dev));
if (hr != S_OK || !dev) {
xone::log_msg(log_level::error, "usb: query device interface failed");
return false;
}
state_->dev = dev;
kr = dev->USBDeviceOpen(dev);
if (kr != kIOReturnSuccess) {
xone::log_msg(log_level::error, "usb: open device failed (%d)", kr);
return false;
}
state_->dev_opened = true;
// Open every interface and collect the endpoint pipes we need.
io_iterator_t children = 0;
kr = IORegistryEntryGetChildIterator(state_->service, kIOServicePlane, &children);
if (kr != kIOReturnSuccess)
return false;
while (true) {
io_service_t child = IOIteratorNext(children);
if (!child)
break;
open_interface(child);
IOObjectRelease(child);
}
IOObjectRelease(children);
if (!state_->in_cmd_pipe || !state_->in_wlan_pipe || !state_->out_pipe) {
xone::log_msg(log_level::error, "usb: missing endpoint (cmd in=%d, wlan in=%d, out=%d)",
state_->in_cmd_pipe.has_value(), state_->in_wlan_pipe.has_value(),
state_->out_pipe.has_value());
return false;
}
// Async completion dispatch for the opened interfaces.
for (auto &conn : state_->ifaces) {
CFRunLoopSourceRef source = nullptr;
if (conn.iface->CreateInterfaceAsyncEventSource(conn.iface, &source) == kIOReturnSuccess
&& source)
state_->sources.push_back(source);
}
// Watch for the dongle going away (unplug or chip reconnect). The
// matching dictionary is consumed by this call.
state_->notify_port = IONotificationPortCreate(0);
kr = IOServiceAddMatchingNotification(state_->notify_port, kIOTerminatedNotification,
vid_match_dict(), on_dongle_terminated, this,
&state_->termination_iter);
if (kr != kIOReturnSuccess) {
xone::log_msg(log_level::error, "usb: add termination notification failed (%d)", kr);
return false;
}
state_->thread = std::thread([this] { worker_loop(); });
state_->thread_started = true;
// Submit the initial async reads (EP 0x05 IN and EP 0x04 IN). Reserve so
// the slot addresses stay valid for the ReadPipeAsync refcons.
state_->slots.reserve(num_in_reads * 2);
auto submit_all = [this](std::optional<state::pipe_ref> const &pipe,
std::uint8_t ep, std::size_t buf_len) -> bool {
for (std::size_t i = 0; i < num_in_reads; i++) {
state_->slots.push_back(
{ this, ep, pipe->iface, pipe->ref, std::vector<std::uint8_t>(buf_len) });
if (!submit_read(&state_->slots.back()))
return false;
}
return true;
};
if (!submit_all(state_->in_cmd_pipe, ep_in_cmd, len_cmd_pkt))
return false;
if (!submit_all(state_->in_wlan_pipe, ep_in_wlan, len_wlan_pkt))
return false;
xone::log_msg(log_level::info, "usb: dongle connected (pid=0x%04x)", state_->pid);
return true;
}
auto transport::open_interface(io_service_t child) -> bool
{
state::iface_conn conn{};
SInt32 score = 0;
if (IOCreatePlugInInterfaceForService(child, kIOUSBInterfaceUserClientTypeID,
kIOCFPlugInInterfaceID, &conn.iodev, &score)
!= kIOReturnSuccess)
return false;
auto *iface = static_cast<IOUSBInterfaceInterface190 *>(nullptr);
if ((*conn.iodev)->QueryInterface(conn.iodev, CFUUIDGetUUIDBytes(kIOUSBInterfaceInterfaceID190),
reinterpret_cast<void **>(&iface)) != S_OK
|| !iface) {
IODestroyPlugInInterface(conn.iodev);
return false;
}
conn.iface = iface;
if (iface->USBInterfaceOpen(iface) != kIOReturnSuccess) {
iface->Release(iface);
IODestroyPlugInInterface(conn.iodev);
return false;
}
state_->ifaces.push_back(conn);
// Scan the interface's pipes for the endpoints we need.
UInt8 num_pipes = 0;
if (iface->GetNumEndpoints(iface, &num_pipes) != kIOReturnSuccess)
return true;
for (UInt8 i = 1; i <= num_pipes; i++) {
UInt8 direction = 0, number = 0, type = 0;
UInt16 max_packet_size = 0;
UInt8 interval = 0;
if (iface->GetPipeProperties(iface, i, &direction, &number, &type,
&max_packet_size, &interval) != kIOReturnSuccess)
continue;
std::uint8_t ep = static_cast<std::uint8_t>(number);
if (direction == kUSBIn && ep == ep_in_cmd && !state_->in_cmd_pipe.has_value())
state_->in_cmd_pipe = { iface, i };
else if (direction == kUSBIn && ep == ep_in_wlan && !state_->in_wlan_pipe.has_value())
state_->in_wlan_pipe = { iface, i };
else if (direction == kUSBOut && ep == ep_out && !state_->out_pipe.has_value())
state_->out_pipe = { iface, i };
}
return true;
}
auto transport::pid() const -> std::uint16_t
{
return state_->pid;
}
auto transport::send_vendor_request(vendor_request req, bool is_read, std::uint16_t w_value,
std::uint16_t w_index, void *data, std::size_t len) -> int
{
IOUSBDevRequest request;
std::memset(&request, 0, sizeof(request));
request.bmRequestType = USBmakebmRequestType(is_read ? static_cast<int>(kUSBIn) : static_cast<int>(kUSBOut),
static_cast<int>(kUSBVendor), static_cast<int>(kUSBDevice));
request.bRequest = static_cast<UInt8>(req);
request.wValue = w_value;
request.wIndex = w_index;
request.wLength = static_cast<UInt16>(len);
request.pData = data;
IOReturn ret = state_->dev->DeviceRequest(state_->dev, &request);
if (ret != kIOReturnSuccess || request.wLenDone != len) {
xone::log_msg(log_level::error, "usb: vendor request 0x%02x failed (%d)",
static_cast<int>(req), ret);
return -EIO;
}
return static_cast<int>(len);
}
auto transport::bulk_write(void const *data, std::size_t len) -> int
{
auto &pipe = state_->out_pipe.value();
IOReturn ret = pipe.iface->WritePipe(pipe.iface, pipe.ref,
const_cast<void *>(data), static_cast<UInt32>(len));
if (ret != kIOReturnSuccess) {
xone::log_msg(log_level::error, "usb: bulk write failed (%d)", ret);
return -EIO;
}
return static_cast<int>(len);
}
auto transport::submit_read(read_slot *slot) -> bool
{
{
std::lock_guard<std::mutex> lock(state_->lock);
if (state_->stopping)
return false;
state_->in_flight++;
}
IOReturn ret = slot->iface->ReadPipeAsync(slot->iface, slot->pipe_ref,
slot->buf.data(), static_cast<UInt32>(slot->buf.size()),
on_read_completion, slot);
if (ret != kIOReturnSuccess) {
std::lock_guard<std::mutex> lock(state_->lock);
state_->in_flight--;
state_->cv.notify_all();
return false;
}
return true;
}
void transport::worker_loop()
{
CFRunLoopRef runloop = CFRunLoopGetCurrent();
for (auto *source : state_->sources)
CFRunLoopAddSource(runloop, source, kCFRunLoopDefaultMode);
CFRunLoopAddSource(runloop, IONotificationPortGetRunLoopSource(state_->notify_port),
kCFRunLoopDefaultMode);
{
std::lock_guard<std::mutex> lock(state_->lock);
state_->runloop = runloop;
state_->loop_ready = true;
state_->cv.notify_all();
}
CFRunLoopRun();
}
void transport::handle_read(read_slot *slot, IOReturn result, std::size_t len)
{
if (result == kIOReturnSuccess && len > 0 && state_->frames)
state_->frames(slot->ep, slot->buf.data(), len);
std::lock_guard<std::mutex> lock(state_->lock);
if (state_->stopping) {
state_->in_flight--;
state_->cv.notify_all();
return;
}
IOReturn ret = slot->iface->ReadPipeAsync(slot->iface, slot->pipe_ref,
slot->buf.data(), static_cast<UInt32>(slot->buf.size()),
on_read_completion, slot);
if (ret != kIOReturnSuccess) {
state_->in_flight--;
state_->cv.notify_all();
}
}
void transport::handle_disconnected()
{
disconnect_callback callback;
{
std::lock_guard<std::mutex> lock(state_->lock);
if (state_->stopping || state_->disconnected_notified)
return;
state_->disconnected_notified = true;
callback = state_->disconnected;
}
xone::log_msg(log_level::info, "usb: dongle disconnected");
if (callback)
callback();
}
void transport::on_read_completion(void *refcon, IOReturn result, void *arg0)
{
auto *slot = static_cast<read_slot *>(refcon);
slot->owner->handle_read(slot, result,
static_cast<std::size_t>(reinterpret_cast<std::uintptr_t>(arg0)));
}
void transport::on_dongle_terminated(void *refcon, io_iterator_t iter)
{
auto *t = static_cast<transport *>(refcon);
bool ours = false;
while (true) {
io_service_t service = IOIteratorNext(iter);
if (!service)
break;
if (read_pid(service) == t->state_->pid)
ours = true;
IOObjectRelease(service);
}
if (ours)
t->handle_disconnected();
}
auto dongle_present() -> bool
{
io_iterator_t iter = 0;
kern_return_t kr = IOServiceGetMatchingServices(0, vid_match_dict(), &iter);
if (kr != kIOReturnSuccess)
return false;
bool present = false;
while (true) {
io_service_t service = IOIteratorNext(iter);
if (!service)
break;
if (is_supported_pid(read_pid(service)))
present = true;
IOObjectRelease(service);
}
IOObjectRelease(iter);
return present;
}
} // namespace xone::usb