Skip to content

AddingAnI2CDeviceType

Rob Dobson edited this page May 3, 2026 · 1 revision

Adding an I2C Device Type

This tutorial walks through adding support for a new I2C sensor end-to-end. We use the TI HDC1080 temperature/humidity sensor as the worked example because:

  • It has a real "device ID" register (good detection),
  • Its conversion formulas are simple linear ones (clean attribute decoding),
  • It uses the trigger-then-read pattern shared with many I²C sensors (AHT20, SHT3x, BME280…).

The same recipe applies to almost any modern I²C device. By the end you will have:

  1. A new entry in DeviceTypeRecords.json that the build system compiles into C++ constants.
  2. Auto-detection on any I²C bus (or multiplexer slot) that your SysMod scans.
  3. Decoded values published to BLE / WebSocket / REST clients with units, ranges and formatting.

Background reading: Device Type Record Format, I2C Device Identification and Polling, I2C Bus, Device Manager.


TL;DR

To add a new device type, append one record to RaftCore/devtypes/DeviceTypeRecords.json with five things populated:

Section What goes in it
addresses I²C address(es) the device might appear at
detectionValues A read that uniquely identifies the chip
initValues The register writes that put it into the mode you want
pollInfo The read (and optional trigger) that fetches data, plus poll period
devInfoJson.resp.a The binary-decoder schema for the polled bytes

Build, flash, and the device shows up automatically when plugged in.


Step 1 — Gather the datasheet facts

Before writing JSON, extract these specifics from the datasheet:

Question HDC1080 answer
I²C address(es) 0x40 (fixed)
Identification register(s) 0xFE → Manufacturer ID 0x5449, 0xFF → Device ID 0x1050
Configuration register 0x02, 16-bit, write 0x1000 for "temperature and humidity, 14-bit each, single trigger"
How a measurement is triggered Write the pointer register 0x00
Conversion time ~6.5 ms (temperature) + ~6.5 ms (humidity) at 14-bit
Where data appears After conversion, a single read of 4 bytes from pointer 0x00 returns T_msb T_lsb H_msb H_lsb
Conversion formulas T(°C) = raw_T × 165 / 65536 − 40
RH(%) = raw_RH × 100 / 65536

Most sensors fit the same template. Dig the equivalent answers out of your datasheet before going further.


Step 2 — Pick a polling pattern

A trigger-and-wait device has two sensible polling shapes. Use the one that matches the device's worst-case conversion time vs. how often you want a sample.

Pattern A — read-then-retrigger (recommended for slow sensors):

  • Initialisation issues the first trigger.
  • Each poll reads the previous result, then issues the next trigger.
  • The poll interval is much longer than the conversion time, so the data is always ready.

This is exactly what the existing AHT20 record does ("c": "=r6&0xac3300=").

Pattern B — write-pause-read in one cycle:

  • Each poll issues the trigger, pauses, then reads.
  • Higher latency per poll; needed only when the device cannot be left armed between polls.

For the HDC1080 we'll use Pattern A with a 1 s interval (the device idles between polls and there is no risk of stale data accumulating).


Step 3 — Write the detection sequence

Detection happens once after the address responds. We want a read that only the HDC1080 will satisfy.

Best candidate: the Device-ID register 0xFF returns the 16-bit value 0x1050.

"detectionValues": "0xFF=0b0001000001010000"

