You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
devices/sen6x/sen6x.go

531 lines
14 KiB
Go

// Copyright 2026 The Periph Authors. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package sen6x
import (
"encoding/binary"
"errors"
"fmt"
"log"
"sync"
"time"
"periph.io/x/conn/v3"
"periph.io/x/conn/v3/i2c"
)
const i2cAddr = 0x6b
// deviceSamplingInterval is the sensor's native sampling interval. The
// datasheet specifies a sampling interval of 1 ± 0.03 seconds so we take
// the maximum value to be safe.
var deviceSamplingInterval = 1030 * time.Millisecond
// SensorValues contains the measurements returned from the device.
// Any values not relevant to the sensor model being used will be nil.
// Any values equal to the device's "unknown" sentinel value (0x7fff or 0xfff
// depending on data type) will also be nil.
type SensorValues struct {
// Particulate matter measurements in μg/m³.
PM1, PM25, PM4, PM10 *float32
// Relative humidity.
RH *float32
// Temp in °C.
Temp *float32
// VOC level in terms of Sensirion's VOC index.
VOC *float32
// NOx level in terms of Sensirion's NOx index.
NOx *float32
// CO2 concentration in ppm.
CO2 *int16
// Formaldehyde (HCHO) concentration in ppb.
HCHO *float32
}
// RawSensorValues contains the raw measurements returned from the device.
// Any values not relevant to the sensor model being used will be nil.
// Any values equal to the device's "unknown" sentinel value (0x7fff or 0xfff
// depending on data type) will also be nil.
type RawSensorValues struct {
// Raw measured relative humidity.
RH *float32
// Raw measured temp in °C.
Temp *float32
// Raw measured VOC ticks without scale factor.
VOC *uint16
// Raw measured NOx ticks without scale factor.
NOx *uint16
// Non-interpolated CO2 concentration in ppm updated every five seconds.
//
// NOTE: This is only applicable to SEN66. While SEN63C and SEN69C also have
// CO2 sensors, only SEN66 returns raw CO2 measurements.
CO2 *uint16
}
// Dev represents a SEN6x sensor.
type Dev struct {
dev *i2c.Dev
model Model
// Sleep function that can be redefined for tests.
// Defaults to time.Sleep.
sleep func(time.Duration)
mu sync.Mutex
stop chan struct{}
wg sync.WaitGroup
}
// New creates a new SEN6x device.
func New(bus i2c.Bus, model Model) *Dev {
return &Dev{
dev: &i2c.Dev{Bus: bus, Addr: i2cAddr},
model: model,
sleep: time.Sleep,
}
}
func (d *Dev) String() string {
return d.model.String()
}
// SenseContinuous puts the sensor in measurement mode and sends measurements over
// the returned channel at the given interval. Call [Dev.Halt] to stop measurement
// and ensure resources are cleaned up (e.g. that the channel is closed and goroutines
// are stopped).
//
// After starting the measurement it takes some time (~1.1 s) until the first
// measurement results are available.
//
// It's the responsibility of the caller to retrieve the values from the
// channel as fast as possible, otherwise the interval may not be respected.
//
// Note on the interval: The sensor's internal measurement interval is 1 ± 0.03
// seconds, so an interval value less than that duration will return values at
// the device's native interval. Higher interval values will work as expected.
//
// Note for SEN63C and SEN69C only: SEN63C and SEN69C condition the CO2 sensor
// during the first 24 seconds after starting a measurement. As this process
// cannot be interrupted, the following limitations apply during this period:
// - You may stop the measurement if needed, but do not start it again until
// at least 24 seconds have passed to avoid a CO2 sensor error.
// - Do not stop the sensor and call [Dev.PerformForcedCO2Recalibration],
// [Dev.SetCO2SensorAutomaticSelfCalibration], or [Dev.PerformCO2SensorFactoryReset].
func (d *Dev) SenseContinuous(interval time.Duration) (<-chan *SensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.stop != nil {
return nil, errors.New("sen6x: already sensing continuously")
}
results := make(chan *SensorValues)
d.stop = make(chan struct{})
d.wg.Add(1)
go func() {
defer d.wg.Done()
defer close(results)
d.doSenseContinuous(interval, results, d.stop)
}()
return results, nil
}
func (d *Dev) doSenseContinuous(interval time.Duration, results chan<- *SensorValues, stop <-chan struct{}) {
t := time.NewTicker(interval)
defer t.Stop()
if err := d.StartContinuousMeasurement(); err != nil {
log.Printf("sen6x: failed to start continuous measurement: %v", err)
return
}
for {
d.mu.Lock()
if interval < deviceSamplingInterval {
if err := d.waitOnDataReady(stop); err != nil {
d.mu.Unlock()
log.Printf("sen6x: failed to check if data is ready: %v", err)
return
}
}
sv, err := d.doReadMeasuredValues()
d.mu.Unlock()
if err != nil {
log.Printf("sen6x: failed to read measured values: %v", err)
return
}
select {
case results <- sv:
case <-stop:
return
}
select {
case <-stop:
return
case <-t.C:
}
}
}
func (d *Dev) waitOnDataReady(stop <-chan struct{}) error {
t := time.NewTicker(300 * time.Millisecond)
defer t.Stop()
for {
ready, err := d.doGetDataReady()
if err != nil {
return err
}
if ready {
return nil
}
select {
case <-stop:
return nil
case <-t.C:
}
}
}
// Halt halts continuous sensing, cleans up resources, and puts the sensor in idle mode.
func (d *Dev) Halt() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.stop == nil {
return nil
}
close(d.stop)
d.stop = nil
d.wg.Wait()
return d.writeAndWait(cmdStopMeasurement, nil)
}
// StartContinuousMeasurement starts a continuous measurement. After starting the
// measurement, it takes some time (~1.1 s) until the first measurement results are
// available.
//
// You may poll [Dev.GetDataReady] to check if results are ready to be read.
//
// Note for SEN63C and SEN69C only: SEN63C and SEN69C condition the CO2 sensor
// during the first 24 seconds after starting a measurement. As this process
// cannot be interrupted, the following limitations apply during this period:
// - You may stop the measurement if needed, but do not start it again until
// at least 24 seconds have passed to avoid a CO2 sensor error.
// - Do not stop the sensor and call [Dev.PerformForcedCO2Recalibration],
// [Dev.SetCO2SensorAutomaticSelfCalibration], or [Dev.PerformCO2SensorFactoryReset].
func (d *Dev) StartContinuousMeasurement() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStartContinuousMeasurement, nil)
}
// StopMeasurement stops the measurement and returns the sensor to idle mode.
// After sending this command, wait at least 1400 ms before starting a new measurement.
func (d *Dev) StopMeasurement() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStopMeasurement, nil)
}
// GetDataReady checks if new measurement results are ready to read. The data ready
// flag is automatically reset after reading the measurement values.
func (d *Dev) GetDataReady() (bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
return d.doGetDataReady()
}
func (d *Dev) doGetDataReady() (bool, error) {
data, err := d.writeAndRead(cmdGetDataReady, nil)
if err != nil {
return false, err
}
return data[1] == 1, nil
}
// ReadMeasuredValues reads the current measured values. Measurement must have
// already been started by [Dev.StartContinuousMeasurement]. After starting the
// measurement, it takes some time (~1.1 s) until the first measurement results
// are available.
//
// [Dev.GetDataReady] may be polled to check if new data is available since the
// last read operation. If no new data is available, the previous values will
// be returned. If no data is available at all for a particular measurement (e.g.
// measurement not running for at least one second), it will be nil.
//
// Any values that aren't applicable to the Dev's sensor model will be nil.
func (d *Dev) ReadMeasuredValues() (*SensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
return d.doReadMeasuredValues()
}
func (d *Dev) doReadMeasuredValues() (*SensorValues, error) {
var cmd command
switch d.model {
case SEN62:
cmd = cmdReadMeasuredValuesSEN62
case SEN63C:
cmd = cmdReadMeasuredValuesSEN63C
case SEN65:
cmd = cmdReadMeasuredValuesSEN65
case SEN66:
cmd = cmdReadMeasuredValuesSEN66
case SEN68:
cmd = cmdReadMeasuredValuesSEN68
case SEN69C:
cmd = cmdReadMeasuredValuesSEN69C
default:
return nil, fmt.Errorf("sen6x: unknown model: %v", d.model)
}
data, err := d.writeAndRead(cmd, nil)
if err != nil {
return nil, err
}
sv := &SensorValues{}
if rawPM1 := binary.BigEndian.Uint16(data[0:2]); rawPM1 != 0xffff {
sv.PM1 = ptr(float32(rawPM1) / 10.0)
}
if rawPM25 := binary.BigEndian.Uint16(data[2:4]); rawPM25 != 0xffff {
sv.PM25 = ptr(float32(rawPM25) / 10.0)
}
if rawPM4 := binary.BigEndian.Uint16(data[4:6]); rawPM4 != 0xffff {
sv.PM4 = ptr(float32(rawPM4) / 10.0)
}
if rawPM10 := binary.BigEndian.Uint16(data[6:8]); rawPM10 != 0xffff {
sv.PM10 = ptr(float32(rawPM10) / 10.0)
}
if rawRH := int16(binary.BigEndian.Uint16(data[8:10])); rawRH != 0x7fff {
sv.RH = ptr(float32(rawRH) / 100.0)
}
if rawTemp := int16(binary.BigEndian.Uint16(data[10:12])); rawTemp != 0x7fff {
sv.Temp = ptr(float32(rawTemp) / 200.0)
}
i := 12
if d.model.hasVOCNOx() {
if rawVOC := int16(binary.BigEndian.Uint16(data[i : i+2])); rawVOC != 0x7fff {
sv.VOC = ptr(float32(rawVOC) / 10.0)
}
i += 2
if rawNOx := int16(binary.BigEndian.Uint16(data[i : i+2])); rawNOx != 0x7fff {
sv.NOx = ptr(float32(rawNOx) / 10.0)
}
i += 2
}
if d.model.hasHCHO() {
if rawHCHO := binary.BigEndian.Uint16(data[i : i+2]); rawHCHO != 0xffff {
sv.HCHO = ptr(float32(rawHCHO) / 10.0)
}
i += 2
}
if d.model.hasCO2() {
if d.model == SEN66 {
// SEN66 encodes CO2 concentration as a uint16.
if rawCO2 := binary.BigEndian.Uint16(data[i : i+2]); rawCO2 != 0xffff {
sv.CO2 = ptr(int16(rawCO2))
}
} else {
// SEN63C and SEN69C encode CO2 concentration as an int16.
if rawCO2 := int16(binary.BigEndian.Uint16(data[i : i+2])); rawCO2 != 0x7fff {
sv.CO2 = ptr(rawCO2)
}
}
}
return sv, nil
}
// ReadMeasuredRawValues reads the current raw measured values.
//
// Any values that aren't applicable to Dev's sensor model will be nil.
//
// [Dev.GetDataReady] may be used to check if new data is available since the
// last read operation. If no new data is available, the previous values will
// be returned. If no data is available at all (e.g. measurement not running
// for at least one second), all values will be nil.
func (d *Dev) ReadMeasuredRawValues() (*RawSensorValues, error) {
d.mu.Lock()
defer d.mu.Unlock()
var cmd command
switch d.model {
case SEN62, SEN63C:
cmd = cmdReadMeasuredRawValuesSEN62SEN63C
case SEN65, SEN68, SEN69C:
cmd = cmdReadMeasuredRawValuesSEN65SEN68SEN69C
case SEN66:
cmd = cmdReadMeasuredRawValuesSEN66
default:
return nil, fmt.Errorf("sen6x: unknown model: %v", d.model)
}
data, err := d.writeAndRead(cmd, nil)
if err != nil {
return nil, err
}
rv := &RawSensorValues{}
if rawRH := int16(binary.BigEndian.Uint16(data[0:2])); rawRH != 0x7fff {
rv.RH = ptr(float32(rawRH) / 100)
}
if rawTemp := int16(binary.BigEndian.Uint16(data[2:4])); rawTemp != 0x7fff {
rv.Temp = ptr(float32(rawTemp) / 200)
}
i := 4
if d.model.hasVOCNOx() {
if rawVOC := binary.BigEndian.Uint16(data[i : i+2]); rawVOC != 0xffff {
rv.VOC = ptr(rawVOC)
}
i += 2
if rawNOx := binary.BigEndian.Uint16(data[i : i+2]); rawNOx != 0xffff {
rv.NOx = ptr(rawNOx)
}
i += 2
}
// We check specifically for SEN66 here instead of using d.model.hasCO2()
// because while SEN63C and SEN69C also have CO2 sensors, only SEN66 returns
// raw CO2 measurements.
if d.model == SEN66 {
if rawCO2 := binary.BigEndian.Uint16(data[i : i+2]); rawCO2 != 0xffff {
rv.CO2 = ptr(rawCO2)
}
}
return rv, nil
}
// GetProductName gets the product name from the device.
func (d *Dev) GetProductName() (string, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetProductName, nil)
if err != nil {
return "", err
}
return string(data[:clen(data)]), nil
}
// GetSerialNumber gets the serial number from the device.
func (d *Dev) GetSerialNumber() (string, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetSerialNumber, nil)
if err != nil {
return "", err
}
return string(data[:clen(data)]), nil
}
// GetVersion gets the firmware version, returning the major and minor version numbers.
func (d *Dev) GetVersion() (uint8, uint8, error) {
d.mu.Lock()
defer d.mu.Unlock()
data, err := d.writeAndRead(cmdGetVersion, nil)
if err != nil {
return 0, 0, err
}
return data[0], data[1], nil
}
// DeviceReset executes a reset on the device. This has the same effect as a power cycle.
func (d *Dev) DeviceReset() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdDeviceReset, nil)
}
// StartFanCleaning triggers fan cleaning. The fan is set to the maximum speed for
// 10 seconds and then automatically stopped. Wait at least 10s after this command
// before starting a measurement.
func (d *Dev) StartFanCleaning() error {
d.mu.Lock()
defer d.mu.Unlock()
return d.writeAndWait(cmdStartFanCleaning, nil)
}
// writeAndWait writes a command followed by optional txData and
// waits for its execution time.
func (d *Dev) writeAndWait(cmd command, txData []byte) error {
buf := make([]byte, 2+len(txData))
buf[0] = byte(cmd.id >> 8)
buf[1] = byte(cmd.id)
copy(buf[2:], txData)
if err := d.dev.Tx(buf[:], nil); err != nil {
return err
}
d.sleep(cmd.execTime)
return nil
}
// writeAndRead writes a command followed by optional txData, waits for
// the command's execution time, and then reads the response.
func (d *Dev) writeAndRead(cmd command, txData []byte) ([]byte, error) {
if err := d.writeAndWait(cmd, txData); err != nil {
return nil, err
}
read := make([]byte, cmd.rxDataLen)
if err := d.dev.Tx(nil, read); err != nil {
return nil, err
}
rxData, err := validateAndStripCRC(read)
if err != nil {
return nil, err
}
return rxData, nil
}
var _ conn.Resource = &Dev{}