pamoja

An SDK for IoT, robotics, and drones

pamojaRev 0.2.0MIT licensepamoja.molex.cloud

Buses#

Every part a node talks to sits on a bus: I2C for the dense breakout sensors, SPI for displays and radios, a GPIO line for a relay or a button, 1-Wire for a waterproof thermometer on a long cable. A driver is a conversation over one of them, in the order and at the pace the part's datasheet lays down: reset, identify, read the calibration, configure, trigger, wait, read. pamoja's drivers are written against the embedded-hal traits rather than against a board, so the same driver runs over a microcontroller's peripheral, over the kernel's /dev/i2c-1 on a Raspberry Pi, and over a part that is not there at all. Buses and links explains each bus in its own terms, and the board pages wire a part to a Raspberry Pi, an ESP32, or an RP2040 and run a driver there.

This guide is the I2C bus as a program holds it: one bus that the program and every driver on it share, whatever is on the other end. That is the kernel's adapter on a Linux board, simulated parts that answer from their registers, or a script of the transfers a driver is expected to make. A driver runs the same way over all three, so a program is written and tested with nothing plugged in and then pointed at /dev/i2c-1, in any of the four languages. The part throughout is a BME280, a temperature, pressure, and humidity sensor on an inexpensive breakout, whose driver pamoja ships.

What the example does#

It reads a BME280 with nothing plugged in, first through a simulated part and then through a script of the datasheet's own sequence, and prints what each step saw.

The simulated part holds a real part's calibration and one measurement that part took. The driver resets it, identifies it, reads the calibration, configures it and measures, and because the part keeps what was written to it, the configuration reads back off the bus afterward: humidity oversampling written before the measurement register, and the part left asleep until a measurement is forced. A simulated bus counts every wait a driver asks for without sleeping through it, so the program reports the datasheet's start-up and measurement times and still finishes at once. Then a second part takes the first one's place, asked to report four degrees at 1013.25 hPa, and the same driver reads it, which is how a program meets a reading it would otherwise wait on the weather to produce. A driver pointed at an address no part holds fails with the bus's own reason.

The script is the other half. It lists the transfers the datasheet prescribes, in its order: the soft reset, the status register once the calibration image has loaded, the chip id, the two calibration blocks, the three configuration writes in the order the part requires, then one forced measurement, a status read, and the burst. A transfer the script does not expect is refused, so a driver that skipped a step or wrote the wrong register fails here rather than on a bench.

The calibration and the burst are what one real part held, and they compensate to 20.44 C, 848.05 hPa, and 44.65 %.

It proves:

  • A driver runs against a part that answers from its registers, with nothing plugged in and no transfer sequence written down first.
  • The configuration goes out in the datasheet's order, ctrl_hum before ctrl_meas, and leaves the part asleep until a measurement is forced.
  • The driver waits the datasheet's 2 ms start-up and 9.3 ms measurement time, which the bus counts rather than sleeps through.
  • A part reports what it is asked to, and a driver carries on across a part swapped in underneath it.
  • An address nothing answers at is an error that names the address, never a reading.
  • The datasheet sequence is eleven transfers and no other, and it compensates to the same reading, to the hundredth, in every language.

Run it#

The example below is a program CI runs on every change, in each language, from a clone of the repository:

Rust

cargo run -p pamoja-examples --example hal

TypeScript

npm --prefix bindings/node run guides -- hal

Python

python bindings/python/guides/hal.py

C#

dotnet run --project bindings/dotnet/samples/Pamoja.Guides -- hal

Rust#

In Rust the bus layer is pamoja-hal, and a driver is generic over its embedded-hal traits, taking the bus by value. On a microcontroller that is the HAL's own I2C peripheral, and in a test a simulated part or a script on its own. The example uses pamoja_hal::bus::I2cBus, the host-side handle a gateway wants: it clones cheaply into every driver on one bus while the program keeps its own, and bus.delay() hands each driver a delay that sleeps only when real parts are on the other end. I2cBus::open needs the linux feature; a single driver that owns the adapter outright can take pamoja_hal::linux::i2c instead. Every call returns a Result, and a driver's error is a DriverError whose Bus variant carries the bus's own BusError.

From examples/guides/hal.rs:

Rust
use pamoja_hal::bus::I2cBus;
use pamoja_hal::script::{I2cScript, I2cStep};
use pamoja_hal::sim::I2cPart;
use pamoja_sensors::bme280::{
    register, sim, Bme280, Config, CtrlHum, CtrlMeas, Mode, Oversampling, CHIP_ID,
    I2C_ADDRESS_PRIMARY, I2C_ADDRESS_SECONDARY, RESET_WORD,
};
use pamoja_sensors::DriverError;

const BME280: u8 = I2C_ADDRESS_PRIMARY;

