mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
slop
This commit is contained in:
@@ -16,9 +16,6 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||
BALANCE_BOARD_UDEV_RULE="/etc/udev/rules.d/99-multirover-balance-board.rules"
|
||||
BALANCE_BOARD_MODULES_LOAD="/etc/modules-load.d/multirover-balance-board.conf"
|
||||
BLUEZ_INPUT_CONFIG="/etc/bluetooth/input.conf"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -141,7 +138,8 @@ dnf install -y \
|
||||
libfreenect-devel \
|
||||
libusb1-devel \
|
||||
bluez \
|
||||
crudini \
|
||||
wiiuse \
|
||||
wiiuse-devel \
|
||||
libcap >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
@@ -159,17 +157,6 @@ EOF
|
||||
chmod 644 "$KINECT_UDEV_RULE"
|
||||
udevadm control --reload-rules
|
||||
|
||||
echo " Installing Balance Board input rule -> $BALANCE_BOARD_UDEV_RULE"
|
||||
cat > "$BALANCE_BOARD_UDEV_RULE" <<EOF
|
||||
# hid-wiimote creates a calibrated evdev device specifically for the Balance
|
||||
# Board extension. Give only the rover service owner access to that exact input
|
||||
# name; the Node process and unrelated local users do not receive broad access
|
||||
# to keyboards, mice, or every device in the input group.
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="Nintendo Wii Remote Balance Board", OWNER="$TARGET_USER", MODE="0660", TAG+="uaccess"
|
||||
EOF
|
||||
chmod 644 "$BALANCE_BOARD_UDEV_RULE"
|
||||
udevadm control --reload-rules
|
||||
|
||||
if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then
|
||||
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
||||
exit 1
|
||||
@@ -204,43 +191,10 @@ if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
fi
|
||||
|
||||
echo " Configuring BlueZ for the Wii Balance Board"
|
||||
for balance_board_module in hidp hid-wiimote; do
|
||||
if ! modinfo "$balance_board_module" >/dev/null 2>&1; then
|
||||
echo "The running kernel does not provide $balance_board_module; install a Fedora kernel with that module." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
install -d -m 0755 /etc/bluetooth /etc/modules-load.d
|
||||
touch "$BLUEZ_INPUT_CONFIG"
|
||||
chmod 0644 "$BLUEZ_INPUT_CONFIG"
|
||||
# Balance Board support is a server hardware prerequisite, just like the native
|
||||
# worker and udev rule above. Install it every time instead of coupling machine
|
||||
# setup to an application setting in config.yaml; that keeps the installer
|
||||
# deterministic and lets the feature flag remain a simple runtime UI switch.
|
||||
# crudini changes only the two Wii compatibility keys and preserves every other
|
||||
# Bluetooth input option already configured by the operator.
|
||||
crudini --set "$BLUEZ_INPUT_CONFIG" General UserspaceHID false
|
||||
crudini --set "$BLUEZ_INPUT_CONFIG" General ClassicBondedOnly false
|
||||
|
||||
# UserspaceHID=false hands the Bluetooth HID channels to the kernel's `hidp`
|
||||
# transport, which then creates a HID device for `hid-wiimote` to calibrate.
|
||||
# They are separate modules, so load and persist both explicitly; loading only
|
||||
# hid-wiimote leaves BlueZ with no kernel transport to create the input socket.
|
||||
cat > "$BALANCE_BOARD_MODULES_LOAD" <<'EOF'
|
||||
# MultiRoombaRover Wii Balance Board support.
|
||||
hidp
|
||||
hid-wiimote
|
||||
EOF
|
||||
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
|
||||
modprobe hidp
|
||||
modprobe hid-wiimote
|
||||
|
||||
# BlueZ reads input.conf only at daemon startup. Restart it before the rover
|
||||
# service so the required HID mode is active immediately without a reboot.
|
||||
# Bluetoothd is still responsible for discovery and the one-time bond. Wiiuse
|
||||
# owns the live HID channels, so no kernel HID modules, input.conf edits, or
|
||||
# broad /dev/input permission rule are involved in the runtime data path.
|
||||
systemctl enable --now bluetooth.service
|
||||
systemctl restart bluetooth.service
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
@@ -42,8 +42,9 @@ function roundedWeight(value) {
|
||||
}
|
||||
|
||||
function rawWeightKg(corners = {}) {
|
||||
// hid-wiimote reports each calibrated load cell in centi-kilograms. The UI
|
||||
// only needs total scale weight, so sum and convert at this single boundary.
|
||||
// The native wiiuse bridge reports each calibrated load cell in
|
||||
// centi-kilograms. The UI only needs total scale weight, so sum and convert
|
||||
// at this single boundary.
|
||||
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
|
||||
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
|
||||
}
|
||||
@@ -107,16 +108,6 @@ function processFrame(message = {}) {
|
||||
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||
}
|
||||
|
||||
function diagnosticDetail(message = {}) {
|
||||
if (message.inputError) return `Input device: ${message.inputError}`;
|
||||
if (message.bluetoothError) return `Bluetooth: ${message.bluetoothError}`;
|
||||
// The native worker now reduces controller events and the matching journal
|
||||
// entry to one concrete stage-specific sentence. Preserve that evidence
|
||||
// verbatim instead of replacing it with generic retry wording here.
|
||||
if (message.reconnectDetail) return String(message.reconnectDetail);
|
||||
return 'Press the front power button. The server will keep trying to connect.';
|
||||
}
|
||||
|
||||
function handleWorkerMessage(message = {}) {
|
||||
if (message.type === 'frame') {
|
||||
processFrame(message);
|
||||
@@ -134,24 +125,6 @@ function handleWorkerMessage(message = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'diagnostics') {
|
||||
const bluetoothConnected = Boolean(message.bluetooth?.connected);
|
||||
const inputReady = message.inputState === 'ready';
|
||||
const reconnectStage = String(message.reconnectStage || 'waiting');
|
||||
if (bluetoothConnected && inputReady) {
|
||||
updateStatus('connected', 'Connected. Waiting for weight readings.');
|
||||
} else if (bluetoothConnected) {
|
||||
updateStatus('connecting', message.inputError || 'Bluetooth connected. Waiting for the input device.');
|
||||
} else if (reconnectStage === 'input-failed' || reconnectStage === 'connection-failed') {
|
||||
connected = false;
|
||||
updateStatus('connection-failed', diagnosticDetail(message));
|
||||
} else if (store.address) {
|
||||
connected = false;
|
||||
updateStatus('waiting', diagnosticDetail(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'status') return;
|
||||
const workerState = String(message.state || 'unknown');
|
||||
if (workerState === 'commissioning') {
|
||||
@@ -169,7 +142,11 @@ function handleWorkerMessage(message = {}) {
|
||||
connected = false;
|
||||
// A controller connection proves the physical board answered, but Bluetooth
|
||||
// does not identify which physical button woke it. Keep the message exact.
|
||||
updateStatus('connecting', 'Board responded. Bluetooth connected; creating the input device.');
|
||||
updateStatus('connecting', 'Board responded. Reading its sensor calibration.');
|
||||
} else if (workerState === 'connection-failed') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
|
||||
} else if (workerState === 'waiting') {
|
||||
connected = false;
|
||||
latestFrame = null;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
CXX ?= g++
|
||||
|
||||
# The bridge deliberately uses the stable Linux input and Bluetooth management
|
||||
# ABIs directly. Keeping it free of third-party libraries makes installation on
|
||||
# the Fedora server predictable and avoids binding the rover service to an old
|
||||
# Wii-specific userspace package.
|
||||
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||
# the original Balance Board rejects that negotiation before an input device is
|
||||
# created.
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
LDLIBS += -pthread
|
||||
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||
|
||||
TARGET := balance_board_worker
|
||||
SRC := balance_board_worker.cpp
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
// 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.
|
||||
// Pair one original Nintendo RVL-WBC-01 through modern BlueZ, then connect to
|
||||
// its Bluetooth HID channels directly with wiiuse and expose calibrated sensor
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// After commissioning, wiiuse opens the HID control and interrupt L2CAP sockets
|
||||
// itself. This is important rather than stylistic: BlueZ's input profile applies
|
||||
// medium security to bonded HID devices, and an original Balance Board rejects
|
||||
// that request with EACCES. Direct low-security HID sockets match the protocol
|
||||
// used by the board and avoid the failing profile entirely.
|
||||
//
|
||||
// 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.
|
||||
// sensor access uses ordinary Bluetooth L2CAP sockets through wiiuse.
|
||||
|
||||
#include <linux/input.h>
|
||||
#include <wiiuse.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
@@ -30,9 +35,8 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <dirent.h>
|
||||
#include <cmath>
|
||||
#include <fcntl.h>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
@@ -40,7 +44,6 @@
|
||||
#include <poll.h>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
@@ -51,24 +54,17 @@
|
||||
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;
|
||||
constexpr int kDirectReconnectDelayMs = 1000;
|
||||
constexpr int kHandshakeWarningMs = 10000;
|
||||
|
||||
std::atomic<bool> running{true};
|
||||
std::mutex output_mutex;
|
||||
@@ -85,18 +81,7 @@ struct PairingSharedState {
|
||||
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 {
|
||||
@@ -111,21 +96,6 @@ struct CommandResult {
|
||||
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;
|
||||
@@ -187,60 +157,6 @@ void emit_frame(const BoardReadings& readings, std::optional<int> battery_percen
|
||||
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);
|
||||
}
|
||||
@@ -361,7 +277,7 @@ bool collect_command_output(RunningCommand* command) {
|
||||
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
|
||||
// updates. Retain only the most recent output 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) {
|
||||
@@ -439,7 +355,7 @@ std::string command_error_summary(const std::string& raw, const std::string& fal
|
||||
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.
|
||||
// never exposes fragments such as `[[0;93mCHG[0m]` as a pairing error.
|
||||
if (ch == 0x1b) {
|
||||
ansi_state = 1;
|
||||
continue;
|
||||
@@ -465,145 +381,13 @@ std::string command_error_summary(const std::string& raw, const std::string& fal
|
||||
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;
|
||||
// bluetoothctl has returned exit code zero for some D-Bus failures across
|
||||
// releases. Check its stable failure text as well so commissioning never
|
||||
// stores an address when BlueZ did not actually finish the bond.
|
||||
return result.exit_code == 0 &&
|
||||
result.output.find("Failed") == std::string::npos &&
|
||||
result.output.find("not available") == std::string::npos;
|
||||
}
|
||||
|
||||
std::optional<BluetoothAddress> find_default_controller() {
|
||||
@@ -618,158 +402,6 @@ std::optional<BluetoothAddress> find_default_controller() {
|
||||
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;
|
||||
@@ -884,17 +516,16 @@ void commissioning_loop(PairingSharedState* shared) {
|
||||
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);
|
||||
// The direct connection loop notices this address immediately. Pairing and
|
||||
// sensor transport stay separate so the red Sync button is needed only for
|
||||
// commissioning; later front-button wakes are caught automatically.
|
||||
emit_status("waiting", address->display);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,127 +602,9 @@ void process_management_events(int fd, PairingSharedState* shared) {
|
||||
}
|
||||
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);
|
||||
@@ -1121,16 +634,173 @@ void stdin_loop(PairingSharedState* shared) {
|
||||
while (running.load() && std::getline(std::cin, line)) handle_command(line, shared);
|
||||
}
|
||||
|
||||
bool sleeping_connection_error(int error_number) {
|
||||
// A powered-off board normally answers an outgoing page with one of these
|
||||
// transport errors. They mean "keep waiting for the front button," not that
|
||||
// installation is broken. Every other errno is surfaced verbatim in the UI.
|
||||
return error_number == EHOSTDOWN || error_number == EHOSTUNREACH ||
|
||||
error_number == ETIMEDOUT || error_number == ECONNREFUSED;
|
||||
}
|
||||
|
||||
void close_wiiuse_sockets(wiimote_t* board) {
|
||||
if (!board) return;
|
||||
|
||||
// Wiiuse 0.15.5 can leave a socket allocated when the second L2CAP connect
|
||||
// fails. Close both descriptors explicitly so an unattended server can page
|
||||
// a sleeping board forever without leaking one descriptor per attempt.
|
||||
const int output_socket = board->out_sock;
|
||||
const int input_socket = board->in_sock;
|
||||
if (output_socket >= 0) close(output_socket);
|
||||
if (input_socket >= 0 && input_socket != output_socket) close(input_socket);
|
||||
board->out_sock = -1;
|
||||
board->in_sock = -1;
|
||||
}
|
||||
|
||||
void prepare_wiiuse_address(wiimote_t* board, const std::string& address) {
|
||||
// Supplying the saved address and DEV_FOUND flag tells wiiuse to skip its own
|
||||
// discovery pass. That makes every reconnect a direct page of the one board
|
||||
// already commissioned to this server.
|
||||
str2ba(address.c_str(), &board->bdaddr);
|
||||
std::snprintf(board->bdaddr_str, sizeof(board->bdaddr_str), "%s", address.c_str());
|
||||
board->state |= WIIMOTE_STATE_DEV_FOUND;
|
||||
}
|
||||
|
||||
wiimote_t** initialize_wiiuse() {
|
||||
// wiiuse_init prints one version banner directly to stdout rather than using
|
||||
// its logger. Suppress only that initialization call before any worker
|
||||
// threads exist, then restore stdout for the JSON protocol.
|
||||
std::fflush(stdout);
|
||||
const int saved_stdout = dup(STDOUT_FILENO);
|
||||
const int null_output = open("/dev/null", O_WRONLY | O_CLOEXEC);
|
||||
if (saved_stdout >= 0 && null_output >= 0) dup2(null_output, STDOUT_FILENO);
|
||||
wiimote_t** boards = wiiuse_init(1);
|
||||
std::fflush(stdout);
|
||||
if (saved_stdout >= 0) {
|
||||
dup2(saved_stdout, STDOUT_FILENO);
|
||||
close(saved_stdout);
|
||||
}
|
||||
if (null_output >= 0) close(null_output);
|
||||
|
||||
// Wiiuse resets its log targets inside wiiuse_init, so configure them after
|
||||
// initialization. Retain actual library errors on stderr, but discard normal
|
||||
// connect/disconnect chatter that repeats for every page while the board is
|
||||
// asleep; structured JSON already describes that state for the panel.
|
||||
wiiuse_set_output(LOGLEVEL_ERROR, stderr);
|
||||
wiiuse_set_output(LOGLEVEL_WARNING, nullptr);
|
||||
wiiuse_set_output(LOGLEVEL_INFO, nullptr);
|
||||
wiiuse_set_output(LOGLEVEL_DEBUG, nullptr);
|
||||
return boards;
|
||||
}
|
||||
|
||||
void direct_connection_loop(PairingSharedState* shared, wiimote_t** boards) {
|
||||
wiimote_t* board = boards ? boards[0] : nullptr;
|
||||
if (!board) {
|
||||
emit_status("error", "", "wiiuse could not initialize the Balance Board connection");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string prepared_address;
|
||||
while (running.load()) {
|
||||
std::optional<std::string> address;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||
address = shared->commissioned_address;
|
||||
}
|
||||
if (!address.has_value()) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prepared_address != *address) {
|
||||
close_wiiuse_sockets(board);
|
||||
wiiuse_disconnected(board);
|
||||
prepare_wiiuse_address(board, *address);
|
||||
prepared_address = *address;
|
||||
}
|
||||
|
||||
// wiiuse_connect opens PSM 0x11 and 0x13 directly. It returns zero when the
|
||||
// sleeping board does not answer; errno retains the actual socket failure,
|
||||
// which lets the panel distinguish normal sleep from a real permission,
|
||||
// adapter, or protocol error.
|
||||
errno = 0;
|
||||
const int connected_count = wiiuse_connect(boards, 1);
|
||||
const int connection_error = errno;
|
||||
if (connected_count != 1 || !WIIMOTE_IS_CONNECTED(board)) {
|
||||
close_wiiuse_sockets(board);
|
||||
wiiuse_disconnected(board);
|
||||
prepare_wiiuse_address(board, *address);
|
||||
if (connection_error != 0 && !sleeping_connection_error(connection_error)) {
|
||||
emit_status("connection-failed", *address,
|
||||
"Direct Balance Board connection failed: " +
|
||||
std::string(std::strerror(connection_error)));
|
||||
} else {
|
||||
emit_status("waiting", *address, "No Bluetooth response from the board yet.");
|
||||
}
|
||||
for (int elapsed = 0; elapsed < kDirectReconnectDelayMs && running.load(); elapsed += 100) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
emit_status("link-detected", *address);
|
||||
bool board_ready = false;
|
||||
bool handshake_warning_sent = false;
|
||||
uint64_t connected_at = monotonic_ms();
|
||||
uint64_t last_frame_at = 0;
|
||||
while (running.load() && WIIMOTE_IS_CONNECTED(board)) {
|
||||
wiiuse_poll(boards, 1);
|
||||
if (board->event == WIIUSE_DISCONNECT ||
|
||||
board->event == WIIUSE_UNEXPECTED_DISCONNECT) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (board->exp.type == EXP_WII_BOARD) {
|
||||
if (!board_ready) {
|
||||
board_ready = true;
|
||||
emit_status("connected", *address);
|
||||
}
|
||||
const uint64_t now = monotonic_ms();
|
||||
if (now - last_frame_at >= kFrameIntervalMs) {
|
||||
// Wiiuse interpolates each sensor using the board's factory 0/17/34kg
|
||||
// calibration values. Preserve the existing centi-kilogram wire unit
|
||||
// so Node's tare and total-weight calculation remain straightforward.
|
||||
const wii_board_t& weights = board->exp.wb;
|
||||
BoardReadings readings{
|
||||
static_cast<int>(std::lround(std::max(0.0F, weights.tr) * 100.0F)),
|
||||
static_cast<int>(std::lround(std::max(0.0F, weights.br) * 100.0F)),
|
||||
static_cast<int>(std::lround(std::max(0.0F, weights.tl) * 100.0F)),
|
||||
static_cast<int>(std::lround(std::max(0.0F, weights.bl) * 100.0F)),
|
||||
};
|
||||
const int battery = static_cast<int>(std::lround(
|
||||
std::clamp(board->battery_level, 0.0F, 1.0F) * 100.0F));
|
||||
emit_frame(readings, battery);
|
||||
last_frame_at = now;
|
||||
}
|
||||
} else if (!handshake_warning_sent &&
|
||||
monotonic_ms() - connected_at >= kHandshakeWarningMs) {
|
||||
// A live ACL connection without the permanent Balance Board expansion
|
||||
// means sensor calibration never completed. Report that precise stage
|
||||
// while continuing to poll, since a delayed response can still recover.
|
||||
handshake_warning_sent = true;
|
||||
emit_status("connection-failed", *address,
|
||||
"Bluetooth connected, but the board did not finish sensor calibration.");
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
|
||||
close_wiiuse_sockets(board);
|
||||
wiiuse_disconnected(board);
|
||||
prepare_wiiuse_address(board, *address);
|
||||
emit_status("waiting", *address, "Board disconnected. Press the front power button.");
|
||||
}
|
||||
|
||||
close_wiiuse_sockets(board);
|
||||
wiiuse_disconnected(board);
|
||||
}
|
||||
|
||||
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},
|
||||
@@ -1166,26 +836,23 @@ int main() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
wiimote_t** boards = initialize_wiiuse();
|
||||
|
||||
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.
|
||||
// An already commissioned board can use ordinary wiiuse L2CAP sockets
|
||||
// without the management socket. Missing capability is fatal only when
|
||||
// the bridge needs to answer the special six-byte pairing PIN.
|
||||
pairing.commissioning = false;
|
||||
emit_status("error", configured_address,
|
||||
"Bluetooth management socket unavailable; install the worker capability");
|
||||
@@ -1193,67 +860,21 @@ int main() {
|
||||
}
|
||||
|
||||
std::thread commission_thread(commissioning_loop, &pairing);
|
||||
std::thread connection_monitor_thread(connection_monitor_loop, &pairing);
|
||||
std::thread connection_thread(direct_connection_loop, &pairing, boards);
|
||||
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();
|
||||
if (connection_thread.joinable()) connection_thread.join();
|
||||
if (boards) wiiuse_cleanup(boards, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user