feat(inputs.modbus): Add support for string-fields (#14145)

This commit is contained in:
Sven Rebhan 2023-11-07 09:48:23 +01:00 committed by GitHub
parent d15ea5178c
commit 247a808769
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 273 additions and 95 deletions

View File

@ -106,6 +106,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
## FLOAT16-IEEE, FLOAT32-IEEE, FLOAT64-IEEE (IEEE 754 binary representation) ## FLOAT16-IEEE, FLOAT32-IEEE, FLOAT64-IEEE (IEEE 754 binary representation)
## FIXED, UFIXED (fixed-point representation on input) ## FIXED, UFIXED (fixed-point representation on input)
## FLOAT32 is a deprecated alias for UFIXED for historic reasons, should be avoided ## FLOAT32 is a deprecated alias for UFIXED for historic reasons, should be avoided
## STRING (byte-sequence converted to string)
## scale - the final numeric variable representation ## scale - the final numeric variable representation
## address - variable address ## address - variable address
@ -116,6 +117,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
{ name = "current", byte_order = "ABCD", data_type = "FIXED", scale=0.001, address = [1,2]}, { name = "current", byte_order = "ABCD", data_type = "FIXED", scale=0.001, address = [1,2]},
{ name = "frequency", byte_order = "AB", data_type = "UFIXED", scale=0.1, address = [7]}, { name = "frequency", byte_order = "AB", data_type = "UFIXED", scale=0.1, address = [7]},
{ name = "power", byte_order = "ABCD", data_type = "UFIXED", scale=0.1, address = [3,4]}, { name = "power", byte_order = "ABCD", data_type = "UFIXED", scale=0.1, address = [3,4]},
{ name = "firmware", byte_order = "AB", data_type = "STRING", address = [5, 6, 7, 8, 9, 10, 11, 12]},
] ]
input_registers = [ input_registers = [
{ name = "tank_level", byte_order = "AB", data_type = "INT16", scale=1.0, address = [0]}, { name = "tank_level", byte_order = "AB", data_type = "INT16", scale=1.0, address = [0]},
@ -177,9 +179,12 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants) ## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and ## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation) ## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1,2 - (optional) factor to scale the variable with ## STRING (byte-sequence converted to string)
## output *1,3 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if ## length *1,2 - (optional) number of registers, ONLY valid for STRING type
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc). ## scale *1,2,4 - (optional) factor to scale the variable with
## output *1,3,4 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64.
## Defaults to FLOAT64 for numeric fields if "scale" is provided.
## Otherwise the input "type" class is used (e.g. INT* -> INT64).
## measurement *1 - (optional) measurement name, defaults to the setting of the request ## 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 ## omit - (optional) omit this field. Useful to leave out single values when querying many registers
## with a single request. Defaults to "false". ## with a single request. Defaults to "false".
@ -189,13 +194,15 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
## *3: This field can only be "UINT16" or "BOOL" if specified for both "coil" ## *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 ## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used. ## output as zero or one in UINT16 format unless "BOOL" is used.
## *4: These fields cannot be used with "STRING"-type fields.
## Coil / discrete input example ## Coil / discrete input example
fields = [ fields = [
{ address=0, name="motor1_run"}, { address=0, name="motor1_run" },
{ address=1, name="jog", measurement="motor"}, { address=1, name="jog", measurement="motor" },
{ address=2, name="motor1_stop", omit=true}, { address=2, name="motor1_stop", omit=true },
{ address=3, name="motor1_overheating", output="BOOL"}, { address=3, name="motor1_overheating", output="BOOL" },
{ address=4, name="firmware", type="STRING", length=8 },
] ]
[inputs.modbus.request.tags] [inputs.modbus.request.tags]
@ -274,22 +281,25 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
# measurement = "modbus" # measurement = "modbus"
## Field definitions ## Field definitions
## register - type of the modbus register, can be "coil", "discrete", ## register - type of the modbus register, can be "coil", "discrete",
## "holding" or "input". Defaults to "holding". ## "holding" or "input". Defaults to "holding".
## address - address of the register to query. For coil and discrete inputs this is the bit address. ## address - address of the register to query. For coil and discrete inputs this is the bit address.
## name - field name ## name - field name
## type *1 - type of the modbus field, can be ## type *1 - type of the modbus field, can be
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants) ## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and ## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation) ## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1 - (optional) factor to scale the variable with ## STRING (byte-sequence converted to string)
## output *2 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if ## length *1 - (optional) number of registers, ONLY valid for STRING type
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc). ## scale *1,3 - (optional) factor to scale the variable with
## output *2,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).
## ##
## *1: These fields are ignored for both "coil" and "discrete"-input type of registers. ## *1: These fields are ignored for both "coil" and "discrete"-input type of registers.
## *2: This field can only be "UINT16" or "BOOL" if specified for both "coil" ## *2: This field can only be "UINT16" or "BOOL" if specified for both "coil"
## and "discrete"-input type of registers. By default the fields are ## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used. ## output as zero or one in UINT16 format unless "BOOL" is used.
## *3: These fields cannot be used with "STRING"-type fields.
fields = [ fields = [
{ register="coil", address=0, name="door_open"}, { register="coil", address=0, name="door_open"},
{ register="coil", address=1, name="status_ok"}, { register="coil", address=1, name="status_ok"},
@ -298,6 +308,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
{ address=5, name="energy", type="FLOAT32", scale=0.001,}, { address=5, name="energy", type="FLOAT32", scale=0.001,},
{ address=7, name="frequency", type="UINT32", scale=0.1 }, { address=7, name="frequency", type="UINT32", scale=0.1 },
{ address=8, name="power_factor", type="INT64", scale=0.01 }, { address=8, name="power_factor", type="INT64", scale=0.01 },
{ address=9, name="firmware", type="STRING", length=8 },
] ]
## Tags assigned to the metric ## Tags assigned to the metric
@ -388,10 +399,10 @@ configuration for a single slave-device.
The field `data_type` defines the representation of the data value on input from 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 the modbus registers. The input values are then converted from the given
`data_type` to a type that is appropriate when sending the value to the output `data_type` to a type that is appropriate when sending the value to the output
plugin. These output types are usually one of string, integer or plugin. These output types are usually an integer or floating-point-number. The
floating-point-number. The size of the output type is assumed to be large enough size of the output type is assumed to be large enough for all supported input
for all supported input types. The mapping from the input type to the output types. The mapping from the input type to the output type is fixed and cannot
type is fixed and cannot be configured. be configured.
##### Booleans: `BOOL` ##### Booleans: `BOOL`
@ -433,6 +444,13 @@ like 'int32 containing fixed-point representation with N decimal places'.
(`FLOAT32` is deprecated and should not be used. `UFIXED` provides the same (`FLOAT32` is deprecated and should not be used. `UFIXED` provides the same
conversion from unsigned values). conversion from unsigned values).
##### String: `STRING`
This type is used to query the number of registers specified in the `address`
setting and convert the byte-sequence to a string. Please note, if the
byte-sequence contains a `null` byte, the string is truncated at this position.
You cannot use the `scale` setting for string fields.
--- ---
### `request` configuration style ### `request` configuration style
@ -563,6 +581,12 @@ half-precision float with a 16-bit representation.
Usually the datatype of the register is listed in the datasheet of your modbus Usually the datatype of the register is listed in the datasheet of your modbus
device in relation to the `address` described above. device in relation to the `address` described above.
The `STRING` datatype is special in that it requires the `length` setting to
be specified containing the length (in terms of number of registers) containing
the string. The returned byte-sequence is interpreted as string and truncated
to the first `null` byte found if any. The `scale` and `output` setting cannot
be used for this `type`.
This setting is ignored if the field's `omit` is set to `true` or if the 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 `register` type is a bit-type (`coil` or `discrete`) and can be omitted in
these cases. these cases.
@ -722,6 +746,12 @@ half-precision float with a 16-bit representation.
Usually the datatype of the register is listed in the datasheet of your modbus Usually the datatype of the register is listed in the datasheet of your modbus
device in relation to the `address` described above. device in relation to the `address` described above.
The `STRING` datatype is special in that it requires the `length` setting to
be specified containing the length (in terms of number of registers) containing
the string. The returned byte-sequence is interpreted as string and truncated
to the first `null` byte found if any. The `scale` and `output` setting cannot
be used for this `type`.
This setting is ignored if the `register` is a bit-type (`coil` or `discrete`) This setting is ignored if the `register` is a bit-type (`coil` or `discrete`)
and can be omitted in these cases. and can be omitted in these cases.

