feat: pair controllers and show them in the app

Port the MT76 client functions (send_wlan, associate_client,
pair_client, set_client_key, remove_client) and wire the RX dispatch
into the session: EP IN frames are parsed, ASSOC_REQ associates a
controller (WCID plus chip programming), PAIR_REQ replies with
PAIR_RESP, and DISASSOC or client-lost removes it. The C API exposes
connected controllers and the app lists them by MAC. Bumps
xone_cli/xone_app to C++23: they were falling back to the default
standard once mt76.hpp started using std::span. Adds a unit test pinning
the WCID regions, the 32-byte rxwi, and the new frame control values.

Co-Authored-By: qwen3.8-27b@q2_k_xl: client functions, RX dispatch, GUI list
This commit is contained in:
portersky
2026-08-17 20:40:47 +02:00
parent b3e747b900
commit dae51f085b
9 changed files with 682 additions and 7 deletions
+218 -2
View File
@@ -2,12 +2,15 @@
#include "app/xone_api.h"
#include <array>
#include <cstdio>
#include <cstring>
#include <memory>
#include <mutex>
#include <span>
#include <string>
#include <thread>
#include <vector>
#include "common/log.hpp"
#include "mt76/mt76.hpp"
@@ -17,11 +20,19 @@
#define XONE_VERSION "0.1.0"
#endif
// One connected controller (tracked for the GUI). Guarded by xone_dongle's
// lock; written by the transport reader thread, read by the UI thread.
struct controller {
std::uint8_t wcid;
std::array<std::uint8_t, 6> mac;
};
// Definition of the opaque struct xone_dongle from app/xone_api.h.
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).
// Guards state, firmware_build, error, and controllers. Written by the
// start worker and the transport reader thread; read by the UI thread.
mutable std::mutex lock;
std::uint16_t pid = 0;
std::uint16_t chip_id = 0;
@@ -32,13 +43,29 @@ struct xone_dongle {
// Firmware load + radio init runs here so the UI thread never blocks.
std::thread worker;
// Connected controllers (wcid 1..16). Guarded by lock.
std::vector<controller> controllers;
~xone_dongle()
{
// Stop the reader thread before the chip is torn down so a late IN
// completion cannot touch freed state.
transport.reset();
// 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();
}
// RX dispatch, driven from the transport reader thread. Port of the
// xone_dongle_process_* functions in transport/dongle.c.
auto process_message(void const *data, std::size_t len) -> void;
auto process_wlan(std::span<std::uint8_t const> buf) -> void;
auto handle_association(std::span<std::uint8_t const> addr) -> void;
auto handle_disassociation(std::uint8_t wcid) -> void;
auto handle_client_command(std::span<std::uint8_t const> payload,
std::uint8_t wcid,
std::span<std::uint8_t const> addr) -> void;
};
namespace {
@@ -47,6 +74,9 @@ namespace {
std::unique_ptr<xone_dongle> current_session;
std::mutex session_mutex; // guards current_session
// 802.11 management frame header length (three addresses, no extensions).
constexpr std::size_t k_mgmt_hdr_len = 24;
// Select the firmware image for a product ID: firmware/xone_dongle_<pid>.bin.
auto firmware_path_for(std::uint16_t pid) -> std::string
{
@@ -57,6 +87,161 @@ auto firmware_path_for(std::uint16_t pid) -> std::string
} // namespace
// --- RX dispatch (port of xone_dongle_process_*) ---------------------------
auto xone_dongle::process_message(void const *data, std::size_t len) -> void
{
using namespace xone::mt76;
if (!chip || len < 2 * cmd_hdr_len)
return;
auto buf = std::span<std::uint8_t const>(
static_cast<std::uint8_t const *>(data), len);
auto info = xone::load_le32(buf.data());
auto port = field_get(mt_mcu_msg_port, info); // bits 29:27
// Ignore command responses (CMD_SEQ == 1).
if (field_get(mt_mcu_msg_cmd_seq, info) == 0x01)
return;
// Strip the header + trailer.
buf = buf.subspan(cmd_hdr_len, len - 2 * cmd_hdr_len);
if (port == dma_msg_port::wlan_port) {
process_wlan(buf);
return;
}
if (port != dma_msg_port::cpu_rx_port)
return;
switch (field_get(mt_rx_fce_info_evt_type, info)) {
case cpu_evt::evt_button:
// Pairing mode is entered via the CLI for now.
break;
case cpu_evt::evt_packet_rx:
process_wlan(buf);
break;
case cpu_evt::evt_client_lost:
if (!buf.empty())
handle_disassociation(buf[0]);
break;
default:
break;
}
}
auto xone_dongle::process_wlan(std::span<std::uint8_t const> buf) -> void
{
using namespace xone::mt76;
if (buf.size() < sizeof(mt76_rxwi))
return;
auto rxinfo = xone::load_le32(buf.data());
auto ctl = xone::load_le32(buf.data() + 4);
auto wcid = static_cast<std::uint8_t>(field_get(mt_rxwi_ctl_wcid, ctl));
// The frame starts after the 32-byte rxwi.
std::span<std::uint8_t const> frame = buf.subspan(sizeof(mt76_rxwi));
if (frame.size() < k_mgmt_hdr_len)
return;
auto fc = xone::load_le16(frame.data());
auto match = fc & (ieee80211_fctl_ftype | ieee80211_fctl_stype);
// L2PAD: 2 bytes of padding after the 802.11 header.
std::size_t pad = (rxinfo & mt_rxinfo_l2pad) ? 2 : 0;
if (match == (ieee80211_ftype_mgmt | ieee80211_stype_assoc_req)) {
handle_association(frame.subspan(10, 6)); // addr2
} else if (match == (ieee80211_ftype_mgmt | ieee80211_stype_disassoc)) {
handle_disassociation(wcid);
} else if (match == (ieee80211_ftype_mgmt | ieee80211_stype_wlan_reserved)) {
handle_client_command(frame.subspan(k_mgmt_hdr_len + pad), wcid,
frame.subspan(10, 6)); // addr2
}
// DATA|QOS_DATA (GIP) is handled in a later increment.
}
auto xone_dongle::handle_association(std::span<std::uint8_t const> addr) -> void
{
// Find a free WCID slot (1..16).
std::uint8_t wcid = 0;
{
std::lock_guard<std::mutex> guard(lock);
for (std::uint8_t i = 1; i <= 16; ++i) {
bool used = false;
for (auto const& c : controllers)
if (c.wcid == i) { used = true; break; }
if (!used) { wcid = i; break; }
}
}
if (wcid == 0)
return; // no free slot
std::array<std::uint8_t, 6> mac{};
std::memcpy(mac.data(), addr.data(), mac.size());
// Program the chip outside the lock (synchronous USB I/O).
if (chip->associate_client(wcid, mac) != 0) {
xone::log_msg(xone::log_level::warn,
"api: associate wcid=%d failed", wcid);
return;
}
std::lock_guard<std::mutex> guard(lock);
controllers.push_back(controller{wcid, mac});
xone::log_msg(xone::log_level::info,
"api: controller associated (wcid=%d)", wcid);
}
auto xone_dongle::handle_disassociation(std::uint8_t wcid) -> void
{
if (wcid == 0 || wcid > 16)
return;
// Remove from the chip outside the lock.
chip->remove_client(wcid);
std::lock_guard<std::mutex> guard(lock);
for (auto it = controllers.begin(); it != controllers.end(); ++it) {
if (it->wcid == wcid) {
controllers.erase(it);
break;
}
}
xone::log_msg(xone::log_level::info,
"api: controller removed (wcid=%d)", wcid);
}
auto xone_dongle::handle_client_command(std::span<std::uint8_t const> payload,
std::uint8_t wcid,
std::span<std::uint8_t const> addr) -> void
{
using namespace xone::mt76;
if (payload.size() < 2 || payload[0] != ieee80211_stype_wlan_reserved)
return;
switch (payload[1]) {
case client_cmd::client_pair_req: {
std::array<std::uint8_t, 6> mac{};
std::memcpy(mac.data(), addr.data(), mac.size());
chip->pair_client(mac);
xone::log_msg(xone::log_level::info,
"api: controller paired (wcid=%d)", wcid);
break;
}
case client_cmd::client_enable_encryption:
// Encryption is enabled in a later increment (needs the GIP key).
break;
default:
break;
}
}
extern "C" const char *xone_version(void)
{
return XONE_VERSION;
@@ -74,7 +259,17 @@ extern "C" xone_dongle *xone_open(void)
return nullptr;
auto s = std::make_unique<xone_dongle>();
s->transport = xone::usb::transport::probe(nullptr, nullptr);
// Wire the RX dispatch into the transport's read pump. The callback
// fires on the reader thread; process_message is null-safe until the
// chip is created below.
auto *session = s.get();
xone::usb::frame_callback frames = [session](std::uint8_t ep,
void const *data,
std::size_t len) {
(void)ep;
session->process_message(data, len);
};
s->transport = xone::usb::transport::probe(frames, nullptr);
if (!s->transport)
return nullptr;
@@ -184,3 +379,24 @@ extern "C" const char *xone_firmware_build(const xone_dongle *d)
std::lock_guard<std::mutex> guard(d->lock);
return d->firmware_build;
}
// Number of connected controllers.
extern "C" int xone_controller_count(xone_dongle const *d)
{
std::lock_guard<std::mutex> guard(d->lock);
return static_cast<int>(d->controllers.size());
}
// Copy the controller's MAC into buf as "xx:xx:xx:xx:xx:xx". Returns 0 on
// success, -1 if index is out of range.
extern "C" int xone_controller_mac(xone_dongle const *d, int index, char *buf,
int len)
{
std::lock_guard<std::mutex> guard(d->lock);
if (index < 0 || index >= static_cast<int>(d->controllers.size()))
return -1;
auto const& c = d->controllers[index];
std::snprintf(buf, len, "%02x:%02x:%02x:%02x:%02x:%02x",
c.mac[0], c.mac[1], c.mac[2], c.mac[3], c.mac[4], c.mac[5]);
return 0;
}
+29 -2
View File
@@ -30,6 +30,8 @@ struct ContentView: View {
// 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
// Connected controllers (MAC strings), refreshed each tick.
@State private var controllers: [String] = []
private let timer = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
@@ -84,6 +86,7 @@ struct ContentView: View {
session = nil
} else if let s = session {
radioState = Int(xone_state(s))
refreshControllers(s)
}
donglePresent = present
}
@@ -130,6 +133,19 @@ struct ContentView: View {
return build.isEmpty ? "not loaded" : build
}
// Query the C API for connected controllers and store their MACs.
private func refreshControllers(_ session: OpaquePointer) {
let count = Int(xone_controller_count(session))
var list: [String] = []
for i in 0..<count {
var buf = [CChar](repeating: 0, count: 18)
if xone_controller_mac(session, Int32(i), &buf, 18) == 0 {
list.append(String(cString: buf))
}
}
controllers = list
}
private var statusView: some View {
HStack(spacing: 8) {
Circle()
@@ -144,8 +160,19 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 8) {
Text("Controllers")
.font(.headline)
Text("No controllers connected.")
.foregroundStyle(.secondary)
if controllers.isEmpty {
Text("No controllers connected.")
.foregroundStyle(.secondary)
} else {
ForEach(controllers, id: \.self) { mac in
HStack {
Image(systemName: "gamecontroller")
.foregroundStyle(.secondary)
Text(mac)
.font(.system(.body, design: .monospaced))
}
}
}
}
}
}
+169 -1
View File
@@ -337,7 +337,7 @@ auto chip::load_cr(cr_mode mode) -> int
return send_command(mcu_cmd::cmd_load_cr, payload, sizeof(payload));
}
auto chip::write_burst(std::uint32_t idx, void *data, std::size_t len) -> int
auto chip::write_burst(std::uint32_t idx, void const *data, std::size_t len) -> int
{
std::vector<std::uint8_t> buf(4 + len);
xone::store_le32(buf.data(), idx + mt_mcu_memmap_wlan); // Register offset in memory.
@@ -761,4 +761,172 @@ auto chip::resume_radio() -> int
return 0;
}
// Build a TXWI (struct mt76_txwi, 20 bytes, packed) for a WLAN frame.
auto build_txwi(std::uint8_t wcid, std::size_t len_ctl)
-> std::array<std::uint8_t, 20>
{
std::array<std::uint8_t, 20> txwi{};
xone::store_le16(txwi.data() + 0, field_prep(mt_txwi_flags_mpdu_density,
ieee80211_ht_mpdu_density_4));
xone::store_le16(txwi.data() + 2,
field_prep(mt_rxwi_rate_phy, phy_type::phy_ofdm));
txwi[4] = mt_txwi_ack_ctl_req;
txwi[5] = wcid; // 0xff for broadcast (assoc/pair), wcid-1 for a client.
xone::store_le16(txwi.data() + 6, static_cast<std::uint16_t>(len_ctl));
return txwi;
}
auto chip::send_wlan(std::span<std::uint8_t const> frame) -> int
{
auto txwi = build_txwi(0xff, frame.size());
std::vector<std::uint8_t> payload(txwi.size() + frame.size());
std::memcpy(payload.data(), txwi.data(), txwi.size());
std::memcpy(payload.data() + txwi.size(), frame.data(), frame.size());
// Enhanced distributed channel access (EDCA), wireless info valid (WIV).
auto info = field_prep(mt_txd_info_dport, dma_msg_port::wlan_port)
| field_prep(mt_txd_info_qsel, qsel::qsel_edca)
| mt_txd_info_wiv
| mt_txd_info_80211;
auto buf = build_message(info, payload.data(), payload.size());
auto ret = transport_.bulk_write(buf.data(), buf.size());
return ret < 0 ? ret : 0;
}
auto chip::associate_client(std::uint8_t wcid,
std::span<std::uint8_t const> addr) -> int
{
auto address = mac_address();
// struct ieee80211_mgmt (assoc_resp), 26 bytes:
// [frame_control][duration][da][sa][bssid][status_code][aid].
std::uint8_t mgmt[26] = {};
xone::store_le16(mgmt + 0, ieee80211_ftype_mgmt
| ieee80211_stype_assoc_resp);
std::memcpy(mgmt + 4, addr.data(), 6); // da
std::memcpy(mgmt + 10, address.data(), 6); // sa
std::memcpy(mgmt + 16, address.data(), 6); // bssid
xone::store_le16(mgmt + 22, 0x0110); // status_code (original)
xone::store_le16(mgmt + 24, 0x0f00); // aid (original)
// Payload: mgmt frame plus 8 bytes of zero padding.
std::vector<std::uint8_t> payload(sizeof(mgmt) + 8, 0);
std::memcpy(payload.data(), mgmt, sizeof(mgmt));
if (auto err = write_burst(mt_wcid_addr(wcid), addr.data(), 6); err != 0)
return err;
// ADD_CLIENT ms_command: {wcid-1, 0, 0, 0, 0x40, 0x1f, 0, 0}.
auto idx = static_cast<std::uint8_t>(wcid - 1);
std::uint8_t data[8] = { idx, 0x00, 0x00, 0x00, 0x40, 0x1f, 0x00, 0x00 };
if (auto err = send_ms_command(ms_command::ms_add_client,
data, sizeof(data)); err != 0)
return err;
return send_wlan(payload);
}
auto chip::pair_client(std::span<std::uint8_t const> addr) -> int
{
auto address = mac_address();
// struct ieee80211_hdr_3addr (22 bytes) plus the reserved command and a
// 9-byte payload: [fc][duration][addr1][addr2][addr3][0x70][PAIR_RESP]
// [data].
std::uint8_t frame[33] = {};
xone::store_le16(frame + 0, ieee80211_ftype_mgmt
| ieee80211_stype_wlan_reserved);
std::memcpy(frame + 4, addr.data(), 6); // addr1
std::memcpy(frame + 10, address.data(), 6); // addr2
std::memcpy(frame + 16, address.data(), 6); // addr3
frame[22] = ieee80211_stype_wlan_reserved; // reserved (0x70)
frame[23] = client_cmd::client_pair_resp;
std::uint8_t data[9] = { 0x00, 0x45, 0x55, 0x01, 0x0f, 0x8f, 0xff, 0x87,
0x1f };
std::memcpy(frame + 24, data, sizeof(data));
return send_wlan({ frame, frame + sizeof(frame) });
}
auto chip::send_client_command(std::uint8_t wcid,
std::span<std::uint8_t const> addr,
client_cmd cmd,
std::span<std::uint8_t const> data) -> int
{
auto address = mac_address();
// Payload: [info(8)][txwi(20)][hdr(22)][0x70][cmd][data].
auto txwi = build_txwi(wcid - 1, 22 + 2 + data.size());
std::vector<std::uint8_t> payload(8 + txwi.size() + 22 + 2 + data.size());
// info: {0, 0, 0, wcid-1, 0, 0, 0, 0}.
payload[3] = wcid - 1;
std::memcpy(payload.data() + 8, txwi.data(), txwi.size());
auto hdr = payload.data() + 28;
xone::store_le16(hdr + 0, ieee80211_ftype_mgmt
| ieee80211_stype_wlan_reserved);
std::memcpy(hdr + 4, addr.data(), 6); // addr1
std::memcpy(hdr + 10, address.data(), 6); // addr2
std::memcpy(hdr + 16, address.data(), 6); // addr3
hdr[22] = ieee80211_stype_wlan_reserved; // reserved (0x70)
hdr[23] = static_cast<std::uint8_t>(cmd);
if (!data.empty())
std::memcpy(hdr + 24, data.data(), data.size());
return send_command(0, payload.data(), payload.size());
}
auto chip::set_client_key(std::uint8_t wcid,
std::span<std::uint8_t const> key) -> int
{
if (key.size() != xone_mt_wcid_key_len)
return -22; // -EINVAL
// IV: {0x01, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00}.
std::uint8_t iv[8] = { 0x01, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00 };
auto attr = field_prep(mt_wcid_attr_pkey_mode, cipher_type::cipher_aes_ccmp)
| mt_wcid_attr_pairwise;
if (auto err = write_burst(mt_wcid_key(wcid), key.data(), key.size());
err != 0)
return err;
if (auto err = write_burst(mt_wcid_iv(wcid), iv, sizeof(iv)); err != 0)
return err;
return write_burst(mt_wcid_attr(wcid), &attr, sizeof(attr));
}
auto chip::remove_client(std::uint8_t wcid) -> int
{
// REMOVE_CLIENT ms_command: {wcid-1, 0, 0, 0}.
std::uint8_t data[4] = { static_cast<std::uint8_t>(wcid - 1), 0x00, 0x00,
0x00 };
if (auto err = send_ms_command(ms_command::ms_remove_client,
data, sizeof(data)); err != 0)
return err;
// Zero the WCID regions.
std::uint8_t addr[6] = {};
std::uint8_t iv[8] = {};
std::uint32_t attr = 0;
std::uint8_t key[xone_mt_wcid_key_len] = {};
if (auto err = write_burst(mt_wcid_addr(wcid), addr, sizeof(addr));
err != 0)
return err;
if (auto err = write_burst(mt_wcid_iv(wcid), iv, sizeof(iv)); err != 0)
return err;
if (auto err = write_burst(mt_wcid_attr(wcid), &attr, sizeof(attr));
err != 0)
return err;
return write_burst(mt_wcid_key(wcid), key, sizeof(key));
}
} // namespace xone::mt76