Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ef8c151
I2C_Async: Debug: add I2C debug mode and restore MAG_CALIB in the deb…
error414 Oct 4, 2026
070c7cc
I2C_Async: I2C: non-blocking bus API and interrupt driven STM32F4 driver
error414 Oct 4, 2026
190ba3c
I2C_Async: I2C: interrupt driven transfers in the F7/H7 HAL driver
error414 Oct 4, 2026
9e5d382
I2C_Async: I2C: interrupt driven transfers for AT32 and RP2350
error414 Oct 4, 2026
0519c7b
I2C_Async: Baro: non-blocking state machine, SPL06 served by the DPS3…
error414 Oct 4, 2026
58604ae
I2C_Async: Baro: non-blocking reads for BMP085, BMP280, BMP388, MS56x…
error414 Oct 4, 2026
e40d2b3
I2C_Async: Compass: non-blocking multi-step reads for all I2C drivers
error414 Oct 4, 2026
5fc6f54
I2C_Async: Pitot: non-blocking reads for MS4525, MS5525 and DLVR
error414 Oct 4, 2026
bf79024
I2C_Async: Rangefinder: non-blocking reads for the I2C drivers
error414 Oct 4, 2026
f47b6e8
I2C_Async: IRLock, INA226, temperature sensors, PCF8574: non-blocking…
error414 Oct 4, 2026
ed6db30
I2C_Async: OLED: queue display bytes and send them in the background
error414 Oct 4, 2026
442cbfe
I2C_Async: Docs: non-blocking I2C sensor access
error414 Oct 4, 2026
ec12733
I2C_Async - merge maitanence-10.x
error414 Oct 5, 2026
9a2da45
I2C_Async - fix hard fault
error414 Oct 5, 2026
0bbace2
I2C_Async - fix restart error handling
error414 Oct 6, 2026
d0a630a
I2C_Async - fix pitot tube samples
error414 Oct 6, 2026
c8e45e6
I2C_Async - fix SPI speed for new targets
error414 Oct 6, 2026
15f26be
I2C_Async - fix Sensors on a shared bus get each other's results
error414 Oct 6, 2026
1751593
I2C_Async - fix error count NACK
error414 Oct 6, 2026
ce0af93
I2C_Async - fix ina226 tests
error414 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ docs — check it before assuming something isn't documented:
versioning rules, when a new setting is warranted)
- `build-system.md` - CMake build system internals
- `pid-to-servo-computation.md` - PID-to-servo signal path (fixed-wing)
- `non-blocking-i2c.md` - Non-blocking I2C sensor access (bus API, baro/compass/pitot hooks, driver conversion)
- `msp/` - MSP protocol doc generation tooling
- Target-specific files in `/src/main/target/`

Expand Down
1 change: 1 addition & 0 deletions docs/Settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ Defines debug values exposed in debug variables (developer / debugging setting)
| ESC | |
| FW_TURN | |
| MAG | |
| I2C | |

---

Expand Down
184 changes: 184 additions & 0 deletions docs/development/non-blocking-i2c.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Non-blocking I2C sensor access

How the barometer, compass and pitot tasks read their sensors without
blocking the scheduler, and how to convert a driver.

## Why

An I2C register read of 6 bytes at 400 kHz keeps the wire busy for about
200 µs. Before this change every `busReadBuf()` spun in the platform driver
until the whole transfer had finished, so a BARO task that only needed a few
microseconds of CPU showed up as ~200 µs in `tasks`, and the compass, pitot
and rangefinder tasks behaved the same way.

Now the sensor task starts a transfer, returns, and parses the result on a
later run. The wire time stays the same, the CPU time moves to a handful of
short interrupts. The model follows Betaflight: no bus queue, no DMA, every
driver owns one outstanding transfer and polls for its completion.

## Layers

### Platform drivers (`drivers/bus_i2c_*.c`)

Every platform implements the same `drivers/bus_i2c.h` API:

| Function | Behaviour |
|---|---|
| `i2cRead()`, `i2cWrite()`, `i2cWriteBuffer()` | Blocking, unchanged for callers. They first wait for a pending non-blocking transfer, so drivers that were not converted keep working on a shared bus. |
| `i2cReadStart()`, `i2cWriteStart()` | Start a transfer and return at once. `false` means nothing was started (bus busy or start failed), try again later. |
| `i2cBusy(device, addr, &error)` | `true` while the given slave's non-blocking transfer runs. `error` reports the outcome of that slave's last completed transfer; the result is kept per address, so another device using the bus in between does not overwrite it. A transfer that never completes is reset after `I2C_TIMEOUT` and reported as failed to its owner. |

