Overview & Platform Availability

Three managers talk to physical peripherals. Each is a C++ class in src/managers/, built and wired as a member of AppCore (src/appcore.{h,cpp}) and exposed as a QML context property.

ManagerQML nameSourceLog categoryTransport
ESP32VolumeManageresp32VolumeManagersrc/managers/esp32volumemanager.{h,cpp}octave.esp32volumeUSB serial (QSerialPort)
BerryIMUManagerberryIMUsrc/managers/berryimumanager.{h,cpp}octave.berryimu (octave.imu.mobile on Android)I2C bus 2 (/dev/i2c-2)
GestureManagergestureSensorsrc/managers/gesturemanager.{h,cpp}octave.gestureI2C bus 2 (/dev/i2c-2)
VolumeControllervolumeControllersrc/managers/volumecontroller.{h,cpp}—— (routes 0–100 % to every audio output)

What runs where

PlatformESP32 knobBerryIMUGesture sensor
Linux / Raspberry Pi / Orange Pi Full Full Full
Windows / macOS Full Compiled but inert: startSensor() is wrapped in #ifndef Q_OS_LINUX, logs "I2C sensors not available on this platform", emits connectionStatusChanged("Disconnected") and never retries.
Android Stub: never emits, is_connected() is false, detail "Not available on mobile" Replaced by a QtSensors implementation (below) Stub: connectionStatus = "Disabled on mobile", never emits

The mobile split is a compile-time switch: every hardware header has an #ifndef Q_OS_MOBILE desktop branch and an #else stub, and CMakeLists.txt defines Q_OS_MOBILE only for Android builds ($<$<BOOL:${ANDROID}>:Q_OS_MOBILE>; iOS is noted as "later"). The stubs exist so QML Connections handlers and typeof esp32VolumeManager !== "undefined" guards resolve on mobile without per-platform QML (see commit 78ab72c).

ESP32 Volume Manager

ESP32VolumeManager provides wireless rotary-encoder volume control. Two ESP32 boards are involved: an encoder unit with the knob, and an ESP32-S3 "receiver dongle" that plugs into the computer over USB and forwards the encoder's commands as newline-terminated text. OCTAVE only ever talks to the dongle.

Linux Windows macOS
Firmware is not in this repository
The ESP32 sketches are not part of the OCTAVE tree (there are no .ino / PlatformIO sources here). What this page documents is the contract OCTAVE implements: the serial parameters, the incoming command grammar and the outgoing LED commands. Firmware must implement this serial protocol; the wireless link and LED behaviour are outside this repository.

ESP-NOW Topology

+-------------------+         ESP-NOW         +-------------------+
|  ESP32 Encoder    | ~~~~~~~~~~~~~~~~~~~~~~> |  ESP32-S3 Dongle  |
|  (Rotary Knob)    |      (Wireless)         |  (USB Receiver)   |
|  - Rotary encoder |                         |  - USB CDC serial |
|  - Button press   |                         |  - LED ring       |
+-------------------+                         +--------+----------+
                                                       |
                                                  USB Serial
                                                  115200 8N1
                                                       |
                                              +--------v----------+
                                              |      OCTAVE       |
                                              | ESP32VolumeManager|
                                              +-------------------+
          

ESP-NOW is Espressif's connectionless peer-to-peer link; the encoder unit sends + / - / M style commands to the dongle, which forwards them over USB. The link between the two ESP32s is invisible to OCTAVE.

Serial Communication

The port is opened at 115200 baud with 8 data bits, no parity, one stop bit and no flow control, set explicitly (QSerialPort::Data8 / NoParity / OneStop / NoFlowControl). There is no reader thread: QSerialPort::readyRead is delivered on the main event loop and onSerialReadyRead() hands readAll() to feedSerialData(), which appends to m_readBuffer, splits on \n, and passes each non-empty trimmed line to the command parser (which upper-cases it). A partial line stays in the buffer until its newline arrives. volumeChangeRequested / muteToggleRequested are therefore emitted directly on the main thread.

A partial line longer than ESP32_MAX_LINE_BYTES (256) without a newline is line noise: it is dropped with one warning per connection and the reader resyncs by discarding everything up to the next \n. feedSerialData() is public so tests/cpp/tst_esp32readbuffer.cpp can drive it without a port.

void ESP32VolumeManager::feedSerialData(QByteArray data)
{
    // (resync after an overlong line elided)
    m_readBuffer.append(QString::fromUtf8(data));
    while (m_readBuffer.contains(QLatin1Char('\n'))) {
        int idx = m_readBuffer.indexOf(QLatin1Char('\n'));
        QString line = m_readBuffer.left(idx).trimmed();
        m_readBuffer = m_readBuffer.mid(idx + 1);
        if (!line.isEmpty())
            processCommand(line);
    }
    // a partial line over ESP32_MAX_LINE_BYTES is dropped and the reader resyncs
}

Device Discovery & Startup

ESP32-S3 boards enumerate with Espressif's USB Vendor ID 0x303A. The scan puts a port whose VID matches (or whose description contains jtag, esp32-s3 or esp32s3) at the front of the list and remembers it as m_esp32S3Port. The OBD serial port (settingsManager.obdBluetoothPort) is always excluded so the two features cannot fight over one device. Results are de-duplicated; availablePorts holds one {port, label, description, isEsp32S3} map per port, ESP32-S3 first then by port (label "<port> - ESP32-S3 (Recommended)" or "<port> - <description>"), and availablePortsChanged fires only when the list actually changes. The Accessories page binds it directly (it used to parse a JSON string from get_ports_with_descriptions(), now removed).

Enumeration is QSerialPortInfo::availablePorts() on every desktop platform, with the VID test vendorIdentifier() == 0x303A. Every enumerated port with a systemLocation() is listed (an earlier USB-serial description filter accepted any such port anyway). No glob patterns are used.

Startup sequence

  1. connect_settings_manager() wires the five esp32*Changed settings signals, reads esp32VolumeStepSize, and starts a 3 s one-shot startup timer.
  2. initialConnect() runs a scan. If esp32VolumeEnabled is false it stops here.
  3. The port comes from esp32VolumePort. Only if that setting is empty does auto-selection happen: the detected ESP32-S3 port is saved via save_esp32_volume_port(). No other port is ever auto-selected: an earlier "first non-OBD port" fallback sent the knob's LED commands to a VM's COM1 / ttyS0 and reported "Connected" with no knob attached.
  4. With a port, connect_device() is called; without one the detail becomes "No port configured - select one above".