View File

@ -33,7 +33,7 @@ func normalizeInputDatatype(dataType string) (string, error) {
switch dataType { switch dataType {
case "INT8L", "INT8H", "UINT8L", "UINT8H", case "INT8L", "INT8H", "UINT8L", "UINT8H",
"INT16", "UINT16", "INT32", "UINT32", "INT64", "UINT64", "INT16", "UINT16", "INT32", "UINT32", "INT64", "UINT64",
"FLOAT16", "FLOAT32", "FLOAT64": "FLOAT16", "FLOAT32", "FLOAT64", "STRING":
return dataType, nil return dataType, nil
} }
return "unknown", fmt.Errorf("unknown input type %q", dataType) return "unknown", fmt.Errorf("unknown input type %q", dataType)
@ -43,7 +43,7 @@ func normalizeOutputDatatype(dataType string) (string, error) {
switch dataType { switch dataType {
case "", "native": case "", "native":
return "native", nil return "native", nil
case "INT64", "UINT64", "FLOAT64": case "INT64", "UINT64", "FLOAT64", "STRING":
return dataType, nil return dataType, nil
} }
return "unknown", fmt.Errorf("unknown output type %q", dataType) return "unknown", fmt.Errorf("unknown output type %q", dataType)

View File

@ -15,6 +15,7 @@ var sampleConfigPartPerMetric string
type metricFieldDefinition struct { type metricFieldDefinition struct {
RegisterType string `toml:"register"` RegisterType string `toml:"register"`
Address uint16 `toml:"address"` Address uint16 `toml:"address"`
Length uint16 `toml:"length"`
Name string `toml:"name"` Name string `toml:"name"`
InputType string `toml:"type"` InputType string `toml:"type"`
Scale float64 `toml:"scale"` Scale float64 `toml:"scale"`
@ -101,16 +102,32 @@ func (c *ConfigurationPerMetric) Check() error {
// Check the input type // Check the input type
switch f.InputType { switch f.InputType {
case "": case "":
case "INT8L", "INT8H", "INT16", "INT32", "INT64": case "INT8L", "INT8H", "INT16", "INT32", "INT64",
case "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64": "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64",
case "FLOAT16", "FLOAT32", "FLOAT64": "FLOAT16", "FLOAT32", "FLOAT64":
if f.Length != 0 {
return fmt.Errorf("length option cannot be used for type %q of field %q", f.InputType, f.Name)
}
if f.OutputType == "STRING" {
return fmt.Errorf("cannot output field %q as string", f.Name)
}
case "STRING":
if f.Length < 1 {
return fmt.Errorf("missing length for string field %q", f.Name)
}
if f.Scale != 0.0 {
return fmt.Errorf("scale option cannot be used for string field %q", f.Name)
}
if f.OutputType != "" && f.OutputType != "STRING" {
return fmt.Errorf("invalid output type %q for string field %q", f.OutputType, f.Name)
}
default: default:
return fmt.Errorf("unknown register data-type %q for field %q", f.InputType, f.Name) return fmt.Errorf("unknown register data-type %q for field %q", f.InputType, f.Name)
} }
// Check output type // Check output type
switch f.OutputType { switch f.OutputType {
case "", "INT64", "UINT64", "FLOAT64": case "", "INT64", "UINT64", "FLOAT64", "STRING":
default: default:
return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name) return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name)
} }
@ -223,7 +240,7 @@ func (c *ConfigurationPerMetric) newField(def metricFieldDefinition, mdef metric
fieldLength := uint16(1) fieldLength := uint16(1)
if typed { if typed {
var err error var err error
if fieldLength, err = c.determineFieldLength(def.InputType); err != nil { if fieldLength, err = c.determineFieldLength(def.InputType, def.Length); err != nil {
return field{}, err return field{}, err
} }
} }
@ -258,8 +275,13 @@ func (c *ConfigurationPerMetric) newField(def metricFieldDefinition, mdef metric
return field{}, err return field{}, err
} }
} else { } else {
// For scaling cases we always want FLOAT64 by default // For scaling cases we always want FLOAT64 by default except for
def.OutputType = "FLOAT64" // string fields
if def.InputType != "STRING" {
def.OutputType = "FLOAT64"
} else {
def.OutputType = "STRING"
}
} }
} }
@ -351,11 +373,13 @@ func (c *ConfigurationPerMetric) determineOutputDatatype(input string) (string,
return "UINT64", nil return "UINT64", nil
case "FLOAT16", "FLOAT32", "FLOAT64": case "FLOAT16", "FLOAT32", "FLOAT64":
return "FLOAT64", nil return "FLOAT64", nil
case "STRING":
return "STRING", nil
} }
return "unknown", fmt.Errorf("invalid input datatype %q for determining output", input) return "unknown", fmt.Errorf("invalid input datatype %q for determining output", input)
} }
func (c *ConfigurationPerMetric) determineFieldLength(input string) (uint16, error) { func (c *ConfigurationPerMetric) determineFieldLength(input string, length uint16) (uint16, error) {
// Handle our special types // Handle our special types
switch input { switch input {
case "INT8L", "INT8H", "UINT8L", "UINT8H": case "INT8L", "INT8H", "UINT8L", "UINT8H":
@ -366,6 +390,8 @@ func (c *ConfigurationPerMetric) determineFieldLength(input string) (uint16, err
return 2, nil return 2, nil
case "INT64", "UINT64", "FLOAT64": case "INT64", "UINT64", "FLOAT64":
return 4, nil return 4, nil
case "STRING":
return length, nil
} }
return 0, fmt.Errorf("invalid input datatype %q for determining field length", input) return 0, fmt.Errorf("invalid input datatype %q for determining field length", input)
} }

View File

@ -162,6 +162,7 @@ func TestMetricResult(t *testing.T) {
0x00, 0x00, 0x08, 0x99, // 2201 0x00, 0x00, 0x08, 0x99, // 2201
0x00, 0x00, 0x08, 0x9A, // 2202 0x00, 0x00, 0x08, 0x9A, // 2202
0x40, 0x49, 0x0f, 0xdb, // float32 of 3.1415927410125732421875 0x40, 0x49, 0x0f, 0xdb, // float32 of 3.1415927410125732421875
0x4d, 0x6f, 0x64, 0x62, 0x75, 0x73, 0x20, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x00, // String "Modbus String"
} }
// Write the data to a fake server // Write the data to a fake server
@ -203,6 +204,13 @@ func TestMetricResult(t *testing.T) {
InputType: "INT16", InputType: "INT16",
RegisterType: "holding", RegisterType: "holding",
}, },
{
Name: "comment",
Address: uint16(11),
Length: 7,
InputType: "STRING",
RegisterType: "holding",
},
}, },
Tags: map[string]string{ Tags: map[string]string{
"location": "main building", "location": "main building",
@ -275,6 +283,7 @@ func TestMetricResult(t *testing.T) {
map[string]interface{}{ map[string]interface{}{
"hours": uint64(10), "hours": uint64(10),
"temperature": int64(42), "temperature": int64(42),
"comment": "Modbus String",
}, },
time.Unix(0, 0), time.Unix(0, 0),
), ),

View File

@ -202,14 +202,14 @@ func (c *ConfigurationOriginal) validateFieldDefinitions(fieldDefs []fieldDefini
case "INT8L", "INT8H", "UINT8L", "UINT8H", case "INT8L", "INT8H", "UINT8L", "UINT8H",
"UINT16", "INT16", "UINT32", "INT32", "UINT64", "INT64", "UINT16", "INT16", "UINT32", "INT32", "UINT64", "INT64",
"FLOAT16-IEEE", "FLOAT32-IEEE", "FLOAT64-IEEE", "FLOAT32", "FIXED", "UFIXED": "FLOAT16-IEEE", "FLOAT32-IEEE", "FLOAT64-IEEE", "FLOAT32", "FIXED", "UFIXED":
// Check scale
if item.Scale == 0.0 {
return fmt.Errorf("invalid scale '%f' in %q - %q", item.Scale, registerType, item.Name)
}
case "STRING":
default: default:
return fmt.Errorf("invalid data type %q in %q - %q", item.DataType, registerType, item.Name) return fmt.Errorf("invalid data type %q in %q - %q", item.DataType, registerType, item.Name)
} }
// check scale
if item.Scale == 0.0 {
return fmt.Errorf("invalid scale '%f' in %q - %q", item.Scale, registerType, item.Name)
}
} else { } else {
// Bit-registers do have less data types // Bit-registers do have less data types
switch item.DataType { switch item.DataType {
@ -220,39 +220,41 @@ func (c *ConfigurationOriginal) validateFieldDefinitions(fieldDefs []fieldDefini
} }
// check address // check address
if len(item.Address) != 1 && len(item.Address) != 2 && len(item.Address) != 4 { if item.DataType != "STRING" {
return fmt.Errorf("invalid address '%v' length '%v' in %q - %q", item.Address, len(item.Address), registerType, item.Name) if len(item.Address) != 1 && len(item.Address) != 2 && len(item.Address) != 4 {
} return fmt.Errorf("invalid address '%v' length '%v' in %q - %q", 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 %q and address '%v' in %q - %q", item.ByteOrder, item.Address, registerType, item.Name)
} }
// Check for the request size corresponding to the data-type if registerType == cInputRegisters || registerType == cHoldingRegisters {
var requiredAddresses int if 2*len(item.Address) != len(item.ByteOrder) {
switch item.DataType { return fmt.Errorf("invalid byte order %q and address '%v' in %q - %q", item.ByteOrder, item.Address, registerType, item.Name)
case "INT8L", "INT8H", "UINT8L", "UINT8H", "UINT16", "INT16", "FLOAT16-IEEE": }
requiredAddresses = 1
case "UINT32", "INT32", "FLOAT32-IEEE":
requiredAddresses = 2
case "UINT64", "INT64", "FLOAT64-IEEE": // Check for the request size corresponding to the data-type
requiredAddresses = 4 var requiredAddresses int
} switch item.DataType {
if requiredAddresses > 0 && len(item.Address) != requiredAddresses { case "INT8L", "INT8H", "UINT8L", "UINT8H", "UINT16", "INT16", "FLOAT16-IEEE":
return fmt.Errorf( requiredAddresses = 1
"invalid address '%v' length '%v'in %q - %q, expecting %d entries for datatype", case "UINT32", "INT32", "FLOAT32-IEEE":
item.Address, len(item.Address), registerType, item.Name, requiredAddresses, requiredAddresses = 2
)
}
// search duplicated case "UINT64", "INT64", "FLOAT64-IEEE":
if len(item.Address) > len(removeDuplicates(item.Address)) { requiredAddresses = 4
return fmt.Errorf("duplicate address '%v' in %q - %q", item.Address, registerType, item.Name) }
if requiredAddresses > 0 && len(item.Address) != requiredAddresses {
return fmt.Errorf(
"invalid address '%v' length '%v'in %q - %q, expecting %d entries for datatype",
item.Address, len(item.Address), registerType, item.Name, requiredAddresses,
)
}
// search duplicated
if len(item.Address) > len(removeDuplicates(item.Address)) {
return fmt.Errorf("duplicate address '%v' in %q - %q", item.Address, registerType, item.Name)
}
} else if len(item.Address) != 1 {
return fmt.Errorf("invalid address '%v' length '%v'in %q - %q", item.Address, len(item.Address), registerType, item.Name)
} }
} else if len(item.Address) != 1 {
return fmt.Errorf("invalid address '%v' length '%v'in %q - %q", item.Address, len(item.Address), registerType, item.Name)
} }
} }
return nil return nil
@ -297,6 +299,8 @@ func (c *ConfigurationOriginal) normalizeInputDatatype(dataType string, words in
return "FLOAT32", nil return "FLOAT32", nil
case "FLOAT64-IEEE": case "FLOAT64-IEEE":
return "FLOAT64", nil return "FLOAT64", nil
case "STRING":
return "STRING", nil
} }
return normalizeInputDatatype(dataType) return normalizeInputDatatype(dataType)
} }