// A bus with one part on it: a BME280 that is not there. It holds a real part's
// calibration and one measurement that part took, and it answers from its registers, so
// the driver runs its whole datasheet sequence against it. On a Raspberry Pi the bus is
// `I2cBus::open("/dev/i2c-1")` and nothing after this line changes.
let bus = I2cBus::simulated([sim::part(BME280)]);
let mut sensor = Bme280::i2c(bus.clone(), BME280, bus.delay());

// Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
// ctrl_meas, and the part left asleep until a measurement is forced. The part keeps what
// the driver wrote, so the configuration reads back off the bus.
sensor.init()?;
let part: I2cPart = bus.part(BME280).ok_or("no part at the address")?;
let humidity = CtrlHum::from_bits(part.register(register::CTRL_HUM)).humidity;
let ctrl = CtrlMeas::from_bits(part.register(register::CTRL_MEAS));
println!(
    "configured   humidity x{}, temperature x{}, pressure x{}, asleep: {}",
    humidity.factor(),
    ctrl.temperature.factor(),
    ctrl.pressure.factor(),
    ctrl.mode == Mode::Sleep
);

// One forced measurement. The driver waits the datasheet's longest measurement time for
// these settings before it reads, and a simulated bus counts that wait rather than
// sleeping through it.
let reading = sensor.measure()?;
println!(
    "measured     {:.2} C, {:.2} hPa, {:.2} %",
    reading.celsius(),
    reading.hectopascals(),
    reading.relative_humidity_percent()
);
println!(
    "waited       {:.2} ms across {} transfers",
    bus.waited_micros() as f64 / 1000.0,
    bus.transfers()
);

// A part reports whatever it is asked to. Putting one in the first one's place is how a
// program meets a reading it would otherwise wait on the weather for, here a cold store
// at four degrees, and the driver carries on without noticing.
bus.attach(sim::reporting(BME280, 4.0, 1013.25, 80.0))?;
let cold = sensor.measure()?;
println!(
    "cold store   {:.2} C, {:.2} hPa, {:.2} %",
    cold.celsius(),
    cold.hectopascals(),
    cold.relative_humidity_percent()
);

// Nothing answers at the part's other address, and the driver says so rather than
// returning a reading.
match Bme280::i2c(bus.clone(), I2C_ADDRESS_SECONDARY, bus.delay()).init() {
    Ok(()) => println!("absent       a part answered"),
    Err(DriverError::Bus(error)) => println!("absent       {error}"),
    Err(error) => println!("absent       {error}"),
}

// The other half of the bus layer. A script plays one conversation and refuses anything
// else, which proves a driver follows the datasheet rather than merely working: the
// reset, the status once the calibration has loaded, the chip id, the two calibration
// blocks, the three configuration writes in the order the part requires, then one forced
// measurement.
let settings = CtrlMeas {
    temperature: Oversampling::X1,
    pressure: Oversampling::X1,
    mode: Mode::Sleep,
};
let forced = CtrlMeas {
    mode: Mode::Forced,
    ..settings
};
let script = I2cBus::scripted(I2cScript::new([
    I2cStep::write(BME280, [register::RESET, RESET_WORD]),
    I2cStep::write_read(BME280, [register::STATUS], [sim::STATUS_IDLE]),
    I2cStep::write_read(BME280, [register::CHIP_ID], [CHIP_ID]),
    I2cStep::write_read(BME280, [register::CALIB_TEMP_PRESS], sim::CALIBRATION),
    I2cStep::write_read(
        BME280,
        [register::CALIB_HUMIDITY],
        sim::CALIBRATION_HUMIDITY,
    ),
    I2cStep::write(BME280, [register::CONFIG, Config::default().bits()]),
    I2cStep::write(
        BME280,
        [
            register::CTRL_HUM,
            CtrlHum {
                humidity: Oversampling::X1,
            }
            .bits(),
        ],
    ),
    I2cStep::write(BME280, [register::CTRL_MEAS, settings.bits()]),
    I2cStep::write(BME280, [register::CTRL_MEAS, forced.bits()]),
    I2cStep::write_read(BME280, [register::STATUS], [sim::STATUS_IDLE]),
    I2cStep::write_read(BME280, [register::DATA], sim::BURST),
]));
let checked = Bme280::i2c(script.clone(), BME280, script.delay()).measure()?;
println!(
    "datasheet    {:.2} C after {} transfers, {} steps left",
    checked.celsius(),
    script.transfers(),
    script.remaining().unwrap_or_default()
);

TypeScript#

In TypeScript the bus is I2cBus from @pamoja/hal, and the driver is Bme280 from @pamoja/sensors. Transfers on the bus are synchronous and throw on failure. The driver's init and measure run on a worker thread, because a measurement waits the datasheet's time before it reads, and return promises that reject with the reason. Bytes go in and come out as Buffers, and the setting codes are named on bme280, as bme280.oversampling.x1 and bme280.mode.sleep.

