diff --git a/README.md b/README.md index 2becbb60..c38830e0 100644 --- a/README.md +++ b/README.md @@ -298,12 +298,15 @@ Support for many devices that use Analog Input/Output (AIO) have a shared set of drivers provided using the `gobot/drivers/aio` package: - [AIO](https://en.wikipedia.org/wiki/Analog-to-digital_converter) <=> [Drivers](https://github.com/hybridgroup/gobot/tree/master/drivers/aio) + - Analog Actuator - Analog Sensor - Grove Light Sensor - Grove Piezo Vibration Sensor - Grove Rotary Dial - Grove Sound Sensor - Grove Temperature Sensor + - Temperature Sensor (supports linear and NTC thermistor in normal and inverse mode) + - Thermal Zone Temperature Sensor Support for devices that use Inter-Integrated Circuit (I2C) have a shared set of drivers provided using the `gobot/drivers/i2c` package: diff --git a/drivers/aio/README.md b/drivers/aio/README.md index 131fb93b..aee95346 100644 --- a/drivers/aio/README.md +++ b/drivers/aio/README.md @@ -12,12 +12,12 @@ Please refer to the main [README.md](https://github.com/hybridgroup/gobot/blob/r Gobot has a extensible system for connecting to hardware devices. The following AIO devices are currently supported: -- Analog Sensor - Analog Actuator +- Analog Sensor - Grove Light Sensor +- Grove Piezo Vibration Sensor - Grove Rotary Dial - Grove Sound Sensor - Grove Temperature Sensor - Temperature Sensor (supports linear and NTC thermistor in normal and inverse mode) - -More drivers are coming soon... +- Thermal Zone Temperature Sensor diff --git a/drivers/aio/analog_sensor_driver.go b/drivers/aio/analog_sensor_driver.go index adae38e3..4742118c 100644 --- a/drivers/aio/analog_sensor_driver.go +++ b/drivers/aio/analog_sensor_driver.go @@ -26,7 +26,7 @@ type sensorScaleOption struct { scaler func(input int) (value float64) } -// AnalogSensorDriver represents an Analog Sensor +// AnalogSensorDriver represents an analog sensor type AnalogSensorDriver struct { *driver sensorCfg *sensorConfiguration @@ -35,6 +35,7 @@ type AnalogSensorDriver struct { gobot.Eventer lastRawValue int lastValue float64 + analogRead func() (int, float64, error) } // NewAnalogSensorDriver returns a new driver for analog sensors, given an AnalogReader and pin. @@ -60,6 +61,7 @@ func NewAnalogSensorDriver(a AnalogReader, pin string, opts ...interface{}) *Ana } d.afterStart = d.initialize d.beforeHalt = d.shutdown + d.analogRead = d.analogSensorRead for _, opt := range opts { switch o := opt.(type) { @@ -168,6 +170,7 @@ func (a *AnalogSensorDriver) initialize() error { go func() { timer := time.NewTimer(a.sensorCfg.readInterval) timer.Stop() + for { // please note, that this ensures the first read is done immediately, but has drawbacks, see notes above rawValue, value, err := a.analogRead() @@ -183,6 +186,7 @@ func (a *AnalogSensorDriver) initialize() error { oldValue = value } } + timer.Reset(a.sensorCfg.readInterval) // ensure that after each read is a wait, independent of duration of read select { case <-timer.C: @@ -205,8 +209,9 @@ func (a *AnalogSensorDriver) shutdown() error { return nil } -// analogRead performs an reading from the sensor and sets the internal attributes and returns the raw and scaled value -func (a *AnalogSensorDriver) analogRead() (int, float64, error) { +// analogSensorRead performs an reading from the sensor, sets the internal attributes and returns +// the raw and scaled value +func (a *AnalogSensorDriver) analogSensorRead() (int, float64, error) { a.mutex.Lock() defer a.mutex.Unlock() diff --git a/drivers/aio/temperature_sensor_driver_test.go b/drivers/aio/temperature_sensor_driver_test.go index cc3786a0..0b9d2b3d 100644 --- a/drivers/aio/temperature_sensor_driver_test.go +++ b/drivers/aio/temperature_sensor_driver_test.go @@ -128,7 +128,7 @@ func TestTemperatureSensorDriver_LinearScaler(t *testing.T) { } } -func TestTemperatureSensorPublishesTemperatureInCelsius(t *testing.T) { +func TestTemperatureSensorWithSensorCyclicRead_PublishesTemperatureInCelsius(t *testing.T) { // arrange sem := make(chan bool) a := newAioTestAdaptor() @@ -155,7 +155,7 @@ func TestTemperatureSensorPublishesTemperatureInCelsius(t *testing.T) { assert.InDelta(t, 31.61532462352477, d.Value(), 0.0) } -func TestTemperatureSensorWithSensorCyclicReadPublishesError(t *testing.T) { +func TestTemperatureSensorWithSensorCyclicRead_PublishesError(t *testing.T) { // arrange sem := make(chan bool) a := newAioTestAdaptor() diff --git a/drivers/aio/thermalzone_driver.go b/drivers/aio/thermalzone_driver.go new file mode 100644 index 00000000..55dfbe16 --- /dev/null +++ b/drivers/aio/thermalzone_driver.go @@ -0,0 +1,83 @@ +package aio + +import ( + "fmt" + + "gobot.io/x/gobot/v2" +) + +// thermalZoneOptionApplier needs to be implemented by each configurable option type +type thermalZoneOptionApplier interface { + apply(cfg *thermalZoneConfiguration) +} + +// thermalZoneConfiguration contains all changeable attributes of the driver. +type thermalZoneConfiguration struct { + scaleUnit func(float64) float64 +} + +// thermalZoneUnitscalerOption is the type for applying another unit scaler to the configuration +type thermalZoneUnitscalerOption struct { + unitscaler func(float64) float64 +} + +// ThermalZoneDriver represents an driver for reading the system thermal zone temperature +type ThermalZoneDriver struct { + *AnalogSensorDriver + thermalZoneCfg *thermalZoneConfiguration +} + +// NewThermalZoneDriver is a driver for reading the system thermal zone temperature, given an AnalogReader and zone id. +// +// Supported options: see also [aio.NewAnalogSensorDriver] +// +// "WithFahrenheit()" +// +// Adds the following API Commands: see [aio.NewAnalogSensorDriver] +func NewThermalZoneDriver(a AnalogReader, zoneID string, opts ...interface{}) *ThermalZoneDriver { + degreeScaler := func(input int) float64 { return float64(input) / 1000 } + d := ThermalZoneDriver{ + AnalogSensorDriver: NewAnalogSensorDriver(a, zoneID, WithSensorScaler(degreeScaler)), + thermalZoneCfg: &thermalZoneConfiguration{ + scaleUnit: func(input float64) float64 { return input }, // 1:1 in °C + }, + } + d.driverCfg.name = gobot.DefaultName("ThermalZone") + d.analogRead = d.thermalZoneRead + + for _, opt := range opts { + switch o := opt.(type) { + case optionApplier: + o.apply(d.driverCfg) + case sensorOptionApplier: + o.apply(d.sensorCfg) + case thermalZoneOptionApplier: + o.apply(d.thermalZoneCfg) + default: + panic(fmt.Sprintf("'%s' can not be applied on '%s'", opt, d.driverCfg.name)) + } + } + + return &d +} + +// WithFahrenheit substitute the default 1:1 °C scaler by a scaler for °F +func WithFahrenheit() thermalZoneOptionApplier { + // (1°C × 9/5) + 32 = 33,8°F + unitscaler := func(input float64) float64 { return input*9.0/5.0 + 32.0 } + return thermalZoneUnitscalerOption{unitscaler: unitscaler} +} + +// thermalZoneRead overrides and extends the analogSensorRead() function to add the unit scaler +func (d *ThermalZoneDriver) thermalZoneRead() (int, float64, error) { + if _, _, err := d.analogSensorRead(); err != nil { + return 0, 0, err + } + // apply unit scaler on value + d.lastValue = d.thermalZoneCfg.scaleUnit(d.lastValue) + return d.lastRawValue, d.lastValue, nil +} + +func (o thermalZoneUnitscalerOption) apply(cfg *thermalZoneConfiguration) { + cfg.scaleUnit = o.unitscaler +} diff --git a/drivers/aio/thermalzone_driver_test.go b/drivers/aio/thermalzone_driver_test.go new file mode 100644 index 00000000..1a3f92d1 --- /dev/null +++ b/drivers/aio/thermalzone_driver_test.go @@ -0,0 +1,91 @@ +package aio + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewThermalZoneDriver(t *testing.T) { + // arrange + const pin = "thermal_zone0" + a := newAioTestAdaptor() + // act + d := NewThermalZoneDriver(a, pin) + // assert: driver attributes + assert.IsType(t, &ThermalZoneDriver{}, d) + assert.NotNil(t, d.driverCfg) + assert.True(t, strings.HasPrefix(d.Name(), "ThermalZone")) + assert.Equal(t, a, d.Connection()) + require.NoError(t, d.afterStart()) + require.NoError(t, d.beforeHalt()) + assert.NotNil(t, d.Commander) + assert.NotNil(t, d.mutex) + // assert: sensor attributes + assert.Equal(t, pin, d.Pin()) + assert.InDelta(t, 0.0, d.lastValue, 0, 0) + assert.Equal(t, 0, d.lastRawValue) + assert.Nil(t, d.halt) // will be created on initialize, if cyclic reading is on + assert.NotNil(t, d.Eventer) + require.NotNil(t, d.sensorCfg) + assert.Equal(t, time.Duration(0), d.sensorCfg.readInterval) + assert.NotNil(t, d.sensorCfg.scale) + // assert: thermal zone attributes + require.NotNil(t, d.thermalZoneCfg) + require.NotNil(t, d.thermalZoneCfg.scaleUnit) + assert.InDelta(t, 1.0, d.thermalZoneCfg.scaleUnit(1), 0.0) +} + +func TestNewThermalZoneDriver_options(t *testing.T) { + // This is a general test, that options are applied in constructor by using the common WithName() option, least one + // option of this driver and one of another driver (which should lead to panic). Further tests for options can also + // be done by call of "WithOption(val).apply(cfg)". + // arrange + const ( + myName = "outlet temperature" + cycReadDur = 10 * time.Millisecond + ) + panicFunc := func() { + NewThermalZoneDriver(newAioTestAdaptor(), "1", WithName("crazy"), + WithActuatorScaler(func(float64) int { return 0 })) + } + // act + d := NewThermalZoneDriver(newAioTestAdaptor(), "1", + WithName(myName), + WithSensorCyclicRead(cycReadDur), + WithFahrenheit()) + // assert + assert.Equal(t, cycReadDur, d.sensorCfg.readInterval) + assert.InDelta(t, 33.8, d.thermalZoneCfg.scaleUnit(1), 0.0) // (1°C × 9/5) + 32 = 33,8°F + assert.Equal(t, myName, d.Name()) + assert.PanicsWithValue(t, "'scaler option for analog actuators' can not be applied on 'crazy'", panicFunc) +} + +func TestThermalZoneWithSensorCyclicRead_PublishesTemperatureInFahrenheit(t *testing.T) { + // arrange + sem := make(chan bool) + a := newAioTestAdaptor() + d := NewThermalZoneDriver(a, "1", WithSensorCyclicRead(10*time.Millisecond), WithFahrenheit()) + a.analogReadFunc = func() (int, error) { + return -100000, nil // -100.000 °C + } + // act: start cyclic reading + require.NoError(t, d.Start()) + // assert + _ = d.Once(d.Event(Value), func(data interface{}) { + //nolint:forcetypeassert // ok here + assert.InDelta(t, -148.0, data.(float64), 0.0) + sem <- true + }) + + select { + case <-sem: + case <-time.After(1 * time.Second): + t.Errorf(" Temperature Sensor Event \"Data\" was not published") + } + + assert.InDelta(t, -148.0, d.Value(), 0.0) +} diff --git a/examples/raspi_thermalzone.go b/examples/raspi_thermalzone.go new file mode 100644 index 00000000..f2608197 --- /dev/null +++ b/examples/raspi_thermalzone.go @@ -0,0 +1,50 @@ +//go:build example +// +build example + +// +// Do not build by default. + +package main + +import ( + "fmt" + "log" + "time" + + "gobot.io/x/gobot/v2" + "gobot.io/x/gobot/v2/drivers/aio" + "gobot.io/x/gobot/v2/platforms/raspi" +) + +// Wiring: no wiring needed +func main() { + adaptor := raspi.NewAdaptor() + therm0C := aio.NewThermalZoneDriver(adaptor, "thermal_zone0") + therm0F := aio.NewThermalZoneDriver(adaptor, "thermal_zone0", aio.WithFahrenheit()) + + work := func() { + gobot.Every(500*time.Millisecond, func() { + t0C, err := therm0C.Read() + if err != nil { + log.Println(err) + } + + t0F, err := therm0F.Read() + if err != nil { + log.Println(err) + } + + fmt.Printf("Zone 0: %2.3f °C, %2.3f °F\n", t0C, t0F) + }) + } + + robot := gobot.NewRobot("thermalBot", + []gobot.Connection{adaptor}, + []gobot.Device{therm0C, therm0F}, + work, + ) + + if err := robot.Start(); err != nil { + panic(err) + } +} diff --git a/examples/tinkerboard_thermalzone.go b/examples/tinkerboard_thermalzone.go new file mode 100644 index 00000000..758ea3f6 --- /dev/null +++ b/examples/tinkerboard_thermalzone.go @@ -0,0 +1,50 @@ +//go:build example +// +build example + +// +// Do not build by default. + +package main + +import ( + "fmt" + "log" + "time" + + "gobot.io/x/gobot/v2" + "gobot.io/x/gobot/v2/drivers/aio" + "gobot.io/x/gobot/v2/platforms/tinkerboard" +) + +// Wiring: no wiring needed +func main() { + adaptor := tinkerboard.NewAdaptor() + therm0 := aio.NewThermalZoneDriver(adaptor, "thermal_zone0") + therm1 := aio.NewThermalZoneDriver(adaptor, "thermal_zone1", aio.WithFahrenheit()) + + work := func() { + gobot.Every(500*time.Millisecond, func() { + t0, err := therm0.Read() + if err != nil { + log.Println(err) + } + + t1, err := therm1.Read() + if err != nil { + log.Println(err) + } + + fmt.Printf("Zone 0: %2.3f °C, Zone 1: %2.3f °F\n", t0, t1) + }) + } + + robot := gobot.NewRobot("thermalBot", + []gobot.Connection{adaptor}, + []gobot.Device{therm0, therm1}, + work, + ) + + if err := robot.Start(); err != nil { + panic(err) + } +} diff --git a/platforms/nanopi/nanopi_adaptor.go b/platforms/nanopi/nanopi_adaptor.go index ff298762..0ef45cff 100644 --- a/platforms/nanopi/nanopi_adaptor.go +++ b/platforms/nanopi/nanopi_adaptor.go @@ -33,6 +33,13 @@ type gpioPinDefinition struct { cdev cdevPin } +type analogPinDefinition struct { + path string + r bool // readable + w bool // writable + bufLen uint16 +} + type pwmPinDefinition struct { channel int dir string @@ -46,6 +53,7 @@ type Adaptor struct { gpioPinMap map[string]gpioPinDefinition pwmPinMap map[string]pwmPinDefinition mutex sync.Mutex + *adaptors.AnalogPinsAdaptor *adaptors.DigitalPinsAdaptor *adaptors.PWMPinsAdaptor *adaptors.I2cBusAdaptor @@ -56,13 +64,13 @@ type Adaptor struct { // // Optional parameters: // -// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) -// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# -// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior -// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor -// adaptors.WithGpiosOpenDrain/Source(pin's): sets the output behavior -// adaptors.WithGpioDebounce(pin, period): sets the input debouncer -// adaptors.WithGpioEventOnFallingEdge/RaisingEdge/BothEdges(pin, handler): activate edge detection +// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) +// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# +// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior +// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor +// adaptors.WithGpiosOpenDrain/Source(pin's): sets the output behavior +// adaptors.WithGpioDebounce(pin, period): sets the input debouncer +// adaptors.WithGpioEventOnFallingEdge/RaisingEdge/BothEdges(pin, handler): activate edge detection func NewNeoAdaptor(opts ...func(adaptors.Optioner)) *Adaptor { sys := system.NewAccesser(system.WithDigitalPinGpiodAccess()) c := &Adaptor{ @@ -71,6 +79,7 @@ func NewNeoAdaptor(opts ...func(adaptors.Optioner)) *Adaptor { gpioPinMap: neoGpioPins, pwmPinMap: neoPwmPins, } + c.AnalogPinsAdaptor = adaptors.NewAnalogPinsAdaptor(sys, c.translateAnalogPin) c.DigitalPinsAdaptor = adaptors.NewDigitalPinsAdaptor(sys, c.translateDigitalPin, opts...) c.PWMPinsAdaptor = adaptors.NewPWMPinsAdaptor(sys, c.translatePWMPin, adaptors.WithPolarityInvertedIdentifier(pwmInvertedIdentifier)) @@ -99,6 +108,10 @@ func (c *Adaptor) Connect() error { return err } + if err := c.AnalogPinsAdaptor.Connect(); err != nil { + return err + } + if err := c.PWMPinsAdaptor.Connect(); err != nil { return err } @@ -116,6 +129,10 @@ func (c *Adaptor) Finalize() error { err = multierror.Append(err, e) } + if e := c.AnalogPinsAdaptor.Finalize(); e != nil { + err = multierror.Append(err, e) + } + if e := c.I2cBusAdaptor.Finalize(); e != nil { err = multierror.Append(err, e) } @@ -143,6 +160,24 @@ func (c *Adaptor) validateI2cBusNumber(busNr int) error { return nil } +func (c *Adaptor) translateAnalogPin(id string) (string, bool, bool, uint16, error) { + pinInfo, ok := analogPinDefinitions[id] + if !ok { + return "", false, false, 0, fmt.Errorf("'%s' is not a valid id for a analog pin", id) + } + + path := pinInfo.path + info, err := c.sys.Stat(path) + if err != nil { + return "", false, false, 0, fmt.Errorf("Error (%v) on access '%s'", err, path) + } + if info.IsDir() { + return "", false, false, 0, fmt.Errorf("The item '%s' is a directory, which is not expected", path) + } + + return path, pinInfo.r, pinInfo.w, pinInfo.bufLen, nil +} + func (c *Adaptor) translateDigitalPin(id string) (string, int, error) { pindef, ok := c.gpioPinMap[id] if !ok { diff --git a/platforms/nanopi/nanopi_adaptor_test.go b/platforms/nanopi/nanopi_adaptor_test.go index bfae8fdd..b145d6e3 100644 --- a/platforms/nanopi/nanopi_adaptor_test.go +++ b/platforms/nanopi/nanopi_adaptor_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "gobot.io/x/gobot/v2" + "gobot.io/x/gobot/v2/drivers/aio" "gobot.io/x/gobot/v2/drivers/gpio" "gobot.io/x/gobot/v2/drivers/i2c" "gobot.io/x/gobot/v2/system" @@ -58,6 +59,7 @@ var ( _ gpio.DigitalWriter = (*Adaptor)(nil) _ gpio.PwmWriter = (*Adaptor)(nil) _ gpio.ServoWriter = (*Adaptor)(nil) + _ aio.AnalogReader = (*Adaptor)(nil) _ i2c.Connector = (*Adaptor)(nil) ) @@ -99,6 +101,29 @@ func TestDigitalIO(t *testing.T) { require.NoError(t, a.Finalize()) } +func TestAnalog(t *testing.T) { + mockPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + } + + a, fs := initTestAdaptorWithMockedFilesystem(mockPaths) + + fs.Files["/sys/class/thermal/thermal_zone0/temp"].Contents = "567\n" + got, err := a.AnalogRead("thermal_zone0") + require.NoError(t, err) + assert.Equal(t, 567, got) + + _, err = a.AnalogRead("thermal_zone10") + require.ErrorContains(t, err, "'thermal_zone10' is not a valid id for a analog pin") + + fs.WithReadError = true + _, err = a.AnalogRead("thermal_zone0") + require.ErrorContains(t, err, "read error") + fs.WithReadError = false + + require.NoError(t, a.Finalize()) +} + func TestInvalidPWMPin(t *testing.T) { a, fs := initTestAdaptorWithMockedFilesystem(pwmMockPaths) preparePwmFs(fs) @@ -342,6 +367,49 @@ func Test_translateDigitalPin(t *testing.T) { } } +func Test_translateAnalogPin(t *testing.T) { + mockedPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + "/sys/class/thermal/thermal_zone1/temp", + } + tests := map[string]struct { + id string + wantPath string + wantReadable bool + wantBufLen uint16 + wantErr string + }{ + "translate_thermal_zone0": { + id: "thermal_zone0", + wantPath: "/sys/class/thermal/thermal_zone0/temp", + wantReadable: true, + wantBufLen: 7, + }, + "unknown_id": { + id: "thermal_zone1", + wantErr: "'thermal_zone1' is not a valid id for a analog pin", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a, _ := initTestAdaptorWithMockedFilesystem(mockedPaths) + // act + path, r, w, buf, err := a.translateAnalogPin(tc.id) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantPath, path) + assert.Equal(t, tc.wantReadable, r) + assert.False(t, w) + assert.Equal(t, tc.wantBufLen, buf) + }) + } +} + func Test_translatePWMPin(t *testing.T) { basePaths := []string{"/sys/devices/platform/soc/1c21400.pwm/pwm/"} tests := map[string]struct { diff --git a/platforms/nanopi/nanopineo_pin_map.go b/platforms/nanopi/nanopineo_pin_map.go index 366473d1..e055c1c2 100644 --- a/platforms/nanopi/nanopineo_pin_map.go +++ b/platforms/nanopi/nanopineo_pin_map.go @@ -23,3 +23,8 @@ var neoPwmPins = map[string]pwmPinDefinition{ // UART_RXD0, GPIOA5, PWM "PWM": {dir: "/sys/devices/platform/soc/1c21400.pwm/pwm/", dirRegexp: "pwmchip[0]$", channel: 0}, } + +var analogPinDefinitions = map[string]analogPinDefinition{ + // +/-273.200 °C need >=7 characters to read: +/-273200 millidegree Celsius + "thermal_zone0": {path: "/sys/class/thermal/thermal_zone0/temp", r: true, w: false, bufLen: 7}, +} diff --git a/platforms/raspi/raspi_adaptor.go b/platforms/raspi/raspi_adaptor.go index f87608c3..75e3fe5e 100644 --- a/platforms/raspi/raspi_adaptor.go +++ b/platforms/raspi/raspi_adaptor.go @@ -24,6 +24,13 @@ const ( defaultSpiMaxSpeed = 500000 ) +type analogPinDefinition struct { + path string + r bool // readable + w bool // writable + bufLen uint16 +} + // Adaptor is the Gobot Adaptor for the Raspberry Pi type Adaptor struct { name string @@ -31,6 +38,7 @@ type Adaptor struct { sys *system.Accesser revision string pwmPins map[string]gobot.PWMPinner + *adaptors.AnalogPinsAdaptor *adaptors.DigitalPinsAdaptor *adaptors.I2cBusAdaptor *adaptors.SpiBusAdaptor @@ -41,13 +49,13 @@ type Adaptor struct { // // Optional parameters: // -// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) -// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# -// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior -// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor -// adaptors.WithGpiosOpenDrain/Source(pin's): sets the output behavior -// adaptors.WithGpioDebounce(pin, period): sets the input debouncer -// adaptors.WithGpioEventOnFallingEdge/RaisingEdge/BothEdges(pin, handler): activate edge detection +// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) +// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# +// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior +// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor +// adaptors.WithGpiosOpenDrain/Source(pin's): sets the output behavior +// adaptors.WithGpioDebounce(pin, period): sets the input debouncer +// adaptors.WithGpioEventOnFallingEdge/RaisingEdge/BothEdges(pin, handler): activate edge detection func NewAdaptor(opts ...func(adaptors.Optioner)) *Adaptor { sys := system.NewAccesser(system.WithDigitalPinGpiodAccess()) c := &Adaptor{ @@ -55,6 +63,7 @@ func NewAdaptor(opts ...func(adaptors.Optioner)) *Adaptor { sys: sys, PiBlasterPeriod: 10000000, } + c.AnalogPinsAdaptor = adaptors.NewAnalogPinsAdaptor(sys, c.translateAnalogPin) c.DigitalPinsAdaptor = adaptors.NewDigitalPinsAdaptor(sys, c.getPinTranslatorFunction(), opts...) c.I2cBusAdaptor = adaptors.NewI2cBusAdaptor(sys, c.validateI2cBusNumber, 1) c.SpiBusAdaptor = adaptors.NewSpiBusAdaptor(sys, c.validateSpiBusNumber, defaultSpiBusNumber, defaultSpiChipNumber, @@ -91,6 +100,10 @@ func (c *Adaptor) Connect() error { return err } + if err := c.AnalogPinsAdaptor.Connect(); err != nil { + return err + } + c.pwmPins = make(map[string]gobot.PWMPinner) return c.DigitalPinsAdaptor.Connect() } @@ -111,6 +124,10 @@ func (c *Adaptor) Finalize() error { } c.pwmPins = nil + if e := c.AnalogPinsAdaptor.Finalize(); e != nil { + err = multierror.Append(err, e) + } + if e := c.I2cBusAdaptor.Finalize(); e != nil { err = multierror.Append(err, e) } @@ -184,6 +201,24 @@ func (c *Adaptor) validateI2cBusNumber(busNr int) error { return nil } +func (c *Adaptor) translateAnalogPin(id string) (string, bool, bool, uint16, error) { + pinInfo, ok := analogPinDefinitions[id] + if !ok { + return "", false, false, 0, fmt.Errorf("'%s' is not a valid id for a analog pin", id) + } + + path := pinInfo.path + info, err := c.sys.Stat(path) + if err != nil { + return "", false, false, 0, fmt.Errorf("Error (%v) on access '%s'", err, path) + } + if info.IsDir() { + return "", false, false, 0, fmt.Errorf("The item '%s' is a directory, which is not expected", path) + } + + return path, pinInfo.r, pinInfo.w, pinInfo.bufLen, nil +} + func (c *Adaptor) getPinTranslatorFunction() func(string) (string, int, error) { return func(pin string) (string, int, error) { var line int diff --git a/platforms/raspi/raspi_adaptor_test.go b/platforms/raspi/raspi_adaptor_test.go index 4fd330ec..c48f7ae4 100644 --- a/platforms/raspi/raspi_adaptor_test.go +++ b/platforms/raspi/raspi_adaptor_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "gobot.io/x/gobot/v2" + "gobot.io/x/gobot/v2/drivers/aio" "gobot.io/x/gobot/v2/drivers/gpio" "gobot.io/x/gobot/v2/drivers/i2c" "gobot.io/x/gobot/v2/drivers/spi" @@ -27,6 +28,7 @@ var ( _ gpio.DigitalWriter = (*Adaptor)(nil) _ gpio.PwmWriter = (*Adaptor)(nil) _ gpio.ServoWriter = (*Adaptor)(nil) + _ aio.AnalogReader = (*Adaptor)(nil) _ i2c.Connector = (*Adaptor)(nil) _ spi.Connector = (*Adaptor)(nil) ) @@ -108,6 +110,29 @@ func TestFinalize(t *testing.T) { require.NoError(t, a.Finalize()) } +func TestAnalog(t *testing.T) { + mockPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + } + + a, fs := initTestAdaptorWithMockedFilesystem(mockPaths) + + fs.Files["/sys/class/thermal/thermal_zone0/temp"].Contents = "567\n" + got, err := a.AnalogRead("thermal_zone0") + require.NoError(t, err) + assert.Equal(t, 567, got) + + _, err = a.AnalogRead("thermal_zone10") + require.ErrorContains(t, err, "'thermal_zone10' is not a valid id for a analog pin") + + fs.WithReadError = true + _, err = a.AnalogRead("thermal_zone0") + require.ErrorContains(t, err, "read error") + fs.WithReadError = false + + require.NoError(t, a.Finalize()) +} + func TestDigitalPWM(t *testing.T) { mockedPaths := []string{"/dev/pi-blaster"} a, fs := initTestAdaptorWithMockedFilesystem(mockedPaths) @@ -339,3 +364,46 @@ func Test_validateI2cBusNumber(t *testing.T) { }) } } + +func Test_translateAnalogPin(t *testing.T) { + mockedPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + "/sys/class/thermal/thermal_zone1/temp", + } + tests := map[string]struct { + id string + wantPath string + wantReadable bool + wantBufLen uint16 + wantErr string + }{ + "translate_thermal_zone0": { + id: "thermal_zone0", + wantPath: "/sys/class/thermal/thermal_zone0/temp", + wantReadable: true, + wantBufLen: 7, + }, + "unknown_id": { + id: "thermal_zone1", + wantErr: "'thermal_zone1' is not a valid id for a analog pin", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a, _ := initTestAdaptorWithMockedFilesystem(mockedPaths) + // act + path, r, w, buf, err := a.translateAnalogPin(tc.id) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantPath, path) + assert.Equal(t, tc.wantReadable, r) + assert.False(t, w) + assert.Equal(t, tc.wantBufLen, buf) + }) + } +} diff --git a/platforms/raspi/raspi_pin_map.go b/platforms/raspi/raspi_pin_map.go index 775b6242..6fdda483 100644 --- a/platforms/raspi/raspi_pin_map.go +++ b/platforms/raspi/raspi_pin_map.go @@ -86,3 +86,8 @@ var pins = map[string]map[string]int{ "3": 21, }, } + +var analogPinDefinitions = map[string]analogPinDefinition{ + // +/-273.200 °C need >=7 characters to read: +/-273200 millidegree Celsius + "thermal_zone0": {path: "/sys/class/thermal/thermal_zone0/temp", r: true, w: false, bufLen: 7}, +} diff --git a/platforms/tinkerboard/adaptor.go b/platforms/tinkerboard/adaptor.go index 63f8a451..f63ce80c 100644 --- a/platforms/tinkerboard/adaptor.go +++ b/platforms/tinkerboard/adaptor.go @@ -23,10 +23,27 @@ const ( defaultSpiMaxSpeed = 500000 ) +type cdevPin struct { + chip uint8 + line uint8 +} + +type gpioPinDefinition struct { + sysfs int + cdev cdevPin +} + +type analogPinDefinition struct { + path string + r bool // readable + w bool // writable + bufLen uint16 +} + type pwmPinDefinition struct { - channel int dir string dirRegexp string + channel int } // Adaptor represents a Gobot Adaptor for the ASUS Tinker Board @@ -34,6 +51,7 @@ type Adaptor struct { name string sys *system.Accesser mutex sync.Mutex + *adaptors.AnalogPinsAdaptor *adaptors.DigitalPinsAdaptor *adaptors.PWMPinsAdaptor *adaptors.I2cBusAdaptor @@ -44,10 +62,10 @@ type Adaptor struct { // // Optional parameters: // -// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) -// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# -// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior -// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor +// adaptors.WithGpiodAccess(): use character device gpiod driver instead of sysfs (still used by default) +// adaptors.WithSpiGpioAccess(sclk, nss, mosi, miso): use GPIO's instead of /dev/spidev#.# +// adaptors.WithGpiosActiveLow(pin's): invert the pin behavior +// adaptors.WithGpiosPullUp/Down(pin's): sets the internal pull resistor // // note from RK3288 datasheet: "The pull direction (pullup or pulldown) for all of GPIOs are software-programmable", but // the latter is not working for any pin (armbian 22.08.7) @@ -57,6 +75,7 @@ func NewAdaptor(opts ...func(adaptors.Optioner)) *Adaptor { name: gobot.DefaultName("Tinker Board"), sys: sys, } + c.AnalogPinsAdaptor = adaptors.NewAnalogPinsAdaptor(sys, c.translateAnalogPin) c.DigitalPinsAdaptor = adaptors.NewDigitalPinsAdaptor(sys, c.translateDigitalPin, opts...) c.PWMPinsAdaptor = adaptors.NewPWMPinsAdaptor(sys, c.translatePWMPin, adaptors.WithPolarityInvertedIdentifier(pwmInvertedIdentifier)) @@ -85,6 +104,10 @@ func (c *Adaptor) Connect() error { return err } + if err := c.AnalogPinsAdaptor.Connect(); err != nil { + return err + } + if err := c.PWMPinsAdaptor.Connect(); err != nil { return err } @@ -102,6 +125,10 @@ func (c *Adaptor) Finalize() error { err = multierror.Append(err, e) } + if e := c.AnalogPinsAdaptor.Finalize(); e != nil { + err = multierror.Append(err, e) + } + if e := c.I2cBusAdaptor.Finalize(); e != nil { err = multierror.Append(err, e) } @@ -130,6 +157,24 @@ func (c *Adaptor) validateI2cBusNumber(busNr int) error { return nil } +func (c *Adaptor) translateAnalogPin(id string) (string, bool, bool, uint16, error) { + pinInfo, ok := analogPinDefinitions[id] + if !ok { + return "", false, false, 0, fmt.Errorf("'%s' is not a valid id for a analog pin", id) + } + + path := pinInfo.path + info, err := c.sys.Stat(path) + if err != nil { + return "", false, false, 0, fmt.Errorf("Error (%v) on access '%s'", err, path) + } + if info.IsDir() { + return "", false, false, 0, fmt.Errorf("The item '%s' is a directory, which is not expected", path) + } + + return path, pinInfo.r, pinInfo.w, pinInfo.bufLen, nil +} + func (c *Adaptor) translateDigitalPin(id string) (string, int, error) { pindef, ok := gpioPinDefinitions[id] if !ok { diff --git a/platforms/tinkerboard/adaptor_test.go b/platforms/tinkerboard/adaptor_test.go index f00374fd..25d1ab77 100644 --- a/platforms/tinkerboard/adaptor_test.go +++ b/platforms/tinkerboard/adaptor_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "gobot.io/x/gobot/v2" + "gobot.io/x/gobot/v2/drivers/aio" "gobot.io/x/gobot/v2/drivers/gpio" "gobot.io/x/gobot/v2/drivers/i2c" "gobot.io/x/gobot/v2/system" @@ -58,6 +59,7 @@ var ( _ gpio.DigitalWriter = (*Adaptor)(nil) _ gpio.PwmWriter = (*Adaptor)(nil) _ gpio.ServoWriter = (*Adaptor)(nil) + _ aio.AnalogReader = (*Adaptor)(nil) _ i2c.Connector = (*Adaptor)(nil) ) @@ -99,6 +101,29 @@ func TestDigitalIO(t *testing.T) { require.NoError(t, a.Finalize()) } +func TestAnalog(t *testing.T) { + mockPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + } + + a, fs := initTestAdaptorWithMockedFilesystem(mockPaths) + + fs.Files["/sys/class/thermal/thermal_zone0/temp"].Contents = "567\n" + got, err := a.AnalogRead("thermal_zone0") + require.NoError(t, err) + assert.Equal(t, 567, got) + + _, err = a.AnalogRead("thermal_zone10") + require.ErrorContains(t, err, "'thermal_zone10' is not a valid id for a analog pin") + + fs.WithReadError = true + _, err = a.AnalogRead("thermal_zone0") + require.ErrorContains(t, err, "read error") + fs.WithReadError = false + + require.NoError(t, a.Finalize()) +} + func TestInvalidPWMPin(t *testing.T) { a, fs := initTestAdaptorWithMockedFilesystem(pwmMockPaths) preparePwmFs(fs) @@ -355,6 +380,55 @@ func Test_translateDigitalPin(t *testing.T) { } } +func Test_translateAnalogPin(t *testing.T) { + mockedPaths := []string{ + "/sys/class/thermal/thermal_zone0/temp", + "/sys/class/thermal/thermal_zone1/temp", + } + tests := map[string]struct { + id string + wantPath string + wantReadable bool + wantBufLen uint16 + wantErr string + }{ + "translate_thermal_zone0": { + id: "thermal_zone0", + wantPath: "/sys/class/thermal/thermal_zone0/temp", + wantReadable: true, + wantBufLen: 7, + }, + "translate_thermal_zone1": { + id: "thermal_zone1", + wantPath: "/sys/class/thermal/thermal_zone1/temp", + wantReadable: true, + wantBufLen: 7, + }, + "unknown_id": { + id: "99", + wantErr: "'99' is not a valid id for a analog pin", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // arrange + a, _ := initTestAdaptorWithMockedFilesystem(mockedPaths) + // act + path, r, w, buf, err := a.translateAnalogPin(tc.id) + // assert + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantPath, path) + assert.Equal(t, tc.wantReadable, r) + assert.False(t, w) + assert.Equal(t, tc.wantBufLen, buf) + }) + } +} + func Test_translatePWMPin(t *testing.T) { basePaths := []string{ "/sys/devices/platform/ff680020.pwm/pwm/", diff --git a/platforms/tinkerboard/pin_map.go b/platforms/tinkerboard/pin_map.go index aa396d9c..40feeaca 100644 --- a/platforms/tinkerboard/pin_map.go +++ b/platforms/tinkerboard/pin_map.go @@ -1,15 +1,5 @@ package tinkerboard -type cdevPin struct { - chip uint8 - line uint8 -} - -type gpioPinDefinition struct { - sysfs int - cdev cdevPin -} - // notes for character device // pins: A=0+Nr, B=8+Nr, C=16+Nr // tested: armbian Linux, OK: work as input and output, IN: work only as input @@ -50,3 +40,9 @@ var pwmPinDefinitions = map[string]pwmPinDefinition{ // GPIO7_C7_UART2TX_PWM3 "32": {dir: "/sys/devices/platform/ff680030.pwm/pwm/", dirRegexp: "pwmchip[0|1|2|3]$", channel: 0}, } + +var analogPinDefinitions = map[string]analogPinDefinition{ + // +/-273.200 °C need >=7 characters to read: +/-273200 millidegree Celsius + "thermal_zone0": {path: "/sys/class/thermal/thermal_zone0/temp", r: true, w: false, bufLen: 7}, + "thermal_zone1": {path: "/sys/class/thermal/thermal_zone1/temp", r: true, w: false, bufLen: 7}, +}