Default port is empty = auto-select
SettingsManager defaults esp32VolumePort to "", so a fresh install runs the auto-selection above and connects to the detected ESP32-S3 on the first start (older files that still carry the former "COM7" default keep trying that port until it is changed). Picking a port in Settings → Accessories → Volume Knob (the list marks the ESP32-S3 "(Recommended)") calls save_esp32_volume_port() and triggers a reconnect 500 ms later.

connect_device()

  1. Returns early if already connected, if there is no settings manager, or if esp32VolumeEnabled is false.
  2. Empty port → status "Not Configured", detail "No port configured", and the 10 s background scan timer starts.
  3. Otherwise status "Connecting" ("Connecting to <port>...") and the port is opened.
  4. Success, port is an ESP32-S3 (looksLikeEsp32S3(): VID 0x303A or a JTAG / ESP32-S3 description) → "Connected" ("Connected to ESP32 on <port>"), reconnect counter reset, read buffer cleared, keepalive timer started only if esp32LedSleepEnabled is false, background scan stopped.
  5. Success, any other port (picked by hand) → "Waiting" ("Port <port> open: turn the knob to confirm"). The port is read but is_connected() stays false, so send_volume_update / send_mute_state / send_theme_color write nothing. The first recognised knob command (+, -, +N, -N, M, P) is acted on and switches it to "Connected", which makes src/main.cpp push volume, mute and colour. The protocol has no reply command, so a knob turn is the only proof the port is the knob. Tested with a pty in tests/cpp/tst_esp32volumemanager.cpp.
  6. Failure → "Error" ("Connection failed: …") and scheduleReconnect().

Background scanning (m_scanTimer, 10 s) runs only while disconnected: it is started when there is no port, when auto-reconnect is disabled, or when the retry budget is exhausted, and stopped on connect or when the feature is disabled.

Command Protocol

Incoming (ESP32 → OCTAVE), one command per line

CommandMeaningEmitted signal
+One tick clockwisevolumeChangeRequested(+step)
+NN ticks clockwise (e.g. +3)volumeChangeRequested(+step × N); a non-integer N logs "invalid command format"
-One tick counter-clockwisevolumeChangeRequested(-step)
-NN ticks counter-clockwisevolumeChangeRequested(-step × N)
MEncoder button pressmuteToggleRequested()
PPlay/pause (reserved for a double-click)Nothing — logged at debug level as "not implemented"
anything else—Nothing — logged as "unknown command"

step is the esp32VolumeStepSize setting (default 1.0, UI range 0.25–10.0 in 0.25 steps), so the delta is a float. The handlers in src/main.cpp accumulate deltas and only apply whole percent:

QObject::connect(&esp32VolumeManager, &ESP32VolumeManager::volumeChangeRequested,
                 [&volumeController, &settingsManager](float delta) {
    static float accumulator = 0.0f;
    accumulator += delta;
    if (std::abs(accumulator) >= 1.0f) {
        int change = static_cast<int>(accumulator);
        accumulator -= change;
        volumeController.applyVolume(settingsManager.currentVolume() + change);
    }
});
QObject::connect(&esp32VolumeManager, &ESP32VolumeManager::muteToggleRequested,
                 [&volumeController, &esp32VolumeManager]() {
    volumeController.toggleMute();
    // answer every press, even one the 150 ms debounce drops
    esp32VolumeManager.send_mute_state(volumeController.muted());
});

Outgoing (OCTAVE → ESP32)

PurposeFormatMethodNotes
Volume level for the LED ringV<0-100>\nsend_volume_update(int)Debounced 50 ms; also used as the keepalive
Mute indicatorM0\n / M1\nsend_mute_state(bool)Sent immediately
LED colourC<r>,<g>,<b>\n (e.g. C255,128,0)send_theme_color(r, g, b)Sent immediately

These three are plain methods, not QML slots — they are called from the application wiring, never from QML. All of them silently return (with an info log) while not connected.

LED Feedback

Volume debounce

send_volume_update() stores the value as pending and starts a 50 ms single-shot timer if one is not already running; when it fires, the most recent pending value is written. This caps LED traffic at 20 updates/s during a fast knob spin or slider drag ("to prevent LED flickering" per the source comment) while guaranteeing the final value is always sent.

Keepalive and LED sleep

The manager assumes firmware that turns its LEDs off after a 5 s silence (source comment: "ESP32 timeout is 5s"). With esp32LedSleepEnabled off, a 2 s keepalive timer re-sends V<currentVolume> so the ring stays lit; with it on (the default), no keepalives are sent and the ring sleeps (the Accessories toggle reads "Stay on while OCTAVE runs (off = sleep after 5s idle)"). Toggling the setting while connected starts or stops the timer immediately. Keepalives are not logged. The keepalive falls back to volume 50 if the currentVolume property is unreadable.

