Keep the condition separate from the story

A motion sensor can raise a pin when a programmed condition becomes true. That is useful evidence, but it does not tell the whole story of a moving object. A LIS3DH low-acceleration request does not directly report a fall, its height, an impact or the number of times an object moved. This experiment follows one narrower path: configure a timed condition, observe a latched request, read its source and clear it deliberately.

A latch remembers that a condition occurred after the condition itself has ended. We first qualify that path with the board resting on a table and a deliberately generous threshold. Then we run the intended lower threshold while gently rotating the supported board. There is no need to throw, release or drop the assembly. The supplied program runs for ten seconds and reports serviced latches, rather than pretending those observations are a physical event counter.

The prerequisite ideas are covered in A decision that remembers and Turn a voltage into a number. Here the memory belongs to a sensor’s interrupt generator, and the clear operation is a register read.

Exact carrier and firmware

Use an original RP2040 Raspberry Pi Pico, the Adafruit PID2809 LIS3DH STEMMA QT carrier, five short jumper wires, soldered headers and a Universal Serial Bus (USB) cable. The target interpreter is the official RPI_PICO MicroPython 1.26.0 build. A stable nonconductive support and a digital multimeter, which measures voltage, help with initial checks. Use a suitable high-impedance oscilloscope, an instrument that shows voltage against time, to qualify the interrupt’s HIGH and LOW levels.

The reviewed current carrier schematic names U1 LIS3DHTR. ST lists that order code as an active land grid array, sixteen-terminal (LGA-16) part supplied on tape and reel. This establishes the maker’s published design, not the suffix or authenticity of an uninspected board in your hand. The WHO_AM_I identity byte returns 0x33; it is not a certificate of package, carrier revision or calibration. Record the actual carrier revision and supplier. The wiring below applies to the current QT carrier, not to a bare sensor assembled from the picture.

The bare device’s normal supply range is 1.71–3.6 V. Its 4.8 V absolute supply maximum is a damage boundary, not a usable 5 V operating target. The carrier accepts 3.3 V at VIN, its power input, and provides the regulator and bus translation. We use 3.3 V throughout; the interrupt is a sensor-domain output and must not be treated as a 5 V-tolerant Pico input.

Five complete nets, with the interrupt flowing toward Pico

Remove power before wiring. Ground (GND) is the shared voltage reference and current return. The Inter-Integrated Circuit (I²C) bus uses serial clock (SCL) and serial data (SDA). The program selects I²C controller 0 at 100 kilohertz (kHz), meaning 100,000 clock cycles per second. General-purpose input/output (GPIO) pin GP2 is configured only as an input; the sensor drives the interrupt wire.

Pico terminalPID2809 QT terminalJob
3V3_OUT, physical 36VIN, JP3 pin 1Single 3.3 V source
GND, physical 38GND, JP3 pin 3Common return
GP1, physical 2SCL, JP3 pin 4I²C clock
GP0, physical 1SDA, JP3 pin 5I²C data
GP2, physical 4, inputI1 / INT1, JP3 pin 6Active-HIGH sensor request

Both figures have editable Scalable Vector Graphics (SVG) originals and full-size Portable Network Graphics (PNG) images.

Five complete carrier connections: Pico physical 36 to PID2809 VIN JP3.1, ground 38 to JP3.3, GP1 physical 2 to SCL JP3.4, GP0 physical 1 to SDA JP3.5, and carrier INT1 JP3.6 to Pico GP2 input physical 4. USB powers only Pico; carrier 3Vo and unused terminals remain open.
Original carrier-level circuit with an explicitly input-only interrupt connection. Every external wire has identified endpoints; no guessed internal wiring is drawn. Editable SVG · Full-size PNG.

Leave the carrier’s 3Vo regulator output unconnected, and do not feed it back into Pico 3V3_OUT. Leave chip-select CS and serial-data-out SDO open on this carrier, with its address jumper unbridged: onboard resistors select I²C operation at seven-bit address 0x18. Closing the address jumper changes the address to 0x19 and would require a separately checked configuration. The second interrupt I2, auxiliary analog inputs A1–A3 and both QT sockets are unused. The auxiliary converter is disabled; ST permits unused analog inputs to float. “I2” is the second interrupt label, not an I²C clock terminal.

