SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
J1 — The sensor bus and its lock
Variant
mtb-mpy and mtb-only

Everything in this chapter is C, and every line of it ships as source in both packages. The MicroPython calls shown in Step 2 run on mtb-mpy only; the C sequence and the on-bus behaviour are identical on both.

Learning goal

After this chapter you can explain why every sensor read in this firmware is sandwiched between sensor_i2c_lock() and sensor_i2c_unlock() — not as house style, but because a second master shares the same SCB — and you can add a device to the bus without breaking the four that are already on it. You will also be able to say, for any given board, which of the two CapSense backends is compiled in and why picking the wrong one produces a device that answers nothing.

The real firmware sequence

One SCB, two masters. The sensor bus is SCB0 I2C on P8.0 (SCL) and P8.1 (SDA), assigned explicitly by sensor_i2c_init() rather than trusted to the BSP, because the BSP may or may not initialise it for CM33_NS (sensor_i2c.c:88, pin assignment at :99-100). The comment immediately below the pin block is the reason this chapter exists: SCB0 is shared on CM33_NS — the OPTIGA Trust M PAL also masters it and brings it up at 100 kHz (sensor_i2c.c:104-110). That same comment records what happens when the block is re-initialised while live: the DPS368, SHT40 and BMI270 all go unreadable while the I3C BMM350 is untouched, and because the init re-runs each boot, a power cycle does not recover it.

Recovery comes before configuration. sensor_i2c_init() calls sensor_i2c_bus_recover() first, before the pins are handed to the peripheral, to free a slave still holding SDA low from a transfer that was interrupted (sensor_i2c.c:93-96). Once the SCB owns the pins you can no longer clock a stuck slave out manually, so the order is load-bearing.

The lock is the contract. sensor_i2c_lock(uint32_t timeout_ms) (sensor_i2c.c:168) and sensor_i2c_unlock() (:175) bracket every transfer in the shipped code. The register helpers are deliberately thin — sensor_i2c_write_reg (:215), sensor_i2c_read_reg (:240), the byte and raw forms at :275, :279, :283, :300 — and none of them takes the lock for you. That is a design decision, not an omission: a multi-register read is only coherent if one lock spans the whole sequence.

Every MicroPython binding follows the same three lines. This is sensors.bmi270.acceleration() on the AI-Kit branch (modsensors.c:167-173):

float ax, ay, az;
sensor_i2c_lock(100);
bool ok = bmi270_read_accel(&ax, &ay, &az);
sensor_i2c_unlock();
if (!ok) {
mp_raise_msg(&mp_type_OSError, MP_ERROR_TEXT("BMI270 accel read failed"));
}

Note where the error is raised: after the unlock. Raising inside the critical section would leave the bus held by a task that has already longjmp'd away.

Who answers. Five devices, four of them on SCB0:

Device Address Bus Header
BMI270 IMU 0x68 SCB0 I2C sensor_bmi270.h:16
BMM350 magnetometer 0x15 I3C, CYBSP_I3C_SCL/SDA (sensor_bmm350.c:237-240) sensor_bmm350.h:20
DPS368 barometer 0x77 SCB0 I2C sensor_dps368.h:16
SHT40 humidity 0x44 SCB0 I2C sensor_sht40.h:17
CapSense 4000T 0x08 see The CapSense fork: one API, two backends sensor_capsense.h:20

The BMM350 is the odd one out — it is on the I3C block, not SCB0 — which is why it can fail on its own while the other three are healthy, and why it has its own diagnostic pair (bmm350_diagnose sensor_bmm350.c:863, bmm350_debug_read :559).

The CapSense fork: one API, two backends

sensor_capsense.h declares one API. Two files implement it, and capsense.mk picks between them at build time (capsense.mk:19-28):

  • Direct I2C — sensor_capsense.c (:60, :93, :115, :126) talks to 0x08 on the sensor bus from CM33_NS.
  • IPC snapshot — sensor_capsense_ipc.c (:75, :83, :95, :106) asks CM55 instead, with IPC_CMD_CONTROLS_STATE and a 100 ms timeout, from the cache cm55_capsense_tick() refreshes every 50 ms; capsense_fetch_snapshot is at :33.

The selection rule, in the makefile's own words: on a QWA309 base board the 4000T sits on the CM55-owned display and touch bus, so the direct backend would read the wrong bus. BSP_HAS_QWA309_BASEBOARD=1 therefore selects the IPC backend.