View File

@ -841,6 +841,24 @@ func TestRegisterHoldingRegisters(t *testing.T) {
write: []byte{0x14, 0xb8}, write: []byte{0x14, 0xb8},
read: float64(-0.509765625), read: float64(-0.509765625),
}, },
{
name: "register250_abcd_string",
address: []uint16{250, 251, 252, 253, 254, 255, 256},
quantity: 7,
byteOrder: "AB",
dataType: "STRING",
write: []byte{0x4d, 0x6f, 0x64, 0x62, 0x75, 0x73, 0x20, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x00},
read: "Modbus String",
},
{
name: "register250_dcba_string",
address: []uint16{250, 251, 252, 253, 254, 255, 256},
quantity: 7,
byteOrder: "BA",
dataType: "STRING",
write: []byte{0x6f, 0x4d, 0x62, 0x64, 0x73, 0x75, 0x53, 0x20, 0x72, 0x74, 0x6e, 0x69, 0x00, 0x67},
read: "Modbus String",
},
} }
serv := mbserver.NewServer() serv := mbserver.NewServer()

View File

@ -17,6 +17,7 @@ type requestFieldDefinition struct {
Address uint16 `toml:"address"` Address uint16 `toml:"address"`
Name string `toml:"name"` Name string `toml:"name"`
InputType string `toml:"type"` InputType string `toml:"type"`
Length uint16 `toml:"length"`
Scale float64 `toml:"scale"` Scale float64 `toml:"scale"`
OutputType string `toml:"output"` OutputType string `toml:"output"`
Measurement string `toml:"measurement"` Measurement string `toml:"measurement"`
@ -121,9 +122,25 @@ func (c *ConfigurationPerRequest) Check() error {
if def.RegisterType == "holding" || def.RegisterType == "input" { if def.RegisterType == "holding" || def.RegisterType == "input" {
switch f.InputType { switch f.InputType {
case "": case "":
case "INT8L", "INT8H", "INT16", "INT32", "INT64": case "INT8L", "INT8H", "INT16", "INT32", "INT64",
case "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64": "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64",
case "FLOAT16", "FLOAT32", "FLOAT64": "FLOAT16", "FLOAT32", "FLOAT64":
if f.Length != 0 {
return fmt.Errorf("length option cannot be used for type %q of field %q", f.InputType, f.Name)
}
if f.OutputType == "STRING" {
return fmt.Errorf("cannot output field %q as string", f.Name)
}
case "STRING":
if f.Length < 1 {
return fmt.Errorf("missing length for string field %q", f.Name)
}
if f.Scale != 0.0 {
return fmt.Errorf("scale option cannot be used for string field %q", f.Name)
}
if f.OutputType != "" && f.OutputType != "STRING" {
return fmt.Errorf("invalid output type %q for string field %q", f.OutputType, f.Name)
}
default: default:
return fmt.Errorf("unknown register data-type %q for field %q", f.InputType, f.Name) return fmt.Errorf("unknown register data-type %q for field %q", f.InputType, f.Name)
} }
@ -142,7 +159,7 @@ func (c *ConfigurationPerRequest) Check() error {
// Check output type // Check output type
if def.RegisterType == "holding" || def.RegisterType == "input" { if def.RegisterType == "holding" || def.RegisterType == "input" {
switch f.OutputType { switch f.OutputType {
case "", "INT64", "UINT64", "FLOAT64": case "", "INT64", "UINT64", "FLOAT64", "STRING":
default: default:
return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name) return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name)
} }
@ -269,7 +286,7 @@ func (c *ConfigurationPerRequest) newFieldFromDefinition(def requestFieldDefinit
fieldLength := uint16(1) fieldLength := uint16(1)
if typed { if typed {
if fieldLength, err = c.determineFieldLength(def.InputType); err != nil { if fieldLength, err = c.determineFieldLength(def.InputType, def.Length); err != nil {
return field{}, err return field{}, err
} }
} }
@ -306,8 +323,13 @@ func (c *ConfigurationPerRequest) newFieldFromDefinition(def requestFieldDefinit
return field{}, err return field{}, err
} }
} else { } else {
// For scaling cases we always want FLOAT64 by default // For scaling cases we always want FLOAT64 by default except for
def.OutputType = "FLOAT64" // string fields
if def.InputType != "STRING" {
def.OutputType = "FLOAT64"
} else {
def.OutputType = "STRING"
}
} }
} }
@ -398,11 +420,13 @@ func (c *ConfigurationPerRequest) determineOutputDatatype(input string) (string,
return "UINT64", nil return "UINT64", nil
case "FLOAT16", "FLOAT32", "FLOAT64": case "FLOAT16", "FLOAT32", "FLOAT64":
return "FLOAT64", nil return "FLOAT64", nil
case "STRING":
return "STRING", nil
} }
return "unknown", fmt.Errorf("invalid input datatype %q for determining output", input) return "unknown", fmt.Errorf("invalid input datatype %q for determining output", input)
} }
func (c *ConfigurationPerRequest) determineFieldLength(input string) (uint16, error) { func (c *ConfigurationPerRequest) determineFieldLength(input string, length uint16) (uint16, error) {
// Handle our special types // Handle our special types
switch input { switch input {
case "INT8L", "INT8H", "UINT8L", "UINT8H": case "INT8L", "INT8H", "UINT8L", "UINT8H":
@ -413,6 +437,8 @@ func (c *ConfigurationPerRequest) determineFieldLength(input string) (uint16, er
return 2, nil return 2, nil
case "INT64", "UINT64", "FLOAT64": case "INT64", "UINT64", "FLOAT64":
return 4, nil return 4, nil
case "STRING":
return length, nil
} }
return 0, fmt.Errorf("invalid input datatype %q for determining field length", input) return 0, fmt.Errorf("invalid input datatype %q for determining field length", input)
} }

View File

@ -457,6 +457,7 @@ func TestRequestTypesHoldingABCD(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
address uint16 address uint16
length uint16
byteOrder string byteOrder string
dataTypeIn string dataTypeIn string
dataTypeOut string dataTypeOut string
@ -989,6 +990,14 @@ func TestRequestTypesHoldingABCD(t *testing.T) {
write: []byte{0xb8, 0x14}, write: []byte{0xb8, 0x14},
read: float64(-0.509765625), read: float64(-0.509765625),
}, },
{
name: "register110_string",
address: 110,
dataTypeIn: "STRING",
length: 7,
write: []byte{0x4d, 0x6f, 0x64, 0x62, 0x75, 0x73, 0x20, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x00},
read: "Modbus String",
},
} }
serv := mbserver.NewServer() serv := mbserver.NewServer()
@ -1024,6 +1033,7 @@ func TestRequestTypesHoldingABCD(t *testing.T) {
OutputType: hrt.dataTypeOut, OutputType: hrt.dataTypeOut,
Scale: hrt.scale, Scale: hrt.scale,
Address: hrt.address, Address: hrt.address,
Length: hrt.length,
}, },
}, },
}, },
@ -1058,6 +1068,7 @@ func TestRequestTypesHoldingDCBA(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
address uint16 address uint16
length uint16
byteOrder string byteOrder string
dataTypeIn string dataTypeIn string
dataTypeOut string dataTypeOut string
@ -1590,6 +1601,14 @@ func TestRequestTypesHoldingDCBA(t *testing.T) {
write: []byte{0xb8, 0x14}, write: []byte{0xb8, 0x14},
read: float64(-0.509765625), read: float64(-0.509765625),
}, },
{
name: "register110_string",
address: 110,
dataTypeIn: "STRING",
length: 7,
write: []byte{0x6f, 0x4d, 0x62, 0x64, 0x73, 0x75, 0x53, 0x20, 0x72, 0x74, 0x6e, 0x69, 0x00, 0x67},
read: "Modbus String",
},
} }
serv := mbserver.NewServer() serv := mbserver.NewServer()
@ -1605,8 +1624,13 @@ func TestRequestTypesHoldingDCBA(t *testing.T) {
t.Run(hrt.name, func(t *testing.T) { t.Run(hrt.name, func(t *testing.T) {
quantity := uint16(len(hrt.write) / 2) quantity := uint16(len(hrt.write) / 2)
invert := make([]byte, 0, len(hrt.write)) invert := make([]byte, 0, len(hrt.write))
for i := len(hrt.write) - 1; i >= 0; i-- { if hrt.dataTypeIn != "STRING" {
invert = append(invert, hrt.write[i]) for i := len(hrt.write) - 1; i >= 0; i-- {
invert = append(invert, hrt.write[i])
}
} else {
// Put in raw data for strings
invert = append(invert, hrt.write...)
} }
_, err := client.WriteMultipleRegisters(hrt.address, quantity, invert) _, err := client.WriteMultipleRegisters(hrt.address, quantity, invert)
require.NoError(t, err) require.NoError(t, err)
@ -1629,6 +1653,7 @@ func TestRequestTypesHoldingDCBA(t *testing.T) {
OutputType: hrt.dataTypeOut, OutputType: hrt.dataTypeOut,
Scale: hrt.scale, Scale: hrt.scale,
Address: hrt.address, Address: hrt.address,
Length: hrt.length,
}, },
}, },
}, },