The carrier contains an AP2112K-3.3 regulator, two 10 microfarad (µF) power capacitors and a 0.1 µF local bypass capacitor. These keep the local supply from depending entirely on long jumper wires. BSS138 bus level shifting and four 10 kilohm (kΩ) pull-ups are already fitted; do not add another pull-up board. The carrier drawing also provides a 10 kΩ chip-select pull-up and a 10 kΩ SDO address pull-down. The INT1 line is a push-pull output: it drives both HIGH and LOW, so do not connect it to another output or drive GP2 in a software test. A nominal 3.3 V across a 10 kΩ resistor is 0.33 milliampere (mA); that calculation is per resistor, not a measurement of the complete carrier current.

With USB connected only to Pico, measure carrier VIN and 3Vo relative to GND before continuing. Do not attach a second positive supply, use 5 V pull-ups or move live wires. Header pin numbers identify electrical terminals; the drawing does not reproduce their physical spacing.

Supply operation alone does not establish logic compatibility. With Pico’s input/output (I/O) supply at 3.3 V, RP2040 specifies HIGH from 2.0 V and LOW up to 0.8 V. LIS3DH specifies output HIGH of at least 0.9 times its actual I/O supply and LOW of at most 0.1 times that supply. At an assumed 3.3 V those budgets are 2.97 V and 0.33 V; they are not a measured regulator guarantee. A sensor rail near its 1.71 V operating minimum could produce a HIGH too low for Pico. During the stationary qualification, check actual INT1 HIGH/LOW at GP2, with the scope ground on the common GND: require comfortable margin above 2.0 V and below 0.8 V, and keep the waveform within Pico’s actual supply rails. A multimeter average can miss brief LOW intervals during automatic service. Record the rail and level measurements and instrument in worksheet notes before interpreting interrupt results.

Three small magnitudes, not three negative directions

The accelerometer measures specific force along its three axes: what pushes its sensing mass, rather than how far the board has travelled. In a supported stationary orientation, the gravity-related vector is approximately 1 g, where g is standard gravitational acceleration, about 9.81 metres per second squared. Changing orientation changes the components. It does not make that vector disappear.

We select the inertial interrupt mode, with orientation recognition disabled. In this mode the low-axis comparators test magnitude: |X|, |Y| and |Z| below the threshold. They are not negative-direction tests from the device’s separate orientation mode. The configuration byte 0x95 chooses the AND combination of those three low conditions. Every selected condition must be true together.

Nominal threshold = threshold_code × 0.016 g     (at ±2 g full scale)
Default:       20 × 0.016 g = 0.320 g
Qualification: 75 × 0.016 g = 1.200 g
Nominal minimum duration = duration_code / output_data_rate
3 / 100 Hz = 0.030 s = 30 ms

The output data rate (ODR) is the sensor’s sample-update rate: 100 hertz (Hz), or 100 updates per second here. A millisecond (ms) is one thousandth of a second. The nominal 30 ms is a sampled hardware qualification setting; it is not a measured delay from an arbitrary analog motion edge. Sample phase, internal processing, actual rate tolerance and host service latency matter.

For an ideal static 1 g vector, at least one component has magnitude at least 1/√3 ≈ 0.577 g. All three therefore cannot be below 0.320 g together. This explains the static-rotation trial. It does not eliminate real offsets, vibration or dynamic false indications. Conversely, all three components below 0.320 g does not mean the vector magnitude is below 0.320 g: the axis-wise region is a cube, not a sphere.

The separate 1.200 g qualification setting is deliberately above each ideal static component. It should let a stationary board exercise the condition, interrupt routing and clear path. It is not a free-fall threshold and its success cannot validate motion accuracy. If it produces no request, investigate configuration, supply and wiring before drawing conclusions about the sensor.

Configure the whole event path