Warning
The mtb-only package is missing the IPC backend. It ships sensor_capsense.c but neither sensor_capsense_ipc.c nor capsense.mk, while the board it targets is a QWA309 board. An mtb-only developer who calls capsense_read() today therefore gets the direct-I2C backend on a bus the device is not on, and reads nothing. Until the package carries the IPC backend, read CapSense on mtb-only through the CM55 side — cm55_controls_snapshot() (cm55_sensor_poll.h, and the weak-hook contract in Weak hooks (implement, don't call)) — rather than through capsense_read(). This is a packaging defect, reported here rather than documented as intended behaviour.

Step by step

Step 1 — Bring the bus up and ask who is there (C, both variants)

From a CM33_NS task, after the scheduler is running:

Example (authored — no shipped call site)
#include "sensor_i2c.h"
uint8_t addrs[16];
if (sensor_i2c_init()) {
if (sensor_i2c_lock(100)) {
int n = sensor_i2c_scan(addrs, (int)(sizeof addrs));
sensor_i2c_unlock();
/* n is the number of 7-bit addresses written into addrs[] */
(void)n;
}
}
What you should observe
sensor_i2c_init() returns true, and it is idempotent — a second call returns true immediately without touching the SCB (sensor_i2c.c:89-91), which is what makes it safe to call from every driver's own init. The scan returns the addresses that ACKed; on a healthy board that includes 0x68, 0x77 and 0x44. Nothing prints: this bus has no console output of its own. If sensor_i2c_lock() returns false you did not get the bus inside the timeout — treat that as "another master is mid-transfer", not as a hardware fault.

Step 2 — The same question from the REPL (mtb-mpy only)

>>> import sensors
>>> sensors.scan()
What you should observe
A list of integers — the 7-bit addresses that answered, the same set the C scan returns, because sensors.scan() is sensor_i2c_scan() under the binding's own lock (modsensors.c:782). This is one of only two peripheral calls with a shipped in-package usage exemplar; the other is sensors.read_all(), and both are shown in the package README.md:130-132. Every other call in this section has no call site in the package, which is why the examples here are labelled as authored.

Step 3 — Hold one lock across a multi-register read

The mistake this step prevents is subtle, because the wrong version works almost all the time.

Example (authored — no shipped call site)
/* Coherent: one lock spans both reads, so the two vectors are from the
* same instant on the bus. */
float ax, ay, az, gx, gy, gz;
sensor_i2c_lock(100);
bool a_ok = bmi270_read_accel(&ax, &ay, &az);
bool g_ok = bmi270_read_gyro(&gx, &gy, &gz);
sensor_i2c_unlock();
What you should observe
Both reads succeed and the six numbers describe one moment. Split into two lock/unlock pairs, they still both succeed — but another task can take the bus between them, and on a moving board the accelerometer and gyroscope vectors then disagree about when "now" was. On mtb-mpy this exact pattern is already packaged for you as sensors.bmi270.motion() (modsensors.c:230), which is the only sensor call in the module that returns two vectors under one lock.

Step 4 — Add a device without breaking the four already there

Example (authored — no shipped call site)
#define MY_DEVICE_ADDR (0x1E)
bool my_device_read_id(uint8_t *out)
{
bool ok = false;
if (sensor_i2c_lock(100)) {
ok = sensor_i2c_read_byte(MY_DEVICE_ADDR, 0x00, out);
sensor_i2c_unlock();
}
return ok;
}
What you should observe
The read succeeds and the four shipped drivers keep working. Two rules make that true, and both are visible in the shipped drivers: the lock is taken inside your function, not by your caller, and it is released on every path out — including the failure path. A driver that returns early while holding the bus wedges every other sensor on the board, and the symptom is "the dashboard froze", not "my new device failed".

Traps

Warning
Never call a register helper without the lock. sensor_i2c_read_reg() and friends do not take it. They will appear to work on a quiet bench and fail when OPTIGA runs a handshake on the same SCB.
Never raise or return while holding the bus. The shipped bindings unlock first and raise second (modsensors.c:167-173). Reversing the two lines leaks the mutex on exactly the path you are least likely to test.
Never run Cy_SCB_I2C_Init() on a live SCB0 to "reset" the bus. The PDL call requires the block disabled; on an enabled block it half-writes the peripheral config — flips the data rate, reassigns the peri-clock, re-points the ISR — and wedges the open-drain bus for every device on it. The failure survives a power cycle because the init re-runs each boot (sensor_i2c.c:104-110). sensor_i2c_init() is idempotent, and its early return at sensor_i2c.c:89-91 is what protects you; do not reach past it. For a genuinely stuck bus the tool is sensor_i2c_bus_recover(), which is what init itself calls.
A quiet device is not always a dead device. Before suspecting hardware, confirm which bus it is on. The BMM350 is on I3C and the CapSense 4000T is on the CM55-owned display bus on this board — neither will ever appear in an SCB0 scan, and their absence from sensors.scan() is expected, not a fault.
Do not build a bus scan into a hot loop. sensor_i2c_scan() addresses every slave on the bus in turn while holding the lock; running it per frame starves the auto-push task described in J3 — The auto-push task and the sensor hub.

Variant box

mtb-mpy mtb-only
Bus source sensor_i2c.c, shipped as source same file, same package path
Who takes the lock the binding, on every call you, in your own driver
Bus scan sensors.scan() or sensor_i2c_scan() sensor_i2c_scan()
CapSense backend IPC snapshot, selected by capsense.mk direct I2C only — capsense.mk and sensor_capsense_ipc.c are not in the package (see the warning in The CapSense fork: one API, two backends)
Console no bus output on either variant; failures surface as a false return or an OSError [HB] t=lus tasks=u only