A 16-bit binary mask is used (one bit per significant bit, X for don't-care). Equivalently you can chain with & to also check the manufacturer ID, which makes detection even safer:

"detectionValues": "0xFE=0b0101010001001001&0xFF=0b0001000001010000"

If your device has no ID register, fall back to checking the reset value of a config register with don't-care bits over the parts that depend on user state — see the MCP9808 and TMP102-style entries in Device Type Record Format for the syntax.


Step 4 — Write the init sequence

We need to:

  1. Configure the device for "temperature + humidity, 14-bit each" by writing 0x1000 to register 0x02.
  2. Issue the very first trigger so that the first poll has data to read.
"initValues": "0x021000=&0x00="

Notes:

  • 0x021000 is a single write — register 0x02, then data bytes 0x10 and 0x00.
  • 0x00= writes only the pointer register 0x00 (no data byte) — this is the "trigger" on the HDC1080.
  • The = after each operation marks the end of the write (no read follows). Operations are chained with &.

If your device needs settling time, add =p20 (20 ms pause). See Init Values.


Step 5 — Write the poll command and decoder schema

Poll command: read 4 bytes (the previous measurement), then re-arm the device.

"pollInfo": {
    "c": "=r4&0x00=",
    "i": 1000,
    "s": 1
}
  • =r4 — read 4 bytes with no register write first (the device's pointer is already at 0x00 from the previous trigger / init).
  • 0x00= — write pointer 0x00 to start the next conversion.
  • i: 1000 — poll every 1000 ms.
  • s: 1 — publish each sample on its own (no batching).

Decoder schema: four bytes → two big-endian unsigned 16-bit values.

"devInfoJson": {
    "name": "HDC1080",
    "desc": "Temp&Humid",
    "manu": "Texas Instruments",
    "type": "HDC1080",
    "clas": ["TEMP", "RH"],
    "resp": {
        "b": 4,
        "a": [
            {
                "n": "temperature",
                "t": ">H",
                "u": "°C",
                "r": [-40, 125],
                "d": 397.1878787,
                "a": -40,
                "f": "3.2f",
                "o": "float"
            },
            {
                "n": "humidity",
                "at": 2,
                "t": ">H",
                "u": "%",
                "r": [0, 100],
                "d": 655.36,
                "f": "3.1f",
                "o": "float"
            }
        ]
    }
}

How the divisors and offsets fall out of the conversion formulas:

  • Temperature: T = raw × 165 / 65536 − 40 ⇒ divisor d = 65536 / 165 ≈ 397.188, post-divisor add a = −40.
  • Humidity: RH = raw × 100 / 65536 ⇒ divisor d = 65536 / 100 = 655.36.

The decoder pipeline (xor → mask → shift → divide → add) is documented in Decoding Pipeline.

The b: 4 byte-count is validated at build time against the bytes implied by the poll command — if it disagrees the build fails. This is your primary safety net against silly mistakes.

r (range) and f (format) are not used for decoding but are surfaced to clients (graphs, gauges, BLE attribute info).


Step 6 — Add the record

Open RaftCore/devtypes/DeviceTypeRecords.json and add the record inside the top-level "devTypes" object (alphabetical order is the convention):

"HDC1080": {
    "addresses": "0x40",
    "deviceType": "HDC1080",
    "detectionValues": "0xFE=0b0101010001001001&0xFF=0b0001000001010000",
    "initValues": "0x021000=&0x00=",
    "pollInfo": { "c": "=r4&0x00=", "i": 1000, "s": 1 },
    "scanPriority": "high",
    "devInfoJson": {
        "name": "HDC1080",
        "desc": "Temp&Humid",
        "manu": "Texas Instruments",
        "type": "HDC1080",
        "clas": ["TEMP", "RH"],
        "resp": {
            "b": 4,
            "a": [
                { "n": "temperature", "t": ">H", "u": "°C",
                  "r": [-40, 125], "d": 397.1878787, "a": -40,
                  "f": "3.2f", "o": "float" },
                { "n": "humidity",    "at": 2, "t": ">H", "u": "%",
                  "r": [0, 100],   "d": 655.36,
                  "f": "3.1f", "o": "float" }
            ]
        }
    }
}

Tips:

  • scanPriority: "high" puts the address into the fast-scan list — appropriate for hot-pluggable user devices. Omit it for rarely-changing system devices to keep the high-priority list short. See I2C Device Scanning.
  • The top-level key ("HDC1080") must equal the deviceType field. The build script enforces this.

Step 7 — Build and flash

DeviceTypeRecords.json is consumed by scripts/ProcessDevTypeJsonToC.py at build time, which produces:

  • A C struct <TYPE>_struct_t (used internally by RaftBusDevice),
  • A decode() lambda (or a transpiled custom-pseudocode function),
  • The const device type record that the device-identification machinery walks.

Build and flash as normal:

raft build && raft flash && raft monitor

Watch for:

  • Build failure with byte count mismatch — your resp.b doesn't match what the poll command reads. Recount.
  • Build failure citing JSON syntax — usually a stray comma or missing quote.

Step 8 — Verify on a real device

With the firmware running and the sensor wired to one of the I²C buses configured in your SysType:

  1. Logs — at boot, after scanning, you should see DevIdent log it as HDC1080@0x40.
  2. REST API:
    • GET /api/devman/typeinfo?bus=I2CA&type=HDC1080 returns the record you just added (sanity check that JSON parsed).
    • GET /api/devman/devicedata?bus=I2CA returns the most recent decoded sample, e.g. {"_t":1234567,"temperature":22.81,"humidity":48.3}.
  3. WebUI / BLE client — the device appears in the device list with class tags TEMP and RH, and (if the WebUI knows those classes) draws a temperature gauge and a humidity bar.

See Device Manager REST API for the full endpoint set.


Step 9 — (Optional) Adding actions

If your device exposes writable parameters (e.g. heater on/off, sample rate, calibration triggers), define an actions array in devInfoJson so clients can discover and send them. Skeleton:

"actions": [
    {
        "n": "heater",
        "t": "B",
        "w": "0210",
        "wz": "00",
        "r": [0, 1],
        "d": 0,
        "desc": "Internal heater (0=off, 1=on)"
    }
]

The full action grammar — including discrete maps, LED-pixel grids and configuration actions that change polling parameters — is documented in Actions.


Alternative — Registering a device type at runtime

If your device support cannot live in DeviceTypeRecords.json (e.g. it is shipped by a third-party module, or it needs a custom C++ decoder you would rather not transpile from pseudocode), you can register it dynamically from your application or SysMod:

DeviceTypeRecordDynamic devTypeRec(
    "MY_SENSOR",                 // type name
    "0x48,0x49",                 // addresses
    "0x00=0x1234",               // detectionValues
    "0x01=0x80",                 // initValues
    "c=01=r6&i=100&s=10",        // pollInfo (compact form)
    6,                           // pollDataSizeBytes
    R"({"name":"MySensor","desc":"...","clas":["TEMP"]})",
    myDecodeFunction);

uint16_t typeIdx = 0;
deviceTypeRecords.addExtendedDeviceTypeRecord(devTypeRec, typeIdx);

Up to 64 such extended records are supported. They are added to the high-priority scan list so newly-plugged devices are picked up promptly. See Adding Extended Device Types.

For devices that need genuinely bespoke behaviour (custom REST endpoints, non-I²C interfacing, software-only "virtual" devices), implement a full RaftDevice subclass and register it with the device factory — see Device Factory and Classes for the pattern.


Troubleshooting

Symptom Likely cause
Device not detected at all Wrong address, wrong bus, pull-ups missing, or detectionValues too strict. Try a permissive detection ("") temporarily and confirm the log shows the address responding.
Detected but immediately dropped Detection succeeds intermittently — the bus is noisy, or your detection register's "fixed" bits are not actually fixed. Tighten with & of two registers, or relax with more X don't-care bits.
Polling returns 0xFFFFs The device hasn't been triggered — check that initValues issues the first trigger, and that your poll c re-triggers each time.
Values look like reasonable numbers but are off by a factor Wrong divisor d, or wrong endianness in t (>H vs <H).
Values are signed when they should be unsigned (or vice versa) Use >h/<h for signed, >H/<H for unsigned. For "12-bit signed in a 16-bit container" use m + sb + ss — see the MCP9808 example.
Build fails with expected b=N got M Your resp.b and the byte count of the poll-command reads disagree. Sum the rN reads and update b.
Device shows up but is decoded as a different type Two device types claim the same address with overlapping detection. Tighten one or both detectionValues.

See also

Clone this wiki locally