A register is a small readable or writable control location inside the device. The program first removes interrupt routing and powers down sampling while setting the following values, then allows a 100 ms settling interval before enabling the event condition. An initial 10 ms wait is an application allowance for cold boot; identity and readback checks still decide whether configuration can proceed.

Register / valueMeaning in this experiment
CTRL_REG1 = 0x57100 Hz, normal mode, all three axes enabled
CTRL_REG2 = 0High-pass filter bypassed for the interrupt path
CTRL_REG3 = 0x40Interrupt generator 1 activity routed to INT1
CTRL_REG4 = 0±2 g range, normal resolution, self-test off
CTRL_REG5 = 0x08Latch interrupt generator 1; buffering/orientation extensions off
CTRL_REG6 = 0Active-HIGH polarity; no other route to INT2
INT1_CFG = 0x95AND of X-low, Y-low and Z-low in inertial mode
INT1_THS = 20 or 750.320 g default or 1.200 g stationary qualification
INT1_DURATION = 3Nominal 30 ms at 100 Hz

The high-pass filter removes slowly changing content. Enabling it would change the role of the static gravity-related components, so we bypass it rather than copying a wake-up recipe that uses it. Auxiliary analog conversion, the alternate interrupt generator and click recognition are disabled. The program reports interrupt-source bytes; it does not read or reconstruct an acceleration history.

Reading INT1_SRC inspects and clears the latched request. Its interrupt-active bit, IA, is bit 6. The remaining axis flags are retained as raw context. A successful source read does not guarantee the pin will remain LOW: an ongoing or new qualifying condition can assert another request. Record the observed post-read pin level instead of requiring LOW as a fictional proof of permanent clearance.

The callback wakes the program; it does not count motion

An interrupt request (IRQ) invokes a short callback when INT1 rises. That callback changes one preallocated byte from 0 to 1. It performs no I²C transaction, printing or timestamp allocation. All slower work happens in the ordinary main loop. Multiple requests can merge into the same byte, and the sensor latch can already have combined conditions before the callback runs.

The main loop checks both that pending flag and the actual pin level. It clears the flag before reading registers. A new callback during the bus transaction therefore remains pending for the next pass. A delayed callback after a successful clear can produce a later source read with no IA bit; this is reported as NO_IA_OBSERVED, without incrementing the serviced-latch count. A source-read error may have happened after the chip cleared its latch, so the result is unknown rather than retried as an unchanged event.

Constructed absolute X, Y and Z magnitude traces cross below 0.320 g in two separate intervals. A nominal 30 ms first interval qualifies the latched INT1 request; a second interval merges before one main-loop source read at 270 ms clears the request. The pending flag clears at 260 ms before the 270 ms source read; it is not a count of the two intervals.
Original constructed timing and magnitude plot, not measured motion or a validated device simulation. The pending flag clears before the source read; the condition is false at the illustrated latch clear; other timing and continued conditions can produce a different post-read level. Editable SVG · Full-size PNG.

The registers are checked at startup, around each source read, every 100 ms of quiet polling and at the end of the run. This is periodic configuration evidence, not continuous integrity monitoring: changes restored between checks may escape detection. A disconnected INT1 wire can also hide a request even when the bus works. Zero serviced latches does not prove no movement, no low-acceleration interval or a healthy interrupt wire.

A mismatch, short reply, invalid pin value or clock fault invalidates that software session. The driver tries each stop action independently: detach the callback, remove routing, disable the generator and power down sampling. Bus acknowledgements and readbacks are not proof of physical power removal. If cleanup is uncertain, an exception marks the run invalid; retain that message with any rows already printed.

Complete raw-register program

Download the full MicroPython program · Blank observation worksheet.

"""Original Pico / MicroPython1.26.0 / Adafruit PID2809 QT carrier.
A serviced interrupt latch is not a fall count, timestamp or safety detector.
"""

ADDRESS = 0x18
WHO_AM_I = 0x0F
CTRL1, CTRL2, CTRL3, CTRL4, CTRL5, CTRL6 = range(0x20, 0x26)
CFG, SRC, THS, DURATION = 0x30, 0x31, 0x32, 0x33