| Platform | Implementation |
|---|---|
| STM32F4 | Own interrupt-driven engine in `bus_i2c_stm32f40x.c`, ported from the Betaflight `bus_i2c_stm32f4xx.c` driver. Fully asynchronous. |
| STM32F7/H7 | `HAL_I2C_Mem_Read_IT()` and friends, completion through the `HAL_I2C_*CpltCallback()` / `HAL_I2C_ErrorCallback()` hooks. Fully asynchronous. |
| AT32F43x | `i2c_memory_read_int()` and friends from `i2c_application`. The library waits for the bus to be free and sends the slave and register address synchronously (tens of µs, bounded by the library timeout), only the data phase runs in the interrupt. |
| RP2350 | Commands are queued into the controller FIFO and finished by the `STOP_DET` / `TX_ABRT` interrupt, ported from the Betaflight Pico driver. Blocking calls still use the Pico SDK functions. |
| Software I2C, SITL | Synchronous: the start call completes the transfer, `i2cBusy()` is always `false`. |

`i2cWriteStart()` copies the data byte into driver-owned storage, the caller
does not have to keep it alive. Buffers given to `i2cReadStart()` must stay
valid until `i2cBusy()` reports idle, so drivers use static buffers.

### Bus layer (`drivers/bus.h`)

| Function | Behaviour |
|---|---|
| `busReadBufStart(dev, reg, buf, len)` | Non-blocking register read. On SPI the read completes inside the call. |
| `busWriteStart(dev, reg, data)` | Non-blocking single register write. |
| `busWriteBufStart(dev, reg, buf, len)` | Non-blocking multi-byte write, the buffer must stay valid until the bus is idle. A zero length sends the register byte alone. |
| `busIsBusy(dev, &error)` | Poll for completion of this device's transfer: `true` while it is on the bus, otherwise `error` carries its outcome. Another device's transfer on the same bus does not affect the result. |
| `busReadStepResult_e` | `BUS_READ_STEP_BUSY` (nothing started), `BUS_READ_STEP_NEXT` (intermediate transfer started), `BUS_READ_STEP_LAST` (final transfer started). Used by sensors whose sample needs several transfers. |

Raw access (`reg == 0xFF` with `DEVFLAGS_USE_RAW_REGISTERS`) works the same
way as for the blocking calls.

### Sensor layers

Each sensor subsystem drives its own small state machine and reschedules
its task with the returned delay. While a transfer is on the bus the task
asks to be called again after 1 ms, otherwise it returns to its regular
period.

**Barometer** (`sensors/barometer.c`, hooks in `drivers/barometer/barometer.h`)

```
start_ut -> (ut_delay) -> read_ut -> get_ut -> start_up -> (up_delay) -> read_up -> get_up -> calculate
```

* `start_*` triggers a measurement, usually a `busWriteStart()`.
* `read_*` starts the data transfer with `busReadBufStart()` and returns
`false` when the bus is busy.
* `get_*` only parses the static buffer and returns `false` when the sample
is unusable (phase is restarted).
* `combined_read = true` skips the temperature phase for sensors that deliver
both values in one read.
* Drivers without `read_*` hooks keep their blocking `get_*` and run exactly
as before.

A delay of 0 keeps the current task period. The estimator is fed only when a
new sample was produced.

**Compass** (`sensors/compass.c`, hooks in `drivers/compass/compass.h`)

`readStart(mag, firstStep)` returns a `busReadStepResult_e`. It is called with
`firstStep` set for the first transfer of a sample and again after every
completed transfer until it returns `BUS_READ_STEP_LAST`; `read()` then parses.
All I2C compass drivers are converted: one step for sensors in continuous
mode (HMC5883L, IST8308, IST8310, VCM5883, MLX90393), status then data for
sensors with a data-ready flag (QMC5883L/P, LIS2MDL, LIS3MDL, MAG3110,
RM3100), and status, data and the trigger of the next single measurement for
the AK8963 (three steps) and AK8975 (four steps, STATUS2 is read as well).

