Bergsonne Labs

Driver Usage & Development

This guide covers the architecture, conventions, and patterns for using and writing Tile drivers. Whether you're building your first project, looking to understand how existing drivers work, or planning to contribute a driver for a new tile this is the place to start. Note that drivers are written in plain C with no host-platform dependencies, meaning that they run on anything from an Arduino Nano to a multi-core STM32.

Get the Tile Driver Library

All drivers, HAL implementations, and templates are in the Kiln repository.

Architecture Overview

Every driver follows a three-layer architecture. At the bottom is the Hardware Abstraction Layer (HAL), which provides a thin connection for your “host platform” (the MCU or framework you're building on, be it Core Tiles, Arduino, ESP-IDF, STM32, Zephyr, etc.). The next layer is the platform-agnostic Tile Driver, which acts as a bridge between your application and the HAL, providing a standardized API free from any host-platform dependencies. Then the top layer is the specific application code, accessing the tile via the driver.

Application

Uses tile driver functions via instance handles without needing to touch the HAL directly.

Tile Drivers

Standardized, platform-agnostic C (one .h/.c pair per tile).

HAL

Function pointers that connect the driver to the tile via the specific host platform.

This separation of concerns keeps the driver code clean and reusable across host platforms, while giving users a consistent interface regardless of their hardware setup. See the Porting Guide for ready-to-use HAL implementations.

Standards

File Structure

Every project that uses tile drivers needs the framework headers, a platform HAL, and the driver pair for each tile:

Required Files

tiles.h + tiles_hal.h: the framework. tiles.h is the only one you include directly — it pulls in tiles_hal.h automatically and provides the tile handle, lifecycle states, versioning, and error reporting.

tiles_hal_<platform>.h/.c: the HAL implementation for your host platform (e.g., tiles_hal_core.h, tiles_hal_arduino.h). Include this alongside tiles.h to set up the HAL.

Each driver then consists of one header and one source file, following a consistent internal layout:

Header (.h) — the public interface

Documentation — what the tile is, which IC, datasheet link, quick-start example

Version — driver version defines + framework compatibility check

Instance map — which instance numbers map to which addresses / CS pins

Configuration — enums for modes, ranges, and settings the user can choose

API — find(), init(), and tile-specific functions (get, set, enable, etc.)

This is what users and the auto-generated docs see.

Source (.c) — the implementation

ID table — maps instance numbers (0, 1, 2…) to hardware identifiers

I/O helpers — private wrappers around HAL reads/writes (keeps the API clean)

find() — lightweight bus probe: is the tile connected?

init() — full setup: probe → verify identity → configure → set state READY

Tile functions — everything the header declares: reads, writes, mode changes

Users never need to open this file. It just works.

Template files are available in the Templates/ directory of the repository. Copy tile_template.h and tile_template.c, then find-and-replace TEMPLATE and CHIPNAME with your tile and IC names.

Naming Conventions

Consistent naming across 75+ drivers keeps the library predictable. When a user has worked with one tile, every other tile should feel familiar.

/* Naming conventions */

/* Files: tile_<family>_<name>.h / .c */
tile_sense_i_9.h       /* Sense.I.9 — 9-axis IMU        */
tile_drive_h.h         /* Drive.H — haptic driver        */
tile_power_l_1t.h      /* Power.L.1T — 1-terminal LDO   */
tile_memory_f.h        /* Memory.F — flash               */

/* Functions: tile_<family>_<name>_<action>() */
tile_sense_i_9_init()
tile_sense_i_9_find()
tile_sense_i_9_get_raw_accels()
tile_sense_i_9_set_accel_range()

/* Enum types: <family>_<name>_<descriptive>_t */
sense_i_9_accel_range_t
drive_h_mode_t
sense_i_9_mag_mode_t

/* Enum values: <FAMILY>_<NAME>_<VALUE> — uppercase */
SENSE_I_9_ACCEL_2G
SENSE_I_9_ACCEL_16G
DRIVE_H_MODE_FIFO

File names match the tile name with dots replaced by underscores and lowercased. The tile_ prefix namespaces all drivers in flat directory listings.

Function names always start with tile_<family>_<name>_ to avoid collisions. Use descriptive verbs: get_, set_, enable_, reset_.

Enum types are prefixed with the tile's family and name (<family>_<name>_) to avoid collisions with other libraries. Enum values follow the same prefix in uppercase. Include units or sensitivity values in doc comments so they're self-documenting.

Versioning

Both the framework (tiles.h) and individual drivers carry their own semantic version. This lets users pin exact versions for reproducibility, and lets the compiler catch incompatibilities at build time — no package manager required.

Framework Version

The framework version is defined directly in tiles.h, alongside a compile-time compatibility macro used by every driver:

/* tiles.h — version defines and compatibility check */

#define TILES_VERSION_MAJOR  1
#define TILES_VERSION_MINOR  0
#define TILES_VERSION_PATCH  0
#define TILES_VERSION_STRING "1.0.0"

/* Compile-time compatibility check.
 * Fires a build error if tiles.h is too old for a driver. */
#define TILES_CHECK_VERSION(req_major, req_minor) \
    _Static_assert( \
        (TILES_VERSION_MAJOR > (req_major)) || \
        (TILES_VERSION_MAJOR == (req_major) && \
         TILES_VERSION_MINOR >= (req_minor)), \
        "tiles.h version too old for this driver")

Per-Driver Version

Each driver declares its own version and the minimum framework version it requires. If a user grabs a newer driver but has an older tiles.h, the build fails with a clear error rather than mysterious runtime bugs:

/* In tile_sense_i_9.h — near the top, after includes */

#define TILE_SENSE_I_9_VERSION_MAJOR  1
#define TILE_SENSE_I_9_VERSION_MINOR  2
#define TILE_SENSE_I_9_VERSION_PATCH  0

/* This driver requires tiles.h >= 1.0 */
TILES_CHECK_VERSION(1, 0);

When to Bump

PATCH — bug fix, no API change (e.g., fix incorrect register value, timing adjustment)

MINOR — new feature, backwards compatible (e.g., add a new function, new enum value)

MAJOR — breaking change (e.g., rename function, change return type, remove function)

The HAL is the stability boundary. As long as tiles_hal_t doesn't change, existing drivers and user code continue to compile. Adding new HAL function pointers (e.g., SPI support) is a minor bump to the framework as long as I2C-only users aren't affected.

Drivers don't depend on each other. Each driver's only shared dependency is tiles.h. There are no driver-to-driver version constraints — if it compiles, it's compatible.

Versioned docs: Every driver version is preserved in the API Reference. When a driver is updated, the previous version's documentation remains accessible at /docs/tiles/family/name/version (e.g. at /docs/tiles/Sense/I9/1.0.0). Users working with older projects can always find the docs that match their driver version.

Implementation

The HAL

The HAL (tiles_hal_t) is how drivers talk to your hardware. It's a struct of function pointers — you fill in the ones for your host platform's bus(es), and every driver uses the same handle. You configure it once at startup, then forget about it.

Setup

Each platform HAL provides a config struct and an init function. Specify which buses you're using via the buses flags — unused bus pointers are left NULL automatically:

/* Arduino — I2C only */
tiles_hal_arduino_cfg_t cfg = { .buses = TILES_BUS_I2C };
tiles_hal_t hal;
tiles_hal_arduino_init(&hal, &cfg);

/* STM32 — I2C + SPI */
tiles_hal_stm32_cfg_t cfg = {
    .i2c = &hi2c1,
    .spi = &hspi2,
    .spi_cs_ports = { GPIOB },
    .spi_cs_pins  = { GPIO_PIN_12 },
    .buses = TILES_BUS_I2C | TILES_BUS_SPI,
};
tiles_hal_t hal;
tiles_hal_stm32_init(&hal, &cfg);

Pre-Built Implementations

Ready-to-use HAL files are in the HAL/ directory of the repository:

Core Tiles

For projects running on Core.L, Core.U, Core.W, or Core.H. Wraps STM32 HAL internally — will adapt as Core expands beyond STM32.

tiles_hal_core.c/.h
Arduino

Wraps Wire and SPI libraries. Works on AVR, SAMD, ESP32-Arduino, RP2040.

tiles_hal_arduino.cpp/.h
ESP-IDF

Uses v5.x i2c_master and spi_master APIs with FreeRTOS delay.

tiles_hal_esp_idf.c/.h
STM32

Generic STM32 HAL with blocking I2C/SPI. Works across F0/F4/G4/L4/H7/U5/WB families.

tiles_hal_stm32.c/.h

The Struct

For reference (or if you're writing a HAL for a new platform), here's the full struct. The handle field is an opaque pointer passed through to every callback — your bus peripheral (&hi2c1, Wire, etc.). The buses field records which buses are active so drivers can check at runtime without probing NULL pointers.

/* tiles_hal.h — platform abstraction */

/* Bus type flags */
#define TILES_BUS_I2C   (1 << 0)
#define TILES_BUS_SPI   (1 << 1)
#define TILES_BUS_QSPI  (1 << 2)
#define TILES_BUS_I3C   (1 << 3)

typedef struct {
    /* I2C — set to NULL if not used */
    int  (*i2c_read)(void* handle, uint8_t addr, uint8_t reg,
                     uint8_t* data, uint16_t len);
    int  (*i2c_write)(void* handle, uint8_t addr, uint8_t reg,
                      const uint8_t* data, uint16_t len);
    int  (*i2c_is_ready)(void* handle, uint8_t addr);

    /* SPI — set to NULL if not used */
    int  (*spi_read)(void* handle, uint8_t cs, uint8_t reg,
                     uint8_t* data, uint16_t len);
    int  (*spi_write)(void* handle, uint8_t cs, uint8_t reg,
                      const uint8_t* data, uint16_t len);

    /* Shared */
    void (*delay_ms)(uint32_t ms);
    void (*on_error)(struct tile t, const char* msg);  /* optional */
    void*    handle;   /* opaque — your bus peripheral handle */
    uint8_t  buses;    /* TILES_BUS_* flags — which buses are active */
} tiles_hal_t;
Using a platform we don't cover? Writing your own HAL is straightforward — implement the function pointers for your platform's I2C/SPI primitives, wrap them in a cfg struct and init function, and you're done. See the Porting Guide for a step-by-step walkthrough and tips.

Tile Handles

Once initialized, the driver will return a lightweighttile_thandle that carries the HAL pointer, resolved device identifier (I2C address, SPI CS pin, etc.), and lifecycle state. This handle will then be passed to every subsequent driver call. No global state, no select() — each call is self-contained and safe for multi-tile setups.

/* tiles.h — the only framework include users need */

#include "tiles_hal.h"

typedef enum {
    TILE_STATE_NONE     = 0,   /* uninitialized                   */
    TILE_STATE_FOUND    = 1,   /* ACKs on bus, not yet configured  */
    TILE_STATE_READY    = 2,   /* fully initialized and operational */
    TILE_STATE_SLEEPING = 3,   /* low-power / standby mode         */
    TILE_STATE_ERROR    = 4,   /* fault detected                   */
} tile_state_t;

typedef void (*tile_callback_fn)(void* ctx);

typedef struct tile {
    tiles_hal_t*     hal;       /* host-platform HAL pointer                */
    uint8_t          id;        /* device identifier (I2C addr, SPI CS)     */
    tile_state_t     state;     /* current lifecycle state                  */
    uint8_t          flags;     /* event flags (reserved for future use)    */
    tile_callback_fn callback;  /* user callback (reserved for future use)  */
    void*            cb_ctx;    /* callback context (reserved for future)   */
} tile_t;

/* General-purpose state accessor */
static inline tile_state_t tile_state(tile_t* tile) {
    return tile->state;
}

/* Convenience — the most common check */
static inline uint8_t tile_is_ready(tile_t* tile) {
    return (tile->state == TILE_STATE_READY) ? 1 : 0;
}

/* Error reporting — routes through the HAL's on_error callback.
 * NULL by default (silent). Set hal.on_error in your app. */
#ifndef TILE_ON_ERROR
#define TILE_ON_ERROR(tile, msg) \
    do { if ((tile).hal && (tile).hal->on_error) \
             (tile).hal->on_error((tile), (msg)); } while(0)
#endif

The tile_t struct and tile_state_t enum are defined in tiles.h, which every driver includes automatically. The error reporting and state management features shown in the code above are covered in detail in Error Handling & State below.

No heap, no hassle. You declare tile_t as a global or local variable and pass its address to _init(), which populates it in place. No malloc, no free, no ownership concerns — you control the memory.

Instance Mapping

Each driver maps an instance number (0, 1, 2, …) to the hardware-specific device identifier — an I2C address, SPI chip-select pin, or whatever the bus requires. Instance 0 is always the default configuration. The mapping is handled internally by a static lookup table and a resolve_id() helper.

I2C — ID is the device address

/* I2C example — ID is the 7-bit device address */

#define BOS1901_I2C_ADDR_DEFAULT  0x2C  /* instance 0 — ADDR floating */
#define BOS1901_I2C_ADDR_ALT     0x2D  /* instance 1 — ADDR to VDD   */

static const uint8_t id_table[] = {
    BOS1901_I2C_ADDR_DEFAULT,   /* instance 0 */
    BOS1901_I2C_ADDR_ALT,       /* instance 1 */
};

SPI — ID is the chip-select pin

/* SPI example — ID is the chip-select pin index */

#define MEMORY_F_CS_DEFAULT  0   /* instance 0 — CS0 */
#define MEMORY_F_CS_ALT      1   /* instance 1 — CS1 */

static const uint8_t id_table[] = {
    MEMORY_F_CS_DEFAULT,   /* instance 0 */
    MEMORY_F_CS_ALT,       /* instance 1 */
};

Shared pattern — resolve instance to device ID

/* Shared pattern — resolve instance to device ID */

static uint8_t resolve_id(uint8_t instance) {
    if (instance >= sizeof(id_table)) return 0xFF;
    return id_table[instance];
}

Document the mapping

Every driver header must include a table in the file-level doc comment showing the instance → identifier → hardware configuration mapping:

InstanceIDBusHardware Config
00x69I2CPad 2 floating (default)
10x68I2CPad 2 to GND
2CS pinSPIPad 3 to GND (becomes CS)

Multiple Tiles of the Same Type

Since every call carries its own instance handle, using multiple tiles of the same type is straightforward — just init each with a different instance number:

/* Multiple tiles of the same type — no global state conflicts */

tile_t motor_left, motor_right;
tile_drive_h_init(&hal, 0, &motor_left);   /* default addr */
tile_drive_h_init(&hal, 1, &motor_right);  /* alternate addr */

/* Each call is self-contained — no select() needed */
tile_drive_h_set_amplitude(&motor_left,  200);
tile_drive_h_set_amplitude(&motor_right, 150);

Tiles Across Different Buses

Separate HAL instances keep buses fully independent — no bus selection, no mutex between unrelated tiles:

/* Tiles on different I2C buses — separate HAL instances */

tiles_hal_stm32_cfg_t cfg1 = { .i2c = &hi2c1, .buses = TILES_BUS_I2C };
tiles_hal_stm32_cfg_t cfg2 = { .i2c = &hi2c2, .buses = TILES_BUS_I2C };

tiles_hal_t hal1, hal2;
tiles_hal_stm32_init(&hal1, &cfg1);
tiles_hal_stm32_init(&hal2, &cfg2);

tile_t imu, motor;
tile_sense_i_9_init(&hal1, 0, &imu);
tile_drive_h_init(&hal2, 0, &motor);

Find and Init

Every driver implements two entry points. find() is a lightweight probe — it checks if a tile is on the bus without configuring it. init() probes, verifies the device identity (when possible), applies defaults, and returns a ready-to-use handle.

Pseudocode

/* === FIND — PATTERN === */

uint8_t tile_<FAMILY>_<NAME>_find(tiles_hal_t* hal, uint8_t instance)
    1. resolve_id(instance) → device ID
    2. hal->i2c_is_ready() or hal->spi_is_ready()
    3. return 1 if device ACKs, 0 otherwise
    /* No state stored, no side effects */

/* === INIT — PATTERN === */

void tile_<FAMILY>_<NAME>_init(tiles_hal_t* hal, uint8_t instance, tile_t* tile)
    1. resolve_id(instance) → device ID
    2. Verify device is on bus (same as find)
    3. Verify identity if possible (WHO_AM_I, known register default, or skip)
    4. Apply default configuration
    5. Set tile->state = TILE_STATE_READY
    /* Any step fails → TILE_ON_ERROR + tile->state = TILE_STATE_ERROR */

Example

/* === FIND — EXAMPLE (Drive.H) === */
uint8_t tile_drive_h_find(tiles_hal_t* hal, uint8_t instance) {
    uint8_t addr = resolve_id(instance);
    if (addr == 0x00) return 0;
    return (hal->i2c_is_ready(hal->handle, addr) == 0) ? 1 : 0;
}

/* === INIT — EXAMPLE (Drive.H) === */
void tile_drive_h_init(tiles_hal_t* hal, uint8_t instance, tile_t* tile) {
    tile->hal   = NULL;
    tile->id    = 0;
    tile->state = TILE_STATE_NONE;
    tile->flags = 0;
    tile->callback = NULL;
    tile->cb_ctx   = NULL;

    uint8_t addr = resolve_id(instance);
    if (addr == 0xFF) {
        TILE_ON_ERROR(tile, "init: invalid instance");
        tile->state = TILE_STATE_ERROR;
        return;
    }

    tile->hal = hal;
    tile->id  = addr;

    /* Verify device is on the bus */
    if (hal->i2c_is_ready(hal->handle, addr) != 0) {
        TILE_ON_ERROR(tile, "init: device not found on bus");
        tile->state = TILE_STATE_ERROR;
        return;
    }

    /* Verify chip ID (if the chip has a WHO_AM_I register) */
    uint8_t whoami = chip_read(tile, BOS1901_REG_CHIP_ID);
    if (whoami != BOS1901_CHIP_ID_DEFAULT) {
        TILE_ON_ERROR(tile, "init: unexpected chip ID");
        tile->state = TILE_STATE_ERROR;
        return;
    }

    /* Apply default configuration... */

    tile->state = TILE_STATE_READY;
}

find() — stateless, no side effects. Useful for bus scanning, connection detection, and pre-flight checks. Takes the HAL and instance number, returns 1/0.

init() — the full setup sequence. Always follows the pattern: resolve ID → verify bus presence → verify device identity (if supported) → apply configuration → set state READY. If any step fails, the handle's state is set to TILE_STATE_ERROR.

Error Handling & State

The tile_state_t field in every instance handle tracks the tile's lifecycle. Drivers update this state as the tile moves through initialization, operation, sleep, and error conditions.

/* Always initialize before using a tile */
tile_t imu;
tile_sense_i_9_init(&hal, 0, &imu);

/* Quick check — covers the common case */
if (tile_is_ready(&imu)) {
    int16_t accel[3];
    tile_sense_i_9_get_raw_accels(&imu, accel);
}

/* Or use tile_state() for full control */
switch (tile_state(&imu)) {
    case TILE_STATE_READY:    /* normal operation */          break;
    case TILE_STATE_SLEEPING: tile_sense_i_9_wake(&imu);     break;
    case TILE_STATE_ERROR:    /* log, retry, or fall back */  break;
    default:                  /* not initialized */           break;
}

/* Drivers update state on lifecycle transitions */
tile_sense_i_9_sleep(&imu);    /* state → TILE_STATE_SLEEPING */
tile_sense_i_9_wake(&imu);     /* state → TILE_STATE_READY    */

State Transitions

NONE→ init() →READY→ sleep() →SLEEPING→ wake() →READY
NONE→ init() fails →ERROR

Driver-Side State Guards

Driver functions should check the instance state and return early if not ready. The TILE_ON_ERROR macro fires when this happens — silent by default, but users can override it for diagnostics.

/* Inside a driver function — guard against wrong state */
void tile_sense_i_9_get_raw_accels(tile_t* tile, int16_t* accel) {
    if (tile->state != TILE_STATE_READY) {
        TILE_ON_ERROR(tile, "get_raw_accels called while not ready");
        return;
    }
    /* ... actual implementation ... */
}

Application-Side Setup

Set hal.on_error once in your application — no need to touch driver files. Errors from any tile bubble up through your callback with the instance handle and a description.

/* In your application — one line, applies to all drivers */
void my_error_handler(tile_t tile, const char* msg) {
    printf("[TILE id=0x%02X] %s\n", tile.id, msg);
    /* or send over UART, BLE, USB, log to flash, etc. */
}

hal.on_error = my_error_handler;
Two tiers of control: By default, TILE_ON_ERROR checks the HAL's on_error callback. If nobody sets it, nothing happens — silent and beginner-friendly. For zero-overhead builds on tight flash budgets, #define TILE_ON_ERROR(tile, msg) ((void)0) before including tiles.h to eliminate the check entirely.

Putting It Together

Here's a complete example — HAL init, tile probe, initialization, and data read:

#include "tiles.h"
#include "tiles_hal_core.h"       /* or _arduino.h, _esp_idf.h, _stm32.h */
#include "tile_sense_i_9.h"

/* Configure the HAL for your Core tile (or other host platform) */
tiles_hal_core_cfg_t cfg = {
    .i2c = &hi2c1,
    .buses = TILES_BUS_I2C,
};
tiles_hal_t hal;
tiles_hal_core_init(&hal, &cfg);

/* Probe first (optional) */
if (tile_sense_i_9_find(&hal, 0)) {
    /* Initialize — instance 0 = default address */
    tile_t imu;
    tile_sense_i_9_init(&hal, 0, &imu);

    if (tile_is_ready(&imu)) {
        int16_t accel[3];
        tile_sense_i_9_get_raw_accels(&imu, accel);
    }
}

Driver Development

Design Considerations

As the library grows to 75+ drivers, these principles keep the codebase maintainable and the user experience consistent:

Memory Footprint

Many tiles will run on small MCUs (32KB flash, 8KB RAM). Keep drivers lean — no dynamic allocation, no large buffers. The tile_t handle is intentionally small (typically 6 bytes). Use stack-local buffers for temporary data.

Blocking, Callbacks & Async

Today, all driver functions are blocking — they call the HAL, wait for the bus transfer to complete, and return with the result. This is the simplest model and works well for most applications.

For better CPU utilization, your HAL implementation can use DMA transparently — the driver still blocks, but the CPU sleeps during the transfer instead of busy-waiting. Same API, better power and throughput. This is the recommended first optimization.

Looking ahead, the tile_t handle includes reserved fields (flags, callback, cb_ctx) for a future event-driven model. The vision: register a callback with a driver function like on_ready(), and a central tiles_process() function dispatches callbacks in your main loop when events occur (data ready, transfer complete, etc.). This enables non-blocking patterns without requiring users to write ISR code.

For the most demanding use cases (high-speed sensor streaming, camera tiles), true async DMA and direct ISR access will be available through the HAL. These are advanced patterns — most applications will never need them.

Thread Safety

Drivers are not thread-safe by design. Two threads calling the same tile instance concurrently will produce undefined behavior. If you need concurrent access, protect calls with a mutex in your application or HAL layer. Different tile instances on different buses can be used concurrently without synchronization.

Multi-Protocol Tiles

The HAL supports I2C, SPI, QSPI, and I3C via bus flags and dedicated function pointers (see the HAL section above). Some tiles are I2C-only, some SPI-only, and some support both. The instance mapping table in each driver header documents which instance numbers correspond to which bus and address. The id field in tile_t is bus-agnostic — it holds an I2C address or SPI CS index depending on the instance.

Dual-Mode Interface Init

Some ICs share physical pins between I2C and SPI (e.g., SCL/SCLK and SDA/SDI on the ICM-20948). At power-on, these chips typically default to I2C and can misinterpret early SPI traffic as garbled I2C. When a tile supports both interfaces, the driver's SPI init path must explicitly disable the I2C interface early in the sequence (e.g., setting an I2C_IF_DIS bit) to prevent bus contention. This is a configure-once-at-init decision — runtime switching between protocols is not currently supported. Tiles include internal pull-ups on configurable pads to ensure a clean default state at power-on and prevent startup ambiguity. Document any interface-specific init requirements and pad configurations in the driver header.

Interrupts & Data-Ready Pins

Many sensor tiles have interrupt output pins (data ready, FIFO watermark, threshold alerts, motion events). Drivers should provide a _data_ready() function that reads the status register, and document which interrupt pins the chip supports.

GPIO/ISR wiring is host-platform-specific — the future tiles_process() system will use these pins to set flags and dispatch callbacks automatically. For now, users who need interrupt-driven behavior can wire the GPIO ISR themselves and call _data_ready() or read data directly from the callback.

Power Management

Tiles that support low-power modes should implement _sleep() and _wake() functions that update the instance state accordingly. Document current consumption in each mode.

Documentation Standards

Driver headers are the primary documentation source. The website's API reference pages are auto-generated from Doxygen comments in the headers, so thorough documentation directly improves the online docs.

File-Level Comment

Every header starts with a block that identifies the tile, its IC, key specs, and a quick-start example. For standalone use, include the link(s) for on-board ICs as well as essential information on the available interface(s).

/**
 * @file   tile_sense_i_9.h
 * @brief  tile driver for the Sense.I.9 tile (ICM-20948).
 *
 * 9-axis IMU: 3-axis accelerometer, 3-axis gyroscope, 3-axis
 * magnetometer (AK09916 die). I2C interface, 1 MHz fast-mode+.
 *
 * Datasheet: https://invensense.tdk.com/...
 *
 * @note   I2C default address: 0x69 (instance 0, AD0 floating)
 */

Function Documentation

Document every public function with @brief, @param, @return, and a @code example where helpful. Include conversion formulas, units, and practical context — not just parameter descriptions.

/**
 * @brief  Read raw accelerometer data for all three axes.
 *
 * Returns signed 16-bit values in sensor frame. To convert to g,
 * divide by the sensitivity for the current range setting:
 *   ±2g → 16384 LSB/g,  ±4g → 8192,  ±8g → 4096,  ±16g → 2048
 *
 * @param  inst   Instance handle from tile_sense_i_9_init()
 * @param  accel  Output buffer for [x, y, z] — must hold 3 int16_t
 *
 * @code
 *   int16_t accel[3];
 *   tile_sense_i_9_get_raw_accels(imu, accel);
 *   float x_g = accel[0] / 16384.0f;  /* at ±2g range */
 * @endcode
 */
void tile_sense_i_9_get_raw_accels(tile_t* tile, int16_t* accel);

Enum Documentation

Document enum values with units, sensitivities, and practical implications. The user writing the application code should understand which value to choose without needing to open the datasheet.

/**
 * @brief  Accelerometer full-scale range.
 *
 * Higher ranges measure faster motion; lower ranges give finer
 * resolution. The sensitivity (LSB/g) halves with each step.
 */
typedef enum {
    SENSE_I_9_ACCEL_2G  = 0x00,  /**< ±2g  — 16384 LSB/g (default) */
    SENSE_I_9_ACCEL_4G  = 0x01,  /**< ±4g  — 8192 LSB/g            */
    SENSE_I_9_ACCEL_8G  = 0x02,  /**< ±8g  — 4096 LSB/g            */
    SENSE_I_9_ACCEL_16G = 0x03,  /**< ±16g — 2048 LSB/g            */
} sense_i_9_accel_range_t;

Private Bus Helpers

Every driver defines static helper functions that wrap the HAL calls with the instance's device ID pre-filled. This keeps the public API implementations clean and readable. The pattern is the same regardless of bus — I2C, SPI, or QSPI — just swap the HAL function pointer. Here's the I2C version (the most common):

/* Private I2C helpers — keep the public API clean */

static void chip_write(tile_t* tile, uint8_t reg, uint8_t value) {
    tile->hal->i2c_write(tile->hal->handle, tile->id, reg, &value, 1);
}

static uint8_t chip_read(tile_t* tile, uint8_t reg) {
    uint8_t value = 0;
    tile->hal->i2c_read(tile->hal->handle, tile->id, reg, &value, 1);
    return value;
}

static void chip_read_buf(tile_t* tile, uint8_t reg,
                           uint8_t* buf, uint16_t len) {
    tile->hal->i2c_read(tile->hal->handle, tile->id, reg, buf, len);
}

For SPI tiles, the pattern is identical but calls hal->spi_read() / hal->spi_write() instead. For chips with 16-bit registers, big-endian byte order, or multi-register reads, add specialized helpers (e.g., chip_read16(), chip_write16()) in the private section.

Contribution Checklist

Before submitting a new driver, verify:

Structure

  • Header and source follow the standard layout
  • File named tile_<family>_<name>.h / .c
  • Includes: tiles.h and stdint.h — nothing else host-platform-specific
  • Include guard matches file path

API

  • find() implemented — stateless probe by instance number
  • init() implemented — populates tile_t with READY or ERROR state
  • All public functions take tile_t as first parameter
  • No global/static mutable state (all state lives in the instance or is const)
  • Instance ID table with resolve_id() helper

Documentation

  • File-level @file, @brief, @note with datasheet link
  • Instance-to-address mapping table in header comment
  • Every public function has @brief, @param, @return
  • At least one @code example in the file-level comment
  • Enums documented with units/sensitivity/practical guidance

Versioning

  • Driver version defines (MAJOR, MINOR, PATCH) in header
  • TILES_CHECK_VERSION() call with minimum framework requirement
  • Version bump follows semver rules (patch/minor/major)

Quality

  • Compiles with -Wall -Wextra -Werror (no warnings)
  • Tested on hardware (at minimum: find → init → read one value)
  • No host-platform-specific includes or types
  • Device identity verification in init() (WHO_AM_I, known register default, or documented as unavailable)