class LatchError(RuntimeError):
    pass


def threshold_g(code):
    if type(code) is not int or not 0 <= code <= 127:
        raise ValueError("threshold code must be integer 0..127")
    return code * 0.016     # Nominal at the selected +/-2g full scale.


def duration_ms(code, odr_hz=100):
    if type(code) is not int or not 0 <= code <= 127:
        raise ValueError("duration code must be integer 0..127")
    if type(odr_hz) is not int or odr_hz <= 0:
        raise ValueError("positive integer output data rate required")
    return 1000 * code / odr_hz


class LatchedEvent:
    def __init__(self, bus, interrupt_pin, ticks_ms, ticks_diff, sleep_ms,
                 threshold_code=20):
        if type(threshold_code) is not int or threshold_code not in (20, 75):
            raise ValueError("use 20 (0.320 g) or 75 (1.200 g qualification), not an unreviewed range")
        self.bus, self.pin = bus, interrupt_pin
        self.ticks, self.diff, self.sleep = ticks_ms, ticks_diff, sleep_ms
        self.threshold = threshold_code
        self.pending = bytearray(1)   # Callback only writes this preallocated flag.
        self.last_clock = None
        self.closed = False
        self.ready = False
        self.fault = None
        self.serviced_latches = 0
        self.config_checked_at = None

    def _fail(self, reason):
        self.fault = self.fault or reason
        raise LatchError(self.fault)

    def _now(self):
        try:
            now = self.ticks()
            if type(now) is not int:
                self._fail("CLOCK_TYPE")
            if self.last_clock is not None:
                gap = self.diff(now, self.last_clock)
                if type(gap) is not int or gap < 0 or gap > 1000:
                    self._fail("CLOCK_GAP")
            self.last_clock = now
            return now
        except BaseException:
            self.fault = self.fault or "CLOCK_FAULT"
            raise

    def _read(self, reg, n=1):
        # LIS3DH bit7 enables auto-increment for a multibyte I2C transfer.
        data = self.bus.readfrom_mem(ADDRESS, reg | (0x80 if n > 1 else 0), n)
        if len(data) != n:
            self._fail("SHORT_READ")
        return data

    def _write(self, reg, value):
        self.bus.writeto_mem(ADDRESS, reg, bytes((value,)))

    def _expect(self, reg, expected):
        if self._read(reg, len(expected)) != bytes(expected):
            self._fail("CONFIG_MISMATCH")

    def _check(self):
        self._expect(CTRL1, (0x57, 0x00, 0x40, 0x00, 0x08, 0x00))
        self._expect(CFG, (0x95,))
        self._expect(THS, (self.threshold, 3))
        self.config_checked_at = self._now()

    def irq(self, pin):
        self.pending[0] = 1

    def start(self):
        if self.ready or self.closed or self.fault:
            raise LatchError("NEW_SESSION_REQUIRED")
        try:
            self.sleep(10)           # Allow cold boot before configuration.
            self._now()
            self._write(CTRL3, 0)       # No interrupt routed during configuration.
            self._write(CTRL1, 0)       # Power-down while changing the event path.
            self._expect(WHO_AM_I, (0x33,))
            for reg, value in ((CFG, 0), (CTRL2, 0), (CTRL4, 0),
                               (CTRL5, 8), (CTRL6, 0), (0x1F, 0),
                               (0x2E, 0), (0x34, 0), (0x38, 0),
                               (THS, self.threshold), (DURATION, 3)):
                self._write(reg, value)
            self._read(SRC)            # Discard/clear a pre-session latch.
            self._write(CTRL1, 0x57)   # 100 Hz, normal mode, XYZ enabled.
            self.sleep(100)            # Application settling allowance, not measurement.
            self._now()
            self.pin.irq(handler=self.irq, trigger=self.pin.IRQ_RISING, hard=True)
            self._write(CFG, 0x95)     # AND of all three low-magnitude conditions.
            self._write(CTRL3, 0x40)   # IA1 generator -> INT1; active HIGH.
            self._check()
            self._now()
            self.ready = True
        except BaseException:
            self.fault = self.fault or "START_FAULT"
            self.close()
            raise

    def _level(self):
        value = self.pin.value()
        if type(value) is not int or value not in (0, 1):
            self._fail("PIN_LEVEL")
        return value

    def service(self):
        if not self.ready or self.closed or self.fault:
            raise LatchError("NEW_SESSION_REQUIRED")
        try:
            now = self._now()
            if not self.pending[0] and not self._level():
                if self.diff(now, self.config_checked_at) >= 100:
                    self._check()     # Quiet-time health check, not continuous proof.
                return None
            # Clear BEFORE I2C. An IRQ during service remains pending afterwards.
            self.pending[0] = 0
            self._check()
            source = self._read(SRC)[0]  # Successful read clears the latched request.
            self._check()
            observed_at = self._now()
            if source & 0x80:           # Reserved bit must remain zero.
                self._fail("SOURCE_RESERVED")
            level_after = self._level()
            ia = bool(source & 0x40)
            if ia:
                self.serviced_latches += 1
            return {"status": "SERVICED_LATCH" if ia else "NO_IA_OBSERVED",
                    "source": source, "ia": ia, "axis_flags": source & 0x3F,
                    "serviced_latches": self.serviced_latches,
                    "threshold_code": self.threshold,
                    "threshold_g_nominal": threshold_g(self.threshold),
                    "duration_ms_nominal": duration_ms(3),
                    "host_service_ms": observed_at,
                    "int1_level_after_read": level_after,
                    "pending_after_service": bool(self.pending[0]),
                    "physical_event_count_known": False,
                    "event_timestamp_known": False}
        except BaseException:
            self.fault = self.fault or "SERVICE_FAULT"
            self.close()
            raise

    def close(self):
        self.closed, self.ready = True, False
        good = True
        try:
            self.pin.irq(handler=None)
        except BaseException:
            good = False
        # Attempt each independent stop even if another stop is uncertain.
        for reg in (CTRL3, CFG, CTRL1):
            try:
                self._write(reg, 0)
                self._expect(reg, (0,))
            except BaseException:
                good = False
        if not good:
            self.fault = self.fault or "STOP_UNCONFIRMED"
        return good


