From 833990f44dfaa458c229310f4703c76b381cc538 Mon Sep 17 00:00:00 2001 From: Artur Nasyrov Date: Fri, 25 Jul 2025 15:54:43 +0300 Subject: [PATCH] Add ens160 i2c driver Driver for ENS160 sensor: https://www.sciosense.com/wp-content/uploads/2023/12/ENS160-Datasheet.pdf --- ens160/ens160.go | 225 ++++++++++++++++++++++++++++++++++++++++ ens160/ens160_test.go | 54 ++++++++++ ens160/registers.go | 65 ++++++++++++ examples/ens160/main.go | 56 ++++++++++ smoketest.sh | 1 + 5 files changed, 401 insertions(+) create mode 100644 ens160/ens160.go create mode 100644 ens160/ens160_test.go create mode 100644 ens160/registers.go create mode 100644 examples/ens160/main.go diff --git a/ens160/ens160.go b/ens160/ens160.go new file mode 100644 index 0000000..04f5a33 --- /dev/null +++ b/ens160/ens160.go @@ -0,0 +1,225 @@ +// Package ens160 provides a driver for the ScioSense ENS160 digital gas sensor. +// +// Datasheet: https://www.sciosense.com/wp-content/uploads/2023/12/ENS160-Datasheet.pdf +package ens160 + +import ( + "encoding/binary" + "errors" + "time" + + "tinygo.org/x/drivers" +) + +const ( + defaultTimeout = 30 * time.Millisecond + shortTimeout = 1 * time.Millisecond +) + +// Conversion constants for environment data compensation. +const ( + kelvinOffsetMilli = 273150 // 273.15 K in milli-units + tempRawFactor = 64 // As per datasheet for TEMP_IN + humRawFactor = 512 // As per datasheet for RH_IN + milliFactor = 1000 // For converting from milli-units + roundingTerm = milliFactor / 2 // For rounding before integer division +) + +// validityStrings provides human-readable descriptions for validity flags. +var validityStrings = [...]string{ + ValidityNormalOperation: "normal operation", + ValidityWarmUpPhase: "warm-up phase, wait ~3 minutes for valid data", + ValidityInitialStartUpPhase: "initial start-up phase, wait ~1 hour for valid data", + ValidityInvalidOutput: "invalid output", +} + +// Device wraps an I2C connection to an ENS160 device. +type Device struct { + bus drivers.I2C // I²C implementation + addr uint16 // 7‑bit bus address, promoted to uint16 per drivers.I2C + + // shadow registers / last measurements + lastTvocPPB uint16 + lastEco2PPM uint16 + lastAqiUBA uint8 + lastValidity uint8 // Store the latest validity status + + // pre‑allocated buffers + wbuf [5]byte // longest write: reg + 4 bytes (TEMP+RH) + rbuf [5]byte // longest read: DATA burst (5 bytes) +} + +// New returns a new ENS160 driver. +func New(bus drivers.I2C, addr uint16) *Device { + if addr == 0 { + addr = DefaultAddress + } + return &Device{ + bus: bus, + addr: addr, + lastValidity: ValidityInvalidOutput, + } +} + +// Connected returns whether a ENS160 has been found. +func (d *Device) Connected() bool { + d.wbuf[0] = regPartID + err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:2]) + return err == nil && d.rbuf[0] == LowPartID && d.rbuf[1] == HighPartID +} + +// Configure sets up the device for reading. +func (d *Device) Configure() error { + // 1. Soft-reset. The device will automatically enter IDLE mode. + if err := d.write1(regOpMode, ModeReset); err != nil { + return err + } + time.Sleep(defaultTimeout) + + // 2. Clear GPR registers, then go to STANDARD mode. + if err := d.write1(regCommand, cmdClrGPR); err != nil { + return err + } + time.Sleep(defaultTimeout) + + if err := d.write1(regOpMode, ModeStandard); err != nil { + return err + } + time.Sleep(defaultTimeout) + + return nil +} + +// calculateTempRaw converts temperature from milli-degrees Celsius to the sensor's raw format. +func calculateTempRaw(tempMilliC int32) uint16 { + // Clip temperature + const ( + minC = -40 * 1000 + maxC = 85 * 1000 + ) + if tempMilliC < minC { + tempMilliC = minC + } else if tempMilliC > maxC { + tempMilliC = maxC + } + + // Integer fixed-point conversion to format required by the sensor. + // Formula from datasheet: T_IN = (T_ambient_C + 273.15) * 64 + return uint16((((tempMilliC + kelvinOffsetMilli) * tempRawFactor) + roundingTerm) / milliFactor) +} + +// calculateHumRaw converts relative humidity from milli-percent to the sensor's raw format. +func calculateHumRaw(rhMilliPct int32) uint16 { + // Clip humidity + if rhMilliPct < 0 { + rhMilliPct = 0 + } else if rhMilliPct > 100*1000 { + rhMilliPct = 100 * 1000 + } + + // Integer fixed-point conversion to format required by the sensor. + // Formula from datasheet: RH_IN = (RH_ambient_% * 512) + return uint16(((rhMilliPct * humRawFactor) + roundingTerm) / milliFactor) +} + +// SetEnvDataMilli sets the ambient temperature and humidity for compensation. +// +// tempMilliC is the temperature in milli-degrees Celsius. +// rhMilliPct is the relative humidity in milli-percent. +func (d *Device) SetEnvDataMilli(tempMilliC, rhMilliPct int32) error { + tempRaw := calculateTempRaw(tempMilliC) + humRaw := calculateHumRaw(rhMilliPct) + + d.wbuf[0] = regTempIn // start address (auto‑increment) + binary.LittleEndian.PutUint16(d.wbuf[1:3], tempRaw) + binary.LittleEndian.PutUint16(d.wbuf[3:5], humRaw) + + return d.bus.Tx(d.addr, d.wbuf[:5], nil) +} + +// Update refreshes the concentration measurements. +func (d *Device) Update(which drivers.Measurement) error { + if which&drivers.Concentration == 0 { + return nil // nothing requested + } + + const maxTries = 1000 + var ( + status uint8 + validity uint8 + ) + var gotData bool + + // Poll DEVICE_STATUS until NEWDAT or timeout + for range maxTries { + var err error + status, err = d.read1(regStatus) + if err != nil { + return err + } + if status&statusSTATER != 0 { + return errors.New("ENS160: error (STATER set)") + } + validity = (status & statusValidityMask) >> statusValidityShift + + if status&statusNEWDAT != 0 { + gotData = true + break // Always break when data available + } + time.Sleep(shortTimeout) + } + if !gotData { + return errors.New("ENS160: timeout waiting for NEWDAT") + } + + // Burst-read data regardless of validity state + d.wbuf[0] = regAQI + if err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:5]); err != nil { + return errors.New("ENS160: burst read failed") + } + + d.lastAqiUBA = d.rbuf[0] + d.lastTvocPPB = binary.LittleEndian.Uint16(d.rbuf[1:3]) + d.lastEco2PPM = binary.LittleEndian.Uint16(d.rbuf[3:5]) + d.lastValidity = validity // Store the validity status + + return nil +} + +// TVOC returns the last total‑VOC concentration in parts‑per‑billion. +func (d *Device) TVOC() uint16 { return d.lastTvocPPB } + +// ECO2 returns the last equivalent CO₂ concentration in parts‑per‑million. +func (d *Device) ECO2() uint16 { return d.lastEco2PPM } + +// AQI returns the last Air‑Quality Index according to UBA (1–5). +func (d *Device) AQI() uint8 { return d.lastAqiUBA } + +// Validity returns the current operating state of the sensor. +func (d *Device) Validity() uint8 { + return d.lastValidity +} + +// ValidityString returns a human-readable string describing the current validity status. +func (d *Device) ValidityString() string { + if int(d.lastValidity) < len(validityStrings) { + return validityStrings[d.lastValidity] + } + return "unknown" +} + +// write1 writes a single byte to a register. +func (d *Device) write1(reg, val uint8) error { + d.wbuf[0] = reg + d.wbuf[1] = val + return d.bus.Tx(d.addr, d.wbuf[:2], nil) +} + +// read1 reads a single byte from a register. +func (d *Device) read1(reg uint8) (uint8, error) { + d.wbuf[0] = reg + if err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:1]); err != nil { + return 0, err + } + return d.rbuf[0], nil +} diff --git a/ens160/ens160_test.go b/ens160/ens160_test.go new file mode 100644 index 0000000..022de16 --- /dev/null +++ b/ens160/ens160_test.go @@ -0,0 +1,54 @@ +package ens160 + +import ( + "testing" +) + +func TestCalculateTempRaw(t *testing.T) { + testCases := []struct { + name string + tempMilliC int32 + expectedRaw uint16 + }{ + {"25°C", 25000, 19082}, + {"-10.5°C", -10500, 16810}, + {"Min temp", -40000, 14922}, + {"Below min", -50000, 14922}, + {"Max temp", 85000, 22922}, + {"Above max", 90000, 22922}, + {"Zero", 0, 17482}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + raw := calculateTempRaw(tc.tempMilliC) + if raw != tc.expectedRaw { + t.Errorf("expected %d, got %d", tc.expectedRaw, raw) + } + }) + } +} + +func TestCalculateHumRaw(t *testing.T) { + testCases := []struct { + name string + rhMilliPct int32 + expectedRaw uint16 + }{ + {"50%", 50000, 25600}, + {"0%", 0, 0}, + {"100%", 100000, 51200}, + {"Below 0%", -10000, 0}, + {"Above 100%", 110000, 51200}, + {"33.3%", 33300, 17050}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + raw := calculateHumRaw(tc.rhMilliPct) + if raw != tc.expectedRaw { + t.Errorf("expected %d, got %d", tc.expectedRaw, raw) + } + }) + } +} diff --git a/ens160/registers.go b/ens160/registers.go new file mode 100644 index 0000000..0650c91 --- /dev/null +++ b/ens160/registers.go @@ -0,0 +1,65 @@ +package ens160 + +// DefaultAddress is the default I2C address for the ENS160 when the ADDR pin is +// connected to high (3.3V). When connected to low (GND), the address is 0x52. +const DefaultAddress = 0x53 + +// Registers +const ( + regPartID = 0x00 + regOpMode = 0x10 + regConfig = 0x11 + regCommand = 0x12 + regTempIn = 0x13 + regRhIn = 0x15 + regStatus = 0x20 + regAQI = 0x21 + regTVOC = 0x22 + regECO2 = 0x24 + regDataT = 0x30 + regDataRH = 0x32 + regMISR = 0x38 + regGPRWrite = 0x40 + regGPRRead = 0x48 +) + +// Operating modes +const ( + ModeDeepSleep = 0x00 + ModeIdle = 0x01 + ModeStandard = 0x02 + ModeReset = 0xF0 +) + +// Status register bits +const ( + statusSTATAS = 1 << 7 + statusSTATER = 1 << 6 + + statusValidityMask = 0x0C + statusValidityShift = 2 + + statusNEWDAT = 1 << 1 + statusNEWGPR = 1 << 0 +) + +// Validity flags +const ( + ValidityNormalOperation = 0x00 + ValidityWarmUpPhase = 0x01 // need ~3 minutes until valid data + ValidityInitialStartUpPhase = 0x02 // need ~1 hour until valid data + ValidityInvalidOutput = 0x03 +) + +// Commands +const ( + cmdNOP = 0x00 + cmdGetAppVer = 0x0E + cmdClrGPR = 0xCC +) + +// Part IDs +const ( + LowPartID = 0x60 + HighPartID = 0x01 +) diff --git a/examples/ens160/main.go b/examples/ens160/main.go new file mode 100644 index 0000000..d357160 --- /dev/null +++ b/examples/ens160/main.go @@ -0,0 +1,56 @@ +// This example demonstrates ENS160 usage. +// +// Wiring: +// - VCC to 3.3V, GND to ground +// - SDA to board SDA, SCL to board SCL + +package main + +import ( + "time" + + "machine" + + "tinygo.org/x/drivers" + "tinygo.org/x/drivers/ens160" +) + +func main() { + err := machine.I2C0.Configure(machine.I2CConfig{ + Frequency: 400 * machine.KHz, + }) + if err != nil { + println("Failed to configure I2C:", err) + } + + dev := ens160.New(machine.I2C0, ens160.DefaultAddress) + + connected := dev.Connected() + if !connected { + println("ENS160 not detected") + return + } + println("ENS160 detected") + + if err := dev.Configure(); err != nil { + println("Failed to configure ENS160:", err) + } + + for { + err := dev.Update(drivers.Concentration) + if err != nil { + println("Error reading ENS160: %v\n", err) + time.Sleep(5 * time.Second) + continue + } + + println( + "AQI:", dev.AQI(), + "TVOC:", dev.TVOC(), + "eCO2:", dev.ECO2(), + "Validity:", dev.ValidityString(), + ) + + time.Sleep(2 * time.Second) + } +} diff --git a/smoketest.sh b/smoketest.sh index 01cc2a9..fef87ce 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -141,6 +141,7 @@ tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/tmc2209/mai tinygo build -size short -o ./build/test.hex -target=pico ./examples/tmc5160/main.go tinygo build -size short -o ./build/test.uf2 -target=nicenano ./examples/sharpmem/main.go tinygo build -size short -o ./build/test.hex -target=feather-nrf52840 ./examples/max6675/main.go +tinygo build -size short -o ./build/test.hex -target=pico ./examples/ens160/main.go # network examples (espat) tinygo build -size short -o ./build/test.hex -target=challenger-rp2040 ./examples/net/ntpclient/ # network examples (wifinina)