mirror of https://github.com/periph/devices
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.
531 lines
14 KiB
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{}
|