mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
1260 lines
49 KiB
C++
1260 lines
49 KiB
C++
// 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 <linux/input.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <dirent.h>
|
|
#include <fcntl.h>
|
|
#include <fstream>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <poll.h>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <sys/ioctl.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <thread>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
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<bool> 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<uint8_t, 6> wire{};
|
|
};
|
|
|
|
struct PairingSharedState {
|
|
std::mutex mutex;
|
|
std::optional<BluetoothAddress> active_target;
|
|
std::optional<BluetoothAddress> active_pin;
|
|
std::optional<std::string> 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<std::string> 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<milliseconds>(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<int>(ch) << std::dec;
|
|
} else {
|
|
out << static_cast<char>(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<std::mutex> 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<int> 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<int> read_board_battery() {
|
|
static uint64_t last_read_at = 0;
|
|
static std::optional<int> 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<BluetoothAddress> parse_address(const std::string& raw) {
|
|
std::array<unsigned int, 6> 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<uint8_t>(bytes[address.wire.size() - 1 - i]);
|
|
}
|
|
return address;
|
|
}
|
|
|
|
CommandResult run_command(const std::vector<std::string>& 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<char*> argv;
|
|
argv.reserve(args.size() + 1);
|
|
for (const auto& arg : args) argv.push_back(const_cast<char*>(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<char, 1024> buffer{};
|
|
ssize_t count = 0;
|
|
while ((count = read(pipe_fds[0], buffer.data(), buffer.size())) > 0) {
|
|
result.output.append(buffer.data(), static_cast<std::size_t>(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<std::string>& 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<char*> argv;
|
|
argv.reserve(args.size() + 1);
|
|
for (const auto& arg : args) argv.push_back(const_cast<char*>(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<char, 1024> buffer{};
|
|
ssize_t count = 0;
|
|
while ((count = read(command->output_fd, buffer.data(), buffer.size())) > 0) {
|
|
const std::string chunk(buffer.data(), static_cast<std::size_t>(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<BluetoothAddress> 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<std::size_t>(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<char>(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<char>(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<int>(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<BluetoothAddress> 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<std::string> address;
|
|
std::string input_state;
|
|
std::string input_error;
|
|
{
|
|
std::lock_guard<std::mutex> 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::seconds>(
|
|
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<BluetoothAddress> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<uint16_t>(AF_BLUETOOTH), kHciDeviceNone, kHciChannelControl};
|
|
if (bind(fd, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) != 0) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
return fd;
|
|
}
|
|
|
|
void write_u16_le(uint8_t* output, uint16_t value) {
|
|
output[0] = static_cast<uint8_t>(value & 0xff);
|
|
output[1] = static_cast<uint8_t>((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<uint8_t, header_size + payload_size> 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<ssize_t>(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<uint8_t, 1024> buffer{};
|
|
ssize_t count = 0;
|
|
while ((count = read(fd, buffer.data(), buffer.size())) > 0) {
|
|
if (count < 6) continue;
|
|
const uint16_t event = static_cast<uint16_t>(buffer[0] | (buffer[1] << 8));
|
|
const uint16_t adapter_index = static_cast<uint16_t>(buffer[2] | (buffer[3] << 8));
|
|
const uint16_t payload_size = static_cast<uint16_t>(buffer[4] | (buffer[5] << 8));
|
|
if (count < 6 + payload_size) continue;
|
|
|
|
if (event == kMgmtPinCodeRequestEvent && payload_size >= 8) {
|
|
std::optional<BluetoothAddress> target;
|
|
std::optional<BluetoothAddress> pin;
|
|
{
|
|
std::lock_guard<std::mutex> 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<BluetoothAddress> commissioned;
|
|
{
|
|
std::lock_guard<std::mutex> 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<std::mutex> 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<input_event, 64> 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<std::size_t>(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<std::string> 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<BoardReadings, 12> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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;
|
|
}
|