feat(inputs.modbus): Allow to convert coil and discrete registers to boolean (#12825)

This commit is contained in:
Sven Rebhan 2023-03-13 12:18:02 +01:00 committed by GitHub
parent 1eb70808d0
commit 2006086262
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 274 additions and 57 deletions

View File

@ -76,6 +76,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
## Digital Variables, Discrete Inputs and Coils
## measurement - the (optional) measurement name, defaults to "modbus"
## name - the variable name
## data_type - the (optional) output type, can be BOOL or UINT16 (default)
## address - variable address
discrete_inputs = [
@ -178,23 +179,24 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1,2 - (optional) factor to scale the variable with
## output *1,2 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if
## output *1,3 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc).
## measurement *1 - (optional) measurement name, defaults to the setting of the request
## omit - (optional) omit this field. Useful to leave out single values when querying many registers
## with a single request. Defaults to "false".
##
## *1: Those fields are ignored if field is omitted ("omit"=true)
##
## *2: Thise fields are ignored for both "coil" and "discrete"-input type of registers. For those register types
## the fields are output as zero or one in UINT64 format by default.
## *1: These fields are ignored if field is omitted ("omit"=true)
## *2: These fields are ignored for both "coil" and "discrete"-input type of registers.
## *3: This field can only be "UINT16" or "BOOL" if specified for both "coil"
## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used.
## Coil / discrete input example
fields = [
{ address=0, name="motor1_run"},
{ address=1, name="jog", measurement="motor"},
{ address=2, name="motor1_stop", omit=true},
{ address=3, name="motor1_overheating"},
{ address=3, name="motor1_overheating", output="BOOL"},
]
[inputs.modbus.request.tags]
@ -320,6 +322,11 @@ floating-point-number. The size of the output type is assumed to be large enough
for all supported input types. The mapping from the input type to the output
type is fixed and cannot be configured.
##### Booleans: `BOOL`
This type is only valid for _coil_ and _discrete_ registers. The value will be
`true` if the register has a non-zero (ON) value and `false` otherwise.
##### Integers: `INT8L`, `INT8H`, `UINT8L`, `UINT8H`
These types are used for 8-bit integer values. Select the one that matches your
@ -329,7 +336,7 @@ the register respectively.
##### Integers: `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64`, `UINT64`
These types are used for integer input values. Select the one that matches your
modbus data source.
modbus data source. For _coil_ and _discrete_ registers only `UINT16` is valid.
##### Floating Point: `FLOAT16-IEEE`, `FLOAT32-IEEE`, `FLOAT64-IEEE`
@ -512,10 +519,11 @@ non-zero value, the output type is `FLOAT64`. Otherwise, the output type
corresponds to the register datatype _class_, i.e. `INT*` will result in
`INT64`, `UINT*` in `UINT64` and `FLOAT*` in `FLOAT64`.
This setting is ignored if the field's `omit` is set to `true` or if the
`register` type is a bit-type (`coil` or `discrete`) and can be omitted in these
cases. For `coil` and `discrete` registers the field-value is output as zero or
one in `UINT16` format.
This setting is ignored if the field's `omit` is set to `true` and can be
omitted. In case the `register` type is a bit-type (`coil` or `discrete`) only
`UINT16` or `BOOL` are valid with the former being the default if omitted.
For `coil` and `discrete` registers the field-value is output as zero or one in
`UINT16` format or as `true` and `false` in `BOOL` format.
#### per-field measurement setting

View File

@ -51,7 +51,7 @@ func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
if !c.workarounds.OnRequestPerField {
maxQuantity = maxQuantityCoils
}
coil, err := c.initRequests(c.Coils, maxQuantity)
coil, err := c.initRequests(c.Coils, maxQuantity, false)
if err != nil {
return nil, err
}
@ -59,7 +59,7 @@ func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
if !c.workarounds.OnRequestPerField {
maxQuantity = maxQuantityDiscreteInput
}
discrete, err := c.initRequests(c.DiscreteInputs, maxQuantity)
discrete, err := c.initRequests(c.DiscreteInputs, maxQuantity, false)
if err != nil {
return nil, err
}
@ -67,7 +67,7 @@ func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
if !c.workarounds.OnRequestPerField {
maxQuantity = maxQuantityHoldingRegisters
}
holding, err := c.initRequests(c.HoldingRegisters, maxQuantity)
holding, err := c.initRequests(c.HoldingRegisters, maxQuantity, true)
if err != nil {
return nil, err
}
@ -75,7 +75,7 @@ func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
if !c.workarounds.OnRequestPerField {
maxQuantity = maxQuantityInputRegisters
}
input, err := c.initRequests(c.InputRegisters, maxQuantity)
input, err := c.initRequests(c.InputRegisters, maxQuantity, true)
if err != nil {
return nil, err
}
@ -90,8 +90,8 @@ func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
}, nil
}
func (c *ConfigurationOriginal) initRequests(fieldDefs []fieldDefinition, maxQuantity uint16) ([]request, error) {
fields, err := c.initFields(fieldDefs)
func (c *ConfigurationOriginal) initRequests(fieldDefs []fieldDefinition, maxQuantity uint16, typed bool) ([]request, error) {
fields, err := c.initFields(fieldDefs, typed)
if err != nil {
return nil, err
}
@ -104,11 +104,11 @@ func (c *ConfigurationOriginal) initRequests(fieldDefs []fieldDefinition, maxQua
return groupFieldsToRequests(fields, params), nil
}
func (c *ConfigurationOriginal) initFields(fieldDefs []fieldDefinition) ([]field, error) {
func (c *ConfigurationOriginal) initFields(fieldDefs []fieldDefinition, typed bool) ([]field, error) {
// Construct the fields from the field definitions
fields := make([]field, 0, len(fieldDefs))
for _, def := range fieldDefs {
f, err := c.newFieldFromDefinition(def)
f, err := c.newFieldFromDefinition(def, typed)
if err != nil {
return nil, fmt.Errorf("initializing field %q failed: %w", def.Name, err)
}
@ -118,7 +118,7 @@ func (c *ConfigurationOriginal) initFields(fieldDefs []fieldDefinition) ([]field
return fields, nil
}
func (c *ConfigurationOriginal) newFieldFromDefinition(def fieldDefinition) (field, error) {
func (c *ConfigurationOriginal) newFieldFromDefinition(def fieldDefinition, typed bool) (field, error) {
// Check if the addresses are consecutive
expected := def.Address[0]
for _, current := range def.Address[1:] {
@ -135,6 +135,17 @@ func (c *ConfigurationOriginal) newFieldFromDefinition(def fieldDefinition) (fie
address: def.Address[0],
length: uint16(len(def.Address)),
}
// Handle coil and discrete registers which do have a limited datatype set
if !typed {
var err error
f.converter, err = determineUntypedConverter(def.DataType)
if err != nil {
return field{}, err
}
return f, nil
}
if def.DataType != "" {
inType, err := c.normalizeInputDatatype(def.DataType, len(def.Address))
if err != nil {
@ -194,6 +205,13 @@ func (c *ConfigurationOriginal) validateFieldDefinitions(fieldDefs []fieldDefini
if item.Scale == 0.0 {
return fmt.Errorf("invalid scale '%f' in %q - %q", item.Scale, registerType, item.Name)
}
} else {
// Bit-registers do have less data types
switch item.DataType {
case "", "UINT16", "BOOL":
default:
return fmt.Errorf("invalid data type %q in %q - %q", item.DataType, registerType, item.Name)
}
}
// check address

View File

@ -102,9 +102,12 @@ func (c *ConfigurationPerRequest) Check() error {
for fidx, f := range def.Fields {
// Check the input type for all fields except the bit-field ones.
// We later need the type (even for omitted fields) to determine the length.
if def.RegisterType == cHoldingRegisters || def.RegisterType == cInputRegisters {
if def.RegisterType == "holding" || def.RegisterType == "input" {
switch f.InputType {
case "INT16", "UINT16", "INT32", "UINT32", "INT64", "UINT64", "FLOAT32", "FLOAT64":
case "":
case "INT8L", "INT8H", "INT16", "INT32", "INT64":
case "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64":
case "FLOAT16", "FLOAT32", "FLOAT64":
default:
return fmt.Errorf("unknown register data-type %q for field %q", f.InputType, f.Name)
}
@ -120,14 +123,20 @@ func (c *ConfigurationPerRequest) Check() error {
return fmt.Errorf("empty field name in request for slave %d", def.SlaveID)
}
// Check fields only relevant for non-bit register types
if def.RegisterType == cHoldingRegisters || def.RegisterType == cInputRegisters {
// Check output type
// Check output type
if def.RegisterType == "holding" || def.RegisterType == "input" {
switch f.OutputType {
case "", "INT64", "UINT64", "FLOAT64":
default:
return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name)
}
} else {
// Bit register types can only be UINT64 or BOOL
switch f.OutputType {
case "", "UINT16", "BOOL":
default:
return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name)
}
}
// Handle the default for measurement
@ -257,6 +266,14 @@ func (c *ConfigurationPerRequest) newFieldFromDefinition(def requestFieldDefinit
omit: def.Omit,
}
// Handle type conversions for coil and discrete registers
if !typed {
f.converter, err = determineUntypedConverter(def.OutputType)
if err != nil {
return field{}, err
}
}
// No more processing for un-typed (coil and discrete registers) or omitted fields
if !typed || def.Omit {
return f, nil

View File

@ -406,8 +406,9 @@ func (m *Modbus) gatherRequestsCoil(requests []request) error {
idx := offset / 8
bit := offset % 8
request.fields[i].value = uint16((bytes[idx] >> bit) & 0x01)
m.Log.Debugf(" field %s with bit %d @ byte %d: %v --> %v", field.name, bit, idx, (bytes[idx]>>bit)&0x01, request.fields[i].value)
v := (bytes[idx] >> bit) & 0x01
request.fields[i].value = field.converter([]byte{v})
m.Log.Debugf(" field %s with bit %d @ byte %d: %v --> %v", field.name, bit, idx, v, request.fields[i].value)
}
// Some (serial) devices require a pause between requests...
@ -432,8 +433,9 @@ func (m *Modbus) gatherRequestsDiscrete(requests []request) error {
idx := offset / 8
bit := offset % 8
request.fields[i].value = uint16((bytes[idx] >> bit) & 0x01)
m.Log.Debugf(" field %s with bit %d @ byte %d: %v --> %v", field.name, bit, idx, (bytes[idx]>>bit)&0x01, request.fields[i].value)
v := (bytes[idx] >> bit) & 0x01
request.fields[i].value = field.converter([]byte{v})
m.Log.Debugf(" field %s with bit %d @ byte %d: %v --> %v", field.name, bit, idx, v, request.fields[i].value)
}
// Some (serial) devices require a pause between requests...

View File

@ -21,6 +21,11 @@ import (
"github.com/influxdata/telegraf/testutil"
)
func TestMain(m *testing.M) {
telegraf.Debug = false
os.Exit(m.Run())
}
func TestControllers(t *testing.T) {
var tests = []struct {
name string
@ -149,65 +154,96 @@ func TestCoils(t *testing.T) {
var coilTests = []struct {
name string
address uint16
dtype string
quantity uint16
write []byte
read uint16
read interface{}
}{
{
name: "coil0_turn_off",
address: 0,
quantity: 1,
write: []byte{0x00},
read: 0,
read: uint16(0),
},
{
name: "coil0_turn_on",
address: 0,
quantity: 1,
write: []byte{0x01},
read: 1,
read: uint16(1),
},
{
name: "coil1_turn_on",
address: 1,
quantity: 1,
write: []byte{0x01},
read: 1,
read: uint16(1),
},
{
name: "coil2_turn_on",
address: 2,
quantity: 1,
write: []byte{0x01},
read: 1,
read: uint16(1),
},
{
name: "coil3_turn_on",
address: 3,
quantity: 1,
write: []byte{0x01},
read: 1,
read: uint16(1),
},
{
name: "coil1_turn_off",
address: 1,
quantity: 1,
write: []byte{0x00},
read: 0,
read: uint16(0),
},
{
name: "coil2_turn_off",
address: 2,
quantity: 1,
write: []byte{0x00},
read: 0,
read: uint16(0),
},
{
name: "coil3_turn_off",
address: 3,
quantity: 1,
write: []byte{0x00},
read: 0,
read: uint16(0),
},
{
name: "coil4_turn_off",
address: 4,
quantity: 1,
write: []byte{0x00},
read: uint16(0),
},
{
name: "coil4_turn_on",
address: 4,
quantity: 1,
write: []byte{0x01},
read: uint16(1),
},
{
name: "coil4_turn_off_bool",
address: 4,
quantity: 1,
dtype: "BOOL",
write: []byte{0x00},
read: false,
},
{
name: "coil4_turn_on_bool",
address: 4,
quantity: 1,
dtype: "BOOL",
write: []byte{0x01},
read: true,
},
}
@ -233,8 +269,9 @@ func TestCoils(t *testing.T) {
modbus.SlaveID = 1
modbus.Coils = []fieldDefinition{
{
Name: ct.name,
Address: []uint16{ct.address},
Name: ct.name,
Address: []uint16{ct.address},
DataType: ct.dtype,
},
}
@ -262,6 +299,101 @@ func TestCoils(t *testing.T) {
}
}
func TestRequestTypesCoil(t *testing.T) {
tests := []struct {
name string
address uint16
dataTypeOut string
write uint16
read interface{}
}{
{
name: "coil-1-off",
address: 1,
write: 0,
read: uint16(0),
},
{
name: "coil-2-on",
address: 2,
write: 0xFF00,
read: uint16(1),
},
{
name: "coil-3-false",
address: 3,
dataTypeOut: "BOOL",
write: 0,
read: false,
},
{
name: "coil-4-true",
address: 4,
dataTypeOut: "BOOL",
write: 0xFF00,
read: true,
},
}
serv := mbserver.NewServer()
require.NoError(t, serv.ListenTCP("localhost:1502"))
defer serv.Close()
handler := mb.NewTCPClientHandler("localhost:1502")
require.NoError(t, handler.Connect())
defer handler.Close()
client := mb.NewClient(handler)
for _, hrt := range tests {
t.Run(hrt.name, func(t *testing.T) {
_, err := client.WriteSingleCoil(hrt.address, hrt.write)
require.NoError(t, err)
modbus := Modbus{
Name: "TestRequestTypesCoil",
Controller: "tcp://localhost:1502",
ConfigurationType: "request",
Log: testutil.Logger{},
}
modbus.Requests = []requestDefinition{
{
SlaveID: 1,
ByteOrder: "ABCD",
RegisterType: "coil",
Fields: []requestFieldDefinition{
{
Name: hrt.name,
OutputType: hrt.dataTypeOut,
Address: hrt.address,
},
},
},
}
expected := []telegraf.Metric{
testutil.MustMetric(
"modbus",
map[string]string{
"type": cCoils,
"slave_id": "1",
"name": modbus.Name,
},
map[string]interface{}{hrt.name: hrt.read},
time.Unix(0, 0),
),
}
var acc testutil.Accumulator
require.NoError(t, modbus.Init())
require.NotEmpty(t, modbus.requests)
require.NoError(t, modbus.Gather(&acc))
acc.Wait(len(expected))
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime())
})
}
}
func TestHoldingRegisters(t *testing.T) {
var holdingRegisterTests = []struct {
name string
@ -2651,7 +2783,15 @@ func TestConfigurationPerRequest(t *testing.T) {
Address: uint16(2),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
{
Name: "coil-3",
Address: uint16(3),
InputType: "INT64",
Scale: 1.2,
OutputType: "BOOL",
Measurement: "modbus",
},
},
@ -2661,20 +2801,28 @@ func TestConfigurationPerRequest(t *testing.T) {
RegisterType: "coil",
Fields: []requestFieldDefinition{
{
Name: "coil-3",
Name: "coil-4",
Address: uint16(6),
},
{
Name: "coil-4",
Name: "coil-5",
Address: uint16(7),
Omit: true,
},
{
Name: "coil-5",
Name: "coil-6",
Address: uint16(8),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
{
Name: "coil-7",
Address: uint16(9),
InputType: "INT64",
Scale: 1.2,
OutputType: "BOOL",
Measurement: "modbus",
},
},
@ -2698,7 +2846,15 @@ func TestConfigurationPerRequest(t *testing.T) {
Address: uint16(2),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
{
Name: "discrete-3",
Address: uint16(3),
InputType: "INT64",
Scale: 1.2,
OutputType: "BOOL",
Measurement: "modbus",
},
},
@ -2793,7 +2949,7 @@ func TestConfigurationPerRequestWithTags(t *testing.T) {
Address: uint16(2),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
},
@ -2821,7 +2977,7 @@ func TestConfigurationPerRequestWithTags(t *testing.T) {
Address: uint16(8),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
},
@ -2850,7 +3006,7 @@ func TestConfigurationPerRequestWithTags(t *testing.T) {
Address: uint16(2),
InputType: "INT64",
Scale: 1.2,
OutputType: "FLOAT64",
OutputType: "UINT16",
Measurement: "modbus",
},
},
@ -3151,7 +3307,7 @@ func TestConfigurationPerRequestFail(t *testing.T) {
},
},
},
errormsg: "cannot process configuration: initializing field \"holding-0\" failed: unknown output type \"UINT8\"",
errormsg: `configuration invalid: unknown output data-type "UINT8" for field "holding-0"`,
},
{
name: "duplicate fields (holding)",
@ -3263,7 +3419,7 @@ func TestConfigurationPerRequestFail(t *testing.T) {
},
},
},
errormsg: "cannot process configuration: initializing field \"input-0\" failed: unknown output type \"UINT8\"",
errormsg: `configuration invalid: unknown output data-type "UINT8" for field "input-0"`,
},
{
name: "duplicate fields (input)",

View File

@ -6,6 +6,7 @@
## Digital Variables, Discrete Inputs and Coils
## measurement - the (optional) measurement name, defaults to "modbus"
## name - the variable name
## data_type - the (optional) output type, can be BOOL or UINT16 (default)
## address - variable address
discrete_inputs = [

View File

@ -57,23 +57,24 @@
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1,2 - (optional) factor to scale the variable with
## output *1,2 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if
## output *1,3 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc).
## measurement *1 - (optional) measurement name, defaults to the setting of the request
## omit - (optional) omit this field. Useful to leave out single values when querying many registers
## with a single request. Defaults to "false".
##
## *1: Those fields are ignored if field is omitted ("omit"=true)
##
## *2: Thise fields are ignored for both "coil" and "discrete"-input type of registers. For those register types
## the fields are output as zero or one in UINT64 format by default.
## *1: These fields are ignored if field is omitted ("omit"=true)
## *2: These fields are ignored for both "coil" and "discrete"-input type of registers.
## *3: This field can only be "UINT16" or "BOOL" if specified for both "coil"
## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used.
## Coil / discrete input example
fields = [
{ address=0, name="motor1_run"},
{ address=1, name="jog", measurement="motor"},
{ address=2, name="motor1_stop", omit=true},
{ address=3, name="motor1_overheating"},
{ address=3, name="motor1_overheating", output="BOOL"},
]
[inputs.modbus.request.tags]

View File

@ -2,6 +2,20 @@ package modbus
import "fmt"
func determineUntypedConverter(outType string) (fieldConverterFunc, error) {
switch outType {
case "", "UINT16":
return func(b []byte) interface{} {
return uint16(b[0])
}, nil
case "BOOL":
return func(b []byte) interface{} {
return b[0] != 0
}, nil
}
return nil, fmt.Errorf("invalid output data-type: %s", outType)
}
func determineConverter(inType, byteOrder, outType string, scale float64) (fieldConverterFunc, error) {
if scale != 0.0 {
return determineConverterScale(inType, byteOrder, outType, scale)