From a151aebabeea800d078ce1ac19b8876594f7b39f Mon Sep 17 00:00:00 2001 From: George Sexton Date: Sun, 11 Jan 2026 19:38:35 -0700 Subject: [PATCH 1/3] Initial add for support of lps2x sensors. --- lps2x/example_test.go | 45 ++++++ lps2x/lps2x.go | 323 ++++++++++++++++++++++++++++++++++++++++++ lps2x/lps2x_test.go | 182 ++++++++++++++++++++++++ 3 files changed, 550 insertions(+) create mode 100644 lps2x/example_test.go create mode 100644 lps2x/lps2x.go create mode 100644 lps2x/lps2x_test.go diff --git a/lps2x/example_test.go b/lps2x/example_test.go new file mode 100644 index 0000000..4e2718e --- /dev/null +++ b/lps2x/example_test.go @@ -0,0 +1,45 @@ +// 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 lps2x_test + +import ( + "fmt" + "log" + "time" + + "periph.io/x/conn/v3/i2c/i2creg" + "periph.io/x/conn/v3/physic" + "periph.io/x/devices/v3/lps2x" + "periph.io/x/host/v3" +) + +func Example() { + // Make sure periph is initialized. + if _, err := host.Init(); err != nil { + log.Fatal(err) + } + + // Use i2creg I²C bus registry to find the first available I²C bus. + b, err := i2creg.Open("") + if err != nil { + log.Fatalf("failed to open I²C: %v", err) + } + defer b.Close() + + // Initialize the device. + // Use default address, 25Hz sample rate, and average over 16 readings. + dev, err := lps2x.New(b, lps2x.DefaultAddress, lps2x.SampleRate25Hertz, lps2x.AverageReadings16) + if err != nil { + log.Fatalf("failed to initialize lps2x: %v", err) + } + time.Sleep(time.Second) + + // Read environment data. + e := physic.Env{} + if err := dev.Sense(&e); err != nil { + log.Fatal(err) + } + fmt.Printf("%8s %10s\n", e.Temperature, e.Pressure) +} diff --git a/lps2x/lps2x.go b/lps2x/lps2x.go new file mode 100644 index 0000000..e7dc3a3 --- /dev/null +++ b/lps2x/lps2x.go @@ -0,0 +1,323 @@ +// 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. + +// This package is driver for the STMicroelectronics LPS series of pressure +// sensors. It supports the LPS22HB, LPS25HB, and LPS28DFW sensors. +// +// # Datasheets +// +// LPS22HB +// https://www.st.com/resource/en/datasheet/lps22hb.pdf +// +// LPS25HB +// https://www.st.com/resource/en/datasheet/lps25hb.pdf +// +// LPS28DFW +// https://www.st.com/resource/en/datasheet/lps28dfw.pdf +package lps2x + +import ( + "errors" + "fmt" + "sync" + "time" + + "periph.io/x/conn/v3" + "periph.io/x/conn/v3/i2c" + "periph.io/x/conn/v3/physic" +) + +const ( + DefaultAddress i2c.Addr = 0x5c + + // The default measuring scale for these devices is HectoPascal, which is + // 100 Pa. + HectoPascal physic.Pressure = 100 * physic.Pascal + + // These devices implement an identify command that returns the model ID. + LPS22HB byte = 0xb1 + LPS25HB byte = 0xbd + LPS28DFW byte = 0xb4 +) + +type SampleRate byte +type AverageRate byte + +const ( + lps22hb = "LPS22HB" + lps25hb = "LPS25HB" + lps28dfw = "LPS28DFW" + + cmdWhoAmI = 0x0f + cmdStatus = 0x27 + cmdSampleRate = 0x10 + cmdResConfLPS25HB = 0x10 + cmdSampleRateLPS25HB = 0x20 + dataReady byte = 0x03 + minTemperature = physic.ZeroCelsius - 40*physic.Kelvin + maxTemperature = physic.ZeroCelsius + 85*physic.Kelvin + + minPressure = 260 * HectoPascal + + minSampleDuration = time.Microsecond +) +const ( + SampleRateOneShot SampleRate = iota + SampleRateHertz + SampleRate4Hertz + SampleRate10Hertz + SampleRate25Hertz + SampleRate50Hertz + SampleRate75Hertz + SampleRate100Hertz + SampleRate200Hertz +) + +const ( + SampleRateLPS25HBHertz = iota + SampleRateLPS25HB7Hertz + SampleRateLPS25HB12_5Hertz + SampleRateLPS25HB25Hertz +) + +const ( + AverageNone AverageRate = iota + AverageReadings4 + AverageReadings8 + AverageReadings16 + AverageReadings32 + AverageReadings64 + AverageReadings128 + AverageReadings512 +) + +var ( + sampleRateTimes = []time.Duration{ + 0, + time.Second, + time.Second / 4, + time.Second / 10, + time.Second / 25, + time.Second / 50, + time.Second / 75, + time.Second / 100, + time.Second / 200, + } + averageMultiple = []int{ + 1, + 4, + 8, + 16, + 32, + 64, + 128, + 512, + } +) + +type Dev struct { + conn conn.Conn + mu sync.Mutex + shutdown chan struct{} + deviceID byte + fsMode byte + sampleRate SampleRate + averageReadings AverageRate +} + +// New creates a new LPS2x device on the specified I²C bus. +// addr is the I²C address (typically DefaultAddress or AlternateAddress). +// sampleRate controls measurement frequency, averageReadings controls internal averaging. +func New(bus i2c.Bus, address i2c.Addr, sampleRate SampleRate, averageRate AverageRate) (*Dev, error) { + dev := &Dev{conn: &i2c.Dev{Bus: bus, Addr: uint16(address)}, sampleRate: sampleRate, averageReadings: averageRate} + + return dev, dev.start() +} + +// start does an i2c transaction to read the device id and returns the error +// if any. +func (dev *Dev) start() error { + + r := []byte{0} + err := dev.conn.Tx([]byte{cmdWhoAmI}, r) + if err != nil { + return err + } + + dev.deviceID = r[0] + if err == nil { + if dev.deviceID == LPS25HB { + // There are some key differences for this model. In this case, the Average Rate + // is in the 0x10 register, and the sample rate is in the 0x20 register. + // Also, the lps25hb supports different sample rates than other members of the + // family. + if dev.sampleRate > SampleRate25Hertz { + return fmt.Errorf("lps2x: invalid sample rate %d, max: %d", dev.sampleRate, SampleRate25Hertz) + } + var tAvg, pAvg byte + switch dev.averageReadings { + case AverageReadings4: + case AverageReadings8: + // the default 0 value is correct. + case AverageReadings16: + tAvg = 1 + pAvg = 1 + case AverageReadings32: + tAvg = 1 + pAvg = 2 + case AverageReadings64: + tAvg = 1 + pAvg = 3 + case AverageReadings128: + tAvg = 2 + pAvg = 3 + case AverageReadings512: + tAvg = 3 + pAvg = 3 + } + + err = dev.conn.Tx([]byte{cmdResConfLPS25HB, tAvg<<2 | pAvg}, nil) + if err != nil { + err = fmt.Errorf("lps2x: error setting average rates %w", err) + } else { + odr := byte(0x80 | (dev.sampleRate << 4)) + err = dev.conn.Tx([]byte{cmdSampleRateLPS25HB, odr}, nil) + } + + } else { + err = dev.conn.Tx([]byte{cmdSampleRate, byte(dev.sampleRate<<3) | byte(dev.averageReadings)}, nil) + } + } + return err +} + +func (dev *Dev) Halt() error { + dev.mu.Lock() + defer dev.mu.Unlock() + if dev.shutdown != nil { + close(dev.shutdown) + } + return nil +} + +func (dev *Dev) Precision(env *physic.Env) { + env.Humidity = 0 + env.Temperature = physic.Kelvin / 100 + env.Pressure = HectoPascal +} + +func (dev *Dev) Sense(env *physic.Env) error { + env.Humidity = 0 + + // We're reading the status byte, and the following 5 bytes: 3 bytes of + // pressure data, and 2 temperature bytes. + w := []byte{cmdStatus} + r := make([]byte, 6) + + err := dev.conn.Tx(w, r) + if err != nil { + env.Temperature = minTemperature + env.Pressure = minPressure + return fmt.Errorf("lps2x: error reading device %w", err) + } + if r[0]&dataReady != dataReady { + env.Temperature = minTemperature + env.Pressure = minPressure + return errors.New("lps2x: data not ready, was sampling started?") + } + + env.Temperature = dev.countToTemp(int16(r[5])<<8 | int16(r[4])) + env.Pressure = dev.countToPressure(convert24BitTo64Bit(r[1:4])) + return nil +} + +func (dev *Dev) SenseContinuous(interval time.Duration) (<-chan physic.Env, error) { + d := sampleRateTimes[dev.sampleRate] + d *= time.Duration(averageMultiple[dev.averageReadings]) + if interval < d { + return nil, fmt.Errorf("Invalid duration. Minimum Duration: %v", d) + } + dev.mu.Lock() + if dev.shutdown != nil { + dev.mu.Unlock() + return nil, errors.New("lps2x: SenseContinuous already running") + } + dev.mu.Unlock() + + if interval < minSampleDuration { + // TODO: Verify + return nil, errors.New("lps2x: sample interval is < device sample rate") + } + dev.shutdown = make(chan struct{}) + ch := make(chan physic.Env, 16) + go func(ch chan<- physic.Env) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer close(ch) + for { + select { + case <-dev.shutdown: + dev.mu.Lock() + defer dev.mu.Unlock() + dev.shutdown = nil + return + case <-ticker.C: + env := physic.Env{} + if err := dev.Sense(&env); err == nil { + ch <- env + } + } + } + }(ch) + return ch, nil +} + +// String returns the device model name. +func (dev *Dev) String() string { + switch dev.deviceID { + case LPS22HB: + return lps22hb + case LPS25HB: + return lps25hb + case LPS28DFW: + return lps28dfw + default: + return "unknown" + } +} + +func convert24BitTo64Bit(bytes []byte) int64 { + // Mask to isolate the lower 24 bits (0x00FFFFFF) + // This ensures we only consider the 24-bit value if it was derived from a larger type + val := uint32(bytes[0]) | uint32(bytes[1])<<8 | uint32(bytes[2])<<16 + + // Check if the 24th bit (the sign bit) is set (0x00800000) + if (val & 0x00800000) != 0 { + // If the sign bit is set, it's a negative number. + // Sign-extend by filling the upper 8 bits with ones (0xFF000000). + val |= 0xFF000000 + } + + return int64(val) +} + +func (dev *Dev) countToTemp(count int16) physic.Temperature { + temp := physic.Temperature(count)*10*physic.MilliKelvin + physic.ZeroCelsius + if temp < minTemperature { + temp = minTemperature + } else if temp > maxTemperature { + temp = maxTemperature + } + return temp +} + +func (dev *Dev) countToPressure(count int64) physic.Pressure { + if dev.fsMode == 0 { + return (physic.Pressure(count) * HectoPascal) / 4096 + } + return (physic.Pressure(count) * HectoPascal) / 2048 +} + +var _ conn.Resource = &Dev{} +var _ physic.SenseEnv = &Dev{} diff --git a/lps2x/lps2x_test.go b/lps2x/lps2x_test.go new file mode 100644 index 0000000..62fb6f9 --- /dev/null +++ b/lps2x/lps2x_test.go @@ -0,0 +1,182 @@ +// 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 lps2x + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "periph.io/x/conn/v3/i2c/i2ctest" + "periph.io/x/conn/v3/physic" +) + +var recordingData = map[string][]i2ctest.IO{ + "TestCountToPressure": []i2ctest.IO{ + {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, + {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, + "TestBasic": []i2ctest.IO{ + {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, + {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, + "TestSense": []i2ctest.IO{ + {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, + {Addr: 0x5c, W: []uint8{0x10, 0x10}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xbf, 0x19, 0x34, 0x2f, 0x9}}}, + "TestSenseContinuous": []i2ctest.IO{ + {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, + {Addr: 0x5c, W: []uint8{0x10, 0x10}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x72, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xe7, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x9e, 0x19, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xd4, 0x18, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x3e, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x93, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x51, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xc9, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xa, 0x1a, 0x34, 0x2f, 0x9}}, + {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x72, 0x1a, 0x34, 0x2f, 0x9}}}, + "TestCountToTemp": []i2ctest.IO{ + {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, + {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, +} + +var liveDevice bool +var timeDurationMultiplier time.Duration + +func getDev(testName string) (*Dev, error) { + ops := recordingData[testName] + dev, err := New(&i2ctest.Playback{Ops: ops, DontPanic: true}, DefaultAddress, SampleRate4Hertz, AverageNone) + return dev, err +} + +func TestInt24ToInt64(t *testing.T) { + if convert24BitTo64Bit([]byte{0xff, 0xff, 0xff}) != 0xffffffff { + t.Error("Error converting -1 to 32bits") + t.Errorf("Error converting -1 to 64 bits, got 0x%x", convert24BitTo64Bit([]byte{0xff, 0xff, 0xff})) + } + if convert24BitTo64Bit([]byte{0xf0, 0xff, 0xff}) != 0xfffffff0 { + t.Errorf("Error converting -16 to 64 bits, got 0x%x", convert24BitTo64Bit([]byte{0xf0, 0xff, 0xff})) + } + if convert24BitTo64Bit([]byte{0x10, 0, 0}) != 16 { + t.Error("Error converting 16 to 32bits") + } +} + +func TestCountToTemp(t *testing.T) { + dev, _ := getDev(t.Name()) + c := dev.countToTemp(0) + if c != physic.ZeroCelsius { + t.Error("expected zero celsius for zero count!") + } + c = dev.countToTemp(5000) + if c != (physic.ZeroCelsius + 50*physic.Kelvin) { + t.Errorf("expected 50 celsius received %s", c.String()) + } +} + +func TestCountToPressure(t *testing.T) { + dev, _ := getDev(t.Name()) + p := dev.countToPressure(0) + if p != 0 { + t.Errorf("expected 0 Pa received %s", p.String()) + } + + p = dev.countToPressure(4096 * 10) + if p != (10 * physic.Pascal * 100) { + t.Errorf("expected 1000 Pa received %s", p.String()) + } + dev.fsMode = 1 + p = dev.countToPressure(4096 * 10) + if p != (20 * physic.Pascal * 100) { + t.Errorf("expected 2000pa received %s", p.String()) + } + +} + +func TestBasic(t *testing.T) { + // Test String() + dev, _ := getDev(t.Name()) + s := dev.String() + if len(s) == 0 { + t.Errorf("String() returned empty") + } + if s != lps28dfw { + t.Errorf("received model %s, expected %s", s, lps28dfw) + } + // Test Precision() + env := physic.Env{} + dev.Precision(&env) + if env.Humidity != 0 { + t.Error("expected 0% RH") + } + if env.Temperature != (physic.Kelvin / 100) { + t.Errorf("expected precision of 1/100 kelvin got %s", env.Temperature.String()) + } + if env.Pressure != HectoPascal { + t.Errorf("expected pressure precision of 1 HectoPascal got %s", env.Pressure.String()) + } +} + +func TestSense(t *testing.T) { + dev, err := getDev(t.Name()) + if err != nil { + t.Fatal(err) + } + time.Sleep(3 * timeDurationMultiplier * time.Second) + env := physic.Env{} + err = dev.Sense(&env) + if err != nil { + t.Error(err) + } + t.Logf("dev=%s", dev.String()) + t.Logf("Temperature: %s Pressure: %s (PSI=%f)", env.Temperature.String(), env.Pressure.String(), float64(env.Pressure/physic.Pascal)*float64(0.000145038)) +} + +func TestSenseContinuous(t *testing.T) { + dev, err := getDev(t.Name()) + var d time.Duration + if liveDevice { + d = time.Second + } else { + d = 250 * time.Millisecond + } + if err != nil { + t.Fatal(err) + } + // So the default is 4hz, average none, so the min reading rate is 250ms + _, err = dev.SenseContinuous(100 * time.Millisecond) + if err == nil { + t.Error("expected error on insufficient sense continuous duration") + } + + counter := atomic.Int32{} + chEnd := make(chan struct{}) + chRead, err := dev.SenseContinuous(d) + if err != nil { + t.Fatal(err) + } + wg := sync.WaitGroup{} + wg.Add(1) + go func(ch <-chan physic.Env, chEnd chan struct{}) { + var env physic.Env + for { + select { + case env = <-ch: + t.Logf("received %#v", env) + counter.Add(1) + case <-chEnd: + wg.Done() + break + } + } + }(chRead, chEnd) + time.Sleep(10 * d) + chEnd <- struct{}{} + wg.Wait() + if counter.Load() != 10 { + t.Errorf("Expected reading count of 10, received %d", counter.Load()) + } +} From 3483a63475f61787709b24dda2c6483c10e975c1 Mon Sep 17 00:00:00 2001 From: George Sexton Date: Sun, 11 Jan 2026 20:21:45 -0700 Subject: [PATCH 2/3] Fix lint errors --- lps2x/lps2x.go | 2 +- lps2x/lps2x_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lps2x/lps2x.go b/lps2x/lps2x.go index e7dc3a3..3240885 100644 --- a/lps2x/lps2x.go +++ b/lps2x/lps2x.go @@ -236,7 +236,7 @@ func (dev *Dev) SenseContinuous(interval time.Duration) (<-chan physic.Env, erro d := sampleRateTimes[dev.sampleRate] d *= time.Duration(averageMultiple[dev.averageReadings]) if interval < d { - return nil, fmt.Errorf("Invalid duration. Minimum Duration: %v", d) + return nil, fmt.Errorf("invalid duration, minimum duration: %v", d) } dev.mu.Lock() if dev.shutdown != nil { diff --git a/lps2x/lps2x_test.go b/lps2x/lps2x_test.go index 62fb6f9..4aa602a 100644 --- a/lps2x/lps2x_test.go +++ b/lps2x/lps2x_test.go @@ -15,17 +15,17 @@ import ( ) var recordingData = map[string][]i2ctest.IO{ - "TestCountToPressure": []i2ctest.IO{ + "TestCountToPressure": { {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, - "TestBasic": []i2ctest.IO{ + "TestBasic": { {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, - "TestSense": []i2ctest.IO{ + "TestSense": { {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, {Addr: 0x5c, W: []uint8{0x10, 0x10}}, {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xbf, 0x19, 0x34, 0x2f, 0x9}}}, - "TestSenseContinuous": []i2ctest.IO{ + "TestSenseContinuous": { {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, {Addr: 0x5c, W: []uint8{0x10, 0x10}}, {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x72, 0x1a, 0x34, 0x2f, 0x9}}, @@ -38,7 +38,7 @@ var recordingData = map[string][]i2ctest.IO{ {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xc9, 0x1a, 0x34, 0x2f, 0x9}}, {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0xa, 0x1a, 0x34, 0x2f, 0x9}}, {Addr: 0x5c, W: []uint8{0x27}, R: []uint8{0x33, 0x72, 0x1a, 0x34, 0x2f, 0x9}}}, - "TestCountToTemp": []i2ctest.IO{ + "TestCountToTemp": { {Addr: 0x5c, W: []uint8{0xf}, R: []uint8{0xb4}}, {Addr: 0x5c, W: []uint8{0x10, 0x10}}}, } From b07b8920f003c23315e1b5d85f2f6881f4ff6f34 Mon Sep 17 00:00:00 2001 From: George Sexton Date: Sun, 11 Jan 2026 20:45:41 -0700 Subject: [PATCH 3/3] Fix TestSenseContinuous to be more robust --- lps2x/lps2x_test.go | 54 ++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/lps2x/lps2x_test.go b/lps2x/lps2x_test.go index 4aa602a..4b8b28c 100644 --- a/lps2x/lps2x_test.go +++ b/lps2x/lps2x_test.go @@ -5,8 +5,6 @@ package lps2x import ( - "sync" - "sync/atomic" "testing" "time" @@ -152,31 +150,41 @@ func TestSenseContinuous(t *testing.T) { t.Error("expected error on insufficient sense continuous duration") } - counter := atomic.Int32{} - chEnd := make(chan struct{}) chRead, err := dev.SenseContinuous(d) if err != nil { t.Fatal(err) } - wg := sync.WaitGroup{} - wg.Add(1) - go func(ch <-chan physic.Env, chEnd chan struct{}) { - var env physic.Env - for { - select { - case env = <-ch: - t.Logf("received %#v", env) - counter.Add(1) - case <-chEnd: - wg.Done() - break - } + + expectedCount := 10 + start := time.Now() + + // Read exactly expectedCount samples + for i := 0; i < expectedCount; i++ { + select { + case env := <-chRead: + t.Logf("received reading %d: %#v", i+1, env) + case <-time.After(3 * d): + t.Fatalf("Timed out waiting for reading %d (waited %v)", i+1, 3*d) } - }(chRead, chEnd) - time.Sleep(10 * d) - chEnd <- struct{}{} - wg.Wait() - if counter.Load() != 10 { - t.Errorf("Expected reading count of 10, received %d", counter.Load()) } + + elapsed := time.Since(start) + + // Verify timing: expectedCount readings at interval d should take approximately (expectedCount-1)*d to expectedCount*d + // Lower bound: readings shouldn't come faster than the ticker interval + minDuration := time.Duration(expectedCount-1) * d + // Upper bound: allow some slack for CI/scheduling delays (1.5x the expected maximum) + maxDuration := time.Duration(expectedCount) * d * 3 / 2 + + if elapsed < minDuration { + t.Errorf("Readings too fast! Got %d readings in %v, expected at least %v. Sample rate may be ignored.", + expectedCount, elapsed, minDuration) + } + if elapsed > maxDuration { + t.Errorf("Readings too slow! Got %d readings in %v, expected at most %v. Sample rate may be too slow.", + expectedCount, elapsed, maxDuration) + } + + // Clean up: stop the background goroutine + _ = dev.Halt() }