From bindings/node/guides/hal.ts:

TypeScript
import { I2cBus, I2cPart, I2cStep } from '@pamoja/hal'
import { Bme280, type Bme280Measurement, bme280 } from '@pamoja/sensors'

const BME280 = bme280.addressPrimary
const x = (code: number): string => `x${bme280.oversamplingFactor(code)}`
const shown = (reading: Bme280Measurement): string =>
  `${reading.celsius.toFixed(2)} C, ${reading.hectopascals.toFixed(2)} hPa, ` +
  `${reading.relativeHumidityPercent.toFixed(2)} %`

async function main() {
  // A bus with one part on it: a BME280 that is not there. It holds a real part's
  // calibration and one measurement that part took, and it answers from its registers, so
  // the driver runs its whole datasheet sequence against it. On a Raspberry Pi the bus is
  // I2cBus.open('/dev/i2c-1') and nothing after this line changes.
  const bus = I2cBus.simulated([bme280.sim.part(BME280)])
  const sensor = new Bme280(bus, BME280)

  // Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
  // ctrl_meas, and the part left asleep until a measurement is forced. The part keeps what
  // the driver wrote, so the configuration reads back off the bus.
  await sensor.init()
  const part = bus.part(BME280)
  if (!(part instanceof I2cPart)) throw new Error('no part with byte-wide registers at the address')
  const humidity = bme280.ctrlHumFromBits(part.register(bme280.register.ctrlHum))
  const ctrl = bme280.ctrlMeasFromBits(part.register(bme280.register.ctrlMeas))
  const asleep = ctrl.mode === bme280.mode.sleep
  console.log(
    `configured   humidity ${x(humidity)}, temperature ${x(ctrl.temperature)}, ` +
      `pressure ${x(ctrl.pressure)}, asleep: ${asleep}`,
  )

  // One forced measurement. The driver waits the datasheet's longest measurement time for
  // these settings before it reads, and a simulated bus counts that wait rather than
  // sleeping through it.
  const reading = await sensor.measure()
  console.log(`measured     ${shown(reading)}`)
  const waitedMs = (bus.waitedMicros / 1000).toFixed(2)
  console.log(`waited       ${waitedMs} ms across ${bus.transfers} transfers`)

  // A part reports whatever it is asked to. Putting one in the first one's place is how a
  // program meets a reading it would otherwise wait on the weather for, here a cold store
  // at four degrees, and the driver carries on without noticing.
  bus.attach(bme280.sim.reporting(BME280, 4.0, 1013.25, 80.0))
  const cold = await sensor.measure()
  console.log(`cold store   ${shown(cold)}`)

  // Nothing answers at the part's other address, and the driver says so rather than
  // returning a reading.
  try {
    await new Bme280(bus, bme280.addressSecondary).init()
    console.log('absent       a part answered')
  } catch (error) {
    console.log(`absent       ${(error as Error).message}`)
  }

  // The other half of the bus layer. A script plays one conversation and refuses anything
  // else, which proves a driver follows the datasheet rather than merely working: the
  // reset, the status once the calibration has loaded, the chip id, the two calibration
  // blocks, the three configuration writes in the order the part requires, then one forced
  // measurement.
  const { register, oversampling, mode, sim } = bme280
  const settings = { temperature: oversampling.x1, pressure: oversampling.x1, mode: mode.sleep }
  const forced = { ...settings, mode: mode.forced }
  const resetState = { standby: bme280.standby.ms0_5, filter: bme280.filter.off, spi3Wire: false }
  const script = I2cBus.scripted([
    I2cStep.write(BME280, Buffer.from([register.reset, bme280.resetWord])),
    I2cStep.writeRead(BME280, Buffer.from([register.status]), Buffer.from([sim.statusIdle])),
    I2cStep.writeRead(BME280, Buffer.from([register.chipId]), Buffer.from([bme280.chipId])),
    I2cStep.writeRead(BME280, Buffer.from([register.calibTempPress]), sim.calibration()),
    I2cStep.writeRead(BME280, Buffer.from([register.calibHumidity]), sim.calibrationHumidity()),
    I2cStep.write(BME280, Buffer.from([register.config, bme280.configBits(resetState)])),
    I2cStep.write(BME280, Buffer.from([register.ctrlHum, bme280.ctrlHumBits(oversampling.x1)])),
    I2cStep.write(BME280, Buffer.from([register.ctrlMeas, bme280.ctrlMeasBits(settings)])),
    I2cStep.write(BME280, Buffer.from([register.ctrlMeas, bme280.ctrlMeasBits(forced)])),
    I2cStep.writeRead(BME280, Buffer.from([register.status]), Buffer.from([sim.statusIdle])),
    I2cStep.writeRead(BME280, Buffer.from([register.data]), sim.burst()),
  ])
  const checked = await new Bme280(script, BME280).measure()
  const left = script.remaining ?? 0
  console.log(
    `datasheet    ${checked.celsius.toFixed(2)} C after ${script.transfers} transfers, ` +
      `${left} steps left`,
  )

  return { humidity, asleep, reading, cold, waited: bus.waitedMicros, checked, script }
}