def run(bus, pin, ticks_ms, ticks_diff, sleep_ms, threshold_code=20, emit=print):
    event = LatchedEvent(bus, pin, ticks_ms, ticks_diff, sleep_ms, threshold_code)
    try:
        event.start()
        emit("QUALIFICATION: nominal 1.200 g" if threshold_code == 75 else "LOW-MAGNITUDE: nominal 0.320 g")
        emit("Source reads service latches; they do NOT count falls or timestamp motion.")
        start = event._now()
        # Finite time AND iteration bounds: a frozen clock cannot loop forever.
        for _ in range(2001):
            now = event._now()
            elapsed = ticks_diff(now, start)
            if type(elapsed) is not int or elapsed < 0:
                event._fail("RUN_CLOCK")
            if elapsed >= 10000:
                break
            record = event.service()
            if record is not None:
                emit(record)
            sleep_ms(5)
        else:
            event._fail("RUN_POLL_BUDGET")
        event._check()                # Final quiet/configuration checkpoint.
        return event.serviced_latches
    finally:
        if not event.close():
            emit("INVALID SESSION: stop was not confirmed")
            raise LatchError("STOP_UNCONFIRMED")


def main(threshold_code=20):
    from machine import Pin, I2C
    from time import ticks_ms, ticks_diff, sleep_ms
    bus = I2C(0, sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000)
    pin = Pin(2, Pin.IN)                # Sensor drives this input; never drive it.
    return run(bus, pin, ticks_ms, ticks_diff, sleep_ms, threshold_code)


if __name__ == "__main__":
    main()

