Skip to content

Commit

Permalink
Modbus refactor (#9141)
Browse files Browse the repository at this point in the history
  • Loading branch information
srebhan authored May 27, 2021
1 parent 58479fd commit 2e7b232
Show file tree
Hide file tree
Showing 13 changed files with 1,555 additions and 678 deletions.
4 changes: 2 additions & 2 deletions docs/LICENSE_OF_DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ following works:
- github.com/go-redis/redis [BSD 2-Clause "Simplified" License](https://github.com/go-redis/redis/blob/master/LICENSE)
- github.com/go-sql-driver/mysql [Mozilla Public License 2.0](https://github.com/go-sql-driver/mysql/blob/master/LICENSE)
- github.com/go-stack/stack [MIT License](https://github.com/go-stack/stack/blob/master/LICENSE.md)
- github.com/goburrow/modbus [BSD 3-Clause "New" or "Revised" License](https://github.com/goburrow/modbus/blob/master/LICENSE)
- github.com/goburrow/serial [MIT License](https://github.com/goburrow/serial/LICENSE)
- github.com/gobwas/glob [MIT License](https://github.com/gobwas/glob/blob/master/LICENSE)
- github.com/gofrs/uuid [MIT License](https://github.com/gofrs/uuid/blob/master/LICENSE)
- github.com/gogo/googleapis [Apache License 2.0](https://github.com/gogo/googleapis/blob/master/LICENSE)
Expand All @@ -92,6 +90,8 @@ following works:
- github.com/gorilla/mux [BSD 3-Clause "New" or "Revised" License](https://github.com/gorilla/mux/blob/master/LICENSE)
- github.com/gorilla/websocket [BSD 2-Clause "Simplified" License](https://github.com/gorilla/websocket/blob/master/LICENSE)
- github.com/gosnmp/gosnmp [BSD 2-Clause "Simplified" License](https://github.com/gosnmp/gosnmp/blob/master/LICENSE)
- github.com/grid-x/modbus [BSD 3-Clause "New" or "Revised" License](https://github.com/grid-x/modbus/blob/master/LICENSE)
- github.com/grid-x/serial [MIT License](https://github.com/grid-x/serial/blob/master/LICENSE)
- github.com/grpc-ecosystem/grpc-gateway [BSD 3-Clause "New" or "Revised" License](https://github.com/grpc-ecosystem/grpc-gateway/blob/master/LICENSE.txt)
- github.com/hailocab/go-hostpool [MIT License](https://github.com/hailocab/go-hostpool/blob/master/LICENSE)
- github.com/harlow/kinesis-consumer [MIT License](https://github.com/harlow/kinesis-consumer/blob/master/MIT-LICENSE)
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ require (
github.com/go-ping/ping v0.0.0-20210201095549-52eed920f98c
github.com/go-redis/redis v6.15.9+incompatible
github.com/go-sql-driver/mysql v1.5.0
github.com/goburrow/modbus v0.1.0
github.com/goburrow/modbus v0.1.0 // indirect
github.com/goburrow/serial v0.1.0 // indirect
github.com/gobwas/glob v0.2.3
github.com/gofrs/uuid v3.3.0+incompatible
Expand All @@ -66,6 +66,7 @@ require (
github.com/gopcua/opcua v0.1.13
github.com/gorilla/mux v1.7.3
github.com/gosnmp/gosnmp v1.32.0
github.com/grid-x/modbus v0.0.0-20210224155242-c4a3d042e99b
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/harlow/kinesis-consumer v0.3.1-0.20181230152818-2f58b136fee0
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/gosnmp/gosnmp v1.32.0 h1:gctewmZx5qFI0oHMzRnjETqIZ093d9NgZy9TQr3V0iA=
github.com/gosnmp/gosnmp v1.32.0/go.mod h1:EIp+qkEpXoVsyZxXKy0AmXQx0mCHMMcIhXXvNDMpgF0=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grid-x/modbus v0.0.0-20210224155242-c4a3d042e99b h1:Y4xqzO0CDNoehCr3ncgie3IgFTO9AzV8PMMEWESFM5c=
github.com/grid-x/modbus v0.0.0-20210224155242-c4a3d042e99b/go.mod h1:YaK0rKJenZ74vZFcSSLlAQqtG74PMI68eDjpDCDDmTw=
github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08 h1:syBxnRYnSPUDdkdo5U4sy2roxBPQDjNiw4od7xlsABQ=
github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08/go.mod h1:kdOd86/VGFWRrtkNwf1MPk0u1gIjc4Y7R2j7nhwc7Rk=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
Expand Down
18 changes: 16 additions & 2 deletions plugins/inputs/modbus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Metric are custom and configured using the `discrete_inputs`, `coils`,

The field `data_type` defines the representation of the data value on input from the modbus registers.
The input values are then converted from the given `data_type` to a type that is apropriate when
sending the value to the output plugin. These output types are usually one of string,
sending the value to the output plugin. These output types are usually one of string,
integer or 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.
Expand All @@ -114,7 +114,7 @@ always include the sign and therefore there exists no variant.

These types are handled as an integer type on input, but are converted to floating point representation
for further processing (e.g. scaling). Use one of these types when the input value is a decimal fixed point
representation of a non-integer value.
representation of a non-integer value.

Select the type `UFIXED` when the input type is declared to hold unsigned integer values, which cannot
be negative. The documentation of your modbus device should indicate this by a term like
Expand All @@ -127,6 +127,20 @@ with N decimal places'.
(FLOAT32 is deprecated and should not be used any more. UFIXED provides the same conversion
from unsigned values).

### Trouble shooting
Modbus documentations are often a mess. People confuse memory-address (starts at one) and register address (starts at zero) or stay unclear about the used word-order. Furthermore, there are some non-standard implementations that also
swap the bytes within the register word (16-bit).

If you get an error or don't get the expected values from your device, you can try the following steps (assuming a 32-bit value).

In case are using a serial device and get an `permission denied` error, please check the permissions of your serial device and change accordingly.

In case you get an `exception '2' (illegal data address)` error you might try to offset your `address` entries by minus one as it is very likely that there is a confusion between memory and register addresses.

In case you see strange values, the `byte_order` might be off. You can either probe all combinations (`ABCD`, `CDBA`, `BADC` or `DCBA`) or you set `byte_order="ABCD" data_type="UINT32"` and use the resulting value(s) in an online converter like [this](https://www.scadacore.com/tools/programming-calculators/online-hex-converter/). This makes especially sense if you don't want to mess with the device, deal with 64-bit values and/or don't know the `data_type` of your register (e.g. fix-point floating values vs. IEEE floating point).

If nothing helps, please post your configuration, error message and/or the output of `byte_order="ABCD" data_type="UINT32"` to one of the telegraf support channels (forum, slack or as issue).

### Example Output

```sh
Expand Down
61 changes: 61 additions & 0 deletions plugins/inputs/modbus/configuration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package modbus

import "fmt"

const (
maxQuantityDiscreteInput = uint16(2000)
maxQuantityCoils = uint16(2000)
maxQuantityInputRegisters = uint16(125)
maxQuantityHoldingRegisters = uint16(125)
)

type Configuration interface {
Check() error
Process() (map[byte]requestSet, error)
}

func removeDuplicates(elements []uint16) []uint16 {
encountered := map[uint16]bool{}
result := []uint16{}

for _, addr := range elements {
if !encountered[addr] {
encountered[addr] = true
result = append(result, addr)
}
}

return result
}

func normalizeInputDatatype(dataType string) (string, error) {
switch dataType {
case "INT16", "UINT16", "INT32", "UINT32", "INT64", "UINT64", "FLOAT32", "FLOAT64":
return dataType, nil
}
return "unknown", fmt.Errorf("unknown type %q", dataType)
}

func normalizeOutputDatatype(dataType string) (string, error) {
switch dataType {
case "", "native":
return "native", nil
case "INT64", "UINT64", "FLOAT64":
return dataType, nil
}
return "unknown", fmt.Errorf("unknown type %q", dataType)
}

func normalizeByteOrder(byteOrder string) (string, error) {
switch byteOrder {
case "ABCD", "MSW-BE", "MSW": // Big endian (Motorola)
return "ABCD", nil
case "BADC", "MSW-LE": // Big endian with bytes swapped
return "BADC", nil
case "CDAB", "LSW-BE": // Little endian with bytes swapped
return "CDAB", nil
case "DCBA", "LSW-LE", "LSW": // Little endian (Intel)
return "DCBA", nil
}
return "unknown", fmt.Errorf("unknown byte-order %q", byteOrder)
}
246 changes: 246 additions & 0 deletions plugins/inputs/modbus/configuration_original.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package modbus

import (
"fmt"
)

type fieldDefinition struct {
Measurement string `toml:"measurement"`
Name string `toml:"name"`
ByteOrder string `toml:"byte_order"`
DataType string `toml:"data_type"`
Scale float64 `toml:"scale"`
Address []uint16 `toml:"address"`
}

type ConfigurationOriginal struct {
SlaveID byte `toml:"slave_id"`
DiscreteInputs []fieldDefinition `toml:"discrete_inputs"`
Coils []fieldDefinition `toml:"coils"`
HoldingRegisters []fieldDefinition `toml:"holding_registers"`
InputRegisters []fieldDefinition `toml:"input_registers"`
}

func (c *ConfigurationOriginal) Process() (map[byte]requestSet, error) {
coil, err := c.initRequests(c.Coils, cCoils, maxQuantityCoils)
if err != nil {
return nil, err
}

discrete, err := c.initRequests(c.DiscreteInputs, cDiscreteInputs, maxQuantityDiscreteInput)
if err != nil {
return nil, err
}

holding, err := c.initRequests(c.HoldingRegisters, cHoldingRegisters, maxQuantityHoldingRegisters)
if err != nil {
return nil, err
}

input, err := c.initRequests(c.InputRegisters, cInputRegisters, maxQuantityInputRegisters)
if err != nil {
return nil, err
}

return map[byte]requestSet{
c.SlaveID: {
coil: coil,
discrete: discrete,
holding: holding,
input: input,
},
}, nil
}

func (c *ConfigurationOriginal) Check() error {
if err := c.validateFieldDefinitions(c.DiscreteInputs, cDiscreteInputs); err != nil {
return err
}

if err := c.validateFieldDefinitions(c.Coils, cCoils); err != nil {
return err
}

if err := c.validateFieldDefinitions(c.HoldingRegisters, cHoldingRegisters); err != nil {
return err
}

return c.validateFieldDefinitions(c.InputRegisters, cInputRegisters)
}

func (c *ConfigurationOriginal) initRequests(fieldDefs []fieldDefinition, registerType string, maxQuantity uint16) ([]request, error) {
fields, err := c.initFields(fieldDefs)
if err != nil {
return nil, err
}
return newRequestsFromFields(fields, c.SlaveID, registerType, maxQuantity), nil
}

func (c *ConfigurationOriginal) initFields(fieldDefs []fieldDefinition) ([]field, error) {
// Construct the fields from the field definitions
fields := make([]field, 0, len(fieldDefs))
for _, def := range fieldDefs {
f, err := c.newFieldFromDefinition(def)
if err != nil {
return nil, fmt.Errorf("initializing field %q failed: %v", def.Name, err)
}
fields = append(fields, f)
}

return fields, nil
}

func (c *ConfigurationOriginal) newFieldFromDefinition(def fieldDefinition) (field, error) {
// Check if the addresses are consecutive
expected := def.Address[0]
for _, current := range def.Address[1:] {
expected++
if current != expected {
return field{}, fmt.Errorf("addresses of field %q are not consecutive", def.Name)
}
}

// Initialize the field
f := field{
measurement: def.Measurement,
name: def.Name,
scale: def.Scale,
address: def.Address[0],
length: uint16(len(def.Address)),
}
if def.DataType != "" {
inType, err := c.normalizeInputDatatype(def.DataType, len(def.Address))
if err != nil {
return f, err
}
outType, err := c.normalizeOutputDatatype(def.DataType)
if err != nil {
return f, err
}
byteOrder, err := c.normalizeByteOrder(def.ByteOrder)
if err != nil {
return f, err
}

f.converter, err = determineConverter(inType, byteOrder, outType, def.Scale)
if err != nil {
return f, err
}
}

return f, nil
}

func (c *ConfigurationOriginal) validateFieldDefinitions(fieldDefs []fieldDefinition, registerType string) error {
nameEncountered := map[string]bool{}
for _, item := range fieldDefs {
//check empty name
if item.Name == "" {
return fmt.Errorf("empty name in '%s'", registerType)
}

//search name duplicate
canonicalName := item.Measurement + "." + item.Name
if nameEncountered[canonicalName] {
return fmt.Errorf("name '%s' is duplicated in measurement '%s' '%s' - '%s'", item.Name, item.Measurement, registerType, item.Name)
}
nameEncountered[canonicalName] = true

if registerType == cInputRegisters || registerType == cHoldingRegisters {
// search byte order
switch item.ByteOrder {
case "AB", "BA", "ABCD", "CDAB", "BADC", "DCBA", "ABCDEFGH", "HGFEDCBA", "BADCFEHG", "GHEFCDAB":
default:
return fmt.Errorf("invalid byte order '%s' in '%s' - '%s'", item.ByteOrder, registerType, item.Name)
}

// search data type
switch item.DataType {
case "UINT16", "INT16", "UINT32", "INT32", "UINT64", "INT64", "FLOAT32-IEEE", "FLOAT64-IEEE", "FLOAT32", "FIXED", "UFIXED":
default:
return fmt.Errorf("invalid data type '%s' in '%s' - '%s'", item.DataType, registerType, item.Name)
}

// check scale
if item.Scale == 0.0 {
return fmt.Errorf("invalid scale '%f' in '%s' - '%s'", item.Scale, registerType, item.Name)
}
}

// check address
if len(item.Address) != 1 && len(item.Address) != 2 && len(item.Address) != 4 {
return fmt.Errorf("invalid address '%v' length '%v' in '%s' - '%s'", item.Address, len(item.Address), registerType, item.Name)
}

if registerType == cInputRegisters || registerType == cHoldingRegisters {
if 2*len(item.Address) != len(item.ByteOrder) {
return fmt.Errorf("invalid byte order '%s' and address '%v' in '%s' - '%s'", item.ByteOrder, item.Address, registerType, item.Name)
}

// search duplicated
if len(item.Address) > len(removeDuplicates(item.Address)) {
return fmt.Errorf("duplicate address '%v' in '%s' - '%s'", item.Address, registerType, item.Name)
}
} else if len(item.Address) != 1 {
return fmt.Errorf("invalid address'%v' length'%v' in '%s' - '%s'", item.Address, len(item.Address), registerType, item.Name)
}
}
return nil
}

func (c *ConfigurationOriginal) normalizeInputDatatype(dataType string, words int) (string, error) {
// Handle our special types
switch dataType {
case "FIXED":
switch words {
case 1:
return "INT16", nil
case 2:
return "INT32", nil
case 4:
return "INT64", nil
default:
return "unknown", fmt.Errorf("invalid length %d for type %q", words, dataType)
}
case "FLOAT32", "UFIXED":
switch words {
case 1:
return "UINT16", nil
case 2:
return "UINT32", nil
case 4:
return "UINT64", nil
default:
return "unknown", fmt.Errorf("invalid length %d for type %q", words, dataType)
}
case "FLOAT32-IEEE":
return "FLOAT32", nil
case "FLOAT64-IEEE":
return "FLOAT64", nil
}
return normalizeInputDatatype(dataType)
}

func (c *ConfigurationOriginal) normalizeOutputDatatype(dataType string) (string, error) {
// Handle our special types
switch dataType {
case "FIXED", "FLOAT32", "UFIXED":
return "FLOAT64", nil
}
return normalizeOutputDatatype("native")
}

func (c *ConfigurationOriginal) normalizeByteOrder(byteOrder string) (string, error) {
// Handle our special types
switch byteOrder {
case "AB", "ABCDEFGH":
return "ABCD", nil
case "BADCFEHG":
return "BADC", nil
case "GHEFCDAB":
return "CDAB", nil
case "BA", "HGFEDCBA":
return "DCBA", nil
}
return normalizeByteOrder(byteOrder)
}
Loading

0 comments on commit 2e7b232

Please sign in to comment.