main()

Python#

In Python the bus is I2cBus from pamoja.hal, the driver is Bme280 from pamoja.sensors, and every call is synchronous. The driver releases the interpreter while the part answers, so other threads keep running through a measurement. The setting codes are IntEnums on bme280, as bme280.Oversampling.X1, and a failure raises PamojaError, which pamoja.core exports, with the reason as its message.

From bindings/python/guides/hal.py:

Python
from pamoja.core import PamojaError
from pamoja.hal import I2cBus, I2cStep
from pamoja.sensors import Bme280, Bme280Config, Bme280CtrlMeas, Bme280Measurement, bme280

BME280 = bme280.ADDRESS_PRIMARY


def shown(reading: Bme280Measurement) -> str:
    """A reading the way every line below prints it."""
    return (
        f"{reading.celsius:.2f} C, {reading.hectopascals:.2f} hPa, "
        f"{reading.relative_humidity_percent:.2f} %"
    )


def x(code: int) -> str:
    """An oversampling setting the way a datasheet writes it."""
    return f"x{bme280.oversampling_factor(code)}"


# A bus with one part on it: a BME280 that is not there. It holds a real part's calibration
# and one measurement that part took, and it answers from its registers, so the driver runs
# its whole datasheet sequence against it. On a Raspberry Pi the bus is
# I2cBus.open("/dev/i2c-1") and nothing after this line changes.
bus = I2cBus.simulated([bme280.sim.part(BME280)])
sensor = Bme280(bus, BME280)

# Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
# ctrl_meas, and the part left asleep until a measurement is forced. The part keeps what the
# driver wrote, so the configuration reads back off the bus.
sensor.init()
part = bus.part(BME280)
humidity = bme280.ctrl_hum_from_bits(part.register(bme280.REGISTER_CTRL_HUM))
ctrl = bme280.ctrl_meas_from_bits(part.register(bme280.REGISTER_CTRL_MEAS))
asleep = ctrl.mode == bme280.Mode.SLEEP
print(
    f"configured   humidity {x(humidity)}, temperature {x(ctrl.temperature)}, "
    f"pressure {x(ctrl.pressure)}, asleep: {str(asleep).lower()}"
)

# One forced measurement. The driver waits the datasheet's longest measurement time for
# these settings before it reads, and a simulated bus counts that wait rather than sleeping
# through it.
reading = sensor.measure()
print(f"measured     {shown(reading)}")
print(f"waited       {bus.waited_micros / 1000:.2f} ms across {bus.transfers} transfers")
waited = bus.waited_micros

# A part reports whatever it is asked to. Putting one in the first one's place is how a
# program meets a reading it would otherwise wait on the weather for, here a cold store at
# four degrees, and the driver carries on without noticing.
bus.attach(bme280.sim.reporting(BME280, 4.0, 1013.25, 80.0))
cold = sensor.measure()
print(f"cold store   {shown(cold)}")

# Nothing answers at the part's other address, and the driver says so rather than
# returning a reading.
try:
    Bme280(bus, bme280.ADDRESS_SECONDARY).init()
    print("absent       a part answered")
except PamojaError as error:
    print(f"absent       {error}")

# The other half of the bus layer. A script plays one conversation and refuses anything
# else, which proves a driver follows the datasheet rather than merely working: the reset,
# the status once the calibration has loaded, the chip id, the two calibration blocks, the
# three configuration writes in the order the part requires, then one forced measurement.
x1 = bme280.Oversampling.X1
settings = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.SLEEP)
forced = Bme280CtrlMeas(temperature=x1, pressure=x1, mode=bme280.Mode.FORCED)
idle = bytes([bme280.sim.STATUS_IDLE])
script = I2cBus.scripted([
    I2cStep.write(BME280, bytes([bme280.REGISTER_RESET, bme280.RESET_WORD])),
    I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
    I2cStep.write_read(BME280, bytes([bme280.REGISTER_CHIP_ID]), bytes([bme280.CHIP_ID])),
    I2cStep.write_read(
        BME280, bytes([bme280.REGISTER_CALIB_TEMP_PRESS]), bme280.sim.calibration()
    ),
    I2cStep.write_read(
        BME280, bytes([bme280.REGISTER_CALIB_HUMIDITY]), bme280.sim.calibration_humidity()
    ),
    I2cStep.write(BME280, bytes([bme280.REGISTER_CONFIG, bme280.config_bits(Bme280Config())])),
    I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_HUM, bme280.ctrl_hum_bits(x1)])),
    I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(settings)])),
    I2cStep.write(BME280, bytes([bme280.REGISTER_CTRL_MEAS, bme280.ctrl_meas_bits(forced)])),
    I2cStep.write_read(BME280, bytes([bme280.REGISTER_STATUS]), idle),
    I2cStep.write_read(BME280, bytes([bme280.REGISTER_DATA]), bme280.sim.burst()),
])
checked = Bme280(script, BME280).measure()
print(
    f"datasheet    {checked.celsius:.2f} C after {script.transfers} transfers, "
    f"{script.remaining} steps left"
)

