← Eggert Gudmundsson Projects

LiDAR ranging · first working bring-up

First Bring-Up

Getting a real 8×8 distance frame out of the VL53L8CX on the nRF9151, sharing an I²C bus with the Grid-EYE. Two software bugs, a bus signal-integrity fix, and one real concurrency bug stood between wiring it up and a clean frame — the concurrency bug was the one that mattered.

Part
ST VL53L8CX, 8×8 zones, SATEL-VL53L8 breakout
Device
cali.EGG68 (nRF9151), shared I²C bus with Grid-EYE
Driver
ST ULD API, own Zephyr I²C platform port
Result
cal tof init → cal tof raw, clean 8×8 frame

Summary

cal tof init then cal tof raw now run cleanly on real hardware — firmware upload, resolution set, ranging start, and a physically coherent 8×8 distance frame. Two real software bugs got in the way (a missing device address, a driver buffer overflow), a bus signal-integrity problem needed a hardware fix, and the failure that actually took longest to find was a missing lock: nothing stopped the Grid-EYE and the LiDAR from touching the shared bus at the same time.

Two panels of the same 8x8 distance frame. Left: a heatmap, columns 0-3 a uniform dark blue near 1750-1800mm (the ceiling), column 7 pale at about 550mm (a nearby shelf edge), with a visible transition band between them. Right: the same data as a 3D bar plot viewed from the near side, short pale bars in front stepping up to a tall dark block behind.
The first clean frame, read two ways. a. Per-zone distance, 31-frame mean — columns 0–3 are the ceiling (~1.8 m), column 7 is a nearby shelf edge (~550 mm). b. The same frame as a 3D block plot, bar height equal to the measured distance — the step down from ceiling to shelf reads at a glance in a way the heatmap alone doesn't, though the heatmap is what actually lets you read an exact value. Kept as a pair, not a pick-one — see Method & caveats.

Two software bugs

Both found by methodical isolation — a second, never-stressed board failed identically, which is what pointed at software rather than a damaged part.

  • Missing device address. lidar_is_alive() never set the platform's device address before calling into the ULD driver — that field is only set inside lidar_init(), and the device struct is a static global, zero-initialized at boot. Calling the standalone liveness check before init had run silently addressed the I²C general-call address instead of the sensor, producing a NACK indistinguishable from a dead chip.
  • Driver write-buffer overflow. The ULD driver's multi-byte register write used a two-message I²C transfer with no stop between them, which hit the nRF TWIM driver's internal concatenation-buffer limit — 16 bytes by default, against write chunks up to 512 bytes. The driver's own error named the exact devicetree property at fault. Fixed by building the combined write buffer locally and sending it as one plain write instead of relying on driver-level message concatenation.

Bus signal integrity

Connecting the sensor to the shared bus produced a sustained ~14 MHz oscillation on both SDA and SCL, swinging well past the 1.8 V bus rail. The board's onboard level translator uses edge-triggered, auto-direction sensing — a known bad combination with an already RC-limited, weak-pullup open-drain bus, and the scope showed exactly that: SCL charging on a slow RC curve rather than switching cleanly. A 100 Ω series resistor between the shared bus and the translator settled it completely, with negligible voltage drop at these pullup values.

The real cause of the intermittent failures

The interesting bug wasn't electrical. Even with clean wiring, ranging failed intermittently in a way that survived a full reinit and a power cycle — both rule out corrupted device state, so the cause had to be elsewhere. It was a missing bus-arbitration lock: the Grid-EYE's periodic capture (main thread, on a wall-clock timer) and the LiDAR's own multi-step ranging sequence (shell thread) could interleave, because Zephyr only serializes individual low-level I²C transfers, not each device's operation as a whole. A capture landing mid-sequence in the LiDAR's start/wait/read/stop chain was enough to break it.

Fixed with one shared mutex, held for each device's entire I²C-touching operation rather than just around each transfer. Validated by computing the next collision window against the Grid-EYE's own schedule and firing continuous ranging calls straight through it: 50 of 50 succeeded, including samples landing right at the collision point itself. The general lesson carries forward: sharing a bus needs a whole-operation lock, not just per-transfer safety — the same requirement will apply when the SPI bus picks up a second device.

Method & caveats

  • Zone row/column orientation versus the sensor's physical field of view is not yet confirmed — unlike Grid-EYE, where it was explicitly checked. The figure above labels the raw ULD zone index (row = index ÷ 8, col = index mod 8), not a verified up/down/left/right mapping. Deliberately parked, not forgotten.
  • target_status read a uniform 6 across all 64 zones. The ULD header comments name 5 and 9 as “OK”, not 6 — not yet resolved against the fuller status-code table, though the data itself is too clean and spatially coherent to suggest bad targets.
  • The two-panel figure style (heatmap + 3D block plot) is now the standard for any LiDAR frame on this project — picked deliberately, not by default: the heatmap is the precise per-zone read, the block plot is the “what does the scene look like” intuition panel, and a real side-by-side check on this exact frame showed neither one alone is enough (the block plot's flat regions aren't individually readable; the heatmap alone doesn't give the geometric “step down to the shelf” read at a glance).

Bring-up data and figure-generation scripts held in the device project repository (coap_GridEye/lidar_bringup/), not published here. Full narrative, including the false leads ruled out along the way, in the project's Journal.