This commit is contained in:
legop3
2026-07-16 23:12:09 -04:00
parent 4bd228547a
commit 06aeca660b
8 changed files with 295 additions and 64 deletions
+11 -6
View File
@@ -205,10 +205,12 @@ if [[ ! -f "$CONFIG_PATH" ]]; then
fi fi
echo " Configuring BlueZ for the Wii Balance Board" echo " Configuring BlueZ for the Wii Balance Board"
if ! modinfo hid-wiimote >/dev/null 2>&1; then for balance_board_module in hidp hid-wiimote; do
echo "The running kernel does not provide hid-wiimote; install a Fedora kernel with that module." >&2 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 exit 1
fi fi
done
install -d -m 0755 /etc/bluetooth /etc/modules-load.d install -d -m 0755 /etc/bluetooth /etc/modules-load.d
touch "$BLUEZ_INPUT_CONFIG" touch "$BLUEZ_INPUT_CONFIG"
@@ -222,14 +224,17 @@ chmod 0644 "$BLUEZ_INPUT_CONFIG"
crudini --set "$BLUEZ_INPUT_CONFIG" General UserspaceHID false crudini --set "$BLUEZ_INPUT_CONFIG" General UserspaceHID false
crudini --set "$BLUEZ_INPUT_CONFIG" General ClassicBondedOnly false crudini --set "$BLUEZ_INPUT_CONFIG" General ClassicBondedOnly false
# The service consumes calibrated evdev axes from the kernel driver. Load the # UserspaceHID=false hands the Bluetooth HID channels to the kernel's `hidp`
# driver now and at every boot so a short board wake is never lost while an # transport, which then creates a HID device for `hid-wiimote` to calibrate.
# operator manually prepares the server. # 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' cat > "$BALANCE_BOARD_MODULES_LOAD" <<'EOF'
# MultiRoombaRover Wii Balance Board support. # MultiRoombaRover Wii Balance Board support.
hidp
hid-wiimote hid-wiimote
EOF EOF
chmod 0644 "$BALANCE_BOARD_MODULES_LOAD" chmod 0644 "$BALANCE_BOARD_MODULES_LOAD"
modprobe hidp
modprobe hid-wiimote modprobe hid-wiimote
# BlueZ reads input.conf only at daemon startup. Restart it before the rover # BlueZ reads input.conf only at daemon startup. Restart it before the rover
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-O4nEBzvV.js"></script> <script type="module" crossorigin src="/assets/index-dx-kgaua.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dpy8jv9j.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dpy8jv9j.css">
</head> </head>
<body> <body>
@@ -110,17 +110,10 @@ function processFrame(message = {}) {
function diagnosticDetail(message = {}) { function diagnosticDetail(message = {}) {
if (message.inputError) return `Input device: ${message.inputError}`; if (message.inputError) return `Input device: ${message.inputError}`;
if (message.bluetoothError) return `Bluetooth: ${message.bluetoothError}`; if (message.bluetoothError) return `Bluetooth: ${message.bluetoothError}`;
if (message.reconnectDetail) { // The native worker now reduces controller events and the matching journal
const reconnectDetail = String(message.reconnectDetail); // entry to one concrete stage-specific sentence. Preserve that evidence
// bluetoothctl can return a long transcript containing every property // verbatim instead of replacing it with generic retry wording here.
// change around one failed attempt. Translate the known Wii HID socket if (message.reconnectDetail) return String(message.reconnectDetail);
// failure into one useful sentence so the panel remains readable while the
// worker continues its automatic retries in the background.
if (reconnectDetail.includes('br-connection-create-socket')) {
return 'Bluetooth rejected the input connection. The server is retrying automatically.';
}
return reconnectDetail;
}
return 'Press the front power button. The server will keep trying to connect.'; return 'Press the front power button. The server will keep trying to connect.';
} }
@@ -144,10 +137,14 @@ function handleWorkerMessage(message = {}) {
if (message.type === 'diagnostics') { if (message.type === 'diagnostics') {
const bluetoothConnected = Boolean(message.bluetooth?.connected); const bluetoothConnected = Boolean(message.bluetooth?.connected);
const inputReady = message.inputState === 'ready'; const inputReady = message.inputState === 'ready';
const reconnectStage = String(message.reconnectStage || 'waiting');
if (bluetoothConnected && inputReady) { if (bluetoothConnected && inputReady) {
updateStatus('connected', 'Connected. Waiting for weight readings.'); updateStatus('connected', 'Connected. Waiting for weight readings.');
} else if (bluetoothConnected) { } else if (bluetoothConnected) {
updateStatus('connecting', message.inputError || 'Bluetooth connected. Waiting for the input device.'); 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) { } else if (store.address) {
connected = false; connected = false;
updateStatus('waiting', diagnosticDetail(message)); updateStatus('waiting', diagnosticDetail(message));
@@ -168,6 +165,11 @@ function handleWorkerMessage(message = {}) {
tareSamples = []; tareSamples = [];
tareKg = 0; tareKg = 0;
updateStatus('zeroing', 'Connected. Keep the board empty for one second while it zeros.'); updateStatus('zeroing', 'Connected. Keep the board empty for one second while it zeros.');
} else if (workerState === 'link-detected') {
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.');
} else if (workerState === 'waiting') { } else if (workerState === 'waiting') {
connected = false; connected = false;
latestFrame = null; latestFrame = null;
@@ -22,6 +22,7 @@
#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>
@@ -56,6 +57,9 @@ 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 kDeviceScanIntervalMs = 500;
@@ -63,6 +67,7 @@ constexpr int kDiscoveryRestartDelayMs = 1000;
constexpr const char* kDiscoveryTimeoutSeconds = "86400"; constexpr const char* kDiscoveryTimeoutSeconds = "86400";
constexpr int kBluetoothMonitorIntervalMs = 2000; constexpr int kBluetoothMonitorIntervalMs = 2000;
constexpr int kReconnectAttemptIntervalMs = 3000; constexpr int kReconnectAttemptIntervalMs = 3000;
constexpr int kConnectionEvidenceWindowMs = 5000;
constexpr int kBatteryRefreshMs = 5000; constexpr int kBatteryRefreshMs = 5000;
std::atomic<bool> running{true}; std::atomic<bool> running{true};
@@ -82,6 +87,14 @@ struct PairingSharedState {
std::optional<std::string> commissioned_address; std::optional<std::string> commissioned_address;
std::string input_state = "not-detected"; std::string input_state = "not-detected";
std::string input_error; 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; bool pairing_available = true;
}; };
@@ -178,6 +191,7 @@ void emit_diagnostics(const std::string& address,
const BluetoothDeviceState& bluetooth, const BluetoothDeviceState& bluetooth,
const std::string& input_state, const std::string& input_state,
const std::string& input_error, const std::string& input_error,
const std::string& reconnect_stage,
const std::string& reconnect_detail) { const std::string& reconnect_detail) {
// Bluetooth bonding, the current radio link, and Linux evdev readiness are // Bluetooth bonding, the current radio link, and Linux evdev readiness are
// separate layers. Report each one explicitly so the server never has to // separate layers. Report each one explicitly so the server never has to
@@ -198,6 +212,9 @@ void emit_diagnostics(const std::string& address,
if (!input_error.empty()) { if (!input_error.empty()) {
fields << ",\"inputError\":\"" << json_escape(input_error) << "\""; fields << ",\"inputError\":\"" << json_escape(input_error) << "\"";
} }
if (!reconnect_stage.empty()) {
fields << ",\"reconnectStage\":\"" << json_escape(reconnect_stage) << "\"";
}
if (!reconnect_detail.empty()) { if (!reconnect_detail.empty()) {
fields << ",\"reconnectDetail\":\"" << json_escape(reconnect_detail) << "\""; fields << ",\"reconnectDetail\":\"" << json_escape(reconnect_detail) << "\"";
} }
@@ -448,6 +465,120 @@ 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; if (result.exit_code != 0) return false;
return result.output.find("Failed") == std::string::npos && return result.output.find("Failed") == std::string::npos &&
@@ -504,25 +635,10 @@ std::string prepare_known_device(const BluetoothAddress& address) {
return ""; return "";
} }
std::string trust_and_connect(const BluetoothAddress& address) {
if (const std::string prepare_error = prepare_known_device(address);
!prepare_error.empty()) {
return prepare_error;
}
// A board remains awake for only a short window after Sync. Connecting the
// HID profile immediately is what teaches it to initiate future connections
// when its front power button is pressed.
const CommandResult connect = run_command({
"bluetoothctl", "--timeout", "8", "connect", address.display});
if (!command_succeeded(connect)) {
return "initial connection failed: " +
command_error_summary(connect.output, "BlueZ returned no detail");
}
return "";
}
void connection_monitor_loop(PairingSharedState* shared) { void connection_monitor_loop(PairingSharedState* shared) {
uint64_t last_reconnect_attempt_at = 0; 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()) { while (running.load()) {
std::optional<std::string> address; std::optional<std::string> address;
std::string input_state; std::string input_state;
@@ -540,7 +656,10 @@ void connection_monitor_loop(PairingSharedState* shared) {
} }
BluetoothDeviceState bluetooth = inspect_bluetooth_device(*address); BluetoothDeviceState bluetooth = inspect_bluetooth_device(*address);
std::string reconnect_detail; 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(); const uint64_t now = monotonic_ms();
if (bluetooth.available && !bluetooth.connected && if (bluetooth.available && !bluetooth.connected &&
now - last_reconnect_attempt_at >= kReconnectAttemptIntervalMs) { now - last_reconnect_attempt_at >= kReconnectAttemptIntervalMs) {
@@ -550,22 +669,86 @@ void connection_monitor_loop(PairingSharedState* shared) {
// server is waiting so the several-second blue-light wake window is caught // server is waiting so the several-second blue-light wake window is caught
// from either direction without requiring another red-Sync operation. // from either direction without requiring another red-Sync operation.
last_reconnect_attempt_at = now; 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({ const CommandResult reconnect = run_command({
"bluetoothctl", "--timeout", "4", "connect", *address}); "bluetoothctl", "--timeout", "4", "connect", *address});
// Query again because Connect() may have changed several properties before // Query again because Connect() may have changed several properties before
// returning. The diagnostics should describe the resulting state, not the // returning. The diagnostics should describe the resulting state, not the
// stale snapshot taken immediately before the attempt. // stale snapshot taken immediately before the attempt.
bluetooth = inspect_bluetooth_device(*address); bluetooth = inspect_bluetooth_device(*address);
if (bluetooth.connected) { 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"; reconnect_detail = "Bluetooth link established; waiting for the Balance Board input device";
} else if (!command_succeeded(reconnect)) { } else if (radio_was_reached || recent_radio_connection ||
reconnect_detail = command_error_summary( reconnect.output.find("br-connection-create-socket") != std::string::npos) {
reconnect.output, "Bluetooth reconnect attempt did not complete"); 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 { } else {
// bluetoothctl's timeout can end a command without a D-Bus error even reconnect_detail = "Board reached the server, but BlueZ could not create its HID input connection: " +
// though the device never connected. Trust the resulting Connected command_error_summary(reconnect.output, "no lower-level error was logged");
// property rather than presenting process exit status as hardware success. }
reconnect_detail = "Reconnect attempt finished without establishing a Bluetooth link"; } 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;
}
} }
} }
@@ -577,7 +760,8 @@ void connection_monitor_loop(PairingSharedState* shared) {
input_state = shared->input_state; input_state = shared->input_state;
input_error = shared->input_error; input_error = shared->input_error;
} }
emit_diagnostics(*address, bluetooth, input_state, input_error, reconnect_detail); emit_diagnostics(
*address, bluetooth, input_state, input_error, reconnect_stage, reconnect_detail);
for (int elapsed = 0; for (int elapsed = 0;
elapsed < kBluetoothMonitorIntervalMs && running.load(); elapsed += 100) { elapsed < kBluetoothMonitorIntervalMs && running.load(); elapsed += 100) {
@@ -700,14 +884,17 @@ void commissioning_loop(PairingSharedState* shared) {
continue; continue;
} }
const std::string initial_connection_error = trust_and_connect(*address); 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) + "\"");
emit_status("waiting", address->display, initial_connection_error); // 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);
} }
} }
@@ -764,12 +951,13 @@ void process_management_events(int fd, PairingSharedState* shared) {
std::array<uint8_t, 1024> buffer{}; std::array<uint8_t, 1024> buffer{};
ssize_t count = 0; ssize_t count = 0;
while ((count = read(fd, buffer.data(), buffer.size())) > 0) { while ((count = read(fd, buffer.data(), buffer.size())) > 0) {
if (count < 14) continue; if (count < 6) continue;
const uint16_t event = static_cast<uint16_t>(buffer[0] | (buffer[1] << 8)); const uint16_t event = static_cast<uint16_t>(buffer[0] | (buffer[1] << 8));
const uint16_t adapter_index = static_cast<uint16_t>(buffer[2] | (buffer[3] << 8)); const uint16_t adapter_index = static_cast<uint16_t>(buffer[2] | (buffer[3] << 8));
const uint16_t payload_size = static_cast<uint16_t>(buffer[4] | (buffer[5] << 8)); const uint16_t payload_size = static_cast<uint16_t>(buffer[4] | (buffer[5] << 8));
if (event != kMgmtPinCodeRequestEvent || payload_size < 8 || count < 6 + payload_size) continue; if (count < 6 + payload_size) continue;
if (event == kMgmtPinCodeRequestEvent && payload_size >= 8) {
std::optional<BluetoothAddress> target; std::optional<BluetoothAddress> target;
std::optional<BluetoothAddress> pin; std::optional<BluetoothAddress> pin;
{ {
@@ -777,11 +965,46 @@ void process_management_events(int fd, PairingSharedState* shared) {
target = shared->active_target; target = shared->active_target;
pin = shared->active_pin; pin = shared->active_pin;
} }
if (!target.has_value() || !pin.has_value() || if (target.has_value() && pin.has_value() &&
!std::equal(target->wire.begin(), target->wire.end(), buffer.begin() + 6)) { std::equal(target->wire.begin(), target->wire.end(), buffer.begin() + 6)) {
answer_pin_request(fd, adapter_index, *target, *pin);
}
continue; continue;
} }
answer_pin_request(fd, adapter_index, *target, *pin);
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);
}
} }
} }
@@ -907,7 +1130,7 @@ void simulated_loop() {
simulated_bluetooth.wake_allowed = 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", "", ""); 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},
@@ -22,6 +22,7 @@ function statusPresentation(value) {
if (status === 'waiting-for-sync') return { label: 'Waiting for red Sync', tone: 'border-amber-600 bg-amber-950 text-amber-200' }; if (status === 'waiting-for-sync') return { label: 'Waiting for red Sync', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'waiting') return { label: 'Waiting for front button', tone: 'border-amber-600 bg-amber-950 text-amber-200' }; if (status === 'waiting') return { label: 'Waiting for front button', tone: 'border-amber-600 bg-amber-950 text-amber-200' };
if (status === 'connecting') return { label: 'Connecting', tone: 'border-sky-600 bg-sky-950 text-sky-200' }; if (status === 'connecting') return { label: 'Connecting', tone: 'border-sky-600 bg-sky-950 text-sky-200' };
if (status === 'connection-failed') return { label: 'Connection failed', tone: 'border-red-600 bg-red-950 text-red-200' };
if (status === 'error') return { label: 'Error', tone: 'border-red-600 bg-red-950 text-red-200' }; if (status === 'error') return { label: 'Error', tone: 'border-red-600 bg-red-950 text-red-200' };
return { label: 'Starting', tone: 'border-slate-600 bg-slate-900 text-slate-200' }; return { label: 'Starting', tone: 'border-slate-600 bg-slate-900 text-slate-200' };
} }