Hardware Managers
ESP32 Volume Knob, BerryIMU v3 Sensor Fusion, PAJ7620U2 Gesture Sensor, and the VolumeController they all feed
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.
| Manager | QML name | Source | Log category | Transport |
|---|---|---|---|---|
ESP32VolumeManager | esp32VolumeManager | src/managers/esp32volumemanager.{h,cpp} | octave.esp32volume | USB serial (QSerialPort) |
BerryIMUManager | berryIMU | src/managers/berryimumanager.{h,cpp} | octave.berryimu (octave.imu.mobile on Android) | I2C bus 2 (/dev/i2c-2) |
GestureManager | gestureSensor | src/managers/gesturemanager.{h,cpp} | octave.gesture | I2C bus 2 (/dev/i2c-2) |
VolumeController | volumeController | src/managers/volumecontroller.{h,cpp} | — | — (routes 0–100 % to every audio output) |
What runs where
| Platform | ESP32 knob | BerryIMU | Gesture 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.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
connect_settings_manager()wires the fiveesp32*Changedsettings signals, readsesp32VolumeStepSize, and starts a 3 s one-shot startup timer.initialConnect()runs a scan. Ifesp32VolumeEnabledis false it stops here.- The port comes from
esp32VolumePort. Only if that setting is empty does auto-selection happen: the detected ESP32-S3 port is saved viasave_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 /ttyS0and reported "Connected" with no knob attached. - With a port,
connect_device()is called; without one the detail becomes "No port configured - select one above".
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()
- Returns early if already connected, if there is no settings manager, or if
esp32VolumeEnabledis false. - Empty port → status "Not Configured", detail "No port configured", and the 10 s background scan timer starts.
- Otherwise status "Connecting" ("Connecting to <port>...") and the port is opened.
- 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 ifesp32LedSleepEnabledis false, background scan stopped. - 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, sosend_volume_update/send_mute_state/send_theme_colorwrite 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. - 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
| Command | Meaning | Emitted signal |
|---|---|---|
+ | One tick clockwise | volumeChangeRequested(+step) |
+N | N ticks clockwise (e.g. +3) | volumeChangeRequested(+step × N); a non-integer N logs "invalid command format" |
- | One tick counter-clockwise | volumeChangeRequested(-step) |
-N | N ticks counter-clockwise | volumeChangeRequested(-step × N) |
M | Encoder button press | muteToggleRequested() |
P | Play/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)
| Purpose | Format | Method | Notes |
|---|---|---|---|
| Volume level for the LED ring | V<0-100>\n | send_volume_update(int) | Debounced 50 ms; also used as the keepalive |
| Mute indicator | M0\n / M1\n | send_mute_state(bool) | Sent immediately |
| LED colour | C<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 theaccentof thealbumArtColorsmap 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
| Event | Handling |
|---|---|
| Port error | QSerialPort::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 error | Every 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
| Signal | Parameters | Description |
|---|---|---|
connectionStatusChanged | QString | "Connected", "Connecting", "Disconnected", "Error", "Not Configured" |
connectionDetailChanged | QString | Human-readable detail shown on the Accessories page |
availablePortsChanged | QVariantList | Scan produced a different port list |
volumeChangeRequested | float | Relative delta = step × ticks (may be fractional) |
muteToggleRequested | — | Encoder button pressed |
Properties
| Property | Type | Notify |
|---|---|---|
connected | bool | connectionStatusChanged |
connectionStatus | QString | connectionStatusChanged |
connectionDetail | QString | connectionDetailChanged |
availablePorts | QVariantList of {port, label, description, isEsp32S3} | availablePortsChanged |
Slots
| Slot | Returns | Description |
|---|---|---|
connect_device() | — | Open the configured port (see sequence above) |
disconnect_device() | — | Stop reconnect + keepalive timers, close the port, emit "Disconnected" |
get_available_ports() | QVariantList | Scan now and return availablePorts |
is_connected() | bool | Read by AccessoriesWidget.qml and the settings page status dot |
get_connection_status() | QString | Same as connectionStatus |
get_connection_detail() | QString | Same 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
| Timer | Interval | Purpose |
|---|---|---|
m_startupTimer | 3 000 ms, one-shot | Delay the first scan + connect after app start |
m_scanTimer | 10 000 ms | Background port scan while disconnected |
m_reconnectTimer | 3–10 s, one-shot | Auto-reconnect attempt |
m_volumeSendTimer | 50 ms, one-shot | Debounce V commands |
m_keepaliveTimer | 2 000 ms | Keep 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.
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).
| Chip | Address | Function | Init writes (reg ← value; source settings) | Data read | Scale |
|---|---|---|---|---|---|
| LSM6DSL | 0x6A |
Accelerometer + gyroscope | 0x10 ← 0x68 accel 416 Hz, ±4 g0x11 ← 0x60 gyro 416 Hz, 245 dps0x12 ← 0x44 BDU + register auto-increment |
Accel: 6 bytes from 0x28Gyro: 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 Hz0x21 ← 0x20 ±8 gauss0x22 ← 0x00 continuous conversion0x23 ← 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
- The constructor schedules
startSensor()with a 1 s single-shot timer;connect_settings_manager()readsimuEnabledandsensorEmitRatefirst, then follows both live (imuEnabledChanged→setEnabled,sensorEmitRateChanged→setEmitRate), as the ESP32 manager does: a page or widget only saves. - If disabled: log "disabled in settings, skipping start", emit
connectionStatusChanged("Disabled"), stop. - Any previous worker is stopped (
stopWorker(), 2 s wait) before a (re)connect. - Open the bus, initialise LSM6DSL, LIS3MDL, BMP388 in that order.
- 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).
| Failure | Handling |
|---|---|
Bus cannot be opened (/dev/i2c-2 absent) | openBus() fails → "Disconnected", worker emits stopped(retryable = true) → scheduleRetry() |
| Permission denied | open() 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 loop | A 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).
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.
| Constant | Value | Purpose |
|---|---|---|
MADGWICK_BETA | 0.03 | Filter gain: "0.01 = very smooth (slow correction), 0.1 = snappy (more noise)". Passed to the constructor (whose own default is 0.04, unused). |
GYRO_DEADBAND | 0.4 dps | Bias-corrected gyro axes with |value| below this are zeroed |
SAMPLE_INTERVAL | 0.005 s (200 Hz target) | Read-loop period; the thread sleeps out the remainder after each I2C round |
EMIT_INTERVAL | 0.016 s (~60 Hz) | Default emission period; changeable at runtime with setEmitRate() |
IDLE_EMIT_INTERVAL | 0.2 s (5 Hz) | Emission period while no page shows the values |
BARO_INTERVAL | 0.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:
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.- Service a pending tare / reset-tare request (atomic flags → mutex-protected
m_tareQ). - Three I2C block reads: accel, gyro, mag.
- Subtract the gyro bias; apply the deadband per axis; convert to rad/s.
- 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. - Remember
ax, ayfor tare capture; compute|a|. - 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=[…]. - If
BARO_INTERVALhas elapsed (it has on the first iteration, becauselastBarostarts at 0), read the BMP388 and emitaltitudeChanged+baroTempChanged. The first successful read is logged once:baro - alt=…m temp=…C(abaroLoggedflag). The emit is skipped when the compensated pressure is 0 (failed read). remaining = SAMPLE_INTERVAL - (getTime() - now); sleep it if positive.
strace -T)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:
-
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 -
Tare.
q_out = q_tare × q_tilt(full Hamilton product) whereq_tareis 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
| File | What 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:
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.orientationChangedis then built from the tared angles asq = q_pitch(Y) × q_roll(X)with yaw = 0 — the same yaw-stripped, tared convention desktop emits, soCarMenurotates 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;
baroTempChangedandhasTemperatureonly 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 whensetActive(false)— the requested sensor rate drops to 5 Hz, andidleThrottled()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 byimuEnabledChanged, 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 isfalse.
BerryIMU Signals, Properties & Slots
Signals
| Signal | Parameters | Rate | Description |
|---|---|---|---|
orientationChanged | float w, x, y, z | emit rate | Tared, yaw-stripped quaternion for the 3D model (on Android rebuilt from the tared pitch / roll) |
pitchChanged | float deg | emit rate | From the tared quaternion, ±90 |
rollChanged | float deg | emit rate | From the tared quaternion, ±180 |
headingChanged | float 0–360 | emit rate | Raw magnetometer atan2(my, mx) |
accelMagnitudeChanged | float g | emit rate | |a|, not bias-corrected |
lateralGChanged | float g | emit rate | ay minus tare bias |
longitudinalGChanged | float g | emit rate | ax minus tare bias |
altitudeChanged | float m | 5 Hz | Barometric altitude vs 1013.25 hPa |
baroTempChanged | float °C | 5 Hz | BMP388 die temperature |
connectionStatusChanged | QString | — | "Connected", "Disconnected", "Error", "Disabled" (Android also "Unavailable") |
hasTemperatureChanged | bool | — | Only ever emitted by the Android variant |
Properties
| Property | Type | Description |
|---|---|---|
connected | bool | Read loop running (notify: connectionStatusChanged) |
hasTemperature | bool | Always true on desktop (the BMP388 always reports temperature); Android flips it on the first non-zero reading. Gates the TEMPERATURE card in SensorMenu. |
Slots
| Slot | Description |
|---|---|
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.
| Register | Address | Purpose |
|---|---|---|
REG_BANK_SEL | 0xEF | Bank select (0 or 1); writing 0 also serves as the wake-up |
REG_CHIP_ID_L / _H | 0x00 / 0x01 | Chip ID, expected 0x7620 ((high << 8) | low) |
REG_GESTURE_0 | 0x43 | Directional + rotation flags |
REG_GESTURE_1 | 0x44 | Wave flag (bit 0) |
Initialisation (after the 1 s start delay)
- Open the bus.
- 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). - Verify chip ID: select bank 0, read
0x00and0x01; anything but0x7620is an init error ("expected chip ID 0x7620, got …"). - 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. - 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 name | Register | Bit | Constant |
|---|---|---|---|
RIGHT | 0x43 | 0x01 | GES_RIGHT |
LEFT | 0x43 | 0x02 | GES_LEFT |
UP | 0x43 | 0x04 | GES_UP |
DOWN | 0x43 | 0x08 | GES_DOWN |
FORWARD | 0x43 | 0x10 | GES_FORWARD |
BACKWARD | 0x43 | 0x20 | GES_BACKWARD |
CLOCKWISE | 0x43 | 0x40 | GES_CLOCKWISE |
COUNTER-CLOCKWISE | 0x43 | 0x80 | GES_COUNTERCLOCKWISE |
WAVE | 0x44 | 0x01 | GES_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
| Constant | Value | Purpose |
|---|---|---|
POLL_INTERVAL | 0.02 s (~50 Hz) | Sleep between polls |
GESTURE_COOLDOWN | 0.3 s | Default minimum gap between gestures; overridden by the gestureCooldown setting |
GES_FORWARD_WAIT | 0.30 s | Settle time after an initial FORWARD flag |
GES_BACKWARD_WAIT | 0.40 s | Settle time after an initial BACKWARD flag |
GES_POST_FLUSH | 0.10 s | Read-and-discard window after a confirmed gesture |
Each iteration reads both flag registers in one 2-byte block read from 0x43, then:
- No flag set → sleep
POLL_INTERVAL, continue. - Cooldown gate: if less than the cooldown has passed since the last accepted gesture, ignore and sleep.
- 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.
- BACKWARD flag: same with a 400 ms wait.
- Anything else (directional, rotation, wave): sleep 20 ms and read again as a "confirmation". The PAJ7620 clears
0x43/0x44on read, so a clean gesture re-reads as the same flag or as nothing — both acceptfirst. 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>"). - Accept: bump the count, stamp
lastGestureTime, emitgestureDetected(name); look up the action; if it is not"none"emitactionTriggered(action)and log<gesture> -> <action> (#n). - 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.
- 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()):
| Action | Effect |
|---|---|
next_track | mediaManager.next_track() (phone: phoneMirrorManager.mediaNext()) |
previous_track | mediaManager.previous_track() (phone: phoneMirrorManager.mediaPrevious()) |
play_pause | mediaManager.toggle_play() (phone: phoneMirrorManager.mediaPlayPause()) |
mute_toggle | volumeController.toggleMute() |
volume_up / volume_down | volumeController.applyVolume(currentVolume ± gestureVolumeStep) |
none | Gesture disabled (the settings UI labels it "Disabled") |
Settings (see Settings Reference):
| Key | Default | UI range | Read by |
|---|---|---|---|
gestureSensorEnabled | true | toggle | connect_settings_manager(), then live on gestureSensorEnabledChanged; the settings toggle and the dashboard Accessories widget only call save_gesture_sensor_enabled() |
gestureMapping | table above | chip picker per gesture | get_gesture_mapping_dict() at startup; setGestureAction() / resetMappingToDefaults() write back through save_gesture_mapping() |
gestureVolumeStep | 5 | 1–100 | The action handler in src/main.cpp |
gestureCooldown | 300 ms | 100–2000, step 50 | connect_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
| Signal | Parameters | Description |
|---|---|---|
gestureDetected | QString | Raw gesture name, emitted for every accepted gesture even if mapped to none |
actionTriggered | QString | Mapped action (not emitted for none) |
connectionStatusChanged | QString | "Connected", "Disconnected", "Error", "Disabled" |
Properties
| Property | Type | Description |
|---|---|---|
connectionStatus | QString | Bindable 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
| Slot | Description |
|---|---|
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.