View File

@ -35,22 +35,25 @@
# measurement = "modbus" # measurement = "modbus"
## Field definitions ## Field definitions
## register - type of the modbus register, can be "coil", "discrete", ## register - type of the modbus register, can be "coil", "discrete",
## "holding" or "input". Defaults to "holding". ## "holding" or "input". Defaults to "holding".
## address - address of the register to query. For coil and discrete inputs this is the bit address. ## address - address of the register to query. For coil and discrete inputs this is the bit address.
## name - field name ## name - field name
## type *1 - type of the modbus field, can be ## type *1 - type of the modbus field, can be
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants) ## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and ## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation) ## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1 - (optional) factor to scale the variable with ## STRING (byte-sequence converted to string)
## output *2 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if ## length *1 - (optional) number of registers, ONLY valid for STRING type
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc). ## scale *1,3 - (optional) factor to scale the variable with
## output *2,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).
## ##
## *1: These fields are ignored for both "coil" and "discrete"-input type of registers. ## *1: These fields are ignored for both "coil" and "discrete"-input type of registers.
## *2: This field can only be "UINT16" or "BOOL" if specified for both "coil" ## *2: This field can only be "UINT16" or "BOOL" if specified for both "coil"
## and "discrete"-input type of registers. By default the fields are ## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used. ## output as zero or one in UINT16 format unless "BOOL" is used.
## *3: These fields cannot be used with "STRING"-type fields.
fields = [ fields = [
{ register="coil", address=0, name="door_open"}, { register="coil", address=0, name="door_open"},
{ register="coil", address=1, name="status_ok"}, { register="coil", address=1, name="status_ok"},
@ -59,6 +62,7 @@
{ address=5, name="energy", type="FLOAT32", scale=0.001,}, { address=5, name="energy", type="FLOAT32", scale=0.001,},
{ address=7, name="frequency", type="UINT32", scale=0.1 }, { address=7, name="frequency", type="UINT32", scale=0.1 },
{ address=8, name="power_factor", type="INT64", scale=0.01 }, { address=8, name="power_factor", type="INT64", scale=0.01 },
{ address=9, name="firmware", type="STRING", length=8 },
] ]
## Tags assigned to the metric ## Tags assigned to the metric

