Skip to content

Commit b66b84f

Browse files
nsr888deadprogram
authored andcommitted
1 parent 0559a5a commit b66b84f

File tree

5 files changed

+401
-0
lines changed

5 files changed

+401
-0
lines changed

ens160/ens160.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Package ens160 provides a driver for the ScioSense ENS160 digital gas sensor.
2+
//
3+
// Datasheet: https://www.sciosense.com/wp-content/uploads/2023/12/ENS160-Datasheet.pdf
4+
package ens160
5+
6+
import (
7+
"encoding/binary"
8+
"errors"
9+
"time"
10+
11+
"tinygo.org/x/drivers"
12+
)
13+
14+
const (
15+
defaultTimeout = 30 * time.Millisecond
16+
shortTimeout = 1 * time.Millisecond
17+
)
18+
19+
// Conversion constants for environment data compensation.
20+
const (
21+
kelvinOffsetMilli = 273150 // 273.15 K in milli-units
22+
tempRawFactor = 64 // As per datasheet for TEMP_IN
23+
humRawFactor = 512 // As per datasheet for RH_IN
24+
milliFactor = 1000 // For converting from milli-units
25+
roundingTerm = milliFactor / 2 // For rounding before integer division
26+
)
27+
28+
// validityStrings provides human-readable descriptions for validity flags.
29+
var validityStrings = [...]string{
30+
ValidityNormalOperation: "normal operation",
31+
ValidityWarmUpPhase: "warm-up phase, wait ~3 minutes for valid data",
32+
ValidityInitialStartUpPhase: "initial start-up phase, wait ~1 hour for valid data",
33+
ValidityInvalidOutput: "invalid output",
34+
}
35+
36+
// Device wraps an I2C connection to an ENS160 device.
37+
type Device struct {
38+
bus drivers.I2C // I²C implementation
39+
addr uint16 // 7‑bit bus address, promoted to uint16 per drivers.I2C
40+
41+
// shadow registers / last measurements
42+
lastTvocPPB uint16
43+
lastEco2PPM uint16
44+
lastAqiUBA uint8
45+
lastValidity uint8 // Store the latest validity status
46+
47+
// pre‑allocated buffers
48+
wbuf [5]byte // longest write: reg + 4 bytes (TEMP+RH)
49+
rbuf [5]byte // longest read: DATA burst (5 bytes)
50+
}
51+
52+
// New returns a new ENS160 driver.
53+
func New(bus drivers.I2C, addr uint16) *Device {
54+
if addr == 0 {
55+
addr = DefaultAddress
56+
}
57+
return &Device{
58+
bus: bus,
59+
addr: addr,
60+
lastValidity: ValidityInvalidOutput,
61+
}
62+
}
63+
64+
// Connected returns whether a ENS160 has been found.
65+
func (d *Device) Connected() bool {
66+
d.wbuf[0] = regPartID
67+
err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:2])
68+
return err == nil && d.rbuf[0] == LowPartID && d.rbuf[1] == HighPartID
69+
}
70+
71+
// Configure sets up the device for reading.
72+
func (d *Device) Configure() error {
73+
// 1. Soft-reset. The device will automatically enter IDLE mode.
74+
if err := d.write1(regOpMode, ModeReset); err != nil {
75+
return err
76+
}
77+
time.Sleep(defaultTimeout)
78+
79+
// 2. Clear GPR registers, then go to STANDARD mode.
80+
if err := d.write1(regCommand, cmdClrGPR); err != nil {
81+
return err
82+
}
83+
time.Sleep(defaultTimeout)
84+
85+
if err := d.write1(regOpMode, ModeStandard); err != nil {
86+
return err
87+
}
88+
time.Sleep(defaultTimeout)
89+
90+
return nil
91+
}
92+
93+
// calculateTempRaw converts temperature from milli-degrees Celsius to the sensor's raw format.
94+
func calculateTempRaw(tempMilliC int32) uint16 {
95+
// Clip temperature
96+
const (
97+
minC = -40 * 1000
98+
maxC = 85 * 1000
99+
)
100+
if tempMilliC < minC {
101+
tempMilliC = minC
102+
} else if tempMilliC > maxC {
103+
tempMilliC = maxC
104+
}
105+
106+
// Integer fixed-point conversion to format required by the sensor.
107+
// Formula from datasheet: T_IN = (T_ambient_C + 273.15) * 64
108+
return uint16((((tempMilliC + kelvinOffsetMilli) * tempRawFactor) + roundingTerm) / milliFactor)
109+
}
110+
111+
// calculateHumRaw converts relative humidity from milli-percent to the sensor's raw format.
112+
func calculateHumRaw(rhMilliPct int32) uint16 {
113+
// Clip humidity
114+
if rhMilliPct < 0 {
115+
rhMilliPct = 0
116+
} else if rhMilliPct > 100*1000 {
117+
rhMilliPct = 100 * 1000
118+
}
119+
120+
// Integer fixed-point conversion to format required by the sensor.
121+
// Formula from datasheet: RH_IN = (RH_ambient_% * 512)
122+
return uint16(((rhMilliPct * humRawFactor) + roundingTerm) / milliFactor)
123+
}
124+
125+
// SetEnvDataMilli sets the ambient temperature and humidity for compensation.
126+
//
127+
// tempMilliC is the temperature in milli-degrees Celsius.
128+
// rhMilliPct is the relative humidity in milli-percent.
129+
func (d *Device) SetEnvDataMilli(tempMilliC, rhMilliPct int32) error {
130+
tempRaw := calculateTempRaw(tempMilliC)
131+
humRaw := calculateHumRaw(rhMilliPct)
132+
133+
d.wbuf[0] = regTempIn // start address (auto‑increment)
134+
binary.LittleEndian.PutUint16(d.wbuf[1:3], tempRaw)
135+
binary.LittleEndian.PutUint16(d.wbuf[3:5], humRaw)
136+
137+
return d.bus.Tx(d.addr, d.wbuf[:5], nil)
138+
}
139+
140+
// Update refreshes the concentration measurements.
141+
func (d *Device) Update(which drivers.Measurement) error {
142+
if which&drivers.Concentration == 0 {
143+
return nil // nothing requested
144+
}
145+
146+
const maxTries = 1000
147+
var (
148+
status uint8
149+
validity uint8
150+
)
151+
var gotData bool
152+
153+
// Poll DEVICE_STATUS until NEWDAT or timeout
154+
for range maxTries {
155+
var err error
156+
status, err = d.read1(regStatus)
157+
if err != nil {
158+
return err
159+
}
160+
if status&statusSTATER != 0 {
161+
return errors.New("ENS160: error (STATER set)")
162+
}
163+
validity = (status & statusValidityMask) >> statusValidityShift
164+
165+
if status&statusNEWDAT != 0 {
166+
gotData = true
167+
break // Always break when data available
168+
}
169+
time.Sleep(shortTimeout)
170+
}
171+
if !gotData {
172+
return errors.New("ENS160: timeout waiting for NEWDAT")
173+
}
174+
175+
// Burst-read data regardless of validity state
176+
d.wbuf[0] = regAQI
177+
if err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:5]); err != nil {
178+
return errors.New("ENS160: burst read failed")
179+
}
180+
181+
d.lastAqiUBA = d.rbuf[0]
182+
d.lastTvocPPB = binary.LittleEndian.Uint16(d.rbuf[1:3])
183+
d.lastEco2PPM = binary.LittleEndian.Uint16(d.rbuf[3:5])
184+
d.lastValidity = validity // Store the validity status
185+
186+
return nil
187+
}
188+
189+
// TVOC returns the last total‑VOC concentration in parts‑per‑billion.
190+
func (d *Device) TVOC() uint16 { return d.lastTvocPPB }
191+
192+
// ECO2 returns the last equivalent CO₂ concentration in parts‑per‑million.
193+
func (d *Device) ECO2() uint16 { return d.lastEco2PPM }
194+
195+
// AQI returns the last Air‑Quality Index according to UBA (1–5).
196+
func (d *Device) AQI() uint8 { return d.lastAqiUBA }
197+
198+
// Validity returns the current operating state of the sensor.
199+
func (d *Device) Validity() uint8 {
200+
return d.lastValidity
201+
}
202+
203+
// ValidityString returns a human-readable string describing the current validity status.
204+
func (d *Device) ValidityString() string {
205+
if int(d.lastValidity) < len(validityStrings) {
206+
return validityStrings[d.lastValidity]
207+
}
208+
return "unknown"
209+
}
210+
211+
// write1 writes a single byte to a register.
212+
func (d *Device) write1(reg, val uint8) error {
213+
d.wbuf[0] = reg
214+
d.wbuf[1] = val
215+
return d.bus.Tx(d.addr, d.wbuf[:2], nil)
216+
}
217+
218+
// read1 reads a single byte from a register.
219+
func (d *Device) read1(reg uint8) (uint8, error) {
220+
d.wbuf[0] = reg
221+
if err := d.bus.Tx(d.addr, d.wbuf[:1], d.rbuf[:1]); err != nil {
222+
return 0, err
223+
}
224+
return d.rbuf[0], nil
225+
}