C##

In C# the bus is I2cBus in Pamoja.Hal, and the driver is Bme280 in Pamoja.Sensors, whose static members are the part's datasheet: its addresses, Bme280.Register, the setting enums, and Bme280.Sim. Buses, parts, and drivers hold native handles, so they are IDisposable and the example takes them with using; a driver keeps its own share of the bus, so the bus may be disposed first. A failure throws PamojaException with the reason as its message, and opening an adapter anywhere but Linux throws PlatformNotSupportedException.

From bindings/dotnet/samples/Pamoja.Guides/HalGuide.cs:

C#
const byte Part = Bme280.AddressPrimary;
static string Fixed(float value) => value.ToString("F2", CultureInfo.InvariantCulture);
static string Shown(Bme280Measurement reading) =>
    $"{Fixed(reading.Celsius)} C, {Fixed(reading.Hectopascals)} hPa, {Fixed(reading.RelativeHumidityPercent)} %";
static string X(Bme280.Oversampling oversampling) => $"x{Bme280.OversamplingFactor(oversampling)}";

// A bus with one part on it: a BME280 that is not there. It holds a real part's
// calibration and one measurement that part took, and it answers from its registers,
// so the driver runs its whole datasheet sequence against it. On a Raspberry Pi the
// bus is I2cBus.Open("/dev/i2c-1") and nothing after this line changes.
using I2cPart simulated = Bme280.Sim.Part(Part);
using I2cBus bus = I2cBus.Simulated(simulated);
using var sensor = new Bme280(bus, Part);

// Reset, identify, calibrate, configure. The datasheet wants ctrl_hum written before
// ctrl_meas, and the part left asleep until a measurement is forced. The part keeps
// what the driver wrote, so the configuration reads back off the bus.
sensor.Init();
using I2cPart part = bus.Part<I2cPart>(Part)!;
Bme280.Oversampling humidity = Bme280.CtrlHumFromBits(part.Register(Bme280.Register.CtrlHum));
Bme280CtrlMeas ctrl = Bme280.CtrlMeasFromBits(part.Register(Bme280.Register.CtrlMeas));
bool asleep = ctrl.Mode == Bme280.Mode.Sleep;
Console.WriteLine(
    $"configured   humidity {X(humidity)}, temperature {X(ctrl.Temperature)}, " +
    $"pressure {X(ctrl.Pressure)}, asleep: {asleep.ToString().ToLowerInvariant()}");

// One forced measurement. The driver waits the datasheet's longest measurement time
// for these settings before it reads, and a simulated bus counts that wait rather
// than sleeping through it.
Bme280Measurement reading = sensor.Measure();
Console.WriteLine($"measured     {Shown(reading)}");
ulong waited = bus.WaitedMicros;
string waitedMs = (waited / 1000.0).ToString("F2", CultureInfo.InvariantCulture);
Console.WriteLine($"waited       {waitedMs} ms across {bus.Transfers} transfers");

// A part reports whatever it is asked to. Putting one in the first one's place is how
// a program meets a reading it would otherwise wait on the weather for, here a cold
// store at four degrees, and the driver carries on without noticing.
using (I2cPart chilled = Bme280.Sim.Reporting(Part, 4.0f, 1013.25f, 80.0f))
{
    bus.Attach(chilled);
}

Bme280Measurement cold = sensor.Measure();
Console.WriteLine($"cold store   {Shown(cold)}");

// Nothing answers at the part's other address, and the driver says so rather than
// returning a reading.
try
{
    using var absent = new Bme280(bus, Bme280.AddressSecondary);
    absent.Init();
    Console.WriteLine("absent       a part answered");
}
catch (PamojaException error)
{
    Console.WriteLine($"absent       {error.Message}");
}

