// Wii Balance Board native bridge. // // Purpose: // Pair one original Nintendo RVL-WBC-01 through modern BlueZ, then expose the // calibrated Linux input readings as newline-delimited JSON for the Node server. // // Why this process exists: // The Linux hid-wiimote driver already performs the board-specific calibration, // but BlueZ removed its Wii PIN helper in 2025. A Wii device expects six raw PIN // bytes equal to the host Bluetooth adapter address in wire order. D-Bus represents PINs // as UTF-8 strings and cannot safely carry arbitrary bytes, so this bridge races // BlueZ's agent response with the correct raw MGMT_OP_PIN_CODE_REPLY. Only the // board currently being commissioned is eligible for that reply. // // Security boundary: // The installed binary receives CAP_NET_ADMIN solely to open the Bluetooth // management socket. The much larger Node server remains unprivileged. Normal // sensor access happens through a narrowly scoped udev rule. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr const char* kBoardBluetoothName = "Nintendo RVL-WBC-01"; constexpr const char* kBoardInputName = "Nintendo Wii Remote Balance Board"; constexpr int kBluetoothProtocolHci = 1; constexpr uint16_t kHciChannelControl = 3; constexpr uint16_t kHciDeviceNone = 0xffff; constexpr uint16_t kMgmtPinCodeRequestEvent = 0x000e; constexpr uint16_t kMgmtPinCodeReplyCommand = 0x0016; constexpr uint16_t kMgmtDeviceConnectedEvent = 0x000b; constexpr uint16_t kMgmtDeviceDisconnectedEvent = 0x000c; constexpr uint16_t kMgmtConnectFailedEvent = 0x000d; constexpr uint8_t kBluetoothClassicAddressType = 0; constexpr int kFrameIntervalMs = 50; constexpr int kDeviceScanIntervalMs = 500; constexpr int kDiscoveryRestartDelayMs = 1000; constexpr const char* kDiscoveryTimeoutSeconds = "86400"; constexpr int kBluetoothMonitorIntervalMs = 2000; constexpr int kReconnectAttemptIntervalMs = 3000; constexpr int kConnectionEvidenceWindowMs = 5000; constexpr int kBatteryRefreshMs = 5000; std::atomic running{true}; std::mutex output_mutex; struct BluetoothAddress { std::string display; // The kernel Bluetooth management API carries addresses least-significant // byte first. These exact six bytes are also the Wii pairing PIN. std::array wire{}; }; struct PairingSharedState { std::mutex mutex; std::optional active_target; std::optional active_pin; std::optional commissioned_address; std::string input_state = "not-detected"; std::string input_error; // The management socket sees the actual controller-level events even when // bluetoothctl reduces them to a generic D-Bus failure. Timestamps let the // reconnect thread associate those events with one specific attempt. uint64_t last_radio_connected_at = 0; uint64_t last_radio_disconnected_at = 0; uint64_t last_connect_failed_at = 0; uint8_t last_disconnect_reason = 0; uint8_t last_connect_status = 0; bool commissioning = false; bool pairing_available = true; }; struct BoardReadings { int top_right = 0; int bottom_right = 0; int top_left = 0; int bottom_left = 0; }; struct CommandResult { int exit_code = -1; std::string output; }; struct BluetoothDeviceState { bool available = false; bool paired = false; bool trusted = false; bool connected = false; bool wake_allowed = false; std::string error; }; struct InputProbe { std::optional path; std::string state = "not-detected"; std::string error; }; struct RunningCommand { pid_t pid = -1; int output_fd = -1; std::string pending_output; std::string transcript; }; uint64_t monotonic_ms() { using namespace std::chrono; return duration_cast(steady_clock::now().time_since_epoch()).count(); } std::string json_escape(const std::string& value) { std::ostringstream out; for (unsigned char ch : value) { switch (ch) { case '\\': out << "\\\\"; break; case '"': out << "\\\""; break; case '\n': out << "\\n"; break; case '\r': out << "\\r"; break; case '\t': out << "\\t"; break; default: if (ch < 0x20) { out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(ch) << std::dec; } else { out << static_cast(ch); } } } return out.str(); } void emit_json(const std::string& fields) { // Pairing and input monitoring run on separate threads. Serialize complete // lines so two status changes can never interleave and corrupt Node's parser. std::lock_guard lock(output_mutex); std::cout << "{" << fields << "}\n"; std::cout.flush(); } void emit_status(const std::string& state, const std::string& address = "", const std::string& error = "") { std::ostringstream fields; fields << "\"type\":\"status\",\"state\":\"" << json_escape(state) << "\""; if (!address.empty()) fields << ",\"address\":\"" << json_escape(address) << "\""; if (!error.empty()) fields << ",\"error\":\"" << json_escape(error) << "\""; emit_json(fields.str()); } void emit_frame(const BoardReadings& readings, std::optional battery_percent = std::nullopt) { std::ostringstream fields; fields << "\"type\":\"frame\",\"corners\":{" << "\"topRight\":" << readings.top_right << "," << "\"bottomRight\":" << readings.bottom_right << "," << "\"topLeft\":" << readings.top_left << "," << "\"bottomLeft\":" << readings.bottom_left << "}"; if (battery_percent.has_value()) fields << ",\"batteryPercent\":" << *battery_percent; emit_json(fields.str()); } void emit_diagnostics(const std::string& address, const BluetoothDeviceState& bluetooth, const std::string& input_state, const std::string& input_error, const std::string& reconnect_stage, const std::string& reconnect_detail) { // Bluetooth bonding, the current radio link, and Linux evdev readiness are // separate layers. Report each one explicitly so the server never has to // infer all hardware failures from the absence of weight frames. std::ostringstream fields; fields << "\"type\":\"diagnostics\"," << "\"address\":\"" << json_escape(address) << "\"," << "\"bluetooth\":{" << "\"available\":" << (bluetooth.available ? "true" : "false") << "," << "\"paired\":" << (bluetooth.paired ? "true" : "false") << "," << "\"trusted\":" << (bluetooth.trusted ? "true" : "false") << "," << "\"connected\":" << (bluetooth.connected ? "true" : "false") << "," << "\"wakeAllowed\":" << (bluetooth.wake_allowed ? "true" : "false") << "}," << "\"inputState\":\"" << json_escape(input_state) << "\""; if (!bluetooth.error.empty()) { fields << ",\"bluetoothError\":\"" << json_escape(bluetooth.error) << "\""; } if (!input_error.empty()) { fields << ",\"inputError\":\"" << json_escape(input_error) << "\""; } if (!reconnect_stage.empty()) { fields << ",\"reconnectStage\":\"" << json_escape(reconnect_stage) << "\""; } if (!reconnect_detail.empty()) { fields << ",\"reconnectDetail\":\"" << json_escape(reconnect_detail) << "\""; } emit_json(fields.str()); } std::optional read_board_battery() { static uint64_t last_read_at = 0; static std::optional cached; const uint64_t now = monotonic_ms(); if (now - last_read_at < kBatteryRefreshMs) return cached; last_read_at = now; DIR* directory = opendir("/sys/class/power_supply"); if (!directory) return cached; while (dirent* entry = readdir(directory)) { if (std::strncmp(entry->d_name, "wiimote_battery_", 16) != 0) continue; std::ifstream capacity(std::string("/sys/class/power_supply/") + entry->d_name + "/capacity"); int value = -1; if (capacity >> value) cached = std::max(0, std::min(100, value)); break; } closedir(directory); return cached; } void signal_handler(int) { running.store(false); } std::optional parse_address(const std::string& raw) { std::array bytes{}; if (std::sscanf(raw.c_str(), "%2x:%2x:%2x:%2x:%2x:%2x", &bytes[0], &bytes[1], &bytes[2], &bytes[3], &bytes[4], &bytes[5]) != 6) { return std::nullopt; } BluetoothAddress address; char normalized[18]{}; std::snprintf(normalized, sizeof(normalized), "%02X:%02X:%02X:%02X:%02X:%02X", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]); address.display = normalized; for (std::size_t i = 0; i < address.wire.size(); ++i) { address.wire[i] = static_cast(bytes[address.wire.size() - 1 - i]); } return address; } CommandResult run_command(const std::vector& args) { CommandResult result; if (args.empty()) return result; int pipe_fds[2]{}; if (pipe(pipe_fds) != 0) { result.output = std::strerror(errno); return result; } const pid_t pid = fork(); if (pid == 0) { dup2(pipe_fds[1], STDOUT_FILENO); dup2(pipe_fds[1], STDERR_FILENO); close(pipe_fds[0]); close(pipe_fds[1]); std::vector argv; argv.reserve(args.size() + 1); for (const auto& arg : args) argv.push_back(const_cast(arg.c_str())); argv.push_back(nullptr); execvp(argv[0], argv.data()); _exit(127); } close(pipe_fds[1]); if (pid < 0) { close(pipe_fds[0]); result.output = std::strerror(errno); return result; } std::array buffer{}; ssize_t count = 0; while ((count = read(pipe_fds[0], buffer.data(), buffer.size())) > 0) { result.output.append(buffer.data(), static_cast(count)); } close(pipe_fds[0]); int status = 0; while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} if (WIFEXITED(status)) result.exit_code = WEXITSTATUS(status); return result; } RunningCommand start_command(const std::vector& args) { RunningCommand command; if (args.empty()) return command; int pipe_fds[2]{}; if (pipe(pipe_fds) != 0) { command.transcript = std::strerror(errno); return command; } const pid_t pid = fork(); if (pid == 0) { dup2(pipe_fds[1], STDOUT_FILENO); dup2(pipe_fds[1], STDERR_FILENO); close(pipe_fds[0]); close(pipe_fds[1]); std::vector argv; argv.reserve(args.size() + 1); for (const auto& arg : args) argv.push_back(const_cast(arg.c_str())); argv.push_back(nullptr); execvp(argv[0], argv.data()); _exit(127); } close(pipe_fds[1]); if (pid < 0) { command.transcript = std::strerror(errno); close(pipe_fds[0]); return command; } // Discovery has no predetermined completion time: it must remain active until // the user wakes the board. A nonblocking pipe lets the commissioning thread // consume BlueZ events while still honoring server shutdown and maintenance // commands promptly. const int current_flags = fcntl(pipe_fds[0], F_GETFL, 0); if (current_flags >= 0) fcntl(pipe_fds[0], F_SETFL, current_flags | O_NONBLOCK); command.pid = pid; command.output_fd = pipe_fds[0]; return command; } bool collect_command_output(RunningCommand* command) { if (!command || command->pid < 0) return false; std::array buffer{}; ssize_t count = 0; while ((count = read(command->output_fd, buffer.data(), buffer.size())) > 0) { const std::string chunk(buffer.data(), static_cast(count)); command->pending_output += chunk; command->transcript += chunk; // A busy Bluetooth environment can produce an unbounded stream of RSSI // updates. Retain only the most recent diagnostics instead of allowing a // commissioning session left open for days to grow the worker indefinitely. constexpr std::size_t max_transcript_size = 8192; if (command->transcript.size() > max_transcript_size) { command->transcript.erase(0, command->transcript.size() - max_transcript_size); } } int status = 0; const pid_t waited = waitpid(command->pid, &status, WNOHANG); if (waited == 0) return true; if (waited == command->pid) { command->pid = -1; } return false; } void stop_command(RunningCommand* command) { if (!command) return; if (command->pid > 0) { // bluetoothctl normally exits immediately on SIGTERM. Bound that grace // period so a wedged D-Bus client cannot prevent the server from stopping. kill(command->pid, SIGTERM); for (int attempt = 0; attempt < 50 && command->pid > 0; ++attempt) { collect_command_output(command); if (command->pid > 0) usleep(10000); } if (command->pid > 0) { kill(command->pid, SIGKILL); int status = 0; while (waitpid(command->pid, &status, 0) < 0 && errno == EINTR) {} command->pid = -1; } } if (command->output_fd >= 0) { close(command->output_fd); command->output_fd = -1; } } std::optional take_discovered_board(RunningCommand* discovery, bool* discovery_started) { if (!discovery) return std::nullopt; std::size_t newline = discovery->pending_output.find('\n'); while (newline != std::string::npos) { const std::string line = discovery->pending_output.substr(0, newline); discovery->pending_output.erase(0, newline + 1); // bluetoothctl reports filter setup before StartDiscovery completes. Treat // only this explicit event as proof that button presses can now be seen; // `SetDiscoveryFilter success` alone is not an active Bluetooth scan. if (discovery_started && line.find("Discovery started") != std::string::npos) { *discovery_started = true; } // A Classic device is initially announced by address and receives its name // in a later change event. Parse every complete scan line so either BlueZ // form works, but require the exact Nintendo board name before accepting an // address. A nearby Wiimote must never become eligible for the raw PIN. if (line.find(kBoardBluetoothName) != std::string::npos) { const std::size_t device_prefix = line.find("Device "); if (device_prefix != std::string::npos && line.size() >= device_prefix + 24) { if (auto address = parse_address(line.substr(device_prefix + 7, 17))) return address; } } newline = discovery->pending_output.find('\n'); } return std::nullopt; } std::string command_error_summary(const std::string& raw, const std::string& fallback) { std::string summary; summary.reserve(std::min(raw.size(), 400)); bool previous_was_space = false; int ansi_state = 0; for (unsigned char ch : raw) { // bluetoothctl emits terminal color CSI sequences even when its output is // captured by a pipe. Drop the entire ESC ... final-byte sequence so the UI // never exposes fragments such as `[[0;93mCHG[0m]` as hardware diagnostics. if (ch == 0x1b) { ansi_state = 1; continue; } if (ansi_state == 1) { ansi_state = ch == '[' ? 2 : 0; continue; } if (ansi_state == 2) { if (ch >= 0x40 && ch <= 0x7e) ansi_state = 0; continue; } const bool is_space = ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; if (is_space) { if (!summary.empty() && !previous_was_space) summary.push_back(' '); } else if (ch >= 0x20) { summary.push_back(static_cast(ch)); } previous_was_space = is_space; if (summary.size() >= 400) break; } while (!summary.empty() && summary.back() == ' ') summary.pop_back(); return summary.empty() ? fallback : summary; } std::string lowercase(std::string value) { std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return value; } std::string relevant_log_detail(const std::string& raw) { std::istringstream lines(raw); std::string line; std::string selected; while (std::getline(lines, line)) { const std::string lowered = lowercase(line); // Bluetoothd and the kernel normally log the useful transport error on a // line containing one of these terms. Ignore routine property changes so // the panel receives the cause, not another multi-line command transcript. const bool relevant = lowered.find("hidp") != std::string::npos || lowered.find("uhid") != std::string::npos || lowered.find("wiimote") != std::string::npos || lowered.find("profiles/input") != std::string::npos || lowered.find("host is down") != std::string::npos || lowered.find("permission denied") != std::string::npos || lowered.find("not supported") != std::string::npos || lowered.find("failed") != std::string::npos || lowered.find("error") != std::string::npos; if (!relevant) continue; const std::string cleaned = command_error_summary(line, ""); if (!cleaned.empty()) selected = cleaned; } return selected; } std::string hardware_setup_failure() { std::ifstream input_config("/etc/bluetooth/input.conf"); std::ostringstream input_config_text; input_config_text << input_config.rdbuf(); std::string normalized = lowercase(input_config_text.str()); normalized.erase(std::remove_if(normalized.begin(), normalized.end(), [](unsigned char ch) { return std::isspace(ch); }), normalized.end()); // These checks describe the machine state after a failed HID creation. They // match the transport path installed by install_server.sh and turn a missed // installer/restart step into an explicit panel error instead of speculation. if (normalized.find("userspacehid=false") == std::string::npos) { return "BlueZ UserspaceHID=false is not active in /etc/bluetooth/input.conf"; } if (access("/sys/module/hidp", F_OK) != 0) { return "the kernel HIDP Bluetooth transport is not loaded"; } if (access("/sys/module/hid_wiimote", F_OK) != 0) { return "the kernel hid-wiimote driver is not loaded"; } return ""; } std::string connection_log_detail(uint64_t attempt_started_epoch_seconds) { // Query only the tiny time window belonging to this connection attempt. On // Fedora the service owner normally has journal access through its ordinary // account groups; if it does not, the management-event diagnosis below still // remains available and no privileged helper is introduced. const uint64_t lookback_seconds = (kConnectionEvidenceWindowMs / 1000) + 1; const std::string since = "@" + std::to_string( attempt_started_epoch_seconds > lookback_seconds ? attempt_started_epoch_seconds - lookback_seconds : 0); const CommandResult bluetooth_log = run_command({ "journalctl", "--quiet", "--no-pager", "--output=cat", "--since", since, "--unit", "bluetooth.service"}); if (bluetooth_log.exit_code == 0) { if (const std::string detail = relevant_log_detail(bluetooth_log.output); !detail.empty()) { return detail; } } const CommandResult kernel_log = run_command({ "journalctl", "--quiet", "--no-pager", "--output=cat", "--since", since, "--dmesg"}); return kernel_log.exit_code == 0 ? relevant_log_detail(kernel_log.output) : ""; } std::string disconnect_reason_detail(uint8_t reason) { switch (reason) { case 0x01: return "Bluetooth connection timed out"; case 0x02: return "the local Bluetooth stack closed the connection"; case 0x03: return "the board closed the connection"; case 0x04: return "Bluetooth authentication failed"; case 0x05: return "the local host suspended the connection"; default: return "the Bluetooth connection closed for an unspecified reason"; } } std::string connect_status_detail(uint8_t status) { // These are the standard Bluetooth controller status values returned by the // kernel management API. Naming the common failures makes an unanswered wake // distinguishable from a bad stored key or a transport timeout. switch (status) { case 0x04: return "the board did not answer the Bluetooth page"; case 0x05: return "Bluetooth authentication failed"; case 0x06: return "the stored Bluetooth PIN or link key is missing"; case 0x08: return "the Bluetooth connection timed out"; case 0x10: return "the board did not accept the connection in time"; default: { std::ostringstream detail; detail << "Bluetooth controller rejected the connection (status 0x" << std::hex << std::setw(2) << std::setfill('0') << static_cast(status) << ")"; return detail.str(); } } } bool command_succeeded(const CommandResult& result) { if (result.exit_code != 0) return false; return result.output.find("Failed") == std::string::npos && result.output.find("not available") == std::string::npos; } bool bluetooth_property_is_yes(const std::string& output, const std::string& property) { return output.find(property + ": yes") != std::string::npos; } BluetoothDeviceState inspect_bluetooth_device(const std::string& address) { const CommandResult info = run_command({ "bluetoothctl", "--timeout", "3", "info", address}); BluetoothDeviceState state; state.available = command_succeeded(info) && info.output.find("Device " + address) != std::string::npos; if (!state.available) { state.error = command_error_summary(info.output, "BlueZ did not return device information"); return state; } state.paired = bluetooth_property_is_yes(info.output, "Paired"); state.trusted = bluetooth_property_is_yes(info.output, "Trusted"); state.connected = bluetooth_property_is_yes(info.output, "Connected"); state.wake_allowed = bluetooth_property_is_yes(info.output, "WakeAllowed"); return state; } std::optional find_default_controller() { const CommandResult controller = run_command({"bluetoothctl", "show"}); std::istringstream lines(controller.output); std::string line; while (std::getline(lines, line)) { const std::size_t controller_prefix = line.find("Controller "); if (controller_prefix == std::string::npos || line.size() < controller_prefix + 28) continue; if (auto address = parse_address(line.substr(controller_prefix + 11, 17))) return address; } return std::nullopt; } std::string prepare_known_device(const BluetoothAddress& address) { const CommandResult trust = run_command({ "bluetoothctl", "--timeout", "8", "trust", address.display}); if (!command_succeeded(trust)) { return "trust failed: " + command_error_summary(trust.output, "BlueZ returned no detail"); } // WakeAllowed tells current BlueZ releases to accept the board's incoming HID // connection after its front power button is pressed. Older releases may not // implement the command; trust + the stored link key still remain effective. const CommandResult wake = run_command({ "bluetoothctl", "--timeout", "8", "wake", address.display, "on"}); if (!command_succeeded(wake)) { return "wake policy failed: " + command_error_summary(wake.output, "BlueZ returned no detail"); } return ""; } void connection_monitor_loop(PairingSharedState* shared) { uint64_t last_reconnect_attempt_at = 0; std::string reconnect_stage = "waiting"; std::string reconnect_detail = "No Bluetooth response from the board yet."; while (running.load()) { std::optional address; std::string input_state; std::string input_error; { std::lock_guard lock(shared->mutex); address = shared->commissioned_address; input_state = shared->input_state; input_error = shared->input_error; } if (!address.has_value()) { std::this_thread::sleep_for(std::chrono::milliseconds(250)); continue; } BluetoothDeviceState bluetooth = inspect_bluetooth_device(*address); if (!bluetooth.connected && input_state != "ready" && reconnect_stage == "input-ready") { reconnect_stage = "waiting"; reconnect_detail = "Board disconnected. Waiting for the next front-button wake."; } const uint64_t now = monotonic_ms(); if (bluetooth.available && !bluetooth.connected && now - last_reconnect_attempt_at >= kReconnectAttemptIntervalMs) { // A bonded Balance Board normally pages its remembered host after the // front button is pressed, but adapters and BlueZ versions do not handle // that incoming reconnect consistently. Page the known address while the // server is waiting so the several-second blue-light wake window is caught // from either direction without requiring another red-Sync operation. last_reconnect_attempt_at = now; const uint64_t attempt_started_at = monotonic_ms(); const uint64_t attempt_started_epoch_seconds = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); const CommandResult reconnect = run_command({ "bluetoothctl", "--timeout", "4", "connect", *address}); // Query again because Connect() may have changed several properties before // returning. The diagnostics should describe the resulting state, not the // stale snapshot taken immediately before the attempt. bluetooth = inspect_bluetooth_device(*address); uint64_t radio_connected_at = 0; uint64_t radio_disconnected_at = 0; uint64_t connect_failed_at = 0; uint8_t disconnect_reason = 0; uint8_t connect_status = 0; { std::lock_guard lock(shared->mutex); radio_connected_at = shared->last_radio_connected_at; radio_disconnected_at = shared->last_radio_disconnected_at; connect_failed_at = shared->last_connect_failed_at; disconnect_reason = shared->last_disconnect_reason; connect_status = shared->last_connect_status; input_state = shared->input_state; input_error = shared->input_error; } const bool radio_was_reached = radio_connected_at >= attempt_started_at || reconnect.output.find("Connected: yes") != std::string::npos; // The board may initiate its own ACL connection while the monitor is // still running the preceding BlueZ property query. Include that short // pre-attempt window so an actual wake event cannot be lost merely due to // thread timing. const uint64_t evidence_window_started_at = attempt_started_at > kConnectionEvidenceWindowMs ? attempt_started_at - kConnectionEvidenceWindowMs : 0; const bool recent_radio_connection = radio_connected_at >= evidence_window_started_at; const bool radio_closed_during_attempt = radio_disconnected_at >= evidence_window_started_at; const bool controller_rejected_attempt = connect_failed_at >= attempt_started_at; if (input_state == "ready") { reconnect_stage = "input-ready"; reconnect_detail = "Balance Board input device is ready."; } else if (bluetooth.connected) { reconnect_stage = "radio-connected"; reconnect_detail = "Bluetooth link established; waiting for the Balance Board input device"; } else if (radio_was_reached || recent_radio_connection || reconnect.output.find("br-connection-create-socket") != std::string::npos) { reconnect_stage = "input-failed"; const std::string setup_error = hardware_setup_failure(); const std::string logged_error = setup_error.empty() ? connection_log_detail(attempt_started_epoch_seconds) : ""; if (!setup_error.empty()) { reconnect_detail = "Board reached the server, but HID input setup failed: " + setup_error + "."; } else if (!logged_error.empty()) { reconnect_detail = "Board reached the server, but HID input setup failed: " + logged_error; } else if (radio_closed_during_attempt) { reconnect_detail = "Board reached the server, but no input device was created before " + disconnect_reason_detail(disconnect_reason) + "."; } else { reconnect_detail = "Board reached the server, but BlueZ could not create its HID input connection: " + command_error_summary(reconnect.output, "no lower-level error was logged"); } } else if (controller_rejected_attempt && connect_status != 0x04 && connect_status != 0x08 && connect_status != 0x10) { // Page/connection timeouts are normal while the board sleeps. Preserve // the last meaningful hardware failure instead of replacing it every // three seconds with noise from an unanswered background page. reconnect_stage = "connection-failed"; reconnect_detail = connect_status_detail(connect_status) + "."; } else if (!command_succeeded(reconnect)) { const std::string command_error = command_error_summary(reconnect.output, ""); if (!command_error.empty() && command_error.find("not available") != std::string::npos) { reconnect_stage = "connection-failed"; reconnect_detail = command_error; } } } { std::lock_guard lock(shared->mutex); // Forget/recommission can complete while bluetoothctl is returning. Never // publish an old board's result after the selected address has changed. if (shared->commissioned_address != address) continue; input_state = shared->input_state; input_error = shared->input_error; } emit_diagnostics( *address, bluetooth, input_state, input_error, reconnect_stage, reconnect_detail); for (int elapsed = 0; elapsed < kBluetoothMonitorIntervalMs && running.load(); elapsed += 100) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } } void commissioning_loop(PairingSharedState* shared) { while (running.load()) { bool should_commission = false; { std::lock_guard lock(shared->mutex); should_commission = shared->commissioning && !shared->commissioned_address.has_value(); } if (!should_commission) { std::this_thread::sleep_for(std::chrono::milliseconds(250)); continue; } emit_status("commissioning"); // Commissioning must be listening before the board's short red-Sync window // begins. Keep one BlueZ discovery client alive continuously and consume its // own event stream. The previous bounded scan exited for twelve seconds at a // time and then queried a second client, making successful discovery depend // on when the physical button happened to be pressed. // BlueZ's command-line client exits after the SetDiscoveryFilter callback // unless non-interactive mode has a timeout. A one-day timeout keeps the // client alive for unattended commissioning; the worker normally stops it // itself as soon as the board appears and restarts it if the day expires. RunningCommand discovery = start_command({ "bluetoothctl", "--timeout", kDiscoveryTimeoutSeconds, "scan", "bredr"}); if (discovery.pid < 0) { emit_status("error", "", "could not start Bluetooth discovery: " + command_error_summary(discovery.transcript, "unknown process error")); std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs)); continue; } std::optional address; bool discovery_started = false; while (running.load() && !address.has_value()) { const bool discovery_running = collect_command_output(&discovery); const bool was_started = discovery_started; address = take_discovered_board(&discovery, &discovery_started); if (!was_started && discovery_started) { // This status clears any prior scanner error and tells the browser that // the server is genuinely listening for the board's red Sync button. emit_status("discovering"); } if (address.has_value()) break; if (!discovery_running) { const std::string detail = command_error_summary( discovery.transcript, "bluetoothctl exited unexpectedly"); emit_status("error", "", discovery_started ? "Bluetooth scanner stopped unexpectedly; retrying automatically: " + detail : "Bluetooth scanner exited before discovery started; retrying automatically: " + detail); break; } bool still_commissioning = false; { std::lock_guard lock(shared->mutex); still_commissioning = shared->commissioning && !shared->commissioned_address.has_value(); } if (!still_commissioning) break; std::this_thread::sleep_for(std::chrono::milliseconds(50)); } if (!address.has_value()) { stop_command(&discovery); if (running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs)); } continue; } const auto controller = find_default_controller(); if (!controller.has_value()) { stop_command(&discovery); emit_status("commissioning", address->display, "no powered Bluetooth controller is available for pairing"); std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs)); continue; } { std::lock_guard lock(shared->mutex); shared->active_target = address; // Red-Sync commissioning stores the host as the board's future reconnect // target. BlueZ's retired wiimote plugin therefore used the local adapter // address—not the board address—as the six raw PIN bytes. shared->active_pin = controller; } emit_status("pairing", address->display); // The management-socket listener answers the PIN request while this command // keeps BlueZ's normal device, SDP, bonding, and input-profile machinery in // charge of everything else. const CommandResult pair_result = run_command({ "bluetoothctl", "--timeout", "12", "--agent", "NoInputNoOutput", "pair", address->display}); // Keep the discovery owner alive through Pair(). BlueZ documents pairing by // address as requiring an active scan report, and the board may stop its // Sync window before a new discovery client could be established. stop_command(&discovery); { std::lock_guard lock(shared->mutex); shared->active_target.reset(); shared->active_pin.reset(); } if (!command_succeeded(pair_result)) { emit_status("commissioning", address->display, "pairing failed: " + command_error_summary( pair_result.output, "BlueZ returned an unknown pairing error")); std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs)); continue; } const std::string prepare_error = prepare_known_device(*address); { std::lock_guard lock(shared->mutex); shared->commissioned_address = address->display; shared->commissioning = false; } emit_json("\"type\":\"paired\",\"address\":\"" + json_escape(address->display) + "\""); // Use the same instrumented monitor for the first post-pair connection and // every later wake. A separate one-off connect here previously produced a // large opaque error before the monitor could observe its controller stages. emit_status("waiting", address->display, prepare_error); } } #pragma pack(push, 1) struct SockaddrHci { uint16_t family; uint16_t device; uint16_t channel; }; #pragma pack(pop) int open_management_socket() { const int fd = socket(AF_BLUETOOTH, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, kBluetoothProtocolHci); if (fd < 0) return -1; const SockaddrHci address{ static_cast(AF_BLUETOOTH), kHciDeviceNone, kHciChannelControl}; if (bind(fd, reinterpret_cast(&address), sizeof(address)) != 0) { close(fd); return -1; } return fd; } void write_u16_le(uint8_t* output, uint16_t value) { output[0] = static_cast(value & 0xff); output[1] = static_cast((value >> 8) & 0xff); } void answer_pin_request(int fd, uint16_t adapter_index, const BluetoothAddress& target, const BluetoothAddress& pin) { // Packet layout is a six-byte mgmt header followed by mgmt_addr_info, // pin_len, and the fixed sixteen-byte PIN buffer. Serializing by hand avoids // compiler padding and documents every privileged byte sent to the kernel. constexpr std::size_t header_size = 6; constexpr std::size_t payload_size = 7 + 1 + 16; std::array packet{}; write_u16_le(packet.data(), kMgmtPinCodeReplyCommand); write_u16_le(packet.data() + 2, adapter_index); write_u16_le(packet.data() + 4, payload_size); std::copy(target.wire.begin(), target.wire.end(), packet.begin() + header_size); packet[header_size + 6] = kBluetoothClassicAddressType; packet[header_size + 7] = 6; std::copy(pin.wire.begin(), pin.wire.end(), packet.begin() + header_size + 8); if (write(fd, packet.data(), packet.size()) != static_cast(packet.size())) { emit_status("error", target.display, "failed to answer the Wii pairing PIN request"); } } void process_management_events(int fd, PairingSharedState* shared) { if (fd < 0) return; std::array buffer{}; ssize_t count = 0; while ((count = read(fd, buffer.data(), buffer.size())) > 0) { if (count < 6) continue; const uint16_t event = static_cast(buffer[0] | (buffer[1] << 8)); const uint16_t adapter_index = static_cast(buffer[2] | (buffer[3] << 8)); const uint16_t payload_size = static_cast(buffer[4] | (buffer[5] << 8)); if (count < 6 + payload_size) continue; if (event == kMgmtPinCodeRequestEvent && payload_size >= 8) { std::optional target; std::optional pin; { std::lock_guard lock(shared->mutex); target = shared->active_target; pin = shared->active_pin; } if (target.has_value() && pin.has_value() && std::equal(target->wire.begin(), target->wire.end(), buffer.begin() + 6)) { answer_pin_request(fd, adapter_index, *target, *pin); } continue; } std::optional commissioned; { std::lock_guard lock(shared->mutex); if (shared->commissioned_address.has_value()) { commissioned = parse_address(*shared->commissioned_address); } } if (!commissioned.has_value() || payload_size < 7 || !std::equal(commissioned->wire.begin(), commissioned->wire.end(), buffer.begin() + 6)) { continue; } const uint64_t observed_at = monotonic_ms(); bool announce_link = false; { std::lock_guard lock(shared->mutex); if (event == kMgmtDeviceConnectedEvent && payload_size >= 13) { shared->last_radio_connected_at = observed_at; announce_link = true; } else if (event == kMgmtDeviceDisconnectedEvent && payload_size >= 8) { shared->last_radio_disconnected_at = observed_at; shared->last_disconnect_reason = buffer[13]; } else if (event == kMgmtConnectFailedEvent && payload_size >= 8) { shared->last_connect_failed_at = observed_at; shared->last_connect_status = buffer[13]; } } if (announce_link) { // This is the first trustworthy proof that the physical board answered // the adapter. Publish it immediately instead of guessing from a later // bluetoothctl timeout; Bluetooth does not identify which button woke it. emit_status("link-detected", commissioned->display); } } } InputProbe probe_board_input() { InputProbe probe; DIR* directory = opendir("/dev/input"); if (!directory) { probe.state = "input-directory-unavailable"; probe.error = std::strerror(errno); return probe; } while (dirent* entry = readdir(directory)) { if (std::strncmp(entry->d_name, "event", 5) != 0) continue; const std::string path = std::string("/dev/input/") + entry->d_name; // Read the sysfs name before opening evdev. The name remains readable when // device permissions are wrong, allowing diagnostics to distinguish “the // kernel never created it” from “the service user cannot open it.” std::ifstream name_file(std::string("/sys/class/input/") + entry->d_name + "/device/name"); std::string name; std::getline(name_file, name); if (name != kBoardInputName) continue; const int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); if (fd < 0) { probe.state = errno == EACCES ? "permission-denied" : "open-failed"; probe.error = std::strerror(errno); break; } close(fd); probe.path = path; probe.state = "detected"; break; } closedir(directory); return probe; } void read_initial_axis(int fd, unsigned int axis, int* destination) { input_absinfo info{}; if (ioctl(fd, EVIOCGABS(axis), &info) == 0) *destination = std::max(0, info.value); } int open_board_input(const std::string& path, BoardReadings* readings) { const int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); if (fd < 0) return -1; // hid-wiimote applies factory calibration before these values reach evdev. // Reading the current axes prevents the first JSON frame from showing three // zero corners merely because only one axis changed after the file was opened. read_initial_axis(fd, ABS_HAT0X, &readings->top_right); read_initial_axis(fd, ABS_HAT0Y, &readings->bottom_right); read_initial_axis(fd, ABS_HAT1X, &readings->top_left); read_initial_axis(fd, ABS_HAT1Y, &readings->bottom_left); return fd; } bool process_input_events(int fd, BoardReadings* readings, uint64_t* last_frame_at) { std::array events{}; const ssize_t bytes = read(fd, events.data(), sizeof(events)); if (bytes == 0) return false; if (bytes < 0) return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; const std::size_t count = static_cast(bytes) / sizeof(input_event); bool synchronized = false; for (std::size_t i = 0; i < count; ++i) { const input_event& event = events[i]; if (event.type == EV_ABS) { const int value = std::max(0, event.value); if (event.code == ABS_HAT0X) readings->top_right = value; if (event.code == ABS_HAT0Y) readings->bottom_right = value; if (event.code == ABS_HAT1X) readings->top_left = value; if (event.code == ABS_HAT1Y) readings->bottom_left = value; } else if (event.type == EV_SYN && event.code == SYN_REPORT) { synchronized = true; } } const uint64_t now = monotonic_ms(); if (synchronized && now - *last_frame_at >= kFrameIntervalMs) { // Reading capacity asks hid-wiimote for a fresh status report, so cache it // for several seconds instead of injecting a Bluetooth command per frame. emit_frame(*readings, read_board_battery()); *last_frame_at = now; } return true; } std::optional extract_command_value(const std::string& line, const std::string& key) { const std::string token = "\"" + key + "\""; const std::size_t key_at = line.find(token); if (key_at == std::string::npos) return std::nullopt; const std::size_t colon = line.find(':', key_at + token.size()); const std::size_t first_quote = line.find('"', colon + 1); const std::size_t second_quote = line.find('"', first_quote + 1); if (colon == std::string::npos || first_quote == std::string::npos || second_quote == std::string::npos) { return std::nullopt; } return line.substr(first_quote + 1, second_quote - first_quote - 1); } void handle_command(const std::string& line, PairingSharedState* shared) { (void)shared; const std::string command = extract_command_value(line, "command").value_or(""); // Pairing and reconnect are deliberately automatic. The only command the // Node supervisor needs is a clean shutdown signal; removing manual pair, // forget, and disconnect modes keeps the hardware flow single-purpose. if (command == "stop") { running.store(false); } } void stdin_loop(PairingSharedState* shared) { std::string line; while (running.load() && std::getline(std::cin, line)) handle_command(line, shared); } void simulated_loop() { BluetoothDeviceState simulated_bluetooth; simulated_bluetooth.available = true; simulated_bluetooth.paired = true; simulated_bluetooth.trusted = true; simulated_bluetooth.connected = true; simulated_bluetooth.wake_allowed = true; // Exercise the same status contract as real hardware so development UI // builds cannot silently break merely because CI lacks a physical board. emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "input-ready", ""); emit_status("waiting", "SIMULATED"); const std::array sequence{{ {0, 0, 0, 0}, {0, 0, 0, 0}, {40, 30, 35, 25}, {95, 82, 90, 76}, {103, 97, 101, 99}, {104, 98, 101, 99}, {103, 98, 102, 99}, {103, 98, 101, 100}, {104, 98, 101, 99}, {75, 65, 70, 60}, {20, 12, 15, 10}, {0, 0, 0, 0}, }}; while (running.load()) { emit_status("connected", "SIMULATED"); for (const auto& readings : sequence) { for (int frame = 0; frame < 12 && running.load(); ++frame) { emit_frame(readings, 82); std::this_thread::sleep_for(std::chrono::milliseconds(kFrameIntervalMs)); } } emit_status("waiting", "SIMULATED"); for (int pause = 0; pause < 30 && running.load(); ++pause) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } } } // namespace int main() { std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); const std::string simulation = std::getenv("BALANCE_BOARD_SIMULATE") ? std::getenv("BALANCE_BOARD_SIMULATE") : ""; if (simulation == "1" || simulation == "true" || simulation == "cycle") { simulated_loop(); return 0; } PairingSharedState pairing; const std::string configured_address = std::getenv("BALANCE_BOARD_ADDRESS") ? std::getenv("BALANCE_BOARD_ADDRESS") : ""; if (auto parsed = parse_address(configured_address)) { pairing.commissioned_address = parsed->display; // A sleeping commissioned board is expected at server startup. Trust and // wake policy are idempotent, but do not page the sleeping device or delay // startup; its front power button will initiate the actual HID connection. prepare_known_device(*parsed); } else { pairing.commissioning = true; } const int management_fd = open_management_socket(); if (management_fd < 0) { pairing.pairing_available = false; if (!pairing.commissioned_address.has_value()) { // An already bonded board can reconnect and stream through evdev without // the management socket. Missing capability is fatal only when the bridge // actually needs to create a new bond. pairing.commissioning = false; emit_status("error", configured_address, "Bluetooth management socket unavailable; install the worker capability"); } } std::thread commission_thread(commissioning_loop, &pairing); std::thread connection_monitor_thread(connection_monitor_loop, &pairing); std::thread input_thread(stdin_loop, &pairing); if (management_fd >= 0 || pairing.commissioned_address.has_value()) { emit_status(pairing.commissioning ? "commissioning" : "waiting", configured_address); } int input_fd = -1; BoardReadings readings; uint64_t last_device_scan_at = 0; uint64_t last_frame_at = 0; while (running.load()) { process_management_events(management_fd, &pairing); if (input_fd < 0 && monotonic_ms() - last_device_scan_at >= kDeviceScanIntervalMs) { last_device_scan_at = monotonic_ms(); const InputProbe probe = probe_board_input(); { std::lock_guard lock(pairing.mutex); pairing.input_state = probe.state; pairing.input_error = probe.error; } if (probe.path.has_value()) { input_fd = open_board_input(*probe.path, &readings); if (input_fd >= 0) { std::string address; { std::lock_guard lock(pairing.mutex); address = pairing.commissioned_address.value_or(""); pairing.input_state = "ready"; pairing.input_error.clear(); } emit_status("connected", address); } else { std::lock_guard lock(pairing.mutex); pairing.input_state = errno == EACCES ? "permission-denied" : "open-failed"; pairing.input_error = std::strerror(errno); } } } if (input_fd >= 0 && !process_input_events(input_fd, &readings, &last_frame_at)) { close(input_fd); input_fd = -1; std::string address; { std::lock_guard lock(pairing.mutex); address = pairing.commissioned_address.value_or(""); pairing.input_state = "not-detected"; pairing.input_error = "Balance Board input device closed"; } emit_status("waiting", address); } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } if (input_fd >= 0) close(input_fd); if (management_fd >= 0) close(management_fd); if (input_thread.joinable()) input_thread.detach(); if (commission_thread.joinable()) commission_thread.join(); if (connection_monitor_thread.joinable()) connection_monitor_thread.join(); return 0; }