Colour source (application wiring in src/main.cpp)

  • esp32LedColorMode == "theme" (default): settingsManager.albumArtColorsChanged → a lambda that reads the accent of the albumArtColors map and sends it.
  • esp32LedColorMode == "static": esp32LedStaticColorChanged → the chosen hex colour (default #00FFFF).
  • Switching modes (esp32LedColorModeChanged) re-sends the current colour; a missing or unparsable colour falls back to cyan (0, 255, 255).
  • On every transition to "Connected", a 500 ms single-shot timer sends the current volume (settingsManager.currentVolume), mute state (volumeController.muted()) and colour so a freshly plugged dongle matches the app; the delay gives the firmware time to finish booting after the port opens.

The whole block sits inside #ifndef Q_OS_MOBILE because the mobile build compiles the ESP32 manager as a stub. The volume itself reaches the ring through VolumeController (below), so a slider drag, a gesture and the knob all light the ring the same way.

Disconnect Detection & Auto-Reconnect

EventHandling
Port errorQSerialPort::errorOccurred with ResourceError (device unplugged), DeviceNotFoundError, PermissionError, ReadError or WriteError → handleDisconnect() (a no-op while not connected, e.g. a failed open(), which schedules its own reconnect); every other error code is only logged
Write errorEvery command goes through writeCommand(), which checks the byte count and logs "failed to send <what>"; a rejected keepalive write → handleDisconnect(), other writes only log

handleDisconnect() flips to "Disconnected" / "Connection lost" and calls scheduleReconnect():

if (!esp32AutoReconnect) {                 // just keep scanning for the dongle
    m_scanTimer.start(); return;
}
if (m_reconnectAttempts >= 5) {         // "Max retries reached. Check connection."
    m_scanTimer.start(); return;
}
m_reconnectAttempts++;
int delay = qMin(10000, 3000 * m_reconnectAttempts);  // 3 s, 6 s, 9 s, 10 s, 10 s (linear, capped)
// detail: "Reconnecting in <delay/1000>s... (<attempts>/5)"

The counter resets on a successful open, when the port setting changes, or via the reset_reconnect_attempts() slot (the settings page calls it before a manual connect_device()). Turning esp32AutoReconnect off stops a pending reconnect timer.

ESP32 Signals, Properties & Slots

Signals

SignalParametersDescription
connectionStatusChangedQString"Connected", "Connecting", "Disconnected", "Error", "Not Configured"
connectionDetailChangedQStringHuman-readable detail shown on the Accessories page
availablePortsChangedQVariantListScan produced a different port list
volumeChangeRequestedfloatRelative delta = step × ticks (may be fractional)
muteToggleRequested—Encoder button pressed

Properties

PropertyTypeNotify
connectedboolconnectionStatusChanged
connectionStatusQStringconnectionStatusChanged
connectionDetailQStringconnectionDetailChanged
availablePortsQVariantList of {port, label, description, isEsp32S3}availablePortsChanged

Slots

SlotReturnsDescription
connect_device()—Open the configured port (see sequence above)
disconnect_device()—Stop reconnect + keepalive timers, close the port, emit "Disconnected"
get_available_ports()QVariantListScan now and return availablePorts
is_connected()boolRead by AccessoriesWidget.qml and the settings page status dot
get_connection_status()QStringSame as connectionStatus
get_connection_detail()QStringSame as connectionDetail
refresh_ports()—Scan now (no return value)
reset_reconnect_attempts()—Zero the retry counter
cleanup()—Stop all five timers, then disconnect_device(); called from the app's quit handler

Non-slot methods (called from src/main.cpp)

send_volume_update(int), send_mute_state(bool), send_theme_color(r, g, b) — see Command Protocol.

Timers

TimerIntervalPurpose
m_startupTimer3 000 ms, one-shotDelay the first scan + connect after app start
m_scanTimer10 000 msBackground port scan while disconnected
m_reconnectTimer3–10 s, one-shotAuto-reconnect attempt
m_volumeSendTimer50 ms, one-shotDebounce V commands
m_keepaliveTimer2 000 msKeep the LED ring awake when LED sleep is off

VolumeController

Every hardware input (knob ticks, gesture volume_up/volume_down) and every QML slider ends in volumeController.applyVolume(percent). It clamps to 0–100, persists currentVolume through the SettingsManager, converts the percent through the quadratic curve linear = (percent / 100)², and pushes the result to every audio output. toLinear(percent) exposes the bare curve to QML for previews without dispatching.

applyVolume does not call the outputs itself: it emits volumeApplied(int percent, float linear), and src/main.cpp connects that to mediaManager.setVolume(linear), phoneMirrorManager.setVolume(linear) and esp32VolumeManager.send_volume_update(percent) (desktop only). src/main.cpp calls applyVolume(settingsManager.currentVolume()) once after every manager is constructed so all outputs start in sync. VolumeController::toLinearStatic(float) is the curve without an instance.

BerryIMU v3 Manager

BerryIMUManager reads the accelerometer, gyroscope, magnetometer and barometer on the BerryIMU v3 over I2C in a worker thread, fuses accelerometer + gyroscope with a Madgwick AHRS filter into a quaternion, strips yaw, applies a user-set tare, and emits orientation plus gauge values to QML. The quaternion drives the rigged Jeep TJ on CarMenu; the scalar values feed SensorMenu, OBD dashboards and the home cards through OBDParameterModel.

Linux (Raspberry Pi / Orange Pi) Android (QtSensors variant)

I2C Sensor Chips & Register Map

The bus number and the three addresses are constants (I2C_BUS = 2; there is no setting to change them). The worker opens /dev/i2c-2 with O_RDWR and selects each chip with ioctl(I2C_SLAVE, addr), then does raw write(reg) / read(n) pairs (readBlockData).

ChipAddressFunctionInit writes (reg ← value; source settings)Data readScale
LSM6DSL 0x6A Accelerometer + gyroscope 0x10 ← 0x68 accel 416 Hz, ±4 g
0x11 ← 0x60 gyro 416 Hz, 245 dps
0x12 ← 0x44 BDU + register auto-increment
Accel: 6 bytes from 0x28
Gyro: 6 bytes from 0x22
(little-endian int16 X, Y, Z)
Accel × 0.000122 → g
Gyro × 0.00875 → dps
LIS3MDL 0x1C Magnetometer 0x20 ← 0xFE ultra-high performance, 155 Hz
0x21 ← 0x20 ±8 gauss
0x22 ← 0x00 continuous conversion
0x23 ← 0x0C ultra-high performance on Z
6 bytes from 0x28 | 0x80 (MSB set = auto-increment on this chip) ÷ 3421 → gauss
BMP388 0x77 Barometric pressure + temperature Read 21 calibration bytes from 0x31, then 0x1C ← 0x03, 0x1D ← 0x02, 0x1B ← 0x33, sleep 50 ms 6 bytes from 0x04: 24-bit pressure, then 24-bit temperature (LSB first) Datasheet compensation → hPa, °C

The chip set and default I2C addresses are documented by the BerryIMU manufacturer; OCTAVE's constants and register writes above are in src/managers/berryimumanager.cpp. The LSM6DSL scale and rate constants also match ST's driver header.

BMP388 calibration and compensation

The 21 calibration bytes unpack to 14 coefficients in this order and width: T1 u16, T2 u16, T3 i8, P1 i16, P2 i16, P3 i8, P4 i8, P5 u16, P6 u16, P7 i8, P8 i8, P9 i16, P10 i8, P11 i8 (the BMP388Calib struct). readBaro() runs the floating-point temperature compensation (three partial terms) and then the pressure polynomial (out1 + out2 + out3 with the eleven pp terms). Pressure is converted to hPa and altitude uses the barometric formula with SEA_LEVEL_PRESSURE = 1013.25:

altitude = 44330.0 * (1.0 - (pressure_hpa / 1013.25) ** (1.0 / 5.255))

Altitude is therefore relative to standard sea-level pressure, not a locally corrected QNH.

Startup & Reconnection

  1. The constructor schedules startSensor() with a 1 s single-shot timer; connect_settings_manager() reads imuEnabled and sensorEmitRate first, then follows both live (imuEnabledChanged → setEnabled, sensorEmitRateChanged → setEmitRate), as the ESP32 manager does: a page or widget only saves.
  2. If disabled: log "disabled in settings, skipping start", emit connectionStatusChanged("Disabled"), stop.
  3. Any previous worker is stopped (stopWorker(), 2 s wait) before a (re)connect.
  4. Open the bus, initialise LSM6DSL, LIS3MDL, BMP388 in that order.
  5. Success: log "all sensors initialized successfully", emit "Connected", reset the retry counter, start the read loop. Note that "Connected" is emitted before gyro calibration, which is the first thing the loop does (~1 s).
FailureHandling
Bus cannot be opened (/dev/i2c-2 absent)openBus() fails → "Disconnected", worker emits stopped(retryable = true) → scheduleRetry()
Permission deniedopen() fails with EACCES / EPERM → logs "permission denied on /dev/i2c-2 - add the user to the i2c group; not retrying", "Disconnected", worker emits stopped(retryable = false) so the manager schedules no retry (fix the i2c group and restart)
A chip fails to initialise"Error" (which chip is logged) + retry
I2C error inside the loopA failed accel / gyro / mag block read zeroes that sample and skips the filter step (zeros are never fed to Madgwick); after MAX_CONSECUTIVE_READ_FAILURES = 5 in a row the loop logs "read loop error: 5 consecutive I2C read failures", exits, closes the bus, emits "Error" and retries. A single NACK therefore rides through without dropping the connection.

Retry policy (identical in the gesture manager): up to 5 attempts with linear backoff 5000 ms × attempt = 5 s, 10 s, 15 s, 20 s, 25 s, then "max retries reached, giving up". The status stays at whatever was last emitted; nothing marks the sensor "unavailable". setEnabled(true) from the settings toggle resets the counter and starts over.

Threading model. The manager moves a BerryIMUWorker QObject to a QThread; QThread::started triggers run() (which opens the bus, initialises the chips and enters the read loop), and the worker's signals are forwarded 1:1 to the manager's public signals (queued connections, so QML always receives them on the GUI thread). stopWorker() sets the atomic m_running false, quits the thread, waits up to 2 s and deletes both objects.

Madgwick AHRS Filter

MadgwickAHRS (declared in berryimumanager.h) implements Sebastian Madgwick's gradient-descent orientation filter. It integrates the gyro rate into a quaternion and applies a beta-scaled corrective step towards the accelerometer's gravity vector (and, when magnetometer data is supplied, the magnetic field).

The filter runs in 6DOF mode — the magnetometer is not fused
The read loop calls update(gx, gy, gz, ax, ay, az, 0.0, 0.0, 0.0, dt). With a zero magnetic vector m_norm < 0.001, so the filter always takes its "6DOF fallback — accel only correction (no heading)" branch. The full 9DOF (MARG) code path is present but never exercised. The magnetometer is read every iteration and used only for the compass heading, computed directly as atan2(my, mx). The source comment gives the reason: in-car magnetic interference (alternator, speakers, steel body) would pull the quaternion around if it were fused, and enabling 9DOF fusion "is a behaviour change that needs on-vehicle testing first". Because yaw is stripped from the output quaternion anyway (see Quaternion Output), the model never depended on magnetic heading.
ConstantValuePurpose
MADGWICK_BETA0.03Filter gain: "0.01 = very smooth (slow correction), 0.1 = snappy (more noise)". Passed to the constructor (whose own default is 0.04, unused).
GYRO_DEADBAND0.4 dpsBias-corrected gyro axes with |value| below this are zeroed
SAMPLE_INTERVAL0.005 s (200 Hz target)Read-loop period; the thread sleeps out the remainder after each I2C round
EMIT_INTERVAL0.016 s (~60 Hz)Default emission period; changeable at runtime with setEmitRate()
IDLE_EMIT_INTERVAL0.2 s (5 Hz)Emission period while no page shows the values
BARO_INTERVAL0.2 s (5 Hz)Barometer read cadence, time-based

Update cycle

void MadgwickAHRS::update(gx, gy, gz, ax, ay, az, mx, my, mz, dt)
    # 1. Gyro quaternion rate of change (rad/s)
    # 2. Normalise accelerometer; if |a| < 0.001 integrate gyro only and return
    # 3. Normalise magnetometer; use_mag = |m| > 0.001   (always False in OCTAVE)
    # 4. Gradient-descent step s0..s3 (9DOF branch or 6DOF accel-only branch), normalised
    # 5. qdot -= beta * s
    # 6. q += qdot * dt; normalise

getEuler() exists on the filter but the read loop derives pitch and roll from the tared output quaternion instead (below), so the helper is unused in production.

The 200 Hz Paced Read Loop

Introduced in commit ed826ed and re-measured in f1eb526. Each iteration:

  1. now = getTime() (std::chrono::steady_clock); dt = now - lastTime (replaced by 1 ms if it is not positive). The filter integrates with the measured dt, so its tuning is independent of the loop rate.
  2. Service a pending tare / reset-tare request (atomic flags → mutex-protected m_tareQ).
  3. Three I2C block reads: accel, gyro, mag.
  4. Subtract the gyro bias; apply the deadband per axis; convert to rad/s.
  5. Only if at least one gyro axis is non-zero call m_ahrs.update(…). When the vehicle is still the quaternion is frozen, so the accelerometer correction cannot cause visible settling.
  6. Remember ax, ay for tare capture; compute |a|.
  7. If the emit interval has elapsed, emit the seven orientation/gauge signals (see emission gating). The very first emission is logged once: first emit - pitch=… roll=… heading=… g=… q=[…].
  8. If BARO_INTERVAL has elapsed (it has on the first iteration, because lastBaro starts at 0), read the BMP388 and emit altitudeChanged + baroTempChanged. The first successful read is logged once: baro - alt=…m temp=…C (a baroLogged flag). The emit is skipped when the compensated pressure is 0 (failed read).
  9. remaining = SAMPLE_INTERVAL - (getTime() - now); sleep it if positive.
What was measured (Orange Pi 5 Plus, strace -T)
The three block reads cost about 4.3 ms per iteration, so the loop is I2C-bound: it settles around 172 Hz with ~1.5 ms of sleep, and the worker thread's ~8 % CPU "is the cost of reading the sensor, not a spin". The earlier fixed 1 ms sleep kept the bus saturated for no benefit: the LSM6DSL output data rate is 416 Hz and emission is 60 Hz. The 200 Hz target stays below the configured output data rate and still gives the filter 3+ samples per emit. Setting SAMPLE_INTERVAL below the I2C read cost skips the explicit sleep and continuously requests bus reads. A longer interval reduces bus work but also gives the filter fewer samples per emitted value; actual rates depend on hardware and bus timing.

The loop exit is logged as read loop exited after N reads.

Emission Gating (60 Hz vs 5 Hz)

Every emit tick sends seven queued cross-thread signals into QML bindings (orientationChanged, pitchChanged, rollChanged, headingChanged, accelMagnitudeChanged, lateralGChanged, longitudinalGChanged). Commit 94d61be added setActive(bool): sampling and fusion continue at full rate, but while no page consumes the stream the effective interval becomes max(emit_interval, IDLE_EMIT_INTERVAL) — 5 Hz, enough to keep the status line in Settings fresh. The flag is an std::atomic<bool> m_idle on the worker, also applied when the worker is (re)created.

The pages that show IMU readings say so themselves: each drops in an frontend/ImuDemand.qml (consumer, wanted), which calls berryIMU.setDemand(consumer, wanted); the manager runs at full rate while any consumer wants it and idles until a page wants it (the IMU's twin of obdManager.setParameterDemand):

// frontend/ImuDemand.qml, dropped into each page that shows IMU readings
App.ImuDemand { consumer: "sensorMenu"; wanted: sensorMenu.visible }
// MainMenu: only while a home card shows an IMU reading
App.ImuDemand {
    consumer: "mainMenu"
    wanted: mainMenu.visible && settingsManager.homeOBDParameters.some(function(id) {
        return App.OBDParameterModel.sensorParameters.indexOf(id) !== -1 })
}

The consumers are CarMenu, SensorMenu, SensorHome, OBDMenu (dashboards can contain compass / G-force gauges that read the IMU through OBDParameterModel) and the main menu while one of its home OBD cards is an IMU id. Until open-backend #10 frontend/Main.qml decided from a list of page objectNames on every StackView change; a new IMU page now needs its own ImuDemand and no edit elsewhere. A page being pre-built is visible for two frames, so the rate can flick to full for that moment.

setEmitRate(hz) clamps to 10–120 Hz and sets the active interval to 1 / hz. The manager applies the sensorEmitRate setting (default 60) when the settings manager is attached and on every sensorEmitRateChanged; the Accessories settings slider (10–120, step 5) only saves it (save_sensor_emit_rate). The interval is stored on the manager (m_emitInterval) and applied to every worker that is created, so a call before the sensor starts or across a retry is not lost.

Quaternion Output

At each emit tick the filter quaternion goes through two transforms before leaving the worker:

  1. Yaw stripping. Decompose q = q_yaw × q_tilt (yaw = rotation about the sensor Z axis) and keep only the tilt, so heading drift can never rotate the car model:
    yaw_norm = sqrt(qw² + qz²)          # if < 0.001 use yw=1, yz=0
    yw, yz   = qw / yaw_norm, qz / yaw_norm
    # q_tilt = [yw, 0, 0, -yz] * q
    tw = yw*qw + yz*qz
    tx = yw*qx + yz*qy
    ty = yw*qy - yz*qx
    tz = yw*qz - yz*qw
  2. Tare. q_out = q_tare × q_tilt (full Hamilton product) where q_tare is the conjugate of the tilt captured at ZERO time (identity until then).

orientationChanged(ow, ox, oy, oz) carries the tared tilt. Pitch and roll for the gauges are extracted from that same tared quaternion (pitch = asin(2(w·y − z·x)) clamped to ±90°, roll = atan2(2(w·x + y·z), 1 − 2(x² + y²))), so the numbers on screen always agree with the model. Heading is atan2(my, mx) in degrees, normalised to 0–360. Lateral G is ay − accel_bias_y, longitudinal G is ax − accel_bias_x; accel magnitude is the raw |a| (about 1.0 g at rest, not bias-corrected).

Gyro Calibration, Zero & Reset

Gyro bias (automatic, every start)

The first thing the worker does after initialising the chips is calibrateGyro(): 200 gyro samples 5 ms apart (~1 s) while the vehicle should be stationary; the mean of each axis becomes the bias subtracted from all later samples. The result is logged as gyro calibrated - bias x=… y=… z=… dps. Because "Connected" was already emitted, the UI shows the sensor connected while this runs.

ZERO (calibrateTare()) — "level the vehicle"

Captures the current yaw-stripped tilt, stores its conjugate (w, -x, -y, -z) as the tare quaternion, and captures the current ax, ay as the G-force zero so lateral / longitudinal G read 0 on the mounting slope. Logged as tare set - q=[…] accel_bias=[…]. The tare is not persisted; it resets to identity on every app start.

calibrateTare() sets the atomic m_tareRequested on the worker; the read loop services it at the top of its next iteration and writes m_tareQ under m_tareMutex, which the emit path also locks, so the quaternion and accel snapshot are read on the thread that writes them. With no worker (sensor stopped) the call does nothing.

RESET (resetTare())

Restores the identity tare and zero accel biases (same threading as above, via m_tareResetRequested). Logged as tare reset to identity.

Both buttons appear on CarMenu (rectangles with objectName: "imuZero" / "imuReset", added in commit 19d8904 so scripted captures can level the model) and on SensorMenu (visible only while connected).

QML Consumers

FileWhat it does with the IMU
frontend/CarMenu.qml onOrientationChanged(w, x, y, z) → carModel.rotation = Qt.quaternion(w, y, z, x) — the sensor's Z-up frame is remapped to Qt Quick 3D's Y-up frame by rotating the vector components. Pitch, roll, heading (with a cardinal lookup dirs[Math.round(h/45) % 8]), altitude, G magnitude and baro temperature are shown as text. imuConnected is seeded from berryIMU.connected on completion and updated by connectionStatusChanged === "Connected". While not connected a 50 ms timer animates a demo attitude with sine/cosine pitch and roll. ZERO / RESET buttons as above.
Steering is not an IMU input: the Jeep's front wheels follow a manual slider (objectName: "jeepSteering", −1…1 → jeep.steeringInput), with "Center wheels" and "Spin wheels" controls; doors, hood, tailgate and lights are likewise manual toggles on JeepVehicle.qml. The IMU supplies whole-body rotation; when OBD is connected, RPM and SPEED also drive the engine and wheels. Those two controls are manual when live OBD drive is inactive.
frontend/SensorMenu.qml 3×2 grid: SensorCards for PITCH, ROLL, HEADING, ALTITUDE, TEMPERATURE plus a GForceDisplay (lateral / longitudinal dot with a trail, maxG 1.5). Binds to settingsManager.sensorDecimalPlaces, sensorTempUnit (F/C), sensorAltitudeUnit (m/ft) and sensorCards (the six sensorShow* keys), so changes in Settings show on return although the page is cached (the emit rate is the manager's own setting). The temperature card is additionally gated on berryIMU.hasTemperature !== false. ZERO / RESET buttons.
frontend/SensorHome.qml Hub page: an "IMU Connected" / "IMU Disconnected" indicator driven by berryIMU.connected and connectionStatusChanged, plus tiles for SensorMenu and CarMenu. The CarMenu tile is dimmed and disabled only when the optional Qt Quick 3D module is missing (has3DView, from main.cpp's hasQuick3D), not by IMU state; a "Calibration" tile is a "Coming soon" placeholder.
frontend/OBDParameterModel.qml src/main.cpp connects the eight IMU signals to the C++ value store (paramStore.ingest(id, value)), which this singleton mirrors, so every gauge, dashboard, PID picker and home card treats them like OBD PIDs:
IDTitleUnitRangeKind
PITCHPitch°−90 … 90bidirectional
ROLLRoll°−180 … 180bidirectional
HEADINGHeading°0 … 360numeric
ALTITUDEAltitudem0 … 3000numeric
ACCEL_MAGG Magnitudeg0 … 3numeric
LATERAL_GLateral Gg−2 … 2bidirectional
LONGITUDINAL_GLongitudinal Gg−2 … 2bidirectional
BARO_TEMPCabin Baro Temp°C−20 … 60temperature
frontend/gauges/GForceGauge.qml self-binds to LATERAL_G / LONGITUDINAL_G from this store. See Gauges & Dashboards.
frontend/settings/AccessoriesSettingsPage.qml "IMU Sensor" tile: status dot + text from getConnectionStatus() / connectionStatusChanged; enable toggle calls save_imu_enabled() (the manager follows the setting); display settings; emit-rate slider.

Android: QtSensors Implementation

Under Q_OS_MOBILE the same header declares a different BerryIMUManager backed by QAccelerometer, QCompass and QPressureSensor (the Sensors Qt module is added to the build only for Android). Same QML name, same signals and slots, but:

  • No Madgwick: pitch = atan2(ay, √(ax²+az²)), roll = atan2(−ax, az) from the accelerometer alone; the tare is a pair of angle offsets subtracted from them. orientationChanged is then built from the tared angles as q = q_pitch(Y) × q_roll(X) with yaw = 0 — the same yaw-stripped, tared convention desktop emits, so CarMenu rotates the model identically on both.
  • Lateral G = ax / 9.81, longitudinal G = ay / 9.81, magnitude = |a| / 9.81 (QtSensors reports m/s²).
  • Heading = compass azimuth, normalised to ≥ 0 because some Android devices report −180…180.
  • Altitude from the pressure sensor (Pa, against 101 325 Pa) at 10 Hz; baroTempChanged and hasTemperature only turn on once a non-zero temperature arrives (most phones report 0).
  • Rates: accelerometer and compass run at setEmitRate() (default 60, clamped 10–120 like desktop) and drop to 5 Hz when setActive(false) — the requested sensor rate drops to 5 Hz, and idleThrottled() separately caps accelerometer and compass emission at once per 200 ms because Android may treat the requested rate as a hint. Pressure remains requested at 10 Hz.
  • Status is "Connected" if the accelerometer starts, otherwise "Unavailable". setEnabled() (driven by imuEnabledChanged, as on desktop; it does not save) starts/stops the sensors and emits "Connected" / "Unavailable" / "Disabled"; because the phone sensors are started in the constructor, connect_settings_manager() stops them again when the persisted value is false.

BerryIMU Signals, Properties & Slots

Signals

SignalParametersRateDescription
orientationChangedfloat w, x, y, zemit rateTared, yaw-stripped quaternion for the 3D model (on Android rebuilt from the tared pitch / roll)
pitchChangedfloat degemit rateFrom the tared quaternion, ±90
rollChangedfloat degemit rateFrom the tared quaternion, ±180
headingChangedfloat 0–360emit rateRaw magnetometer atan2(my, mx)
accelMagnitudeChangedfloat gemit rate|a|, not bias-corrected
lateralGChangedfloat gemit rateay minus tare bias
longitudinalGChangedfloat gemit rateax minus tare bias
altitudeChangedfloat m5 HzBarometric altitude vs 1013.25 hPa
baroTempChangedfloat °C5 HzBMP388 die temperature
connectionStatusChangedQString—"Connected", "Disconnected", "Error", "Disabled" (Android also "Unavailable")
hasTemperatureChangedbool—Only ever emitted by the Android variant

Properties

PropertyTypeDescription
connectedboolRead loop running (notify: connectionStatusChanged)
hasTemperatureboolAlways true on desktop (the BMP388 always reports temperature); Android flips it on the first non-zero reading. Gates the TEMPERATURE card in SensorMenu.

Slots

SlotDescription
setEnabled(bool)Runtime switch, called on imuEnabledChanged; does not save. Starts (retry counter reset) or stops the worker and emits "Disabled"
isEnabled()Current enabled flag
setEmitRate(int hz)Clamp 10–120, interval = 1/hz; stored on the manager and applied to every worker
setActive(bool)Driven by setDemand; false drops emission to 5 Hz
setDemand(QString consumer, bool wanted)A page showing IMU readings registers while on screen (frontend/ImuDemand.qml); full rate while any consumer wants it
calibrateTare()ZERO — request the read loop to capture the current tilt and accel bias (does nothing while no worker exists)
resetTare()RESET — identity tare, zero biases
getConnectionStatus()"Connected" or "Disconnected" (Android: "Connected" / "Unavailable")
cleanup()Sets the shutting-down flag (suppresses retries), stops the thread, closes the bus

Gesture Manager

GestureManager drives a PixArt PAJ7620U2 gesture sensor over I2C for touchless control. A worker thread polls the sensor's two gesture-flag registers, resolves nine gestures, maps each to an action string and emits it; the application wiring turns actions into media / volume calls.

Linux (Raspberry Pi / Orange Pi)

PAJ7620U2 Sensor

Same bus as the IMU (I2C_BUS = 2), address 0x73. The worker uses the same raw ioctl/read/write helpers as the IMU worker.

RegisterAddressPurpose
REG_BANK_SEL0xEFBank select (0 or 1); writing 0 also serves as the wake-up
REG_CHIP_ID_L / _H0x00 / 0x01Chip ID, expected 0x7620 ((high << 8) | low)
REG_GESTURE_00x43Directional + rotation flags
REG_GESTURE_10x44Wave flag (bit 0)

Initialisation (after the 1 s start delay)

  1. Open the bus.
  2. Wake up: write 0xEF ← 0x00, sleep 10 ms. If the first write fails, wait 50 ms and try once more (the chip is asleep until addressed).
  3. Verify chip ID: select bank 0, read 0x00 and 0x01; anything but 0x7620 is an init error ("expected chip ID 0x7620, got …").
  4. Write the init table: INIT_REGISTER_ARRAY, the DFRobot reference configuration — 219 register/value pairs starting with a bank-0 select, switching to bank 1 midway. It sets the sensor's gain, thresholds and gesture-engine parameters.
  5. Select bank 0 again for gesture reading; log PAJ7620U2 initialized (chip ID: 0x7620); emit "Connected".

All of this runs on the worker thread (GestureWorker::run()), not the GUI thread.

Failure handling, retries (5 × linear 5 s), the no-retry on a permission error and the dead-bus threshold (5 consecutive failed reads of the flag registers → "Error" → retry) are identical to the IMU manager.

Recognized Gestures

Gesture nameRegisterBitConstant
RIGHT0x430x01GES_RIGHT
LEFT0x430x02GES_LEFT
UP0x430x04GES_UP
DOWN0x430x08GES_DOWN
FORWARD0x430x10GES_FORWARD
BACKWARD0x430x20GES_BACKWARD
CLOCKWISE0x430x40GES_CLOCKWISE
COUNTER-CLOCKWISE0x430x80GES_COUNTERCLOCKWISE
WAVE0x440x01GES_WAVE

decodeGesture(flag0, flag1) tests the 0x43 bits in the table order and returns the first set bit's name; WAVE is only reported when 0x43 is entirely zero.

Read Loop & Two-Pass Disambiguation

ConstantValuePurpose
POLL_INTERVAL0.02 s (~50 Hz)Sleep between polls
GESTURE_COOLDOWN0.3 sDefault minimum gap between gestures; overridden by the gestureCooldown setting
GES_FORWARD_WAIT0.30 sSettle time after an initial FORWARD flag
GES_BACKWARD_WAIT0.40 sSettle time after an initial BACKWARD flag
GES_POST_FLUSH0.10 sRead-and-discard window after a confirmed gesture

Each iteration reads both flag registers in one 2-byte block read from 0x43, then:

  1. No flag set → sleep POLL_INTERVAL, continue.
  2. Cooldown gate: if less than the cooldown has passed since the last accepted gesture, ignore and sleep.
  3. FORWARD flag: sleep 300 ms, re-read. If the re-read decodes to a gesture other than FORWARD/BACKWARD, use it (the approach was really the start of a swipe); otherwise it is a FORWARD.
  4. BACKWARD flag: same with a 400 ms wait.
  5. Anything else (directional, rotation, wave): sleep 20 ms and read again as a "confirmation". The PAJ7620 clears 0x43/0x44 on read, so a clean gesture re-reads as the same flag or as nothing — both accept first. A different non-empty gesture on the re-read means the sensor is still settling on some other motion, so the sample is discarded (logged at debug level as "discarding <first> - confirmation read saw <second>").
  6. Accept: bump the count, stamp lastGestureTime, emit gestureDetected(name); look up the action; if it is not "none" emit actionTriggered(action) and log <gesture> -> <action> (#n).
  7. Post-flush: for 100 ms read and discard the flag registers every 10 ms so the tail of the same hand motion cannot fire twice.
  8. Sleep POLL_INTERVAL.

A failed block read is skipped (sleep POLL_INTERVAL, poll again); after 5 consecutive failures (MAX_CONSECUTIVE_READ_FAILURES) the loop logs "read loop error: 5 consecutive I2C read failures", exits, closes the bus, emits "Error" and retries.

Action Mapping & Settings

Each gesture maps to an action string. The vocabulary is one table, src/managers/gesturevocabulary.h: the nine gestures, the actions (with their labels, none = "Disabled") and the default mapping, which is both the gestureMapping settings default and GestureManager's fallback. The Accessories page lists gestureSensor.gestures and gestureSensor.actions rather than its own copy:

"gestureMapping": {
    "RIGHT":             "next_track",
    "LEFT":              "previous_track",
    "UP":                "volume_up",
    "DOWN":              "volume_down",
    "FORWARD":           "play_pause",
    "BACKWARD":          "mute_toggle",
    "CLOCKWISE":         "volume_up",
    "COUNTER-CLOCKWISE": "volume_down",
    "WAVE":              "play_pause"
}

The action handler is a lambda on actionTriggered in src/main.cpp. The three track actions go to the phone instead of local media while phone mirroring holds the media controls (phoneMirrorManager.mediaActive()):

ActionEffect
next_trackmediaManager.next_track() (phone: phoneMirrorManager.mediaNext())
previous_trackmediaManager.previous_track() (phone: phoneMirrorManager.mediaPrevious())
play_pausemediaManager.toggle_play() (phone: phoneMirrorManager.mediaPlayPause())
mute_togglevolumeController.toggleMute()
volume_up / volume_downvolumeController.applyVolume(currentVolume ± gestureVolumeStep)
noneGesture disabled (the settings UI labels it "Disabled")

Settings (see Settings Reference):

KeyDefaultUI rangeRead by
gestureSensorEnabledtruetoggleconnect_settings_manager(), then live on gestureSensorEnabledChanged; the settings toggle and the dashboard Accessories widget only call save_gesture_sensor_enabled()
gestureMappingtable abovechip picker per gestureget_gesture_mapping_dict() at startup; setGestureAction() / resetMappingToDefaults() write back through save_gesture_mapping()
gestureVolumeStep51–100The action handler in src/main.cpp
gestureCooldown300 ms100–2000, step 50connect_settings_manager(), then live on gestureCooldownChanged; slider and chips only call save_gesture_cooldown()

The mapping and cooldown live on the manager and are pushed to the worker under m_configMutex whenever they change (or when a worker is created), because the worker thread reads them on every gesture.

Gesture Signals, Properties & Slots

Signals

SignalParametersDescription
gestureDetectedQStringRaw gesture name, emitted for every accepted gesture even if mapped to none
actionTriggeredQStringMapped action (not emitted for none)
connectionStatusChangedQString"Connected", "Disconnected", "Error", "Disabled"

Properties

PropertyTypeDescription
connectionStatusQStringBindable form of getConnectionStatus(); read by frontend/settings/widgets/AccessoriesWidget.qml. Added to the desktop class in commit 78ab72c (it had been mobile-stub-only).

Slots

SlotDescription
setGestureAction(gesture, action)Remap one gesture and persist the whole mapping
getGestureAction(gesture)Current action, "none" if unmapped
resetMappingToDefaults()Restore the default table and persist it
setEnabled(bool)Runtime switch, called on gestureSensorEnabledChanged; does not save. Start (retry counter reset) or stop the worker and emit "Disabled"
setCooldown(int ms)Runtime cooldown, called on gestureCooldownChanged; does not save. cooldownMs() reads it back
isEnabled()Current enabled flag
getConnectionStatus()"Connected" / "Disconnected" ("Disabled on mobile" from the Android stub)
cleanup()Set shutting-down, stop the worker, close the bus

Design Decisions

Each rationale below is taken from source comments or commit messages, not inferred.

Worker threads for I2C, an event loop for serial

The IMU and gesture drivers run their loops on dedicated threads (QThread + worker QObject) rather than QTimer ticks on the GUI thread. The measurement in f1eb526 is the reason it has to be that way: one IMU iteration blocks on the bus for ~4.3 ms (80 %+ of the period), and the gesture loop deliberately sleeps 300–400 ms mid-detection. The loops, and the bus initialisation before them, therefore run away from the thread that paints QML. The price is that every emitted value crosses threads as a queued signal, which is exactly what the emission gating below manages. The ESP32 knob, by contrast, sends a few bytes per second, so QSerialPort simply integrates with the event loop (readyRead) and needs no thread.

A paced loop instead of "read as fast as you can"

ed826ed: the loops used to sleep a fixed 1 ms, which "with three I2C reads per iteration kept the bus saturated and left the worker thread in D-state at ~9 % CPU on the Orange Pi. The LSM6DSL ODR is 416 Hz and the emit rate is 60 Hz, so reading that fast bought nothing." The 5 ms period is chosen to stay under the configured sensor output data rate while giving the filter 3+ samples per emit, and sleeping out the remainder makes the cadence independent of bus speed. Moving the barometer from "every 80th iteration" to a time-based interval removed a cadence that only approximated 5 Hz at the old loop rate. f1eb526 then corrected the record: the old loop was not spinning, it was bus-bound, so the CPU shown is the cost of reading the sensor; the explicit period is kept for its predictability and the 100 Hz option is documented for anyone who wants the CPU back.

Emission gating rather than stopping the sensor

94d61be keeps "sampling and Madgwick fusion at full rate, but the seven queued signals per tick into QML drop from 60 Hz to 5 Hz" when nothing consumes them. Stopping the loop instead would lose the gyro-integrated attitude and the tare context, and would make the first frame after opening CarMenu wrong; 5 Hz still keeps the Settings status line and hub tiles honest. Driving it from Main.qml's StackView means pages "need no changes". 2fef514 widened the full-rate set once dashboards and home cards started reading the IMU through OBDParameterModel, so the consumer list is a property of what is on screen, not of which page type it is.

One quaternion, yaw stripped, heading from the raw magnetometer

The file header states the goal: "Uses Madgwick AHRS for sensor fusion — outputs a quaternion directly to the 3D model, completely avoiding gimbal lock." Yaw is removed because the model only needs pitch and roll and heading drift must never rotate it; the compass value is computed from the raw magnetometer instead of the filter. Freezing the filter when the deadbanded gyro is zero stops the accelerometer correction from producing visible settling on a parked vehicle. The magnetometer is left out of the fusion entirely (zeros are passed) because in-car magnetic interference from the alternator, speakers and steel body would pull the quaternion around; that leaves the 9DOF code idle until fusion can be tested on a vehicle.

Tare over persistent mounting calibration

Rather than a mounting-angle setting, the user presses ZERO on a level surface. The tare is a quaternion captured in the same yaw-stripped domain as the output "so the tare operates in the same domain as the values it's applied to", and it captures the accelerometer bias at the same moment so the G displays zero with it. Gyro bias is re-measured on every start because it is temperature-dependent; the tare is not persisted.

A single volume entry point

src/managers/volumecontroller.h explains it: "Every audio output in the app must use this same curve or they will drift out of sync when the user moves the slider." The curve and the dispatch therefore live in one place (commit d2b047b, "volume curve de-duplication"). Hardware handlers never call a manager's setVolume; they call applyVolume(currentVolume + delta). The same header gives the reason for the signal-based fan-out: "VolumeController doesn't need pointers to managers that may not exist yet." The delta accumulator exists because the knob step can be as small as 0.25 %, and applyVolume takes an integer percent.

LED debounce, keepalive and sleep

The 50 ms trailing debounce ("max 20 updates/sec") exists "to prevent LED flickering" during a fast knob spin or slider drag, while always delivering the final value. The 2 s keepalive is sized against the dongle's 5 s LED timeout, and LED sleep is implemented by simply not sending keepalives, so the firmware needs no extra command and the ring goes dark on its own when OCTAVE exits, which is why sleep is the default.

Two-pass gesture disambiguation and post-flush

From the source: "Two-pass disambiguation for forward/backward compound gestures (the sensor initially reports directional, then settles)." The waits (300 / 400 ms) and the init table follow the DFRobot reference driver. The 100 ms flush after a confirmed gesture keeps "the same motion" from firing twice, and the user-tunable cooldown is the coarse guard above that.

Linear retries, permission errors excluded

Both I2C managers retry five times at 5 s × n. A missing /dev/i2c-2 may appear later (overlay loading, hot-plug), so it is retried; a permission error is not, because only the user can fix group membership: the worker checks errno for EACCES / EPERM after open() and reports stopped(retryable = false). Inside the loop the worker tolerates up to five consecutive failed block reads before it declares the bus dead, because a single NACK on a long cable is not worth a reconnect cycle.

Compile-time stubs for mobile

Rather than #ifdef-ing every QML binding, each hardware header carries a mobile stub with the same names; 78ab72c added the signals "QML Connections handlers reference" to those stubs so the shared frontend loads unchanged on Android. The stubs now carry the exact desktop signatures: the ESP32 stub declares the four properties (connected, connectionStatus, connectionDetail, availablePorts), every desktop slot (availablePorts is empty) and the desktop signal set (the former stub-only connectionStateChanged is gone); the gesture stub declares gestureDetected, actionTriggered, connectionStatusChanged(QString), setCooldown(int), isEnabled() and getConnectionStatus(). The IMU is the one manager with a real mobile implementation because a phone has the sensors on board.