**Pitot** (`sensors/pitotmeter.c`, hooks in `drivers/pitotmeter/pitotmeter.h`)

Same `readStart(pitot, firstStep)` contract inside the existing protothread.
`start()` may be non-blocking as well. The MS4525 reads two frames per
sample (`NEXT`, `LAST`), MS5525 and DLVR one.

**Rangefinder** (`sensors/rangefinder.c`, hooks in `drivers/rangefinder/rangefinder.h`)

`update()` returns the delay until its next call, 0 meaning the regular
`delayMs`. Each I2C driver keeps a small step machine: it starts one
transfer per call, returns `RANGEFINDER_UPDATE_RETRY_US` while the transfer
is on the bus and evaluates the result on the next call. The VL53L0X start
sequence of eight register writes takes eight calls, the VL53L1X sends the
16 bit register index and reads the data in separate steps, the interrupt
polarity it needs is read once at init.

**Others**

* IRLock: `read()` returns `IRLOCK_READ_PENDING` while the sync scan or the
frame read is on the bus, `irlockUpdate()` reschedules the task.
* INA226: `ina226Update()` is polled from the battery task and alternates
bus and shunt voltage reads in the background; the read functions return
the last completed values, so each quantity refreshes every other battery
task cycle.
* LM75: `readStart()` plus `read()`, the protothread waits for the bus with
`ptWait()`. The result lands in the device scratchpad.
* DS2482 1-Wire bridge: commands are fired with `busWriteStart()` (blocking
fallback when the bus is busy), `owBusReady()` starts a status read on one
poll and evaluates it on the next. Reading the data register stays
blocking, two short transfers per byte.
* PCF8574: the output write is fired and forgotten, with the blocking write
as fallback.
* OLED (`display_ug2864hsweg01.c`): the drawing calls write into a shadow
of the panel's GDDRAM (8 pages x 128 columns) and never wait for the bus.
`i2c_OLED_pump()` streams the columns that changed: a 3 byte page/column
address run, then data runs of up to 15 bytes, one transfer per call. The
dashboard task calls the pump every millisecond while something is dirty,
the CMS display port reports that through `isTransferInProgress`. A
column rewritten with the same value costs nothing, a chunk lost to a bus
error is marked dirty again and the pump backs off for 200 ms. Only the
initialisation (command table, first clear) is blocking.

## Converting a driver

1. Replace every bus access in the measurement path with a static buffer
and a `busReadBufStart()` / `busWriteStart()` call that returns the
`BUSY` / started result to the sensor layer.
2. Keep `get()` / `read()` free of bus access: parse the buffer, validate,
return `false` for an unusable sample.
3. Leave initialisation and detection on the blocking calls.
4. If the sample needs several transfers, keep the step counter in the
driver, reset it on `firstStep`, and return `NEXT` until the last transfer.
5. Check the cadence: forced-mode sensors now sample every
`delay + 1 ms` instead of `2 * delay`, because the next measurement is
triggered right after the previous one was read.

Drivers for other buses (SPI gyros, MSP, fake sensors) need no change.

Every driver that was converted keeps its blocking init path; only the
periodic measurement path changed.

## Debugging

`set debug_mode = I2C`, then `debug` in the CLI:

| Field | Meaning |
|---|---|
| `debug[0]` | interrupts during the last transfer (about 10 for a 6 byte read on F4) |
| `debug[1]` | error interrupts since boot |
| `debug[2]` | duration of the last transfer in µs (about 200 for 6 bytes at 400 kHz) |
| `debug[3]` | longest single interrupt handler run in µs (F4 only) |
| `debug[4]` | longest wait for a START / STOP bit release in µs (F4 only) |
| `debug[5]` | I2C error counter, also shown by `status` |
| `debug[6]` | interrupt enables found at transfer start (F4 only, 1024 is normal) |
| `debug[7]` | time spent in the start call in µs, should be a few µs on F4/F7/H7/RP2350 |

In `tasks` the BARO, COMPASS and PITOT rates alternate between the regular
rate and 1000 Hz, because a cycle consists of a long wait and a 1 ms step.
The average execution time is what matters.

## Known limitations

