mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -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"
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
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
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
echo "This installer must be run with sudo/root." >&2
|
||||||
@@ -141,7 +138,8 @@ dnf install -y \
|
|||||||
libfreenect-devel \
|
libfreenect-devel \
|
||||||
libusb1-devel \
|
libusb1-devel \
|
||||||
bluez \
|
bluez \
|
||||||
crudini \
|
wiiuse \
|
||||||
|
wiiuse-devel \
|
||||||
libcap >/dev/null
|
libcap >/dev/null
|
||||||
NODE_BIN="$(command -v node)"
|
NODE_BIN="$(command -v node)"
|
||||||
|
|
||||||
@@ -159,17 +157,6 @@ EOF
|
|||||||
chmod 644 "$KINECT_UDEV_RULE"
|
chmod 644 "$KINECT_UDEV_RULE"
|
||||||
udevadm control --reload-rules
|
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
|
if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then
|
||||||
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
||||||
exit 1
|
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."
|
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Configuring BlueZ for the Wii Balance Board"
|
# Bluetoothd is still responsible for discovery and the one-time bond. Wiiuse
|
||||||
for balance_board_module in hidp hid-wiimote; do
|
# owns the live HID channels, so no kernel HID modules, input.conf edits, or
|
||||||
if ! modinfo "$balance_board_module" >/dev/null 2>&1; then
|
# broad /dev/input permission rule are involved in the runtime data path.
|
||||||
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.
|
|
||||||
systemctl enable --now bluetooth.service
|
systemctl enable --now bluetooth.service
|
||||||
systemctl restart bluetooth.service
|
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
tmpdir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ function roundedWeight(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rawWeightKg(corners = {}) {
|
function rawWeightKg(corners = {}) {
|
||||||
// hid-wiimote reports each calibrated load cell in centi-kilograms. The UI
|
// The native wiiuse bridge reports each calibrated load cell in
|
||||||
// only needs total scale weight, so sum and convert at this single boundary.
|
// centi-kilograms. The UI only needs total scale weight, so sum and convert
|
||||||
|
// at this single boundary.
|
||||||
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
|
return ['topRight', 'bottomRight', 'topLeft', 'bottomLeft']
|
||||||
.reduce((total, key) => total + Math.max(0, Number(corners[key]) || 0), 0) / 100;
|
.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);
|
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 = {}) {
|
function handleWorkerMessage(message = {}) {
|
||||||
if (message.type === 'frame') {
|
if (message.type === 'frame') {
|
||||||
processFrame(message);
|
processFrame(message);
|
||||||
@@ -134,24 +125,6 @@ function handleWorkerMessage(message = {}) {
|
|||||||
return;
|
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;
|
if (message.type !== 'status') return;
|
||||||
const workerState = String(message.state || 'unknown');
|
const workerState = String(message.state || 'unknown');
|
||||||
if (workerState === 'commissioning') {
|
if (workerState === 'commissioning') {
|
||||||
@@ -169,7 +142,11 @@ function handleWorkerMessage(message = {}) {
|
|||||||
connected = false;
|
connected = false;
|
||||||
// A controller connection proves the physical board answered, but Bluetooth
|
// A controller connection proves the physical board answered, but Bluetooth
|
||||||
// does not identify which physical button woke it. Keep the message exact.
|
// 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') {
|
} else if (workerState === 'waiting') {
|
||||||
connected = false;
|
connected = false;
|
||||||
latestFrame = null;
|
latestFrame = null;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
CXX ?= g++
|
CXX ?= g++
|
||||||
|
|
||||||
# The bridge deliberately uses the stable Linux input and Bluetooth management
|
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||||
# ABIs directly. Keeping it free of third-party libraries makes installation on
|
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||||
# the Fedora server predictable and avoids binding the rover service to an old
|
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||||
# Wii-specific userspace package.
|
# the original Balance Board rejects that negotiation before an input device is
|
||||||
|
# created.
|
||||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||||
LDLIBS += -pthread
|
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||||
|
|
||||||
TARGET := balance_board_worker
|
TARGET := balance_board_worker
|
||||||
SRC := balance_board_worker.cpp
|
SRC := balance_board_worker.cpp
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
// Wii Balance Board native bridge.
|
// Wii Balance Board native bridge.
|
||||||
//
|
//
|
||||||
// Purpose:
|
// Purpose:
|
||||||
// Pair one original Nintendo RVL-WBC-01 through modern BlueZ, then expose the
|
// Pair one original Nintendo RVL-WBC-01 through modern BlueZ, then connect to
|
||||||
// calibrated Linux input readings as newline-delimited JSON for the Node server.
|
// its Bluetooth HID channels directly with wiiuse and expose calibrated sensor
|
||||||
|
// readings as newline-delimited JSON for the Node server.
|
||||||
//
|
//
|
||||||
// Why this process exists:
|
// Why this process exists:
|
||||||
// The Linux hid-wiimote driver already performs the board-specific calibration,
|
// BlueZ removed its Wii PIN helper in 2025. A Wii device expects six raw PIN
|
||||||
// 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
|
// 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
|
// 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
|
// BlueZ's agent response with the correct raw MGMT_OP_PIN_CODE_REPLY. Only the
|
||||||
// board currently being commissioned is eligible for that reply.
|
// 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:
|
// Security boundary:
|
||||||
// The installed binary receives CAP_NET_ADMIN solely to open the Bluetooth
|
// The installed binary receives CAP_NET_ADMIN solely to open the Bluetooth
|
||||||
// management socket. The much larger Node server remains unprivileged. Normal
|
// 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 <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cctype>
|
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
@@ -30,9 +35,8 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <dirent.h>
|
#include <cmath>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <fstream>
|
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -40,7 +44,6 @@
|
|||||||
#include <poll.h>
|
#include <poll.h>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <sys/ioctl.h>
|
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
@@ -51,24 +54,17 @@
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char* kBoardBluetoothName = "Nintendo RVL-WBC-01";
|
constexpr const char* kBoardBluetoothName = "Nintendo RVL-WBC-01";
|
||||||
constexpr const char* kBoardInputName = "Nintendo Wii Remote Balance Board";
|
|
||||||
constexpr int kBluetoothProtocolHci = 1;
|
constexpr int kBluetoothProtocolHci = 1;
|
||||||
constexpr uint16_t kHciChannelControl = 3;
|
constexpr uint16_t kHciChannelControl = 3;
|
||||||
constexpr uint16_t kHciDeviceNone = 0xffff;
|
constexpr uint16_t kHciDeviceNone = 0xffff;
|
||||||
constexpr uint16_t kMgmtPinCodeRequestEvent = 0x000e;
|
constexpr uint16_t kMgmtPinCodeRequestEvent = 0x000e;
|
||||||
constexpr uint16_t kMgmtPinCodeReplyCommand = 0x0016;
|
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 uint8_t kBluetoothClassicAddressType = 0;
|
||||||
constexpr int kFrameIntervalMs = 50;
|
constexpr int kFrameIntervalMs = 50;
|
||||||
constexpr int kDeviceScanIntervalMs = 500;
|
|
||||||
constexpr int kDiscoveryRestartDelayMs = 1000;
|
constexpr int kDiscoveryRestartDelayMs = 1000;
|
||||||
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
|
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
|
||||||
constexpr int kBluetoothMonitorIntervalMs = 2000;
|
constexpr int kDirectReconnectDelayMs = 1000;
|
||||||
constexpr int kReconnectAttemptIntervalMs = 3000;
|
constexpr int kHandshakeWarningMs = 10000;
|
||||||
constexpr int kConnectionEvidenceWindowMs = 5000;
|
|
||||||
constexpr int kBatteryRefreshMs = 5000;
|
|
||||||
|
|
||||||
std::atomic<bool> running{true};
|
std::atomic<bool> running{true};
|
||||||
std::mutex output_mutex;
|
std::mutex output_mutex;
|
||||||
@@ -85,18 +81,7 @@ struct PairingSharedState {
|
|||||||
std::optional<BluetoothAddress> active_target;
|
std::optional<BluetoothAddress> active_target;
|
||||||
std::optional<BluetoothAddress> active_pin;
|
std::optional<BluetoothAddress> active_pin;
|
||||||
std::optional<std::string> commissioned_address;
|
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 commissioning = false;
|
||||||
bool pairing_available = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct BoardReadings {
|
struct BoardReadings {
|
||||||
@@ -111,21 +96,6 @@ struct CommandResult {
|
|||||||
std::string output;
|
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 {
|
struct RunningCommand {
|
||||||
pid_t pid = -1;
|
pid_t pid = -1;
|
||||||
int output_fd = -1;
|
int output_fd = -1;
|
||||||
@@ -187,60 +157,6 @@ void emit_frame(const BoardReadings& readings, std::optional<int> battery_percen
|
|||||||
emit_json(fields.str());
|
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) {
|
void signal_handler(int) {
|
||||||
running.store(false);
|
running.store(false);
|
||||||
}
|
}
|
||||||
@@ -361,7 +277,7 @@ bool collect_command_output(RunningCommand* command) {
|
|||||||
command->pending_output += chunk;
|
command->pending_output += chunk;
|
||||||
command->transcript += chunk;
|
command->transcript += chunk;
|
||||||
// A busy Bluetooth environment can produce an unbounded stream of RSSI
|
// 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.
|
// commissioning session left open for days to grow the worker indefinitely.
|
||||||
constexpr std::size_t max_transcript_size = 8192;
|
constexpr std::size_t max_transcript_size = 8192;
|
||||||
if (command->transcript.size() > max_transcript_size) {
|
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) {
|
for (unsigned char ch : raw) {
|
||||||
// bluetoothctl emits terminal color CSI sequences even when its output is
|
// 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
|
// 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) {
|
if (ch == 0x1b) {
|
||||||
ansi_state = 1;
|
ansi_state = 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -465,145 +381,13 @@ std::string command_error_summary(const std::string& raw, const std::string& fal
|
|||||||
return summary.empty() ? fallback : summary;
|
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) {
|
bool command_succeeded(const CommandResult& result) {
|
||||||
if (result.exit_code != 0) return false;
|
// bluetoothctl has returned exit code zero for some D-Bus failures across
|
||||||
return result.output.find("Failed") == std::string::npos &&
|
// releases. Check its stable failure text as well so commissioning never
|
||||||
result.output.find("not available") == std::string::npos;
|
// stores an address when BlueZ did not actually finish the bond.
|
||||||
}
|
return result.exit_code == 0 &&
|
||||||
|
result.output.find("Failed") == std::string::npos &&
|
||||||
bool bluetooth_property_is_yes(const std::string& output, const std::string& property) {
|
result.output.find("not available") == std::string::npos;
|
||||||
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() {
|
std::optional<BluetoothAddress> find_default_controller() {
|
||||||
@@ -618,158 +402,6 @@ std::optional<BluetoothAddress> find_default_controller() {
|
|||||||
return std::nullopt;
|
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) {
|
void commissioning_loop(PairingSharedState* shared) {
|
||||||
while (running.load()) {
|
while (running.load()) {
|
||||||
bool should_commission = false;
|
bool should_commission = false;
|
||||||
@@ -884,17 +516,16 @@ void commissioning_loop(PairingSharedState* shared) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string prepare_error = prepare_known_device(*address);
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(shared->mutex);
|
std::lock_guard<std::mutex> lock(shared->mutex);
|
||||||
shared->commissioned_address = address->display;
|
shared->commissioned_address = address->display;
|
||||||
shared->commissioning = false;
|
shared->commissioning = false;
|
||||||
}
|
}
|
||||||
emit_json("\"type\":\"paired\",\"address\":\"" + json_escape(address->display) + "\"");
|
emit_json("\"type\":\"paired\",\"address\":\"" + json_escape(address->display) + "\"");
|
||||||
// Use the same instrumented monitor for the first post-pair connection and
|
// The direct connection loop notices this address immediately. Pairing and
|
||||||
// every later wake. A separate one-off connect here previously produced a
|
// sensor transport stay separate so the red Sync button is needed only for
|
||||||
// large opaque error before the monitor could observe its controller stages.
|
// commissioning; later front-button wakes are caught automatically.
|
||||||
emit_status("waiting", address->display, prepare_error);
|
emit_status("waiting", address->display);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,127 +602,9 @@ void process_management_events(int fd, PairingSharedState* shared) {
|
|||||||
}
|
}
|
||||||
continue;
|
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) {
|
std::optional<std::string> extract_command_value(const std::string& line, const std::string& key) {
|
||||||
const std::string token = "\"" + key + "\"";
|
const std::string token = "\"" + key + "\"";
|
||||||
const std::size_t key_at = line.find(token);
|
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);
|
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() {
|
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
|
// Exercise the same status contract as real hardware so development UI
|
||||||
// builds cannot silently break merely because CI lacks a physical board.
|
// builds cannot silently break merely because CI lacks a physical board.
|
||||||
emit_diagnostics("SIMULATED", simulated_bluetooth, "ready", "", "input-ready", "");
|
|
||||||
emit_status("waiting", "SIMULATED");
|
emit_status("waiting", "SIMULATED");
|
||||||
const std::array<BoardReadings, 12> sequence{{
|
const std::array<BoardReadings, 12> sequence{{
|
||||||
{0, 0, 0, 0}, {0, 0, 0, 0}, {40, 30, 35, 25}, {95, 82, 90, 76},
|
{0, 0, 0, 0}, {0, 0, 0, 0}, {40, 30, 35, 25}, {95, 82, 90, 76},
|
||||||
@@ -1166,26 +836,23 @@ int main() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wiimote_t** boards = initialize_wiiuse();
|
||||||
|
|
||||||
PairingSharedState pairing;
|
PairingSharedState pairing;
|
||||||
const std::string configured_address = std::getenv("BALANCE_BOARD_ADDRESS")
|
const std::string configured_address = std::getenv("BALANCE_BOARD_ADDRESS")
|
||||||
? std::getenv("BALANCE_BOARD_ADDRESS") : "";
|
? std::getenv("BALANCE_BOARD_ADDRESS") : "";
|
||||||
if (auto parsed = parse_address(configured_address)) {
|
if (auto parsed = parse_address(configured_address)) {
|
||||||
pairing.commissioned_address = parsed->display;
|
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 {
|
} else {
|
||||||
pairing.commissioning = true;
|
pairing.commissioning = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int management_fd = open_management_socket();
|
const int management_fd = open_management_socket();
|
||||||
if (management_fd < 0) {
|
if (management_fd < 0) {
|
||||||
pairing.pairing_available = false;
|
|
||||||
if (!pairing.commissioned_address.has_value()) {
|
if (!pairing.commissioned_address.has_value()) {
|
||||||
// An already bonded board can reconnect and stream through evdev without
|
// An already commissioned board can use ordinary wiiuse L2CAP sockets
|
||||||
// the management socket. Missing capability is fatal only when the bridge
|
// without the management socket. Missing capability is fatal only when
|
||||||
// actually needs to create a new bond.
|
// the bridge needs to answer the special six-byte pairing PIN.
|
||||||
pairing.commissioning = false;
|
pairing.commissioning = false;
|
||||||
emit_status("error", configured_address,
|
emit_status("error", configured_address,
|
||||||
"Bluetooth management socket unavailable; install the worker capability");
|
"Bluetooth management socket unavailable; install the worker capability");
|
||||||
@@ -1193,67 +860,21 @@ int main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::thread commission_thread(commissioning_loop, &pairing);
|
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);
|
std::thread input_thread(stdin_loop, &pairing);
|
||||||
if (management_fd >= 0 || pairing.commissioned_address.has_value()) {
|
if (management_fd >= 0 || pairing.commissioned_address.has_value()) {
|
||||||
emit_status(pairing.commissioning ? "commissioning" : "waiting", configured_address);
|
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()) {
|
while (running.load()) {
|
||||||
process_management_events(management_fd, &pairing);
|
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));
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input_fd >= 0) close(input_fd);
|
|
||||||
if (management_fd >= 0) close(management_fd);
|
if (management_fd >= 0) close(management_fd);
|
||||||
if (input_thread.joinable()) input_thread.detach();
|
if (input_thread.joinable()) input_thread.detach();
|
||||||
if (commission_thread.joinable()) commission_thread.join();
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user