View File

@ -34,6 +34,7 @@
## FLOAT16-IEEE, FLOAT32-IEEE, FLOAT64-IEEE (IEEE 754 binary representation) ## FLOAT16-IEEE, FLOAT32-IEEE, FLOAT64-IEEE (IEEE 754 binary representation)
## FIXED, UFIXED (fixed-point representation on input) ## FIXED, UFIXED (fixed-point representation on input)
## FLOAT32 is a deprecated alias for UFIXED for historic reasons, should be avoided ## FLOAT32 is a deprecated alias for UFIXED for historic reasons, should be avoided
## STRING (byte-sequence converted to string)
## scale - the final numeric variable representation ## scale - the final numeric variable representation
## address - variable address ## address - variable address
@ -44,6 +45,7 @@
{ name = "current", byte_order = "ABCD", data_type = "FIXED", scale=0.001, address = [1,2]}, { name = "current", byte_order = "ABCD", data_type = "FIXED", scale=0.001, address = [1,2]},
{ name = "frequency", byte_order = "AB", data_type = "UFIXED", scale=0.1, address = [7]}, { name = "frequency", byte_order = "AB", data_type = "UFIXED", scale=0.1, address = [7]},
{ name = "power", byte_order = "ABCD", data_type = "UFIXED", scale=0.1, address = [3,4]}, { name = "power", byte_order = "ABCD", data_type = "UFIXED", scale=0.1, address = [3,4]},
{ name = "firmware", byte_order = "AB", data_type = "STRING", address = [5, 6, 7, 8, 9, 10, 11, 12]},
] ]
input_registers = [ input_registers = [
{ name = "tank_level", byte_order = "AB", data_type = "INT16", scale=1.0, address = [0]}, { name = "tank_level", byte_order = "AB", data_type = "INT16", scale=1.0, address = [0]},

View File

@ -52,9 +52,12 @@
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants) ## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and ## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation) ## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
## scale *1,2 - (optional) factor to scale the variable with ## STRING (byte-sequence converted to string)
## output *1,3 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64. Defaults to FLOAT64 if ## length *1,2 - (optional) number of registers, ONLY valid for STRING type
## "scale" is provided and to the input "type" class otherwise (i.e. INT* -> INT64, etc). ## scale *1,2,4 - (optional) factor to scale the variable with
## output *1,3,4 - (optional) type of resulting field, can be INT64, UINT64 or FLOAT64.
## Defaults to FLOAT64 for numeric fields if "scale" is provided.
## Otherwise the input "type" class is used (e.g. INT* -> INT64).
## measurement *1 - (optional) measurement name, defaults to the setting of the request ## 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 ## omit - (optional) omit this field. Useful to leave out single values when querying many registers
## with a single request. Defaults to "false". ## with a single request. Defaults to "false".
@ -64,13 +67,15 @@
## *3: This field can only be "UINT16" or "BOOL" if specified for both "coil" ## *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 ## and "discrete"-input type of registers. By default the fields are
## output as zero or one in UINT16 format unless "BOOL" is used. ## output as zero or one in UINT16 format unless "BOOL" is used.
## *4: These fields cannot be used with "STRING"-type fields.
## Coil / discrete input example ## Coil / discrete input example
fields = [ fields = [
{ address=0, name="motor1_run"}, { address=0, name="motor1_run" },
{ address=1, name="jog", measurement="motor"}, { address=1, name="jog", measurement="motor" },
{ address=2, name="motor1_stop", omit=true}, { address=2, name="motor1_stop", omit=true },
{ address=3, name="motor1_overheating", output="BOOL"}, { address=3, name="motor1_overheating", output="BOOL" },
{ address=4, name="firmware", type="STRING", length=8 },
] ]
[inputs.modbus.request.tags] [inputs.modbus.request.tags]

View File

@ -1,6 +1,8 @@
package modbus package modbus
import "fmt" import (
"fmt"
)
func determineUntypedConverter(outType string) (fieldConverterFunc, error) { func determineUntypedConverter(outType string) (fieldConverterFunc, error) {
switch outType { switch outType {
@ -17,7 +19,7 @@ func determineUntypedConverter(outType string) (fieldConverterFunc, error) {
} }
func determineConverter(inType, byteOrder, outType string, scale float64) (fieldConverterFunc, error) { func determineConverter(inType, byteOrder, outType string, scale float64) (fieldConverterFunc, error) {
if scale != 0.0 { if scale != 0.0 && inType != "STRING" {
return determineConverterScale(inType, byteOrder, outType, scale) return determineConverterScale(inType, byteOrder, outType, scale)
} }
return determineConverterNoScale(inType, byteOrder, outType) return determineConverterNoScale(inType, byteOrder, outType)
@ -83,6 +85,8 @@ func determineConverterNoScale(inType, byteOrder, outType string) (fieldConverte
return determineConverterF32(outType, byteOrder) return determineConverterF32(outType, byteOrder)
case "FLOAT64": case "FLOAT64":
return determineConverterF64(outType, byteOrder) return determineConverterF64(outType, byteOrder)
case "STRING":
return determineConverterString(byteOrder)
} }
return nil, fmt.Errorf("invalid input data-type: %s", inType) return nil, fmt.Errorf("invalid input data-type: %s", inType)
} }

View File

@ -0,0 +1,25 @@
package modbus
import (
"bytes"
)
func determineConverterString(byteOrder string) (fieldConverterFunc, error) {
tohost, err := endiannessConverter16(byteOrder)
if err != nil {
return nil, err
}
return func(b []byte) interface{} {
// Swap the bytes according to endianness
var buf bytes.Buffer
for i := 0; i < len(b); i += 2 {
v := tohost(b[i : i+2])
_ = buf.WriteByte(byte(v >> 8))
_ = buf.WriteByte(byte(v & 0xFF))
}
// Remove everything after null-termination
s, _ := bytes.CutSuffix(buf.Bytes(), []byte{0x00})
return string(s)
}, nil
}