Copy the program to Pico under the shown filename, rather than installing it as a startup file. Use the read–evaluate–print loop (REPL), the interactive Python console, to load the definitions without triggering the default main guard. Run the qualification and default trials explicitly:

namespace = {"__name__": "lis3dh_demo"}
with open("pico-lis3dh-latched-event.py") as source:
    exec(source.read(), namespace)
namespace["main"](75)  # stationary qualification, nominal 1.200 g
namespace["main"](20)  # intended low-magnitude setting, nominal 0.320 g

Each call has a ten-second time limit and a separate finite poll budget. The I²C timeout is 50,000 microseconds, where a microsecond is one millionth of a second. Long bus, console or scheduling delays still affect service latency and coalescence. The clock guard rejects backwards observations or gaps over one second; ordinary tick wrap is supported, but an entire unobserved wrap cannot be inferred from the counter alone. A faulted object is never reused. After an uncertain run, remove power, inspect the cause and start a fresh session manually.

A supported-board experiment

  1. Inspect unpowered. Trace all five nets, confirm the open address jumper and input-only GP2, and record board/firmware identity. Place the carrier on a nonconductive support. Keep unused terminals away from loose conductors.
  2. Check the rail. Power Pico by USB and measure VIN and 3Vo against GND. The carrier regulator means they need not be identical. Investigate an unexpected rail before proceeding.
  3. Qualify while stationary. Run main(75). Keep the board supported. Look for serviced source records, not a prescribed physical count. Record threshold, raw source byte, observed post-read level and any errors. A sustained condition can leave or reassert HIGH; do not call that alone a failed clear.
  4. Return to the intended threshold. After a successful stop, run main(20). Gently rotate the board while supporting it continuously. An ideal static orientation should not qualify the all-axis low condition; record what actually happens, including a fault or unexpected indication.
  5. Separate servicing from counting. Inspect the constructed plot and host race checks: two qualified intervals before a source read can merge into one latch. Do not infer an omitted event’s time or divide the printed count into a motion frequency.
  6. Check failure without shorting a live bus. Stop and remove power before disconnecting the carrier. Power Pico again and run; identity/bus failure must yield no invented event record. Reconnect only with power removed. Configuration and timing fault behavior are covered by the actual-source host checks, rather than by driving against INT1.

This procedure does not qualify a fall detector, height estimator or protective device. It checks a register-controlled event path and its software interpretation. Investigating actual mechanics would require an appropriate controlled fixture, calibrated references, timing observations and recorded results; none are supplied here.

Checks performed, and what remains unknown

Thirty host-executed groups exercise the actual delivered driver: both allowed configurations, nominal threshold/duration calculations, no-work idle service, source-read clearing, callback coalescence, missed and delayed callbacks, a request arriving during service, failed reads after a clear side effect, all configuration/service bus operation failures, short data, register changes, quiet-time health checks, clock wrap/gaps, finite loops and independent shutdown attempts. Fake registers supply the input conditions; these checks do not measure a real sensor or validate a physical timing model.

No MicroPython firmware was built or executed on a Pico, and no acceleration, electrical current, bus waveform, interrupt latency or drop performance was measured. The bench log and worksheet are empty. The final illustrations were traced against current pinouts and carrier design; desktop/mobile readability and independent technical acceptance are separate from physical qualification.

Challenge: is “all below” a vector limit?

A synthetic sample has X = 0.30 g, Y = −0.30 g and Z = 0.30 g. Is each axis below the 0.320 g magnitude threshold? Is its total magnitude below 0.320 g? Would that one sample alone satisfy the configured duration?

Open the answer

Every absolute component is 0.30 g, so all three low comparisons are true in this inertial mode. The vector magnitude is √(0.30² + 0.30² + 0.30²) ≈ 0.520 g, which exceeds 0.320 g. One sample alone does not establish the nominal 30 ms qualification. The example shows why neither one source flag nor an axis-wise threshold should be rewritten as a precise physical fall measurement.

Primary references

The current manufacturer document list and targeted search did not identify a separate applicable LIS3DH errata sheet; that is not proof that none exists.