ens160/ens160_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package ens160
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestCalculateTempRaw(t *testing.T) {
8+
testCases := []struct {
9+
name string
10+
tempMilliC int32
11+
expectedRaw uint16
12+
}{
13+
{"25°C", 25000, 19082},
14+
{"-10.5°C", -10500, 16810},
15+
{"Min temp", -40000, 14922},
16+
{"Below min", -50000, 14922},
17+
{"Max temp", 85000, 22922},
18+
{"Above max", 90000, 22922},
19+
{"Zero", 0, 17482},
20+
}
21+
22+
for _, tc := range testCases {
23+
t.Run(tc.name, func(t *testing.T) {
24+
raw := calculateTempRaw(tc.tempMilliC)
25+
if raw != tc.expectedRaw {
26+
t.Errorf("expected %d, got %d", tc.expectedRaw, raw)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestCalculateHumRaw(t *testing.T) {
33+
testCases := []struct {
34+
name string
35+
rhMilliPct int32
36+
expectedRaw uint16
37+
}{
38+
{"50%", 50000, 25600},
39+
{"0%", 0, 0},
40+
{"100%", 100000, 51200},
41+
{"Below 0%", -10000, 0},
42+
{"Above 100%", 110000, 51200},
43+
{"33.3%", 33300, 17050},
44+
}
45+
46+
for _, tc := range testCases {
47+
t.Run(tc.name, func(t *testing.T) {
48+
raw := calculateHumRaw(tc.rhMilliPct)
49+
if raw != tc.expectedRaw {
50+
t.Errorf("expected %d, got %d", tc.expectedRaw, raw)
51+
}
52+
})
53+
}
54+
}

ens160/registers.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package ens160
2+
3+
// DefaultAddress is the default I2C address for the ENS160 when the ADDR pin is
4+
// connected to high (3.3V). When connected to low (GND), the address is 0x52.
5+
const DefaultAddress = 0x53
6+
7+
// Registers
8+
const (
9+
regPartID = 0x00
10+
regOpMode = 0x10
11+
regConfig = 0x11
12+
regCommand = 0x12
13+
regTempIn = 0x13
14+
regRhIn = 0x15
15+
regStatus = 0x20
16+
regAQI = 0x21
17+
regTVOC = 0x22
18+
regECO2 = 0x24
19+
regDataT = 0x30
20+
regDataRH = 0x32
21+
regMISR = 0x38
22+
regGPRWrite = 0x40
23+
regGPRRead = 0x48
24+
)
25+
26+
// Operating modes
27+
const (
28+
ModeDeepSleep = 0x00
29+
ModeIdle = 0x01
30+
ModeStandard = 0x02
31+
ModeReset = 0xF0
32+
)
33+
34+
// Status register bits
35+
const (
36+
statusSTATAS = 1 << 7
37+
statusSTATER = 1 << 6
38+
39+
statusValidityMask = 0x0C
40+
statusValidityShift = 2
41+
42+
statusNEWDAT = 1 << 1
43+
statusNEWGPR = 1 << 0
44+
)
45+
46+
// Validity flags
47+
const (
48+
ValidityNormalOperation = 0x00
49+
ValidityWarmUpPhase = 0x01 // need ~3 minutes until valid data
50+
ValidityInitialStartUpPhase = 0x02 // need ~1 hour until valid data
51+
ValidityInvalidOutput = 0x03
52+
)
53+
54+
// Commands
55+
const (
56+
cmdNOP = 0x00
57+
cmdGetAppVer = 0x0E
58+
cmdClrGPR = 0xCC
59+
)
60+
61+
// Part IDs
62+
const (
63+
LowPartID = 0x60
64+
HighPartID = 0x01
65+
)

0 commit comments

Comments
 (0)