* No bus arbitration queue: a device that finds the bus busy retries 1 ms
later. Fine at the 10 to 60 Hz rates these sensors use.
* Initialisation and detection of every driver, the DS2482 data register
read and the VL53L1X register helpers used at init still block. They are
safe because the blocking calls wait for a pending transfer first.
* The MPU9250 internal compass is read through the gyro's SPI slave interface
and keeps its own polling state machine.
* On the RP2350 a non-blocking write must fit the controller FIFO: the
register byte plus 15 data bytes.
* SPI stays synchronous.
2 changes: 0 additions & 2 deletions src/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,6 @@ main_sources(COMMON_SRC
drivers/barometer/barometer_lps25h.h
drivers/barometer/barometer_ms56xx.c
drivers/barometer/barometer_ms56xx.h
drivers/barometer/barometer_spl06.c
drivers/barometer/barometer_spl06.h
drivers/barometer/barometer_msp.c
drivers/barometer/barometer_msp.h
drivers/barometer/barometer_crsf.c
Expand Down
1 change: 1 addition & 0 deletions src/main/build/debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ typedef enum {
DEBUG_ESC,
DEBUG_FW_TURN,
DEBUG_MAG,
DEBUG_I2C,
DEBUG_COUNT // also update debugModeNames in cli.c
} debugType_e;

Expand Down
34 changes: 26 additions & 8 deletions src/main/drivers/1-wire/ds2482.c
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ static bool ds2482Reset(owDev_t *owDev)
return busWrite(owDev->busDev, 0xFF, DS2482_RESET_CMD);
}

// 1-Wire commands are fired without waiting for the bus; the blocking write is the fallback when another
// device's transfer is in flight, so a command is never lost
static bool ds2482WriteCommand(owDev_t *owDev, uint8_t reg, uint8_t data)
{
return busWriteStart(owDev->busDev, reg, data) || busWrite(owDev->busDev, reg, data);
}

static bool ds2482SetReadPtr(owDev_t *owDev, uint8_t reg)
{
return busWrite(owDev->busDev, DS2482_SET_READ_PTR_CMD, reg);
Expand Down Expand Up @@ -137,16 +144,27 @@ static bool ds2482WaitForBus(owDev_t *owDev)
return ds2482Poll(owDev, true, NULL);
}

// Polled from the temperature protothread: a status read is started on one call and evaluated on a later one
static bool ds2482OwBusReady(owDev_t *owDev)
{
bool ack = busRead(owDev->busDev, 0xFF, &owDev->status);
if (!ack) return false;
return !DS2482_1WIRE_BUSY(owDev->status);
static bool statusReadPending = false;
bool busError = false;

if (statusReadPending) {
if (busIsBusy(owDev->busDev, &busError) && !busError) return false;
statusReadPending = false;
if (busError) return false;
return !DS2482_1WIRE_BUSY(owDev->status);
}

// after a command the read pointer sits on the status register
statusReadPending = busReadBufStart(owDev->busDev, 0xFF, &owDev->status, 1);
return false;
}

static bool ds2482OwResetCommand(owDev_t *owDev)
{
return busWrite(owDev->busDev, 0xFF, DS2482_1WIRE_RESET_CMD);
return ds2482WriteCommand(owDev, 0xFF, DS2482_1WIRE_RESET_CMD);
}

static bool ds2482OwReset(owDev_t *owDev)
Expand All @@ -158,7 +176,7 @@ static bool ds2482OwReset(owDev_t *owDev)

static bool ds2482OwWriteByteCommand(owDev_t *owDev, uint8_t byte)
{
return busWrite(owDev->busDev, DS2482_1WIRE_WRITE_BYTE_CMD, byte);
return ds2482WriteCommand(owDev, DS2482_1WIRE_WRITE_BYTE_CMD, byte);
}

static bool ds2482OwWriteByte(owDev_t *owDev, uint8_t byte)
Expand All @@ -179,7 +197,7 @@ static bool ds2482OwWriteBuf(owDev_t *owDev, const uint8_t *buf, uint8_t len)

static bool ds2482OwReadByteCommand(owDev_t *owDev)
{
return busWrite(owDev->busDev, 0xFF, DS2482_1WIRE_READ_BYTE_CMD);
return ds2482WriteCommand(owDev, 0xFF, DS2482_1WIRE_READ_BYTE_CMD);
}

static bool ds2482OwReadByte(owDev_t *owDev, uint8_t *result)
Expand All @@ -204,7 +222,7 @@ static bool ds2482OwReadBuf(owDev_t *owDev, uint8_t *buf, uint8_t len)