// The other half of the bus layer. A script plays one conversation and refuses
// anything else, which proves a driver follows the datasheet rather than merely
// working: the reset, the status once the calibration has loaded, the chip id, the
// two calibration blocks, the three configuration writes in the order the part
// requires, then one forced measurement.
const Bme280.Oversampling X1 = Bme280.Oversampling.X1;
var settings = new Bme280CtrlMeas(X1, X1, Bme280.Mode.Sleep);
var forced = settings with { Mode = Bme280.Mode.Forced };
var resetState = new Bme280Config(Bme280.Standby.Ms0_5, Bme280.Filter.Off, Spi3Wire: false);
byte[] idle = [Bme280.Sim.StatusIdle];
using I2cBus script = I2cBus.Scripted(
    I2cStep.Write(Part, [Bme280.Register.Reset, Bme280.ResetWord]),
    I2cStep.WriteRead(Part, [Bme280.Register.Status], idle),
    I2cStep.WriteRead(Part, [Bme280.Register.ChipId], [Bme280.ChipId]),
    I2cStep.WriteRead(Part, [Bme280.Register.CalibTempPress], Bme280.Sim.Calibration()),
    I2cStep.WriteRead(Part, [Bme280.Register.CalibHumidity], Bme280.Sim.CalibrationHumidity()),
    I2cStep.Write(Part, [Bme280.Register.Config, Bme280.ConfigBits(resetState)]),
    I2cStep.Write(Part, [Bme280.Register.CtrlHum, Bme280.CtrlHumBits(X1)]),
    I2cStep.Write(Part, [Bme280.Register.CtrlMeas, Bme280.CtrlMeasBits(settings)]),
    I2cStep.Write(Part, [Bme280.Register.CtrlMeas, Bme280.CtrlMeasBits(forced)]),
    I2cStep.WriteRead(Part, [Bme280.Register.Status], idle),
    I2cStep.WriteRead(Part, [Bme280.Register.Data], Bme280.Sim.Burst()));
using var datasheet = new Bme280(script, Part);
Bme280Measurement checkedReading = datasheet.Measure();
Console.WriteLine(
    $"datasheet    {Fixed(checkedReading.Celsius)} C after {script.Transfers} transfers, " +
    $"{script.Remaining ?? 0} steps left");

On a board#

The same driver on a Raspberry Pi, reading a real BME280 every two seconds. Only the line that opens the bus changes. Wire the breakout to the header's I2C pins:

BME280 breakoutRaspberry Pi
VINa 3V3 pin
GNDa ground pin
SDAGPIO2
SCLGPIO3

GPIO2 and GPIO3 carry the board's own pull-up resistors, so the breakout needs none. Turn the I2C interface on, make sure the account is in the i2c group, and run i2cdetect -y 1 before anything else: it prints every address that answers, and a part missing from that grid is a wiring problem, not a software one. The Raspberry Pi page runs the same program as the first of four.

Rust#

From examples/boards/raspberry-pi/src/main.rs:

Rust
use pamoja_hal::bus::I2cBus;
use pamoja_sensors::bme280::{Bme280, I2C_ADDRESS_PRIMARY};

fn main() -> Result<(), Box<dyn Error>> {
    // The header's I2C bus is a file once the interface is on: /dev/i2c-1 on every model.
    let bus = I2cBus::open("/dev/i2c-1")?;

    // The driver runs the datasheet's sequence over that bus: reset, identify, read the
    // calibration, configure, and then a forced measurement per read.
    let mut sensor = Bme280::i2c(bus.clone(), I2C_ADDRESS_PRIMARY, bus.delay());
    sensor.init()?;

    loop {
        let measurement = sensor.measure()?;
        println!(
            "{:.2} C, {:.2} hPa, {:.2} % humidity",
            measurement.celsius(),
            measurement.hectopascals(),
            measurement.relative_humidity_percent()
        );
        thread::sleep(Duration::from_secs(2));
    }
}
Shell
cd examples/boards/raspberry-pi
cargo run --release

TypeScript#

From bindings/node/boards/raspberry-pi/sensor.ts:

TypeScript
import { setTimeout as sleep } from 'node:timers/promises'
import { I2cBus } from '@pamoja/hal'
import { Bme280, bme280 } from '@pamoja/sensors'

async function main(): Promise<void> {
  // The header's I2C bus is a file once the interface is on: /dev/i2c-1 on every model.
  const bus = I2cBus.open('/dev/i2c-1')

  // The driver runs the datasheet's sequence over that bus: reset, identify, read the
  // calibration, configure, and then a forced measurement per read.
  const sensor = new Bme280(bus, bme280.addressPrimary)
  await sensor.init()

  for (;;) {
    const reading = await sensor.measure()
    console.log(
      `${reading.celsius.toFixed(2)} C, ${reading.hectopascals.toFixed(2)} hPa, ` +
        `${reading.relativeHumidityPercent.toFixed(2)} % humidity`,
    )
    await sleep(2000)
  }
}

main().catch((error: Error) => {
  console.error(error.message)
  process.exitCode = 1
})
Shell
npm --prefix bindings/node run boards
node bindings/node/build/boards/raspberry-pi/sensor.js

