feat(inputs.modbus): Add per-metric configuration style (#13507)
This commit is contained in:
parent
657eca5cf0
commit
96b9845853
|
|
@ -1,3 +1,4 @@
|
|||
<!-- markdownlint-disable MD024 -->
|
||||
# Modbus Input Plugin
|
||||
|
||||
The Modbus plugin collects Discrete Inputs, Coils, Input Registers and Holding
|
||||
|
|
@ -14,7 +15,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
|
|||
|
||||
## Configuration
|
||||
|
||||
```toml @sample_general_begin.conf @sample_register.conf @sample_request.conf @sample_general_end.conf
|
||||
```toml @sample_general_begin.conf @sample_register.conf @sample_request.conf @sample_metric.conf @sample_general_end.conf
|
||||
# Retrieve data from MODBUS slave devices
|
||||
[[inputs.modbus]]
|
||||
## Connection Configuration
|
||||
|
|
@ -66,6 +67,7 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
|
|||
## Define the configuration schema
|
||||
## |---register -- define fields per register type in the original style (only supports one slave ID)
|
||||
## |---request -- define fields on a requests base
|
||||
## |---metric -- define fields on a metric base
|
||||
configuration_type = "register"
|
||||
|
||||
## --- "register" configuration style ---
|
||||
|
|
@ -238,6 +240,74 @@ See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
|
|||
machine = "impresser"
|
||||
location = "main building"
|
||||
|
||||
## --- "metric" configuration style ---
|
||||
|
||||
## Per metric definition
|
||||
##
|
||||
|
||||
## Request optimization algorithm across metrics
|
||||
## |---none -- Do not perform any optimization and just group requests
|
||||
## | within metrics (default)
|
||||
## |---max_insert -- Collate registers across all defined metrics and fill in
|
||||
## holes to optimize the number of requests.
|
||||
# optimization = "none"
|
||||
|
||||
## Maximum number of registers the optimizer is allowed to insert between
|
||||
## non-consecutive registers to save requests.
|
||||
## This option is only used for the 'max_insert' optimization strategy and
|
||||
## effectively denotes the hole size between registers to fill.
|
||||
# optimization_max_register_fill = 50
|
||||
|
||||
## Define a metric produced by the requests to the device
|
||||
## Multiple of those metrics can be defined. The referenced registers will
|
||||
## be collated into requests send to the device
|
||||
[[inputs.modbus.metric]]
|
||||
## ID of the modbus slave device to query
|
||||
## If you need to query multiple slave-devices, create several "metric" definitions.
|
||||
slave_id = 1
|
||||
|
||||
## Byte order of the data
|
||||
## |---ABCD -- Big Endian (Motorola)
|
||||
## |---DCBA -- Little Endian (Intel)
|
||||
## |---BADC -- Big Endian with byte swap
|
||||
## |---CDAB -- Little Endian with byte swap
|
||||
# byte_order = "ABCD"
|
||||
|
||||
## Name of the measurement
|
||||
# measurement = "modbus"
|
||||
|
||||
## Field definitions
|
||||
## register - type of the modbus register, can be "coil", "discrete",
|
||||
## "holding" or "input". Defaults to "holding".
|
||||
## address - address of the register to query. For coil and discrete inputs this is the bit address.
|
||||
## name - field name
|
||||
## type *1 - type of the modbus field, can be
|
||||
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
|
||||
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
|
||||
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
|
||||
## scale *1 - (optional) factor to scale the variable with
|
||||
## output *2 - (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.
|
||||
## *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
|
||||
## output as zero or one in UINT16 format unless "BOOL" is used.
|
||||
fields = [
|
||||
{ register="coil", address=0, name="door_open"},
|
||||
{ register="coil", address=1, name="status_ok"},
|
||||
{ register="holding", address=0, name="voltage", type="INT16" },
|
||||
{ address=1, name="current", type="INT32", scale=0.001 },
|
||||
{ address=5, name="energy", type="FLOAT32", scale=0.001,},
|
||||
{ address=7, name="frequency", type="UINT32", scale=0.1 },
|
||||
{ address=8, name="power_factor", type="INT64", scale=0.01 },
|
||||
]
|
||||
|
||||
## Tags assigned to the metric
|
||||
# [inputs.modbus.metric.tags]
|
||||
# machine = "impresser"
|
||||
# location = "main building"
|
||||
|
||||
## RS485 specific settings. Only take effect for serial controllers.
|
||||
## Note: This has to be at the end of the modbus configuration due to
|
||||
## TOML constraints.
|
||||
|
|
@ -304,6 +374,7 @@ Directly jump to the styles:
|
|||
|
||||
- [original / register plugin style](#register-configuration-style)
|
||||
- [per-request style](#request-configuration-style)
|
||||
- [per-metrict style](#metric-configuration-style)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -554,6 +625,143 @@ __Please note:__ These tags take precedence over predefined tags such as `name`,
|
|||
|
||||
---
|
||||
|
||||
### `metric` configuration style
|
||||
|
||||
This style can be used to specify the desired metrics directly instead of
|
||||
focusing on the modbus view. Multiple `[[inputs.modbus.metric]]` sections
|
||||
including multiple slave-devices can be specified. This way, _modbus_ gateway
|
||||
devices can be queried. The plugin automatically collects registers across
|
||||
the specified metrics, groups them per slave and register-type and (optionally)
|
||||
optimizes the resulting requests for non-consecutive addresses.
|
||||
|
||||
#### Slave device
|
||||
|
||||
You can use the `slave_id` setting to specify the ID of the slave device to
|
||||
query. It should be specified for each metric section, otherwise it defaults to
|
||||
zero. Please note, only one `slave_id` can be specified per metric section.
|
||||
|
||||
#### Byte order of the registers
|
||||
|
||||
The `byte_order` setting specifies the byte and word-order of the registers. It
|
||||
can be set to `ABCD` for _big endian (Motorola)_ or `DCBA` for _little endian
|
||||
(Intel)_ format as well as `BADC` and `CDAB` for _big endian_ or _little endian_
|
||||
with _byte swap_.
|
||||
|
||||
#### Measurement name
|
||||
|
||||
You can specify the name of the measurement for the fields defined in the
|
||||
given section using the `measurement` setting. If the setting is omitted
|
||||
`modbus` is used.
|
||||
|
||||
#### Optimization setting
|
||||
|
||||
__Please only use request optimization if you do understand the implications!__
|
||||
The `optimization` setting can specified globally, i.e. __NOT__ per metric
|
||||
section, and is used to optimize the actual requests sent to the device. Here,
|
||||
the optimization is applied across _all metric sections_! The following
|
||||
algorithms are available
|
||||
|
||||
##### `none` (_default_)
|
||||
|
||||
Do not perform any optimization. Please note that consecutive registers are
|
||||
still grouped into one requests while obeying the maximum request sizes. This
|
||||
setting should be used if you want to touch as less registers as possible at
|
||||
the cost of more requests sent to the device.
|
||||
|
||||
##### `max_insert`
|
||||
|
||||
Fields are assigned to the same request as long as the hole between the touched
|
||||
registers does not exceed the maximum fill size given via
|
||||
`optimization_max_register_fill`. This optimization might lead to a drastically
|
||||
reduced request number and thus an improved query time. The trade-off here is
|
||||
between the cost of reading additional registers trashed later and the cost of
|
||||
many requests.
|
||||
|
||||
__Please note:__ The optimal value for `optimization_max_register_fill` depends
|
||||
on the network and the queried device. It is hence recommended to test several
|
||||
values and assess performance in order to find the best value. Use the
|
||||
`--test --debug` flags to monitor how may requests are sent and the number of
|
||||
touched registers.
|
||||
|
||||
#### Field definitions
|
||||
|
||||
Each `metric` can contain a list of fields to collect from the modbus device.
|
||||
The specified fields directly corresponds to the fields of the resulting metric.
|
||||
|
||||
##### register
|
||||
|
||||
The `register` setting specifies the modbus register-set to query and can be set
|
||||
to `coil`, `discrete`, `holding` or `input`.
|
||||
|
||||
##### address
|
||||
|
||||
A field is identified by an `address` that reflects the modbus register
|
||||
address. You can usually find the address values for the different data-points
|
||||
in the datasheet of your modbus device. This is a mandatory setting.
|
||||
|
||||
For _coil_ and _discrete input_ registers this setting specifies the __bit__
|
||||
containing the value of the field.
|
||||
|
||||
##### name
|
||||
|
||||
Using the `name` setting you can specify the field-name in the metric as output
|
||||
by the plugin.
|
||||
|
||||
__Please note:__ There cannot be multiple fields with the same `name` in one
|
||||
metric identified by `measurement`, `slave_id`, `register` and tag-set.
|
||||
|
||||
##### register datatype
|
||||
|
||||
The `type` setting specifies the datatype of the modbus register and can be
|
||||
set to `INT8L`, `INT8H`, `UINT8L`, `UINT8H` where `L` is the lower byte of the
|
||||
register and `H` is the higher byte.
|
||||
Furthermore, the types `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64` or `UINT64`
|
||||
for integer types or `FLOAT16`, `FLOAT32` and `FLOAT64` for IEEE 754 binary
|
||||
representations of floating point values exist. `FLOAT16` denotes a
|
||||
half-precision float with a 16-bit representation.
|
||||
Usually the datatype of the register is listed in the datasheet of your modbus
|
||||
device in relation to the `address` described above.
|
||||
|
||||
This setting is ignored if the `register` is a bit-type (`coil` or `discrete`)
|
||||
and can be omitted in these cases.
|
||||
|
||||
##### scaling
|
||||
|
||||
You can use the `scale` setting to scale the register values, e.g. if the
|
||||
register contains a fix-point values in `UINT32` format with two decimal places
|
||||
for example. To convert the read register value to the actual value you can set
|
||||
the `scale=0.01`. The scale is used as a factor e.g. `field_value * scale`.
|
||||
|
||||
This setting is ignored if the `register` is a bit-type (`coil` or `discrete`)
|
||||
and can be omitted in these cases.
|
||||
|
||||
__Please note:__ The resulting field-type will be set to `FLOAT64` if no output
|
||||
format is specified.
|
||||
|
||||
##### output datatype
|
||||
|
||||
Using the `output` setting you can explicitly specify the output
|
||||
field-datatype. The `output` type can be `INT64`, `UINT64` or `FLOAT64`. If not
|
||||
set explicitly, the output type is guessed as follows: If `scale` is set to a
|
||||
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`.
|
||||
|
||||
In case the `register` 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.
|
||||
|
||||
#### Tags definitions
|
||||
|
||||
Each `metric` can be accompanied by a set of tag. These tags directly correspond
|
||||
to the tags of the resulting metric.
|
||||
|
||||
__Please note:__ These tags take precedence over predefined tags such as `name`,
|
||||
`type` or `slave_id`.
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
Metrics are custom and configured using the `discrete_inputs`, `coils`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
package modbus
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
|
||||
"github.com/influxdata/telegraf"
|
||||
)
|
||||
|
||||
//go:embed sample_metric.conf
|
||||
var sampleConfigPartPerMetric string
|
||||
|
||||
type metricFieldDefinition struct {
|
||||
RegisterType string `toml:"register"`
|
||||
Address uint16 `toml:"address"`
|
||||
Name string `toml:"name"`
|
||||
InputType string `toml:"type"`
|
||||
Scale float64 `toml:"scale"`
|
||||
OutputType string `toml:"output"`
|
||||
}
|
||||
|
||||
type metricDefinition struct {
|
||||
SlaveID byte `toml:"slave_id"`
|
||||
ByteOrder string `toml:"byte_order"`
|
||||
Measurement string `toml:"measurement"`
|
||||
Fields []metricFieldDefinition `toml:"fields"`
|
||||
Tags map[string]string `toml:"tags"`
|
||||
}
|
||||
|
||||
type ConfigurationPerMetric struct {
|
||||
Optimization string `toml:"optimization"`
|
||||
MaxExtraRegisters uint16 `toml:"optimization_max_register_fill"`
|
||||
Metrics []metricDefinition `toml:"metric"`
|
||||
workarounds ModbusWorkarounds
|
||||
logger telegraf.Logger
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) SampleConfigPart() string {
|
||||
return sampleConfigPartPerMetric
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) Check() error {
|
||||
seed := maphash.MakeSeed()
|
||||
seenFields := make(map[uint64]bool)
|
||||
|
||||
// Check optimization algorithm
|
||||
switch c.Optimization {
|
||||
case "", "none":
|
||||
c.Optimization = "none"
|
||||
case "max_insert":
|
||||
if c.MaxExtraRegisters == 0 {
|
||||
c.MaxExtraRegisters = 50
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown optimization %q", c.Optimization)
|
||||
}
|
||||
|
||||
for defidx, def := range c.Metrics {
|
||||
// Check byte order of the data
|
||||
switch def.ByteOrder {
|
||||
case "":
|
||||
def.ByteOrder = "ABCD"
|
||||
case "ABCD", "DCBA", "BADC", "CDAB", "MSW-BE", "MSW-LE", "LSW-LE", "LSW-BE":
|
||||
default:
|
||||
return fmt.Errorf("unknown byte-order %q", def.ByteOrder)
|
||||
}
|
||||
|
||||
// Set the default for measurement if required
|
||||
if def.Measurement == "" {
|
||||
def.Measurement = "modbus"
|
||||
}
|
||||
|
||||
// Reject any configuration without fields as it
|
||||
// makes no sense to not define anything but a request.
|
||||
if len(def.Fields) == 0 {
|
||||
return errors.New("found request section without fields")
|
||||
}
|
||||
|
||||
// Check the fields
|
||||
for fidx, f := range def.Fields {
|
||||
// Name is mandatory
|
||||
if f.Name == "" {
|
||||
return fmt.Errorf("empty field name in request for slave %d", def.SlaveID)
|
||||
}
|
||||
|
||||
// Check register type
|
||||
switch f.RegisterType {
|
||||
case "":
|
||||
f.RegisterType = "holding"
|
||||
case "coil", "discrete", "holding", "input":
|
||||
default:
|
||||
return fmt.Errorf("unknown register-type %q for field %q", f.RegisterType, f.Name)
|
||||
}
|
||||
|
||||
// Check the input and output type for all fields as we later need
|
||||
// it to determine the number of registers to query.
|
||||
switch f.RegisterType {
|
||||
case "holding", "input":
|
||||
// Check the input type
|
||||
switch f.InputType {
|
||||
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)
|
||||
}
|
||||
|
||||
// Check output type
|
||||
switch f.OutputType {
|
||||
case "", "INT64", "UINT64", "FLOAT64":
|
||||
default:
|
||||
return fmt.Errorf("unknown output data-type %q for field %q", f.OutputType, f.Name)
|
||||
}
|
||||
case "coil", "discrete":
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
def.Fields[fidx] = f
|
||||
|
||||
// Check for duplicate field definitions
|
||||
id, err := c.fieldID(seed, def, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine field id for %q: %w", f.Name, err)
|
||||
}
|
||||
if seenFields[id] {
|
||||
return fmt.Errorf("field %q duplicated in measurement %q (slave %d)", f.Name, def.Measurement, def.SlaveID)
|
||||
}
|
||||
seenFields[id] = true
|
||||
}
|
||||
c.Metrics[defidx] = def
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) Process() (map[byte]requestSet, error) {
|
||||
collection := make(map[byte]map[string][]field)
|
||||
|
||||
// Collect the requested registers across metrics and transform them into
|
||||
// requests. This will produce one request per slave and register-type
|
||||
for _, def := range c.Metrics {
|
||||
// Make sure we have a set to work with
|
||||
set, found := collection[def.SlaveID]
|
||||
if !found {
|
||||
set = make(map[string][]field)
|
||||
}
|
||||
|
||||
for _, fdef := range def.Fields {
|
||||
// Construct the field from the field definition
|
||||
f, err := c.newField(fdef, def)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initializing field %q of measurement %q failed: %w", fdef.Name, def.Measurement, err)
|
||||
}
|
||||
|
||||
// Attach the field to the correct set
|
||||
set[fdef.RegisterType] = append(set[fdef.RegisterType], f)
|
||||
}
|
||||
collection[def.SlaveID] = set
|
||||
}
|
||||
|
||||
result := make(map[byte]requestSet)
|
||||
|
||||
params := groupingParams{
|
||||
Optimization: c.Optimization,
|
||||
MaxExtraRegisters: c.MaxExtraRegisters,
|
||||
Log: c.logger,
|
||||
}
|
||||
for sid, scollection := range collection {
|
||||
var set requestSet
|
||||
for registerType, fields := range scollection {
|
||||
switch registerType {
|
||||
case "coil":
|
||||
params.MaxBatchSize = maxQuantityCoils
|
||||
if c.workarounds.OnRequestPerField {
|
||||
params.MaxBatchSize = 1
|
||||
}
|
||||
params.EnforceFromZero = c.workarounds.ReadCoilsStartingAtZero
|
||||
requests := groupFieldsToRequests(fields, params)
|
||||
set.coil = append(set.coil, requests...)
|
||||
case "discrete":
|
||||
params.MaxBatchSize = maxQuantityDiscreteInput
|
||||
if c.workarounds.OnRequestPerField {
|
||||
params.MaxBatchSize = 1
|
||||
}
|
||||
requests := groupFieldsToRequests(fields, params)
|
||||
set.discrete = append(set.discrete, requests...)
|
||||
case "holding":
|
||||
params.MaxBatchSize = maxQuantityHoldingRegisters
|
||||
if c.workarounds.OnRequestPerField {
|
||||
params.MaxBatchSize = 1
|
||||
}
|
||||
requests := groupFieldsToRequests(fields, params)
|
||||
set.holding = append(set.holding, requests...)
|
||||
case "input":
|
||||
params.MaxBatchSize = maxQuantityInputRegisters
|
||||
if c.workarounds.OnRequestPerField {
|
||||
params.MaxBatchSize = 1
|
||||
}
|
||||
requests := groupFieldsToRequests(fields, params)
|
||||
set.input = append(set.input, requests...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown register type %q", registerType)
|
||||
}
|
||||
}
|
||||
if !set.Empty() {
|
||||
result[sid] = set
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) newField(def metricFieldDefinition, mdef metricDefinition) (field, error) {
|
||||
typed := def.RegisterType == "holding" || def.RegisterType == "input"
|
||||
|
||||
fieldLength := uint16(1)
|
||||
if typed {
|
||||
var err error
|
||||
if fieldLength, err = c.determineFieldLength(def.InputType); err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the field
|
||||
f := field{
|
||||
measurement: mdef.Measurement,
|
||||
name: def.Name,
|
||||
address: def.Address,
|
||||
length: fieldLength,
|
||||
tags: mdef.Tags,
|
||||
}
|
||||
|
||||
// Handle type conversions for coil and discrete registers
|
||||
if !typed {
|
||||
var err error
|
||||
f.converter, err = determineUntypedConverter(def.OutputType)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
// No more processing for un-typed (coil and discrete registers) fields
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Automagically determine the output type...
|
||||
if def.OutputType == "" {
|
||||
if def.Scale == 0.0 {
|
||||
// For non-scaling cases we should choose the output corresponding to the input class
|
||||
// i.e. INT64 for INT*, UINT64 for UINT* etc.
|
||||
var err error
|
||||
if def.OutputType, err = c.determineOutputDatatype(def.InputType); err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
} else {
|
||||
// For scaling cases we always want FLOAT64 by default
|
||||
def.OutputType = "FLOAT64"
|
||||
}
|
||||
}
|
||||
|
||||
// Setting default byte-order
|
||||
byteOrder := mdef.ByteOrder
|
||||
if byteOrder == "" {
|
||||
byteOrder = "ABCD"
|
||||
}
|
||||
|
||||
// Normalize the data relevant for determining the converter
|
||||
inType, err := normalizeInputDatatype(def.InputType)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
outType, err := normalizeOutputDatatype(def.OutputType)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
order, err := normalizeByteOrder(byteOrder)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
|
||||
f.converter, err = determineConverter(inType, order, outType, def.Scale)
|
||||
if err != nil {
|
||||
return field{}, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) fieldID(seed maphash.Seed, def metricDefinition, field metricFieldDefinition) (uint64, error) {
|
||||
var mh maphash.Hash
|
||||
mh.SetSeed(seed)
|
||||
|
||||
if err := mh.WriteByte(def.SlaveID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := mh.WriteString(field.RegisterType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := mh.WriteString(def.Measurement); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := mh.WriteString(field.Name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Tags
|
||||
for k, v := range def.Tags {
|
||||
if _, err := mh.WriteString(k); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte('='); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := mh.WriteString(v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := mh.WriteByte(':'); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := mh.WriteByte(0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return mh.Sum64(), nil
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) determineOutputDatatype(input string) (string, error) {
|
||||
// Handle our special types
|
||||
switch input {
|
||||
case "INT8L", "INT8H", "INT16", "INT32", "INT64":
|
||||
return "INT64", nil
|
||||
case "UINT8L", "UINT8H", "UINT16", "UINT32", "UINT64":
|
||||
return "UINT64", nil
|
||||
case "FLOAT16", "FLOAT32", "FLOAT64":
|
||||
return "FLOAT64", nil
|
||||
}
|
||||
return "unknown", fmt.Errorf("invalid input datatype %q for determining output", input)
|
||||
}
|
||||
|
||||
func (c *ConfigurationPerMetric) determineFieldLength(input string) (uint16, error) {
|
||||
// Handle our special types
|
||||
switch input {
|
||||
case "INT8L", "INT8H", "UINT8L", "UINT8H":
|
||||
return 1, nil
|
||||
case "INT16", "UINT16", "FLOAT16":
|
||||
return 1, nil
|
||||
case "INT32", "UINT32", "FLOAT32":
|
||||
return 2, nil
|
||||
case "INT64", "UINT64", "FLOAT64":
|
||||
return 4, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid input datatype %q for determining field length", input)
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
package modbus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mb "github.com/grid-x/modbus"
|
||||
"github.com/influxdata/telegraf"
|
||||
"github.com/influxdata/telegraf/metric"
|
||||
"github.com/influxdata/telegraf/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tbrandon/mbserver"
|
||||
)
|
||||
|
||||
func TestMetric(t *testing.T) {
|
||||
plugin := Modbus{
|
||||
Name: "Test",
|
||||
Controller: "tcp://localhost:1502",
|
||||
ConfigurationType: "metric",
|
||||
Log: testutil.Logger{},
|
||||
}
|
||||
plugin.Metrics = []metricDefinition{
|
||||
{
|
||||
SlaveID: 1,
|
||||
ByteOrder: "ABCD",
|
||||
Measurement: "test",
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "coil-0",
|
||||
Address: uint16(0),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "coil-1",
|
||||
Address: uint16(1),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "holding-0",
|
||||
Address: uint16(0),
|
||||
InputType: "INT16",
|
||||
},
|
||||
{
|
||||
Name: "holding-1",
|
||||
Address: uint16(1),
|
||||
InputType: "UINT16",
|
||||
RegisterType: "holding",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SlaveID: 1,
|
||||
ByteOrder: "ABCD",
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "coil-0",
|
||||
Address: uint16(2),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "coil-1",
|
||||
Address: uint16(3),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "holding-0",
|
||||
Address: uint16(2),
|
||||
InputType: "INT64",
|
||||
Scale: 1.2,
|
||||
OutputType: "FLOAT64",
|
||||
},
|
||||
},
|
||||
Tags: map[string]string{
|
||||
"location": "main building",
|
||||
"device": "mydevice",
|
||||
},
|
||||
},
|
||||
{
|
||||
SlaveID: 2,
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "coil-6",
|
||||
Address: uint16(6),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "coil-7",
|
||||
Address: uint16(7),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "discrete-0",
|
||||
Address: uint16(0),
|
||||
RegisterType: "discrete",
|
||||
},
|
||||
{
|
||||
Name: "holding-99",
|
||||
Address: uint16(99),
|
||||
InputType: "INT16",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SlaveID: 2,
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "coil-4",
|
||||
Address: uint16(4),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "coil-5",
|
||||
Address: uint16(5),
|
||||
RegisterType: "coil",
|
||||
},
|
||||
{
|
||||
Name: "input-0",
|
||||
Address: uint16(0),
|
||||
RegisterType: "input",
|
||||
InputType: "UINT16",
|
||||
},
|
||||
{
|
||||
Name: "input-1",
|
||||
Address: uint16(2),
|
||||
RegisterType: "input",
|
||||
InputType: "UINT16",
|
||||
},
|
||||
{
|
||||
Name: "holding-9",
|
||||
Address: uint16(9),
|
||||
InputType: "INT16",
|
||||
},
|
||||
},
|
||||
Tags: map[string]string{
|
||||
"location": "main building",
|
||||
"device": "mydevice",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, plugin.Init())
|
||||
require.NotEmpty(t, plugin.requests)
|
||||
|
||||
require.NotNil(t, plugin.requests[1])
|
||||
require.Len(t, plugin.requests[1].coil, 1, "coil 1")
|
||||
require.Len(t, plugin.requests[1].holding, 1, "holding 1")
|
||||
require.Empty(t, plugin.requests[1].discrete)
|
||||
require.Empty(t, plugin.requests[1].input)
|
||||
|
||||
require.NotNil(t, plugin.requests[2])
|
||||
require.Len(t, plugin.requests[2].coil, 1, "coil 2")
|
||||
require.Len(t, plugin.requests[2].holding, 2, "holding 2")
|
||||
require.Len(t, plugin.requests[2].discrete, 1, "discrete 2")
|
||||
require.Len(t, plugin.requests[2].input, 2, "input 2")
|
||||
}
|
||||
|
||||
func TestMetricResult(t *testing.T) {
|
||||
data := []byte{
|
||||
0x00, 0x0A, // 10
|
||||
0x00, 0x2A, // 42
|
||||
0x00, 0x00, 0x08, 0x98, // 2200
|
||||
0x00, 0x00, 0x08, 0x99, // 2201
|
||||
0x00, 0x00, 0x08, 0x9A, // 2202
|
||||
0x40, 0x49, 0x0f, 0xdb, // float32(3.1415927410125732421875)
|
||||
}
|
||||
|
||||
// Write the data to a fake server
|
||||
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)
|
||||
|
||||
quantity := uint16(len(data) / 2)
|
||||
_, err := client.WriteMultipleRegisters(1, quantity, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup the plugin
|
||||
plugin := Modbus{
|
||||
Name: "FAKEMETER",
|
||||
Controller: "tcp://localhost:1502",
|
||||
ConfigurationType: "metric",
|
||||
Log: testutil.Logger{},
|
||||
}
|
||||
plugin.Metrics = []metricDefinition{
|
||||
{
|
||||
SlaveID: 1,
|
||||
ByteOrder: "ABCD",
|
||||
Measurement: "machine",
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "hours",
|
||||
Address: uint16(1),
|
||||
InputType: "UINT16",
|
||||
RegisterType: "holding",
|
||||
},
|
||||
{
|
||||
Name: "temperature",
|
||||
Address: uint16(2),
|
||||
InputType: "INT16",
|
||||
RegisterType: "holding",
|
||||
},
|
||||
},
|
||||
Tags: map[string]string{
|
||||
"location": "main building",
|
||||
"device": "machine A",
|
||||
},
|
||||
},
|
||||
{
|
||||
SlaveID: 1,
|
||||
ByteOrder: "ABCD",
|
||||
Measurement: "machine",
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "hours",
|
||||
Address: uint16(3),
|
||||
InputType: "UINT32",
|
||||
Scale: 0.01,
|
||||
},
|
||||
{
|
||||
Name: "temperature",
|
||||
Address: uint16(5),
|
||||
InputType: "INT32",
|
||||
Scale: 0.02,
|
||||
},
|
||||
{
|
||||
Name: "output",
|
||||
Address: uint16(7),
|
||||
InputType: "UINT32",
|
||||
},
|
||||
},
|
||||
Tags: map[string]string{
|
||||
"location": "main building",
|
||||
"device": "machine B",
|
||||
},
|
||||
},
|
||||
{
|
||||
SlaveID: 1,
|
||||
Fields: []metricFieldDefinition{
|
||||
{
|
||||
Name: "pi",
|
||||
Address: uint16(9),
|
||||
InputType: "FLOAT32",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, plugin.Init())
|
||||
|
||||
// Check the generated requests
|
||||
require.Len(t, plugin.requests, 1)
|
||||
require.NotNil(t, plugin.requests[1])
|
||||
require.Len(t, plugin.requests[1].holding, 1)
|
||||
require.Empty(t, plugin.requests[1].coil)
|
||||
require.Empty(t, plugin.requests[1].discrete)
|
||||
require.Empty(t, plugin.requests[1].input)
|
||||
|
||||
// Gather the data and verify the resulting metrics
|
||||
var acc testutil.Accumulator
|
||||
require.NoError(t, plugin.Gather(&acc))
|
||||
|
||||
expected := []telegraf.Metric{
|
||||
metric.New(
|
||||
"machine",
|
||||
map[string]string{
|
||||
"name": "FAKEMETER",
|
||||
"location": "main building",
|
||||
"device": "machine A",
|
||||
"slave_id": "1",
|
||||
"type": "holding_register",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"hours": uint64(10),
|
||||
"temperature": int64(42),
|
||||
},
|
||||
time.Unix(0, 0),
|
||||
),
|
||||
metric.New(
|
||||
"machine",
|
||||
map[string]string{
|
||||
"name": "FAKEMETER",
|
||||
"location": "main building",
|
||||
"device": "machine B",
|
||||
"slave_id": "1",
|
||||
"type": "holding_register",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"hours": float64(22.0),
|
||||
"temperature": float64(44.02),
|
||||
"output": uint64(2202),
|
||||
},
|
||||
time.Unix(0, 0),
|
||||
),
|
||||
metric.New(
|
||||
"modbus",
|
||||
map[string]string{
|
||||
"name": "FAKEMETER",
|
||||
"slave_id": "1",
|
||||
"type": "holding_register",
|
||||
},
|
||||
map[string]interface{}{"pi": float64(3.1415927410125732421875)},
|
||||
time.Unix(0, 0),
|
||||
),
|
||||
}
|
||||
|
||||
actual := acc.GetTelegrafMetrics()
|
||||
testutil.RequireMetricsEqual(t, expected, actual, testutil.IgnoreTime())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -63,6 +63,7 @@ type Modbus struct {
|
|||
// Configuration type specific settings
|
||||
ConfigurationOriginal
|
||||
ConfigurationPerRequest
|
||||
ConfigurationPerMetric
|
||||
|
||||
// Connection handling
|
||||
client mb.Client
|
||||
|
|
@ -97,6 +98,7 @@ type field struct {
|
|||
omit bool
|
||||
converter fieldConverterFunc
|
||||
value interface{}
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -108,10 +110,11 @@ const (
|
|||
|
||||
// SampleConfig returns a basic configuration for the plugin
|
||||
func (m *Modbus) SampleConfig() string {
|
||||
configs := []Configuration{}
|
||||
cfgOriginal := m.ConfigurationOriginal
|
||||
cfgPerRequest := m.ConfigurationPerRequest
|
||||
configs = append(configs, &cfgOriginal, &cfgPerRequest)
|
||||
configs := []Configuration{
|
||||
&m.ConfigurationOriginal,
|
||||
&m.ConfigurationPerRequest,
|
||||
&m.ConfigurationPerMetric,
|
||||
}
|
||||
|
||||
totalConfig := sampleConfigStart
|
||||
for _, c := range configs {
|
||||
|
|
@ -143,6 +146,10 @@ func (m *Modbus) Init() error {
|
|||
m.ConfigurationPerRequest.workarounds = m.Workarounds
|
||||
m.ConfigurationPerRequest.logger = m.Log
|
||||
cfg = &m.ConfigurationPerRequest
|
||||
case "metric":
|
||||
m.ConfigurationPerMetric.workarounds = m.Workarounds
|
||||
m.ConfigurationPerMetric.logger = m.Log
|
||||
cfg = &m.ConfigurationPerMetric
|
||||
default:
|
||||
return fmt.Errorf("unknown configuration type %q", m.ConfigurationType)
|
||||
}
|
||||
|
|
@ -503,16 +510,15 @@ func (m *Modbus) gatherRequestsInput(requests []request) error {
|
|||
func (m *Modbus) collectFields(acc telegraf.Accumulator, timestamp time.Time, tags map[string]string, requests []request) {
|
||||
grouper := metric.NewSeriesGrouper()
|
||||
for _, request := range requests {
|
||||
// Collect tags from global and per-request
|
||||
rtags := map[string]string{}
|
||||
for k, v := range tags {
|
||||
rtags[k] = v
|
||||
}
|
||||
for k, v := range request.tags {
|
||||
rtags[k] = v
|
||||
}
|
||||
|
||||
for _, field := range request.fields {
|
||||
// Collect tags from global and per-request
|
||||
ftags := map[string]string{}
|
||||
for k, v := range tags {
|
||||
ftags[k] = v
|
||||
}
|
||||
for k, v := range field.tags {
|
||||
ftags[k] = v
|
||||
}
|
||||
// In case no measurement was specified we use "modbus" as default
|
||||
measurement := "modbus"
|
||||
if field.measurement != "" {
|
||||
|
|
@ -520,7 +526,7 @@ func (m *Modbus) collectFields(acc telegraf.Accumulator, timestamp time.Time, ta
|
|||
}
|
||||
|
||||
// Group the data by series
|
||||
grouper.Add(measurement, rtags, timestamp, field.name, field.value)
|
||||
grouper.Add(measurement, ftags, timestamp, field.name, field.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,7 +10,6 @@ type request struct {
|
|||
address uint16
|
||||
length uint16
|
||||
fields []field
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
func countRegisters(requests []request) uint64 {
|
||||
|
|
@ -203,6 +202,14 @@ func groupFieldsToRequests(fields []field, params groupingParams) []request {
|
|||
var groups []request
|
||||
var current request
|
||||
for _, f := range fields {
|
||||
// Add tags from higher up
|
||||
if f.tags == nil {
|
||||
f.tags = make(map[string]string, len(params.Tags))
|
||||
}
|
||||
for k, v := range params.Tags {
|
||||
f.tags[k] = v
|
||||
}
|
||||
|
||||
// Check if we need to interrupt the current chunk and require a new one
|
||||
if current.length > 0 && f.address == current.address+current.length {
|
||||
// Still safe to add the field to the current request
|
||||
|
|
@ -285,12 +292,5 @@ func groupFieldsToRequests(fields []field, params groupingParams) []request {
|
|||
}
|
||||
}
|
||||
|
||||
// Copy the tags
|
||||
for i := range requests {
|
||||
requests[i].tags = make(map[string]string)
|
||||
for k, v := range params.Tags {
|
||||
requests[i].tags[k] = v
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,4 +49,5 @@
|
|||
## Define the configuration schema
|
||||
## |---register -- define fields per register type in the original style (only supports one slave ID)
|
||||
## |---request -- define fields on a requests base
|
||||
## |---metric -- define fields on a metric base
|
||||
configuration_type = "register"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
## --- "metric" configuration style ---
|
||||
|
||||
## Per metric definition
|
||||
##
|
||||
|
||||
## Request optimization algorithm across metrics
|
||||
## |---none -- Do not perform any optimization and just group requests
|
||||
## | within metrics (default)
|
||||
## |---max_insert -- Collate registers across all defined metrics and fill in
|
||||
## holes to optimize the number of requests.
|
||||
# optimization = "none"
|
||||
|
||||
## Maximum number of registers the optimizer is allowed to insert between
|
||||
## non-consecutive registers to save requests.
|
||||
## This option is only used for the 'max_insert' optimization strategy and
|
||||
## effectively denotes the hole size between registers to fill.
|
||||
# optimization_max_register_fill = 50
|
||||
|
||||
## Define a metric produced by the requests to the device
|
||||
## Multiple of those metrics can be defined. The referenced registers will
|
||||
## be collated into requests send to the device
|
||||
[[inputs.modbus.metric]]
|
||||
## ID of the modbus slave device to query
|
||||
## If you need to query multiple slave-devices, create several "metric" definitions.
|
||||
slave_id = 1
|
||||
|
||||
## Byte order of the data
|
||||
## |---ABCD -- Big Endian (Motorola)
|
||||
## |---DCBA -- Little Endian (Intel)
|
||||
## |---BADC -- Big Endian with byte swap
|
||||
## |---CDAB -- Little Endian with byte swap
|
||||
# byte_order = "ABCD"
|
||||
|
||||
## Name of the measurement
|
||||
# measurement = "modbus"
|
||||
|
||||
## Field definitions
|
||||
## register - type of the modbus register, can be "coil", "discrete",
|
||||
## "holding" or "input". Defaults to "holding".
|
||||
## address - address of the register to query. For coil and discrete inputs this is the bit address.
|
||||
## name - field name
|
||||
## type *1 - type of the modbus field, can be
|
||||
## INT8L, INT8H, UINT8L, UINT8H (low and high byte variants)
|
||||
## INT16, UINT16, INT32, UINT32, INT64, UINT64 and
|
||||
## FLOAT16, FLOAT32, FLOAT64 (IEEE 754 binary representation)
|
||||
## scale *1 - (optional) factor to scale the variable with
|
||||
## output *2 - (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.
|
||||
## *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
|
||||
## output as zero or one in UINT16 format unless "BOOL" is used.
|
||||
fields = [
|
||||
{ register="coil", address=0, name="door_open"},
|
||||
{ register="coil", address=1, name="status_ok"},
|
||||
{ register="holding", address=0, name="voltage", type="INT16" },
|
||||
{ address=1, name="current", type="INT32", scale=0.001 },
|
||||
{ address=5, name="energy", type="FLOAT32", scale=0.001,},
|
||||
{ address=7, name="frequency", type="UINT32", scale=0.1 },
|
||||
{ address=8, name="power_factor", type="INT64", scale=0.01 },
|
||||
]
|
||||
|
||||
## Tags assigned to the metric
|
||||
# [inputs.modbus.metric.tags]
|
||||
# machine = "impresser"
|
||||
# location = "main building"
|
||||
|
|
@ -13,6 +13,17 @@ func firstSection(t *T, root ast.Node) error {
|
|||
var n ast.Node
|
||||
n = root.FirstChild()
|
||||
|
||||
// Ignore HTML comments such as linter ignore sections
|
||||
for {
|
||||
if n == nil {
|
||||
break
|
||||
}
|
||||
if _, ok := n.(*ast.HTMLBlock); !ok {
|
||||
break
|
||||
}
|
||||
n = n.NextSibling()
|
||||
}
|
||||
|
||||
t.assertKind(ast.KindHeading, n)
|
||||
t.assertHeadingLevel(1, n)
|
||||
t.assertFirstChildRegexp(` Plugin$`, n)
|
||||
|
|
|
|||
Loading…
Reference in New Issue