static bool ds2482OwSingleBitCommand(owDev_t *owDev, uint8_t type)
{
return busWrite(owDev->busDev, DS2482_1WIRE_SINGLE_BIT_CMD, type);
return ds2482WriteCommand(owDev, DS2482_1WIRE_SINGLE_BIT_CMD, type);
}

static bool ds2482OwSingleBitResult(owDev_t *owDev)
Expand All @@ -225,7 +243,7 @@ static bool ds2482OwSingleBit(owDev_t *owDev, uint8_t type, bool *result)

static bool ds2482OwTripletCommand(owDev_t *owDev, uint8_t direction)
{
return busWrite(owDev->busDev, DS2482_1WIRE_TRIPLET_CMD, direction << 7);
return ds2482WriteCommand(owDev, DS2482_1WIRE_TRIPLET_CMD, direction << 7);
}

static uint8_t ds2482OwTripletResult(owDev_t *owDev)
Expand Down
11 changes: 11 additions & 0 deletions src/main/drivers/barometer/barometer.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ struct baroDev_s;
typedef bool (*baroOpFuncPtr)(struct baroDev_s * baro);
typedef bool (*baroCalculateFuncPtr)(struct baroDev_s * baro, int32_t *pressure, int32_t *temperature);

/*
* Measurement cycle driven by baroUpdate(): start_ut -> (ut_delay) -> read_ut -> get_ut -> start_up -> (up_delay) -> read_up -> get_up -> calculate
*
* A driver may leave any hook NULL. The optional read_* hooks start a non-blocking bus transfer and must return false
* when the bus is busy; the matching get_* then only parses the received buffer once the bus reports idle and returns
* false if the sample is not usable (the phase is restarted). Drivers without read_* hooks do their blocking bus access
* inside get_*. A delay of 0 keeps the current task period.
*/
typedef struct baroDev_s {
busDevice_t * busDev;
bool combined_read; // get_up delivers temperature as well, the temperature phase is skipped
uint16_t ut_delay;
uint16_t up_delay;
baroOpFuncPtr start_ut;
baroOpFuncPtr read_ut;
baroOpFuncPtr get_ut;
baroOpFuncPtr start_up;
baroOpFuncPtr read_up;
baroOpFuncPtr get_up;
baroCalculateFuncPtr calculate;
} baroDev_t;
18 changes: 14 additions & 4 deletions src/main/drivers/barometer/barometer_2smpb_02b.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <math.h>

#include <platform.h>

Expand Down Expand Up @@ -169,17 +170,21 @@ static bool deviceDetect(busDevice_t * busDev)
static bool b2smpbStartUP(baroDev_t *baro)
{
// start a forced measurement
return busWrite(baro->busDev, REG_CTRL_MEAS, REG_CLT_MEAS_VAL_TAVG4X_PAVG32X_FORCED);
return busWriteStart(baro->busDev, REG_CTRL_MEAS, REG_CLT_MEAS_VAL_TAVG4X_PAVG32X_FORCED);
}

static bool b2smpbReadUP(baroDev_t *baro)
{
// kick off the read of pressure and temperature, b2smpbGetUP() parses it once the bus is idle
return busReadBufStart(baro->busDev, REG_PRESS_TXD2, &baroDataBuf[0], 6);
}

static bool b2smpbGetUP(baroDev_t *baro)
{
int32_t dtp;
float tr, pl, tmp;

if (!busReadBuf(baro->busDev, REG_PRESS_TXD2, &baroDataBuf[0], 6)) {
return false;
}
UNUSED(baro);

// Calculate compensated temperature
dtp = getSigned24bitValue(&baroDataBuf[3]);
Expand Down Expand Up @@ -234,12 +239,17 @@ bool baro2SMPB02BDetect(baroDev_t *baro)
return false;
}

// Forced mode: one measurement per cycle, pressure and temperature come out of one read
baro->combined_read = true;

baro->up_delay = 35000; // measurement takes 33.7 ms with 4x / 32x averaging
baro->start_up = b2smpbStartUP;
baro->read_up = b2smpbReadUP;
baro->get_up = b2smpbGetUP;

baro->ut_delay = 0;
baro->start_ut = NULL;
baro->read_ut = NULL;
baro->get_ut = NULL;

baro->calculate = deviceCalculate;
Expand Down
Loading
Loading