Python#

From bindings/python/boards/raspberry_pi/sensor.py:

Python
import time

from pamoja.hal import I2cBus
from pamoja.sensors import Bme280, bme280


def main() -> None:
    # The header's I2C bus is a file once the interface is on: /dev/i2c-1 on every model.
    bus = I2cBus.open("/dev/i2c-1")

    # The driver runs the datasheet's sequence over that bus: reset, identify, read the
    # calibration, configure, and then a forced measurement per read.
    sensor = Bme280(bus, bme280.ADDRESS_PRIMARY)
    sensor.init()

    while True:
        reading = sensor.measure()
        print(
            f"{reading.celsius:.2f} C, {reading.hectopascals:.2f} hPa, "
            f"{reading.relative_humidity_percent:.2f} % humidity"
        )
        time.sleep(2)
Shell
python bindings/python/boards/raspberry_pi/sensor.py

C##

From bindings/dotnet/samples/Pamoja.Boards/RaspberryPi/Sensor.cs:

C#
using System.Globalization;

using Pamoja.Hal;
using Pamoja.Sensors;

namespace Boards.RaspberryPi;

/// <summary>
/// The first program on a Raspberry Pi: a BME280 on the 40-pin header's I2C bus, read through
/// the driver pamoja ships, printed every two seconds. Wire the BME280's SDA to GPIO2, its SCL
/// to GPIO3, VIN to 3V3, and GND to ground, and turn the I2C interface on.
/// </summary>
public static class Sensor
{
    /// <summary>Runs until the process is stopped.</summary>
    public static void Run()
    {
        // The header's I2C bus is a file once the interface is on: /dev/i2c-1 on every model.
        using I2cBus bus = I2cBus.Open("/dev/i2c-1");

        // The driver runs the datasheet's sequence over that bus: reset, identify, read the
        // calibration, configure, and then a forced measurement per read.
        using var sensor = new Bme280(bus, Bme280.AddressPrimary);
        sensor.Init();

        while (true)
        {
            Bme280Measurement reading = sensor.Measure();
            Console.WriteLine(string.Create(
                CultureInfo.InvariantCulture,
                $"{reading.Celsius:F2} C, {reading.Hectopascals:F2} hPa, {reading.RelativeHumidityPercent:F2} % humidity"));
            Thread.Sleep(TimeSpan.FromSeconds(2));
        }
    }
}
Shell
dotnet run --project bindings/dotnet/samples/Pamoja.Boards -- raspberry-pi/sensor

Values at a glance#

What answers on a bus decides what a transfer does and whether a driver's wait is spent:

KindMade withWhat answersA driver's wait
Adapteropen(path), such as /dev/i2c-1the parts wired to the bussleeps the process
Simulatedsimulated(parts)each part at its own address, in the shape of its datasheet; nothing anywhere elsecounted, not slept
Scriptedscripted(steps)the next step, when the transfer matches it; a refusal otherwisecounted, not slept

A simulated part comes in three kinds, because parts talk in three ways. Every driver's simulated part is one of them, and a bus holds any mix:

KindHow it talksParts built on it
I2cParta register address, then bytes from 256 byte-wide registersBME280, BMP280
WordParta pointer byte, then 16-bit registers sent most significant byte first, with bits the part keeps for itself marked read-onlyTMP117, OPT3001, HDC1080, INA219, INA226, ADS1115
CommandParta command and its arguments, then a read that takes the reply that command left, onceSHT3x, SCD4x

The BME280 answers at 0x76 with its SDO pin low and 0x77 with it high. The registers a driver touches, in the order it first reaches them:

RegisterAddressWhat it holds
reset0xE0writing 0xB6 restarts the part
status0xF3whether a measurement is running, and whether the calibration image is still loading
chip_id0xD00x60 on a BME280, 0x58 on a BMP280
calibration0x88 and 0xE126 bytes and 7 bytes, read once at start-up
config0xF5the normal-mode standby period, the IIR filter, and 3-wire SPI
ctrl_hum0xF2humidity oversampling, which takes effect at the next ctrl_meas write
ctrl_meas0xF4temperature and pressure oversampling, and the power mode
data0xF78 bytes: pressure, temperature, and humidity

Oversampling averages samples for each measurement: code 0 skips it, and codes 1 to 5 average 1, 2, 4, 8, and 16. More samples mean less noise and a longer wait, which the driver takes from the datasheet's longest measurement time for the settings in use:

SettingsTemperaturePressureHumidityLongest measurementTypical
each measured oncex1x1x19.3 ms8 ms
humidity left outx1x1skipped6.425 ms5.5 ms
pressure averaged over 16x2x16x146.1 ms40 ms
each averaged over 16x16x16x16112.8 ms98 ms

The IIR filter smooths pressure and temperature across measurements, never humidity. Codes 0 to 4 give off, 2, 4, 8, and 16. The standby period only matters in normal mode, which the driver does not use: it measures on demand in forced mode, and the part sleeps in between.

The same calls in each language:

ToRustTypeScriptPythonC#
open the kernel's adapterI2cBus::open(path)I2cBus.open(path)I2cBus.open(path)I2cBus.Open(path)
put parts on a busI2cBus::simulated(parts)I2cBus.simulated(parts)I2cBus.simulated(parts)I2cBus.Simulated(parts)
swap a part inbus.attach(part)bus.attach(part)bus.attach(part)bus.Attach(part)
play a scriptI2cBus::scripted(I2cScript::new(steps))I2cBus.scripted(steps)I2cBus.scripted(steps)I2cBus.Scripted(steps)
read a registerbus.write_read(a, bytes, &mut out)bus.writeRead(a, bytes, n)bus.write_read(a, data, n)bus.WriteRead(a, bytes, n)
see what a part holdsbus.part::<I2cPart>(a)bus.part(a)bus.part(a)bus.Part<I2cPart>(a)
count transfers and waitstransfers(), waited_micros()transfers, waitedMicrostransfers, waited_microsTransfers, WaitedMicros
see what a script has leftremaining()remainingremainingRemaining
build a BME280 driverBme280::i2c(bus.clone(), a, bus.delay())new Bme280(bus, a)Bme280(bus, a)new Bme280(bus, a)
stand a BME280 upsim::part(a), sim::reporting(a, c, hpa, rh)bme280.sim.part(a), bme280.sim.reporting(...)bme280.sim.part(a), bme280.sim.reporting(...)Bme280.Sim.Part(a), Bme280.Sim.Reporting(...)

A step can also fail on purpose, the way a missing or busy part does. The faults are the ones embedded-hal names: no acknowledge of the address, of a data byte, or of either; a bus error; lost arbitration; an overrun; and anything else.

When it goes wrong#

What a bus or a driver refuses, and what it says:

What happenedThe messageWhat to check
an adapter opened anywhere but Linuxan I2C adapter is opened through the kernel's i2c-dev interface, which only Linux hasrun it on the board, or run it on a simulated bus
the I2C interface is off/dev/i2c-1: No such file or directory (os error 2)turn it on and reboot
the account may not open the bus/dev/i2c-1: Permission denied (os error 13)add the account to the i2c group
nothing wired answered on a Raspberry Pi/dev/i2c-1: EREMOTEIO: Remote I/O errorthe wiring, the power, and the address; i2cdetect -y 1 shows what answers
nothing on a simulated bus holds the addressnothing answered at 0x77the part's address, or attach a part there
a part answered that is not a BME280sensor identification mismatchthe chip id; 0x58 is a BMP280, which has no humidity
a kernel driver already holds the part/dev/i2c-1: EBUSY: Device or resource busya dtoverlay=i2c-sensor line in config.txt, which gives the part to the kernel's own driver; remove it and reboot
the part stayed busy past the datasheet's timethe part did not finish its conversion in timethe supply, and whether another program is driving the same part
a script met a transfer it did not expecti2c script step 3: expected ..., the driver issued ...the step the message names, against the datasheet

How each language hands those over:

LanguageA transfer or a driver call that failsOpening an adapter off Linux
RustErr(DriverError::Bus(BusError)), Err(DriverError::Sensor(..)), Err(DriverError::Timeout)Err(OpenError::Unsupported)
TypeScripta thrown Error, or a rejected promise from init and measurea thrown Error
PythonPamojaErrorPamojaError
C#PamojaExceptionPlatformNotSupportedException

A Raspberry Pi's I2C controllers report a part that did not acknowledge as EREMOTEIO, which the bus treats as a missing acknowledge, the same as a simulated bus with nothing at the address.

The mistakes that cost an afternoon:

  • Nothing answers at all. SDA and SCL are swapped, or the breakout has no power. i2cdetect -y 1 prints an empty grid until they are right.
  • The part answers at the other address. Breakouts differ in which way they tie SDO, so one board answers at 0x76 and another at 0x77. i2cdetect -y 1 shows which.
  • The chip id says 0x58. The part is a BMP280, which comes on breakouts that look the same: it measures pressure and temperature and has no humidity at all. Its driver is in the sensor drivers guide.
  • Two parts share an address. Both answer every transfer and garble each other's replies. Move one with its address jumper.
  • The breakout is powered from 5 V. The header's pins are 3.3 V logic, and a breakout whose pull-ups go to its own supply puts that supply on SDA and SCL. Power it from a 3V3 pin, as the Raspberry Pi page explains.

Where next#

Reference#

Edit this page on GitHub