From 9228afe593e754c5fd419977948d761ea3c40608 Mon Sep 17 00:00:00 2001 From: Paula Iacoban Date: Thu, 20 Mar 2025 15:49:44 +0100 Subject: [PATCH 1/2] feat(modbus_server): add modbus_server io plugins --- docs/LICENSE_OF_DEPENDENCIES.md | 2 + go.mod | 1 + go.sum | 3 + plugins/common/modbus_server/handler.go | 251 ++++++ plugins/common/modbus_server/handler_test.go | 189 ++++ plugins/common/modbus_server/memory.go | 277 ++++++ plugins/common/modbus_server/memory_test.go | 837 ++++++++++++++++++ .../common/modbus_server/type_conversions.go | 130 +++ .../modbus_server/type_conversions16.go | 187 ++++ .../modbus_server/type_conversions32.go | 200 +++++ .../modbus_server/type_conversions64.go | 184 ++++ .../common/modbus_server/type_conversions8.go | 253 ++++++ .../modbus_server/type_conversions_bit.go | 14 + .../modbus_server/type_conversions_string.go | 61 ++ plugins/inputs/all/modbus_server.go | 5 + plugins/inputs/modbus_server/README.md | 95 ++ plugins/inputs/modbus_server/modbus_server.go | 229 +++++ .../modbus_server/modbus_server_test.go | 558 ++++++++++++ plugins/inputs/modbus_server/sample.conf | 38 + plugins/outputs/all/modbus_server.go | 5 + plugins/outputs/modbus_server/README.md | 85 ++ .../modbus_server/hash_id_generator.go | 73 ++ .../outputs/modbus_server/modbus_server.go | 251 ++++++ .../modbus_server/modbus_server_test.go | 646 ++++++++++++++ plugins/outputs/modbus_server/sample.conf | 38 + 25 files changed, 4612 insertions(+) create mode 100644 plugins/common/modbus_server/handler.go create mode 100644 plugins/common/modbus_server/handler_test.go create mode 100644 plugins/common/modbus_server/memory.go create mode 100644 plugins/common/modbus_server/memory_test.go create mode 100644 plugins/common/modbus_server/type_conversions.go create mode 100644 plugins/common/modbus_server/type_conversions16.go create mode 100644 plugins/common/modbus_server/type_conversions32.go create mode 100644 plugins/common/modbus_server/type_conversions64.go create mode 100644 plugins/common/modbus_server/type_conversions8.go create mode 100644 plugins/common/modbus_server/type_conversions_bit.go create mode 100644 plugins/common/modbus_server/type_conversions_string.go create mode 100644 plugins/inputs/all/modbus_server.go create mode 100644 plugins/inputs/modbus_server/README.md create mode 100644 plugins/inputs/modbus_server/modbus_server.go create mode 100644 plugins/inputs/modbus_server/modbus_server_test.go create mode 100644 plugins/inputs/modbus_server/sample.conf create mode 100644 plugins/outputs/all/modbus_server.go create mode 100644 plugins/outputs/modbus_server/README.md create mode 100644 plugins/outputs/modbus_server/hash_id_generator.go create mode 100644 plugins/outputs/modbus_server/modbus_server.go create mode 100644 plugins/outputs/modbus_server/modbus_server_test.go create mode 100644 plugins/outputs/modbus_server/sample.conf diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index d9bcc58a6334c..9f9e1598ec161 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -164,6 +164,7 @@ following works: - 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/go-stomp/stomp [Apache License 2.0](https://github.com/go-stomp/stomp/blob/master/LICENSE.txt) +- github.com/goburrow/serial [MIT License](https://github.com/goburrow/serial?tab=MIT-1-ov-file#readme) - github.com/gobwas/glob [MIT License](https://github.com/gobwas/glob/blob/master/LICENSE) - github.com/goccy/go-json [MIT License](https://github.com/goccy/go-json/blob/master/LICENSE) - github.com/godbus/dbus [BSD 2-Clause "Simplified" License](https://github.com/godbus/dbus/blob/master/LICENSE) @@ -361,6 +362,7 @@ following works: - github.com/signalfx/golib [Apache License 2.0](https://github.com/signalfx/golib/blob/master/LICENSE) - github.com/signalfx/sapm-proto [Apache License 2.0](https://github.com/signalfx/sapm-proto/blob/master/LICENSE) - github.com/sijms/go-ora [MIT License](https://github.com/sijms/go-ora/blob/master/LICENSE) +- github.com/simonvetter/modbus [MIT License](https://github.com/simonvetter/modbus/blob/master/LICENSE.txt) - github.com/sirupsen/logrus [MIT License](https://github.com/sirupsen/logrus/blob/master/LICENSE) - github.com/sleepinggenius2/gosmi [MIT License](https://github.com/sleepinggenius2/gosmi/blob/master/LICENSE) - github.com/snowflakedb/gosnowflake [Apache License 2.0](https://github.com/snowflakedb/gosnowflake/blob/master/LICENSE) diff --git a/go.mod b/go.mod index 3f589bcc9b673..628eb4584b0ef 100644 --- a/go.mod +++ b/go.mod @@ -185,6 +185,7 @@ require ( github.com/showwin/speedtest-go v1.7.10 github.com/signalfx/golib/v3 v3.3.54 github.com/sijms/go-ora/v2 v2.8.22 + github.com/simonvetter/modbus v1.6.3 github.com/sirupsen/logrus v1.9.3 github.com/sleepinggenius2/gosmi v0.4.4 github.com/snowflakedb/gosnowflake v1.11.2 diff --git a/go.sum b/go.sum index 0ef4124e73f6a..76966282113c7 100644 --- a/go.sum +++ b/go.sum @@ -1274,6 +1274,7 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goburrow/modbus v0.1.0 h1:DejRZY73nEM6+bt5JSP6IsFolJ9dVcqxsYbpLbeW/ro= github.com/goburrow/modbus v0.1.0/go.mod h1:Kx552D5rLIS8E7TyUwQ/UdHEqvX5T8tyiGBTlzMcZBg= +github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/goburrow/serial v0.1.1-0.20211022031912-bfb69110f8dd h1:qJthTC7IG7e/QYR4i2QHxcDmDdB72FXsaGo4CUQvsPo= github.com/goburrow/serial v0.1.1-0.20211022031912-bfb69110f8dd/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -2212,6 +2213,8 @@ github.com/signalfx/sapm-proto v0.12.0 h1:OtOe+Jm8L61Ml8K6X8a89zc8/RlaaMRElCImeG github.com/signalfx/sapm-proto v0.12.0/go.mod h1:wQEki8RNCYjkv19jw5aWDcmDMTQru0ckfUbgHI69U2E= github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU= github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= +github.com/simonvetter/modbus v1.6.3 h1:kDzwVfIPczsM4Iz09il/Dij/bqlT4XiJVa0GYaOVA9w= +github.com/simonvetter/modbus v1.6.3/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/plugins/common/modbus_server/handler.go b/plugins/common/modbus_server/handler.go new file mode 100644 index 0000000000000..8321e40d3b2a9 --- /dev/null +++ b/plugins/common/modbus_server/handler.go @@ -0,0 +1,251 @@ +package modbus_server + +import ( + "sync" + "time" + + "github.com/simonvetter/modbus" + + "github.com/influxdata/telegraf" +) + +// Handler object, passed to the NewServer() constructor above. +type Handler struct { + // this lock is used to avoid concurrency issues between goroutines, as + // Handler methods are called from different goroutines + // (1 goroutine per client) + lock sync.RWMutex + + // these are here to hold client-provided (written) values, for both coils and + // holding registers + + coils []bool + coilOffset uint16 + holdingRegisters []uint16 + registerOffset uint16 + LastEdit chan time.Time + logger telegraf.Logger +} + +func NewRequestHandler(coilsLen, coilOffset, registersLen, registerOffset uint16, logger telegraf.Logger) (*Handler, error) { + if coilsLen == 0 && registersLen == 0 { + return nil, modbus.ErrConfigurationError + } + + return &Handler{ + coils: make([]bool, coilsLen), + coilOffset: coilOffset, + holdingRegisters: make([]uint16, registersLen), + registerOffset: registerOffset, + LastEdit: make(chan time.Time, 1), + logger: logger, + }, nil +} + +func (h *Handler) updateLastEdit() { + // Check if the channel is empty. If empty write the current time to the channel, otherwise update the time. + select { + case <-h.LastEdit: + h.LastEdit <- time.Now() + default: + h.LastEdit <- time.Now() + } +} + +func (h *Handler) GetCoilsAndOffset() ([]bool, uint16) { + h.lock.Lock() + defer h.lock.Unlock() + + coils := make([]bool, len(h.coils)) + registers := make([]uint16, len(h.holdingRegisters)) + + copy(coils, h.coils) + copy(registers, h.holdingRegisters) + + return coils, h.coilOffset +} + +func (h *Handler) GetRegistersAndOffset() ([]uint16, uint16) { + h.lock.Lock() + defer h.lock.Unlock() + + coils := make([]bool, len(h.coils)) + registers := make([]uint16, len(h.holdingRegisters)) + + copy(coils, h.coils) + copy(registers, h.holdingRegisters) + + return registers, h.registerOffset +} + +func (h *Handler) getRegisters(address, quantity uint16) ([]uint16, error) { + if address < h.registerOffset || address+quantity > h.registerOffset+uint16(len(h.holdingRegisters)) { + h.logger.Errorf("Reading address out of range: %v, %v, %v", address, quantity, h.registerOffset) + return nil, modbus.ErrIllegalDataAddress + } + + res := make([]uint16, quantity) + copy(res, h.holdingRegisters[address-h.registerOffset:address-h.registerOffset+quantity]) + + return res, nil +} + +func (h *Handler) setRegisters(address uint16, values []uint16) []uint16 { + res := make([]uint16, 0) + for i, value := range values { + // check if the address is within the range of the holding registers, if not skip the value + if address+uint16(i) >= h.registerOffset+uint16(len(h.holdingRegisters)) || address+uint16(i) < h.registerOffset { + continue + } + h.holdingRegisters[address-h.registerOffset+uint16(i)] = value + res = append(res, value) + } + return res +} + +func (h *Handler) WriteBitToHoldingRegister(address uint16, bitValue bool, bitIndex uint8) (register uint16, err error) { + h.lock.Lock() + defer h.lock.Unlock() + + registers, err := h.getRegisters(address, 1) + if err != nil { + return 0, err + } + + currentValue := registers[0] + if bitValue { + // Set the bit (use OR to ensure the bit is 1) + currentValue |= 1 << bitIndex + } else { + // Clear the bit (use AND with NOT to ensure the bit is 0) + currentValue &^= 1 << bitIndex + } + + registers = h.setRegisters(address, []uint16{currentValue}) + if len(registers) == 0 { + return 0, nil + } + + h.updateLastEdit() + return registers[0], nil +} + +func (h *Handler) WriteCoils(address uint16, values []bool) ([]bool, error) { + h.lock.Lock() + defer h.lock.Unlock() + + res := make([]bool, 0) + for i, value := range values { + // check if the address is within the range of the coils, if not skip the value + if address+uint16(i) >= h.coilOffset+uint16(len(h.coils)) || address+uint16(i) < h.coilOffset { + continue + } + h.coils[address-h.coilOffset+uint16(i)] = value + res = append(res, value) + } + + h.updateLastEdit() + return res, nil +} + +func (h *Handler) ReadCoils(address, quantity uint16) ([]bool, error) { + h.lock.Lock() + defer h.lock.Unlock() + + // check if the address is within the range of the coils + if address < h.coilOffset || address+quantity > h.coilOffset+uint16(len(h.coils)) { + h.logger.Errorf("Reading address out of range: %v, %v, %v", address, quantity, h.coilOffset) + return nil, modbus.ErrIllegalDataAddress + } + + res := make([]bool, quantity) + copy(res, h.coils[address-h.coilOffset:address-h.coilOffset+quantity]) + return res, nil +} + +func (h *Handler) WriteHoldingRegisters(address uint16, values []uint16) ([]uint16, error) { + h.lock.Lock() + defer h.lock.Unlock() + + res := h.setRegisters(address, values) + + if len(res) > 0 { + h.updateLastEdit() + } + + return res, nil +} + +func (h *Handler) ReadHoldingRegisters(address, quantity uint16) ([]uint16, error) { + h.lock.Lock() + defer h.lock.Unlock() + + if address < h.registerOffset || address+quantity > h.registerOffset+uint16(len(h.holdingRegisters)) { + h.logger.Errorf("Reading address out of range: %v, %v, %v", address, quantity, h.registerOffset) + return nil, modbus.ErrIllegalDataAddress + } + + res := make([]uint16, quantity) + copy(res, h.holdingRegisters[address-h.registerOffset:address-h.registerOffset+quantity]) + return res, nil +} + +// HandleCoils handler method. +// This method gets called whenever a valid modbus request asking for a coil operation is +// received by the server. +func (h *Handler) HandleCoils(req *modbus.CoilsRequest) (res []bool, err error) { + h.logger.Debugf("Handling coils request: %+v", req) + if req.IsWrite { + h.logger.Debugf("Writing coils: %+v, args: %+v", req.Addr, req.Args) + res, err = h.WriteCoils(req.Addr, req.Args) + h.logger.Debugf("Write coils: %+v", res) + // Check if the channel is empty. If empty write the current time to the channel, otherwise update the time. + } else { + h.logger.Debugf("Reading coils: %+v, quantity %+v", req.Addr, req.Quantity) + res, err = h.ReadCoils(req.Addr, req.Quantity) + h.logger.Debugf("Read coils: %+v", res) + } + return res, err +} + +// HandleDiscreteInputs handler method. +// Note that we're returning ErrIllegalFunction unconditionally. +// This will cause the client to receive "illegal function", which is the modbus way of +// reporting that this server does not support/implement the discrete input type. +func (h *Handler) HandleDiscreteInputs(_ *modbus.DiscreteInputsRequest) (res []bool, err error) { + // this is the equivalent of saying + // "discrete inputs are not supported by this device" + // (try it with modbus-cli --target tcp://localhost:5502 rdi:1) + h.logger.Error("Discrete inputs are not supported by this device") + err = modbus.ErrIllegalFunction + + return res, err +} + +// HandleHoldingRegisters handler method. +// This method gets called whenever a valid modbus request asking for a holding register +// operation (either read or write) received by the server. +func (h *Handler) HandleHoldingRegisters(req *modbus.HoldingRegistersRequest) (res []uint16, err error) { + h.logger.Debugf("Handling register reguest: %+v", req) + if req.IsWrite { + h.logger.Debugf("Writing registers: %+v, args: %+v", req.Addr, req.Args) + res, err = h.WriteHoldingRegisters(req.Addr, req.Args) + h.logger.Debugf("Write registers: %+v", res) + } else { + h.logger.Debugf("Reading registers: %+v, quantity: %+v ", req.Addr, req.Quantity) + res, err = h.ReadHoldingRegisters(req.Addr, req.Quantity) + h.logger.Debugf("Read registers: %+v", res) + } + + return res, err +} + +// HandleInputRegisters handler method. +// This method gets called whenever a valid modbus request asking for an input register +// operation is received by the server. +// Note that input registers are always read-only as per the modbus spec. +func (h *Handler) HandleInputRegisters(_ *modbus.InputRegistersRequest) (res []uint16, err error) { + h.logger.Error("Input registers are not supported by this device") + err = modbus.ErrIllegalFunction + return res, err +} diff --git a/plugins/common/modbus_server/handler_test.go b/plugins/common/modbus_server/handler_test.go new file mode 100644 index 0000000000000..ebdd93af985b7 --- /dev/null +++ b/plugins/common/modbus_server/handler_test.go @@ -0,0 +1,189 @@ +package modbus_server + +import ( + "testing" + + "github.com/simonvetter/modbus" + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf/testutil" +) + +func TestNewRequestHandler(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(10, 0, 10, 0, logger) + require.NoError(t, err) + require.NotNil(t, handler) + require.Len(t, handler.coils, 10) + require.Len(t, handler.holdingRegisters, 10) +} + +func TestWriteCoils(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(10, 0, 0, 0, logger) + require.NoError(t, err) + + values := []bool{true, false, true} + res, err := handler.WriteCoils(0, values) + require.NoError(t, err) + require.Equal(t, values, res) + + // writing outside the server memory + res, err = handler.WriteCoils(20, values) + require.NoError(t, err) + + require.Empty(t, res) + + // writing partly outside the server memory + res, err = handler.WriteCoils(8, values) + require.NoError(t, err) + require.Equal(t, []bool{true, false}, res) + res, err = handler.ReadCoils(0, 10) + require.Equal(t, []bool{true, false, true, false, false, false, false, false, true, false}, res) + require.NoError(t, err) +} + +func TestReadCoils(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(10, 0, 0, 0, logger) + require.NoError(t, err) + values := []bool{true, false, true} + _, err = handler.WriteCoils(0, values) + require.NoError(t, err) + res, err := handler.ReadCoils(0, 3) + require.NoError(t, err) + require.Equal(t, values, res) +} + +func TestWriteHoldingRegisters(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(0, 0, 10, 0, logger) + require.NoError(t, err) + values := []uint16{123, 456, 789} + res, err := handler.WriteHoldingRegisters(0, values) + require.NoError(t, err) + require.Equal(t, values, res) + + // writing outside the server memory + res, err = handler.WriteHoldingRegisters(20, values) + require.NoError(t, err) + require.Empty(t, res) + + // writing partly outside the server memory + res, err = handler.WriteHoldingRegisters(8, values) + require.NoError(t, err) + require.Equal(t, []uint16{123, 456}, res) + res, err = handler.ReadHoldingRegisters(0, 10) + require.Equal(t, []uint16{123, 456, 789, 0, 0, 0, 0, 0, 123, 456}, res) + require.NoError(t, err) +} + +func TestReadHoldingRegisters(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(0, 0, 10, 0, logger) + require.NoError(t, err) + values := []uint16{123, 456, 789} + _, err = handler.WriteHoldingRegisters(0, values) + require.NoError(t, err) + res, err := handler.ReadHoldingRegisters(0, 3) + require.NoError(t, err) + require.Equal(t, values, res) +} + +func TestHandleCoils(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(10, 0, 0, 0, logger) + require.NoError(t, err) + + req := &modbus.CoilsRequest{ + IsWrite: true, + Addr: 0, + Args: []bool{true, false, true}, + Quantity: 3, + } + res, err := handler.HandleCoils(req) + require.NoError(t, err) + require.Equal(t, req.Args, res) + + req = &modbus.CoilsRequest{ + IsWrite: false, + Addr: 0, + Quantity: 3, + } + res, err = handler.HandleCoils(req) + require.NoError(t, err) + require.Equal(t, []bool{true, false, true}, res) +} + +func TestHandleDiscreteInputs(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(10, 0, 10, 0, logger) + require.NoError(t, err) + + req := &modbus.DiscreteInputsRequest{} + _, err = handler.HandleDiscreteInputs(req) + require.Error(t, err) + require.Equal(t, modbus.ErrIllegalFunction, err) +} + +func TestHandleHoldingRegisters(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(0, 0, 10, 0, logger) + require.NoError(t, err) + + req := &modbus.HoldingRegistersRequest{ + IsWrite: true, + Addr: 0, + Args: []uint16{123, 456, 789}, + Quantity: 3, + } + res, err := handler.HandleHoldingRegisters(req) + require.NoError(t, err) + require.Equal(t, req.Args, res) + + req = &modbus.HoldingRegistersRequest{ + IsWrite: false, + Addr: 0, + Quantity: 3, + } + res, err = handler.HandleHoldingRegisters(req) + require.NoError(t, err) + require.Equal(t, []uint16{123, 456, 789}, res) +} + +func TestHandleInputRegisters(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(0, 0, 10, 0, logger) + require.NoError(t, err) + + req := &modbus.InputRegistersRequest{} + _, err = handler.HandleInputRegisters(req) + require.Error(t, err) + require.Equal(t, modbus.ErrIllegalFunction, err) +} + +func TestWriteBitToHoldingRegister(t *testing.T) { + logger := testutil.Logger{} + handler, err := NewRequestHandler(0, 0, 10, 0, logger) + require.NoError(t, err) + + // Test setting a bit + register, err := handler.WriteBitToHoldingRegister(0, true, 0) + require.NoError(t, err) + require.Equal(t, uint16(1), register) + + // Test clearing a bit + register, err = handler.WriteBitToHoldingRegister(0, false, 0) + require.NoError(t, err) + require.Equal(t, uint16(0), register) + + // Test setting a different bit + register, err = handler.WriteBitToHoldingRegister(0, true, 1) + require.NoError(t, err) + require.Equal(t, uint16(2), register) + + // Test setting a bit out of range + _, err = handler.WriteBitToHoldingRegister(20, true, 0) + require.Error(t, err) + require.Equal(t, modbus.ErrIllegalDataAddress, err) +} diff --git a/plugins/common/modbus_server/memory.go b/plugins/common/modbus_server/memory.go new file mode 100644 index 0000000000000..e588c93787415 --- /dev/null +++ b/plugins/common/modbus_server/memory.go @@ -0,0 +1,277 @@ +//go:generate ../../../tools/readme_config_includer/generator +package modbus_server + +import ( + "fmt" + "math" + + "github.com/x448/float16" +) + +// Define the size (in addresses) for supported types +var typeSizes = map[string]uint16{ + "BIT": 1, // 1 bit + "INT8L": 1, // 1 byte + "INT8H": 1, // 1 byte + "UINT8L": 1, // 1 byte + "UINT8H": 1, // 1 byte + "FLOAT16": 1, // 2 bytes + "INT16": 1, // 2 bytes + "UINT16": 1, // 2 bytes + "FLOAT32": 2, // 4 bytes + "INT32": 2, // 4 bytes + "UINT32": 2, // 4 bytes + "INT64": 4, // 8 bytes + "UINT64": 4, // 8 bytes + "FLOAT64": 4, // 8 bytes +} + +// MemoryEntry represents a single memory entry with an address and type +type MemoryEntry struct { + Address uint16 + CoilInitialValue bool + Type string + Register string + Scale float64 + Bit uint8 + Length uint16 + Measurement string + HashID uint64 + Field string +} + +type MemoryLayout []MemoryEntry + +// getBounds calculates the start and end address range for an entry based on its type +func (entry MemoryEntry) getBounds() (start, end uint16, bit uint8) { + if entry.Register == "coil" { + start, end, bit = entry.Address, entry.Address+1, 0 + return start, end, bit + } + + if entry.Register != "coil" && entry.Length > 0 { + start, end, bit = entry.Address, entry.Address+entry.Length, 0 + return start, end, bit + } + + size, ok := typeSizes[entry.Type] + if !ok { + start, end, bit = 0, 0, 0 + } else { + start = entry.Address + end = entry.Address + size + bit = entry.Bit + } + + return start, end, bit +} + +// HasOverlap checks a list of memory entries for overlaps +func (entries MemoryLayout) HasOverlap() (bool, []string, error) { + usedAddresses := make(map[int]bool) // Map to track used addresses + var overlaps []string + usedBits := make(map[int][]bool) // Map to track used bits + + for _, entry := range entries { + if _, ok := typeSizes[entry.Type]; !ok && entry.Register != "coil" && entry.Type != "STRING" { + return false, overlaps, fmt.Errorf("unsupported type: %s", entry.Type) + } + + start, end, bit := entry.getBounds() + // check for BIT overlap + if entry.Register != "coil" && entry.Type == "BIT" { + bitIndex := int(entry.Bit) + if bitIndex > 15 { + overlaps = append(overlaps, fmt.Sprintf("Entry at address %d overlaps with type %s", entry.Address, entry.Type)) + return true, overlaps, fmt.Errorf("bit index %d out of range", bitIndex) + } + if usedBits[int(entry.Address)] == nil { + usedBits[int(entry.Address)] = make([]bool, 16) + } else if usedBits[int(entry.Address)][bitIndex] { + overlaps = append(overlaps, fmt.Sprintf("Entry at address %d overlaps with type %s", entry.Address, entry.Type)) + } else { + usedBits[int(entry.Address)][bitIndex] = true + } + } + // Check for overlaps + for addr := start; addr < end; addr++ { + if bit == 0 && usedAddresses[int(addr)] { + overlaps = append(overlaps, fmt.Sprintf("Entry at address %d overlaps with type %s", entry.Address, entry.Type)) + } + usedAddresses[int(addr)] = true + } + } + return len(overlaps) > 0, overlaps, nil +} + +func (entries MemoryLayout) GetMemoryOffsets() (coilOffset, registerOffset uint16) { + coilOffset, registerOffset = math.MaxUint16, math.MaxUint16 + + for _, entry := range entries { + if entry.Register == "coil" { + if coilOffset > entry.Address { + coilOffset = entry.Address + } + } else { + if registerOffset > entry.Address { + registerOffset = entry.Address + } + } + } + return coilOffset, registerOffset +} + +func (entries MemoryLayout) GetMaxAddresses() (maxAddressCoil, maxAddressRegister uint16) { + maxAddressCoil, maxAddressRegister = 0, 0 + + for _, entry := range entries { + if entry.Register == "coil" { + if maxAddressCoil < entry.Address { + maxAddressCoil = entry.Address + } + } else { + if maxAddressRegister < entry.Address { + maxAddressRegister = entry.Address + typeSizes[entry.Type] - 1 + } + } + } + return maxAddressCoil, maxAddressRegister +} + +func ParseMemory(byteOrder string, entry MemoryEntry, coilOffset, registerOffset uint16, coils []bool, registers []uint16) (any, error) { + var value any + if entry.Register == "coil" { + value = coils[entry.Address-coilOffset] + } else { + startAddr := entry.Address - registerOffset + endAddr := startAddr + entry.Length - 1 + + if entry.Type != "STRING" { + endAddr = startAddr + typeSizes[entry.Type] - 1 + } + + contents := registers[startAddr : endAddr+1] + converter, err := determineConverter(entry.Type, byteOrder, "native", entry.Scale, entry.Bit, "") + if err != nil { + return nil, err + } + + converterToBytes, err := endiannessConverterToBytes(byteOrder) + if err != nil { + return nil, err + } + var bytesValue []byte + for _, content := range contents { + bytesValue = append(bytesValue, converterToBytes(content)...) + } + + value = converter(bytesValue) + if entry.Type == "BIT" { + value = value != uint8(0) + } + } + return value, nil +} + +func (entries MemoryLayout) GetCoilsAndRegisters() ([]bool, []uint16) { + coilOffset, registerOffset := entries.GetMemoryOffsets() + + maxCoilAddr, maxRegisterAddr := entries.GetMaxAddresses() + + coils := make([]bool, maxCoilAddr-coilOffset+1) + registers := make([]uint16, maxRegisterAddr-registerOffset+1) + + for _, entry := range entries { + if entry.Register == "coil" { + coils[entry.Address-coilOffset] = false + } else { + for i := uint16(0); i < typeSizes[entry.Type]; i++ { + registers[entry.Address-registerOffset+i] = 0 + } + } + } + return coils, registers +} + +func (entries MemoryLayout) GetMemoryMappedByHashID() (map[uint64]map[string]MemoryEntry, error) { + memoryMap := make(map[uint64]map[string]MemoryEntry) + for _, entry := range entries { + if _, ok := memoryMap[entry.HashID]; ok { + continue + } + memoryMap[entry.HashID] = make(map[string]MemoryEntry) + } + for _, entry := range entries { + memoryMap[entry.HashID][entry.Field] = entry + } + return memoryMap, nil +} + +// cast a 64-bit number to the specified type +func castToType(value any, valueType string) any { + type casters map[string]func(any) any + + casterMap := casters{ + "INT8L": func(v any) any { return int8(v.(int64)) }, + "UINT8L": func(v any) any { return uint8(v.(uint64)) }, + "INT8H": func(v any) any { return int8(v.(int64)) }, + "UINT8H": func(v any) any { return uint8(v.(uint64)) }, + "INT16": func(v any) any { return int16(v.(int64)) }, + "UINT16": func(v any) any { return uint16(v.(uint64)) }, + "FLOAT16": func(v any) any { return float16.Fromfloat32(float32(v.(float64))) }, + "FLOAT32": func(v any) any { return float32(v.(float64)) }, + "INT32": func(v any) any { return int32(v.(int64)) }, + "UINT32": func(v any) any { return uint32(v.(uint64)) }, + "INT64": func(v any) any { return v.(int64) }, + "UINT64": func(v any) any { return v.(uint64) }, + "FLOAT64": func(v any) any { return v.(float64) }, + "STRING": func(v any) any { return v.(string) }, + } + + if castFunc, exists := casterMap[valueType]; exists { + return castFunc(value) + } + return nil +} + +func ParseMetric(byteOrder string, value any, valueType string, scale float64) ([]uint16, error) { + value = castToType(value, valueType) + if value == nil { + return nil, fmt.Errorf("unsupported type: %s", valueType) + } + + // ignore endianness for strings + if valueType == "STRING" { + byteOrder = "ABCD" + } + + converter, err := determineConverter("UINT16", byteOrder, "native", scale, 0, "") + if err != nil { + return nil, err + } + converterToBytes, err := endiannessConverterToBytes(byteOrder) + if err != nil { + return nil, err + } + bytesValue := converterToBytes(value) + // Add padding for odd-length strings + if valueType == "STRING" && len(bytesValue)%2 != 0 { + bytesValue = append(bytesValue, 0) + } + + var registerValues []uint16 + for i := 0; i < len(bytesValue); i++ { + // Convert the 8-bit values to uint16 + if valueType == "INT8L" || valueType == "UINT8L" { + registerValues = append(registerValues, uint16(bytesValue[i])) + } else if valueType == "INT8H" || valueType == "UINT8H" { + registerValues = append(registerValues, uint16(bytesValue[i])<<8) // Shift the byte to the high position + } else if i+1 < len(bytesValue) { // convert >= 16-bit values to uint16 + registerValues = append(registerValues, converter(bytesValue[i:i+2]).(uint16)) + i++ // Skip the next byte since we processed two bytes + } else { + return nil, fmt.Errorf("unexpected end of bytesValue for %s", valueType) + } + } + return registerValues, nil +} diff --git a/plugins/common/modbus_server/memory_test.go b/plugins/common/modbus_server/memory_test.go new file mode 100644 index 0000000000000..33612f1fefbf0 --- /dev/null +++ b/plugins/common/modbus_server/memory_test.go @@ -0,0 +1,837 @@ +package modbus_server + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/x448/float16" +) + +func TestGetBounds(t *testing.T) { + tests := []struct { + entry MemoryEntry + expectedStart uint16 + expectedEnd uint16 + expectError bool + }{ + {MemoryEntry{Address: 0, Type: "BIT"}, 0, 1, false}, + {MemoryEntry{Address: 100, Type: "UINT16"}, 100, 101, false}, + {MemoryEntry{Address: 200, Type: "FLOAT32"}, 200, 202, false}, + {MemoryEntry{Address: 300, Type: "INT64"}, 300, 304, false}, + {MemoryEntry{Address: 400, Type: "INVALID"}, 0, 0, true}, + } + + for _, test := range tests { + start, end, _ := test.entry.getBounds() + require.Equal(t, test.expectedStart, start) + require.Equal(t, test.expectedEnd, end) + // check for unsupported type + _, _, err := MemoryLayout{test.entry}.HasOverlap() + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + + // Test for unsupported type + start, end, bit := MemoryEntry{Address: 1, Type: "INVALID"}.getBounds() + require.Equal(t, uint16(0), start) + require.Equal(t, uint16(0), end) + require.Equal(t, uint8(0), bit) + + _, _, err := MemoryLayout{MemoryEntry{Address: 0, Type: "INVALID"}}.HasOverlap() + require.Error(t, err) +} + +func TestHasOverlap(t *testing.T) { + tests := []struct { + layout MemoryLayout + expectOverlap bool + expectedError bool + }{ + { + MemoryLayout{ + {Address: 0, Type: "BIT"}, + {Address: 1, Type: "UINT16"}, + {Address: 3, Type: "FLOAT32"}, + }, + false, + false, + }, + { + MemoryLayout{ + {Address: 0, Type: "BIT"}, + {Address: 1, Type: "UINT16"}, + {Address: 2, Type: "FLOAT32"}, + }, + false, + false, + }, + { + MemoryLayout{ + {Address: 0, Type: "BIT"}, + {Address: 1, Type: "FLOAT32"}, + {Address: 2, Type: "UINT16"}, + }, + true, + false, + }, + { + MemoryLayout{ + {Address: 0, Type: "BIT", Register: "register", Bit: 0}, + {Address: 0, Type: "BIT", Register: "register", Bit: 1}, + {Address: 0, Type: "BIT", Register: "register", Bit: 2}, + {Address: 0, Type: "BIT", Register: "register", Bit: 3}, + {Address: 0, Type: "BIT", Register: "register", Bit: 4}, + {Address: 0, Type: "BIT", Register: "register", Bit: 5}, + {Address: 0, Type: "BIT", Register: "register", Bit: 6}, + {Address: 0, Type: "BIT", Register: "register", Bit: 7}, + {Address: 0, Type: "BIT", Register: "register", Bit: 8}, + {Address: 0, Type: "BIT", Register: "register", Bit: 9}, + {Address: 0, Type: "BIT", Register: "register", Bit: 10}, + {Address: 0, Type: "BIT", Register: "register", Bit: 11}, + {Address: 0, Type: "BIT", Register: "register", Bit: 12}, + {Address: 0, Type: "BIT", Register: "register", Bit: 13}, + {Address: 0, Type: "BIT", Register: "register", Bit: 14}, + {Address: 0, Type: "BIT", Register: "register", Bit: 15}, + {Address: 0, Type: "BIT", Register: "register", Bit: 16}, + }, + true, + true, + }, + { + MemoryLayout{ + {Address: 0, Type: "BIT", Register: "register", Bit: 0}, + {Address: 0, Type: "BIT", Register: "register", Bit: 1}, + {Address: 0, Type: "BIT", Register: "register", Bit: 2}, + {Address: 0, Type: "BIT", Register: "register", Bit: 3}, + {Address: 0, Type: "BIT", Register: "register", Bit: 4}, + {Address: 0, Type: "BIT", Register: "register", Bit: 5}, + {Address: 0, Type: "BIT", Register: "register", Bit: 6}, + {Address: 0, Type: "BIT", Register: "register", Bit: 7}, + {Address: 0, Type: "BIT", Register: "register", Bit: 8}, + {Address: 0, Type: "BIT", Register: "register", Bit: 9}, + {Address: 0, Type: "BIT", Register: "register", Bit: 10}, + {Address: 0, Type: "BIT", Register: "register", Bit: 11}, + {Address: 0, Type: "BIT", Register: "register", Bit: 12}, + {Address: 0, Type: "BIT", Register: "register", Bit: 13}, + {Address: 0, Type: "BIT", Register: "register", Bit: 14}, + {Address: 0, Type: "BIT", Register: "register", Bit: 15}, + }, + false, + false, + }, + } + + for _, test := range tests { + hasOverlap, _, err := test.layout.HasOverlap() + require.Equal(t, test.expectOverlap, hasOverlap) + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} + +func TestGetCoilsAndRegisters(t *testing.T) { + layout := MemoryLayout{ + {Address: 1, Register: "coil"}, + {Address: 3, Register: "coil"}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 0}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 1}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 2}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 3}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 4}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 5}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 6}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 7}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 8}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 9}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 10}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 11}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 12}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 13}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 14}, + {Address: 40000, Type: "BIT", Register: "register", Bit: 15}, + {Address: 40001, Type: "UINT32", Register: "register"}, + {Address: 40003, Type: "UINT32", Register: "register"}, + } + expectedCoils := []bool{false, false, false} + expectedRegisters := []uint16{0, 0, 0, 0, 0} + expectedCoilOffset := uint16(1) + expectedRegisterOffset := uint16(40000) + + coils, registers := layout.GetCoilsAndRegisters() + coilOffset, registerOffset := layout.GetMemoryOffsets() + require.Equal(t, expectedCoils, coils) + require.Equal(t, expectedRegisters, registers) + require.Equal(t, expectedCoilOffset, coilOffset) + require.Equal(t, expectedRegisterOffset, registerOffset) +} + +func TestParseMemoryBigEndian(t *testing.T) { + var emptyRegisters []uint16 + var emptyCoils []bool + + tests := []struct { + byteOrder string + entry MemoryEntry + coilOffset uint16 + registerOffset uint16 + coils []bool + registers []uint16 + expected any + expectError bool + }{ + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Register: "coil"}, + coilOffset: 0, registerOffset: 0, coils: []bool{true}, registers: emptyRegisters, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "UINT16", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{12345}, expected: uint16(12345), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "FLOAT32", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0x3f80, 0x0000}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "INT32", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0xffff, 0xffff}, expected: int32(-1), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "UINT32", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0x0000, 0x0001}, expected: uint32(1), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "INT64", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expected: int64(-1), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "UINT64", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0x0000, 0x0000, 0x0000, 0x0001}, expected: uint64(1), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "FLOAT64", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0x3ff0, 0x0000, 0x0000, 0x0000}, expected: float64(1.0), + expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "INT8L", Register: "register"}, + coilOffset: 0, registerOffset: 0, coils: emptyCoils, registers: []uint16{0x007f}, expected: int8(127), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "INT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x7f00}, expected: int8(127), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "UINT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x00ff}, expected: uint8(255), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "UINT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xff00}, expected: uint8(255), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "FLOAT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3c00}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "INVALID", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: emptyRegisters, expected: nil, expectError: true, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x1110}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "STRING", Register: "register", Length: 3}, coilOffset: 0, registerOffset: 0, + coils: emptyCoils, registers: []uint16{0x4865, 0x6c6c, 0x6f00}, expected: "Hello", expectError: false, + }, + } + for _, test := range tests { + value, err := ParseMemory(test.byteOrder, test.entry, test.coilOffset, test.registerOffset, test.coils, test.registers) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, value) + } + } +} + +func TestParseMemoryBigEndianByteSwap(t *testing.T) { + var emptyRegisters []uint16 + var emptyCoils []bool + + tests := []struct { + byteOrder string + entry MemoryEntry + coilOffset uint16 + registerOffset uint16 + coils []bool + registers []uint16 + expected any + expectError bool + }{ + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Register: "coil"}, coilOffset: 0, registerOffset: 0, coils: []bool{true}, + registers: emptyRegisters, + expected: true, expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "UINT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{12345}, expected: uint16(12345), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "FLOAT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3f80, 0x0000}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "INT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff}, expected: int32(-1), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "UINT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x0001}, expected: uint32(1), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "INT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expected: int64(-1), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "UINT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x0000, 0x0000, 0x0001}, expected: uint64(1), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "FLOAT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3ff0, 0x0000, 0x0000, 0x0000}, expected: float64(1.0), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "INT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x007f}, expected: nil, expectError: true, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "INT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x7f00}, expected: nil, expectError: true, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "UINT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x00ff}, expected: nil, expectError: true, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "UINT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xff00}, expected: nil, expectError: true, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "FLOAT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3c00}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "INVALID", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: emptyRegisters, expected: nil, expectError: true, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001}, expected: true, expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x1110}, expected: false, expectError: false, + }, + { + byteOrder: "BADC", entry: MemoryEntry{Address: 0, Type: "STRING", Register: "register", Length: 3}, coilOffset: 0, registerOffset: 0, + coils: emptyCoils, registers: []uint16{0x4865, 0x6c6c, 0x6f00}, expected: "Hello", expectError: false, + }, + } + + for _, test := range tests { + value, err := ParseMemory(test.byteOrder, test.entry, test.coilOffset, test.registerOffset, test.coils, test.registers) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, value) + } + } +} + +func TestParseMemoryLittleEndian(t *testing.T) { + var emptyRegisters []uint16 + var emptyCoils []bool + + tests := []struct { + byteOrder string + entry MemoryEntry + coilOffset uint16 + registerOffset uint16 + coils []bool + registers []uint16 + expected any + expectError bool + }{ + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Register: "coil"}, coilOffset: 0, registerOffset: 0, coils: []bool{true}, + registers: emptyRegisters, + expected: true, expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "UINT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{12345}, expected: uint16(12345), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "FLOAT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x3f80}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "INT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff}, expected: int32(-1), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "UINT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001, 0x0000}, expected: uint32(1), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "INT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expected: int64(-1), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "UINT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001, 0x0000, 0x0000, 0x0000}, expected: uint64(1), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "FLOAT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x0000, 0x0000, 0x3ff0}, expected: float64(1.0), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "INT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x007f}, expected: int8(127), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "INT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x7f00}, expected: int8(127), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "UINT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x00ff}, expected: uint8(255), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "UINT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xff00}, expected: uint8(255), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "FLOAT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3c00}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "INVALID", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: emptyRegisters, expected: nil, expectError: true, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001}, expected: true, expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x1110}, expected: false, expectError: false, + }, + { + byteOrder: "DCBA", entry: MemoryEntry{Address: 0, Type: "STRING", Register: "register", Length: 3}, coilOffset: 0, registerOffset: 0, + coils: emptyCoils, registers: []uint16{0x4865, 0x6c6c, 0x6f00}, expected: "Hello", expectError: false, + }, + } + + for _, test := range tests { + value, err := ParseMemory(test.byteOrder, test.entry, test.coilOffset, test.registerOffset, test.coils, test.registers) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, value) + } + } +} + +func TestParseMemoryLittleEndianByteSwap(t *testing.T) { + var emptyRegisters []uint16 + var emptyCoils []bool + + tests := []struct { + byteOrder string + entry MemoryEntry + coilOffset uint16 + registerOffset uint16 + coils []bool + registers []uint16 + expected any + expectError bool + }{ + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Register: "coil"}, coilOffset: 0, registerOffset: 0, coils: []bool{true}, + registers: emptyRegisters, + expected: true, expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "UINT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{12345}, expected: uint16(12345), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "FLOAT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x3f80}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "INT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff}, expected: int32(-1), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "UINT32", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001, 0x0000}, expected: uint32(1), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "INT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expected: int64(-1), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "UINT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001, 0x0000, 0x0000, 0x0000}, expected: uint64(1), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "FLOAT64", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0000, 0x0000, 0x0000, 0x3ff0}, expected: float64(1.0), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "INT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x007f}, expected: nil, expectError: true, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "INT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x7f00}, expected: nil, expectError: true, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "UINT8L", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x00ff}, expected: nil, expectError: true, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "UINT8H", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0xff00}, expected: nil, expectError: true, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "FLOAT16", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x3c00}, expected: float32(1.0), expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "INVALID", Register: "register"}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: emptyRegisters, expected: nil, expectError: true, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x0001}, expected: true, expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{0x1110}, expected: false, expectError: false, + }, + { + byteOrder: "CDAB", entry: MemoryEntry{Address: 0, Type: "STRING", Register: "register", Length: 3}, coilOffset: 0, registerOffset: 0, + coils: emptyCoils, registers: []uint16{0x4865, 0x6c6c, 0x6f00}, expected: "Hello", expectError: false, + }, + } + + for _, test := range tests { + value, err := ParseMemory(test.byteOrder, test.entry, test.coilOffset, test.registerOffset, test.coils, test.registers) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, value) + } + } +} + +func TestParseBits(t *testing.T) { + var emptyCoils []bool + + tests := []struct { + byteOrder string + entry MemoryEntry + coilOffset uint16 + registerOffset uint16 + coils []bool + registers []uint16 + expected any + expectError bool + }{ + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 0}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 1}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 2}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 3}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 4}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 5}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 6}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 7}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 8}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 9}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 10}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 11}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 12}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 13}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: true, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 14}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + { + byteOrder: "ABCD", entry: MemoryEntry{Address: 0, Type: "BIT", Register: "register", Bit: 15}, coilOffset: 0, registerOffset: 0, coils: emptyCoils, + registers: []uint16{11784}, expected: false, expectError: false, + }, + } + + // 11784 = 00101110 00001000 + + for _, test := range tests { + value, err := ParseMemory(test.byteOrder, test.entry, test.coilOffset, test.registerOffset, test.coils, test.registers) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, value) + } + } +} + +func TestGetMemoryMappedByName(t *testing.T) { + entries := MemoryLayout{ + {HashID: 0, Measurement: "measurement1", Field: "field1", Address: 0, Type: "UINT16"}, + {HashID: 0, Measurement: "measurement1", Field: "field2", Address: 1, Type: "FLOAT32"}, + {HashID: 1, Measurement: "measurement2", Field: "field1", Address: 2, Type: "INT32"}, + } + + expected := map[uint64]map[string]MemoryEntry{ + 0: { + "field1": {HashID: 0, Measurement: "measurement1", Field: "field1", Address: 0, Type: "UINT16"}, + "field2": {HashID: 0, Measurement: "measurement1", Field: "field2", Address: 1, Type: "FLOAT32"}, + }, + 1: { + "field1": {HashID: 1, Measurement: "measurement2", Field: "field1", Address: 2, Type: "INT32"}, + }, + } + + memoryMap, err := entries.GetMemoryMappedByHashID() + require.NoError(t, err) + require.Equal(t, expected, memoryMap) +} + +func TestCastToType(t *testing.T) { + tests := []struct { + value any + valueType string + expected any + }{ + {value: int64(127), valueType: "INT8L", expected: int8(127)}, + {value: uint64(255), valueType: "UINT8L", expected: uint8(255)}, + {value: int64(127), valueType: "INT8H", expected: int8(127)}, + {value: uint64(255), valueType: "UINT8H", expected: uint8(255)}, + {value: float64(1.0), valueType: "FLOAT16", expected: float16.Fromfloat32(1.0)}, + {value: int64(123), valueType: "INT16", expected: int16(123)}, + {value: uint64(123), valueType: "UINT16", expected: uint16(123)}, + {value: float64(1.23), valueType: "FLOAT32", expected: float32(1.23)}, + {value: int64(123), valueType: "INT32", expected: int32(123)}, + {value: uint64(123), valueType: "UINT32", expected: uint32(123)}, + {value: int64(123), valueType: "INT64", expected: int64(123)}, + {value: uint64(123), valueType: "UINT64", expected: uint64(123)}, + {value: float64(1.23), valueType: "FLOAT64", expected: float64(1.23)}, + {value: "test", valueType: "STRING", expected: "test"}, + } + + for _, test := range tests { + result := castToType(test.value, test.valueType) + require.Equal(t, test.expected, result) + } +} + +func TestParseMetricBigEndian(t *testing.T) { + tests := []struct { + byteOrder string + value any + valueType string + expected []uint16 + expectError bool + }{ + {byteOrder: "ABCD", value: uint64(12345), valueType: "UINT16", expected: []uint16{12345}, expectError: false}, + {byteOrder: "ABCD", value: float64(1.0), valueType: "FLOAT32", expected: []uint16{0x3f80, 0x0000}, expectError: false}, + {byteOrder: "ABCD", value: int64(-1), valueType: "INT32", expected: []uint16{0xffff, 0xffff}, expectError: false}, + {byteOrder: "ABCD", value: uint64(1), valueType: "UINT32", expected: []uint16{0x0000, 0x0001}, expectError: false}, + {byteOrder: "ABCD", value: int64(-1), valueType: "INT64", expected: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expectError: false}, + {byteOrder: "ABCD", value: uint64(1), valueType: "UINT64", expected: []uint16{0x0000, 0x0000, 0x0000, 0x0001}, expectError: false}, + {byteOrder: "ABCD", value: float64(1.0), valueType: "FLOAT64", expected: []uint16{0x3ff0, 0x0000, 0x0000, 0x0000}, expectError: false}, + {byteOrder: "ABCD", value: "invalid", valueType: "INVALID", expected: nil, expectError: true}, + {byteOrder: "ABCD", value: int64(127), valueType: "INT8L", expected: []uint16{0x007f}, expectError: false}, + {byteOrder: "ABCD", value: int64(127), valueType: "INT8H", expected: []uint16{0x7f00}, expectError: false}, + {byteOrder: "ABCD", value: uint64(255), valueType: "UINT8L", expected: []uint16{0x00ff}, expectError: false}, + {byteOrder: "ABCD", value: uint64(255), valueType: "UINT8H", expected: []uint16{0xff00}, expectError: false}, + {byteOrder: "ABCD", value: float64(1.0), valueType: "FLOAT16", expected: []uint16{0x3c00}, expectError: false}, + {byteOrder: "ABCD", value: "Hello", valueType: "STRING", expected: []uint16{0x4865, 0x6c6c, 0x6f00}, expectError: false}, + } + + for _, test := range tests { + result, err := ParseMetric(test.byteOrder, test.value, test.valueType, 0) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, result) + } + } +} + +func TestParseMetricBigEndianByteSwap(t *testing.T) { + tests := []struct { + byteOrder string + value any + valueType string + expected []uint16 + expectError bool + }{ + {byteOrder: "BADC", value: uint64(12345), valueType: "UINT16", expected: []uint16{12345}, expectError: false}, + {byteOrder: "BADC", value: float64(1.0), valueType: "FLOAT32", expected: []uint16{0x0000, 0x3f80}, expectError: false}, + {byteOrder: "BADC", value: int64(-1), valueType: "INT32", expected: []uint16{0xffff, 0xffff}, expectError: false}, + {byteOrder: "BADC", value: uint64(1), valueType: "UINT32", expected: []uint16{0x0001, 0x0000}, expectError: false}, + {byteOrder: "BADC", value: int64(-1), valueType: "INT64", expected: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expectError: false}, + {byteOrder: "BADC", value: uint64(1), valueType: "UINT64", expected: []uint16{0x0001, 0x0000, 0x0000, 0x0000}, expectError: false}, + {byteOrder: "BADC", value: float64(1.0), valueType: "FLOAT64", expected: []uint16{0x0000, 0x0000, 0x0000, 0x3ff0}, expectError: false}, + {byteOrder: "BADC", value: "invalid", valueType: "INVALID", expected: nil, expectError: true}, + {byteOrder: "BADC", value: int64(127), valueType: "INT8L", expected: []uint16{0x007f}, expectError: false}, + {byteOrder: "BADC", value: int64(127), valueType: "INT8H", expected: []uint16{0x7f00}, expectError: false}, + {byteOrder: "BADC", value: uint64(255), valueType: "UINT8L", expected: []uint16{0x00ff}, expectError: false}, + {byteOrder: "BADC", value: uint64(255), valueType: "UINT8H", expected: []uint16{0xff00}, expectError: false}, + {byteOrder: "BADC", value: float64(1.0), valueType: "FLOAT16", expected: []uint16{0x3c00}, expectError: false}, + {byteOrder: "BADC", value: "Hello", valueType: "STRING", expected: []uint16{0x4865, 0x6c6c, 0x6f00}, expectError: false}, + } + + for _, test := range tests { + result, err := ParseMetric(test.byteOrder, test.value, test.valueType, 0) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, result) + } + } +} + +func TestParseMetricLittleEndian(t *testing.T) { + tests := []struct { + byteOrder string + value any + valueType string + expected []uint16 + expectError bool + }{ + {byteOrder: "DCBA", value: uint64(12345), valueType: "UINT16", expected: []uint16{12345}, expectError: false}, + {byteOrder: "DCBA", value: float64(1.0), valueType: "FLOAT32", expected: []uint16{0x0000, 0x3f80}, expectError: false}, + {byteOrder: "DCBA", value: int64(-1), valueType: "INT32", expected: []uint16{0xffff, 0xffff}, expectError: false}, + {byteOrder: "DCBA", value: uint64(1), valueType: "UINT32", expected: []uint16{0x0001, 0x0000}, expectError: false}, + {byteOrder: "DCBA", value: int64(-1), valueType: "INT64", expected: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expectError: false}, + {byteOrder: "DCBA", value: uint64(1), valueType: "UINT64", expected: []uint16{0x0001, 0x0000, 0x0000, 0x0000}, expectError: false}, + {byteOrder: "DCBA", value: float64(1.0), valueType: "FLOAT64", expected: []uint16{0x0000, 0x0000, 0x0000, 0x3ff0}, expectError: false}, + {byteOrder: "DCBA", value: "invalid", valueType: "INVALID", expected: nil, expectError: true}, + {byteOrder: "DCBA", value: int64(127), valueType: "INT8L", expected: []uint16{0x007f}, expectError: false}, + {byteOrder: "DCBA", value: int64(127), valueType: "INT8H", expected: []uint16{0x7f00}, expectError: false}, + {byteOrder: "DCBA", value: uint64(255), valueType: "UINT8L", expected: []uint16{0x00ff}, expectError: false}, + {byteOrder: "DCBA", value: uint64(255), valueType: "UINT8H", expected: []uint16{0xff00}, expectError: false}, + {byteOrder: "DCBA", value: float64(1.0), valueType: "FLOAT16", expected: []uint16{0x3c00}, expectError: false}, + {byteOrder: "DCBA", value: "Hello", valueType: "STRING", expected: []uint16{0x4865, 0x6c6c, 0x6f00}, expectError: false}, + } + + for _, test := range tests { + result, err := ParseMetric(test.byteOrder, test.value, test.valueType, 0) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, result) + } + } +} + +func TestParseMetricLittleEndianByteSwap(t *testing.T) { + tests := []struct { + byteOrder string + value any + valueType string + expected []uint16 + expectError bool + }{ + {byteOrder: "CDAB", value: uint64(12345), valueType: "UINT16", expected: []uint16{12345}, expectError: false}, + {byteOrder: "CDAB", value: float64(1.0), valueType: "FLOAT32", expected: []uint16{0x3f80, 0x0000}, expectError: false}, + {byteOrder: "CDAB", value: int64(-1), valueType: "INT32", expected: []uint16{0xffff, 0xffff}, expectError: false}, + {byteOrder: "CDAB", value: uint64(1), valueType: "UINT32", expected: []uint16{0x0000, 0x0001}, expectError: false}, + {byteOrder: "CDAB", value: int64(-1), valueType: "INT64", expected: []uint16{0xffff, 0xffff, 0xffff, 0xffff}, expectError: false}, + {byteOrder: "CDAB", value: uint64(1), valueType: "UINT64", expected: []uint16{0x0000, 0x0000, 0x0000, 0x0001}, expectError: false}, + {byteOrder: "CDAB", value: float64(1.0), valueType: "FLOAT64", expected: []uint16{0x3ff0, 0x0000, 0x0000, 0x0000}, expectError: false}, + {byteOrder: "CDAB", value: "invalid", valueType: "INVALID", expected: nil, expectError: true}, + {byteOrder: "CDAB", value: int64(127), valueType: "INT8L", expected: []uint16{0x007f}, expectError: false}, + {byteOrder: "CDAB", value: int64(127), valueType: "INT8H", expected: []uint16{0x7f00}, expectError: false}, + {byteOrder: "CDAB", value: uint64(255), valueType: "UINT8L", expected: []uint16{0x00ff}, expectError: false}, + {byteOrder: "CDAB", value: uint64(255), valueType: "UINT8H", expected: []uint16{0xff00}, expectError: false}, + {byteOrder: "CDAB", value: float64(1.0), valueType: "FLOAT16", expected: []uint16{0x3c00}, expectError: false}, + {byteOrder: "CDAB", value: "Hello", valueType: "STRING", expected: []uint16{0x4865, 0x6c6c, 0x6f00}, expectError: false}, + } + for _, test := range tests { + result, err := ParseMetric(test.byteOrder, test.value, test.valueType, 0) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, result) + } + } +} diff --git a/plugins/common/modbus_server/type_conversions.go b/plugins/common/modbus_server/type_conversions.go new file mode 100644 index 0000000000000..1b241edc3c63f --- /dev/null +++ b/plugins/common/modbus_server/type_conversions.go @@ -0,0 +1,130 @@ +package modbus_server + +import ( + "bytes" + "encoding/binary" + "fmt" +) + +type fieldConverterFunc func([]byte) interface{} + +type convertToBytes func(any) []byte + +func endiannessConverterToBytes(byteOrder string) (convertToBytes, error) { + switch byteOrder { + case "ABCD", "CDAB": // Big endian (Motorola) + return func(b any) []byte { + switch v := b.(type) { + case string: + return []byte(v) + default: + buf := new(bytes.Buffer) + err := binary.Write(buf, binary.BigEndian, b) + if err != nil { + return nil + } + return buf.Bytes() + } + }, nil + case "DCBA", "BADC": // Little endian (Intel) + return func(b any) []byte { + switch v := b.(type) { + case string: + return []byte(v) + default: + buf := new(bytes.Buffer) + err := binary.Write(buf, binary.LittleEndian, b) + if err != nil { + return nil + } + return buf.Bytes() + } + }, nil + } + return nil, fmt.Errorf("invalid byte-order: %s", byteOrder) +} + +func determineConverter(inType, byteOrder, outType string, scale float64, bit uint8, strloc string) (fieldConverterFunc, error) { + switch inType { + case "STRING": + switch strloc { + case "", "both": + return determineConverterString(byteOrder) + case "lower": + return determineConverterStringLow(byteOrder) + case "upper": + return determineConverterStringHigh(byteOrder) + } + case "BIT": + return determineConverterBit(byteOrder, bit) + } + + if scale != 0.0 { + return determineConverterScale(inType, byteOrder, outType, scale) + } + return determineConverterNoScale(inType, byteOrder, outType) +} + +func determineConverterScale(inType, byteOrder, outType string, scale float64) (fieldConverterFunc, error) { + switch inType { + case "INT8L": + return determineConverterI8LScale(outType, byteOrder, scale) + case "INT8H": + return determineConverterI8HScale(outType, byteOrder, scale) + case "UINT8L": + return determineConverterU8LScale(outType, byteOrder, scale) + case "UINT8H": + return determineConverterU8HScale(outType, byteOrder, scale) + case "INT16": + return determineConverterI16Scale(outType, byteOrder, scale) + case "UINT16": + return determineConverterU16Scale(outType, byteOrder, scale) + case "INT32": + return determineConverterI32Scale(outType, byteOrder, scale) + case "UINT32": + return determineConverterU32Scale(outType, byteOrder, scale) + case "INT64": + return determineConverterI64Scale(outType, byteOrder, scale) + case "UINT64": + return determineConverterU64Scale(outType, byteOrder, scale) + case "FLOAT16": + return determineConverterF16Scale(outType, byteOrder, scale) + case "FLOAT32": + return determineConverterF32Scale(outType, byteOrder, scale) + case "FLOAT64": + return determineConverterF64Scale(outType, byteOrder, scale) + } + return nil, fmt.Errorf("invalid input data-type: %s", inType) +} + +func determineConverterNoScale(inType, byteOrder, outType string) (fieldConverterFunc, error) { + switch inType { + case "INT8L": + return determineConverterI8L(outType, byteOrder) + case "INT8H": + return determineConverterI8H(outType, byteOrder) + case "UINT8L": + return determineConverterU8L(outType, byteOrder) + case "UINT8H": + return determineConverterU8H(outType, byteOrder) + case "INT16": + return determineConverterI16(outType, byteOrder) + case "UINT16": + return determineConverterU16(outType, byteOrder) + case "INT32": + return determineConverterI32(outType, byteOrder) + case "UINT32": + return determineConverterU32(outType, byteOrder) + case "INT64": + return determineConverterI64(outType, byteOrder) + case "UINT64": + return determineConverterU64(outType, byteOrder) + case "FLOAT16": + return determineConverterF16(outType, byteOrder) + case "FLOAT32": + return determineConverterF32(outType, byteOrder) + case "FLOAT64": + return determineConverterF64(outType, byteOrder) + } + return nil, fmt.Errorf("invalid input data-type: %s", inType) +} diff --git a/plugins/common/modbus_server/type_conversions16.go b/plugins/common/modbus_server/type_conversions16.go new file mode 100644 index 0000000000000..cd73b68d42da2 --- /dev/null +++ b/plugins/common/modbus_server/type_conversions16.go @@ -0,0 +1,187 @@ +package modbus_server + +import ( + "encoding/binary" + "fmt" + + "github.com/x448/float16" +) + +type convert16 func([]byte) uint16 + +func endiannessConverter16(byteOrder string) (convert16, error) { + switch byteOrder { + case "ABCD", "CDAB": // Big endian (Motorola) + return binary.BigEndian.Uint16, nil + case "DCBA", "BADC": // Little endian (Intel) + return binary.LittleEndian.Uint16, nil + } + return nil, fmt.Errorf("invalid byte-order: %s", byteOrder) +} + +// I16 - no scale +func determineConverterI16(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return int16(tohost(b)) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(int16(tohost(b))) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(int16(tohost(b))) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(int16(tohost(b))) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U16 - no scale +func determineConverterU16(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return tohost(b) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(tohost(b)) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(tohost(b)) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(tohost(b)) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F16 - no scale +func determineConverterF16(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + raw := tohost(b) + return float16.Frombits(raw).Float32() + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + in := float16.Frombits(raw).Float32() + return float64(in) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I16 - scale +func determineConverterI16Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := int16(tohost(b)) + return int16(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := int16(tohost(b)) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int16(tohost(b)) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int16(tohost(b)) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U16 - scale +func determineConverterU16Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := tohost(b) + return uint16(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := tohost(b) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := tohost(b) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := tohost(b) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F16 - scale +func determineConverterF16Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + raw := tohost(b) + in := float16.Frombits(raw) + return in.Float32() * float32(scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + in := float16.Frombits(raw) + return float64(in.Float32()) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} diff --git a/plugins/common/modbus_server/type_conversions32.go b/plugins/common/modbus_server/type_conversions32.go new file mode 100644 index 0000000000000..fd57a55e0e3a4 --- /dev/null +++ b/plugins/common/modbus_server/type_conversions32.go @@ -0,0 +1,200 @@ +package modbus_server + +import ( + "encoding/binary" + "fmt" + "math" +) + +type convert32 func([]byte) uint32 + +func binaryMSWLEU32(b []byte) uint32 { + _ = b[3] // bounds check hint to compiler; see golang.org/issue/14808 + return uint32(binary.LittleEndian.Uint16(b[0:]))<<16 | uint32(binary.LittleEndian.Uint16(b[2:])) +} + +func binaryLSWBEU32(b []byte) uint32 { + _ = b[3] // bounds check hint to compiler; see golang.org/issue/14808 + return uint32(binary.BigEndian.Uint16(b[2:]))<<16 | uint32(binary.BigEndian.Uint16(b[0:])) +} + +func endiannessConverter32(byteOrder string) (convert32, error) { + switch byteOrder { + case "ABCD": // Big endian (Motorola) + return binary.BigEndian.Uint32, nil + case "BADC": // Big endian with bytes swapped + return binaryMSWLEU32, nil + case "CDAB": // Little endian with bytes swapped + return binaryLSWBEU32, nil + case "DCBA": // Little endian (Intel) + return binary.LittleEndian.Uint32, nil + } + return nil, fmt.Errorf("invalid byte-order: %s", byteOrder) +} + +// I32 - no scale +func determineConverterI32(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return int32(tohost(b)) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(int32(tohost(b))) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(int32(tohost(b))) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(int32(tohost(b))) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U32 - no scale +func determineConverterU32(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return tohost(b) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(tohost(b)) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(tohost(b)) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(tohost(b)) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F32 - no scale +func determineConverterF32(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + raw := tohost(b) + return math.Float32frombits(raw) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + in := math.Float32frombits(raw) + return float64(in) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I32 - scale +func determineConverterI32Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := int32(tohost(b)) + return int32(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := int32(tohost(b)) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int32(tohost(b)) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int32(tohost(b)) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U32 - scale +func determineConverterU32Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := tohost(b) + return uint32(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := tohost(b) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := tohost(b) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := tohost(b) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F32 - scale +func determineConverterF32Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter32(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + raw := tohost(b) + in := math.Float32frombits(raw) + return float32(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + in := math.Float32frombits(raw) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} diff --git a/plugins/common/modbus_server/type_conversions64.go b/plugins/common/modbus_server/type_conversions64.go new file mode 100644 index 0000000000000..8f9fa4bdd59df --- /dev/null +++ b/plugins/common/modbus_server/type_conversions64.go @@ -0,0 +1,184 @@ +package modbus_server + +import ( + "encoding/binary" + "fmt" + "math" +) + +type convert64 func([]byte) uint64 + +func binaryMSWLEU64(b []byte) uint64 { + _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808 + return uint64(binary.LittleEndian.Uint16(b[0:]))<<48 | uint64(binary.LittleEndian.Uint16(b[2:]))<<32 | + uint64(binary.LittleEndian.Uint16(b[4:]))<<16 | uint64(binary.LittleEndian.Uint16(b[6:])) +} + +func binaryLSWBEU64(b []byte) uint64 { + _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808 + return uint64(binary.BigEndian.Uint16(b[6:]))<<48 | uint64(binary.BigEndian.Uint16(b[4:]))<<32 | + uint64(binary.BigEndian.Uint16(b[2:]))<<16 | uint64(binary.BigEndian.Uint16(b[0:])) +} + +func endiannessConverter64(byteOrder string) (convert64, error) { + switch byteOrder { + case "ABCD": // Big endian (Motorola) + return binary.BigEndian.Uint64, nil + case "BADC": // Big endian with bytes swapped + return binaryMSWLEU64, nil + case "CDAB": // Little endian with bytes swapped + return binaryLSWBEU64, nil + case "DCBA": // Little endian (Intel) + return binary.LittleEndian.Uint64, nil + } + return nil, fmt.Errorf("invalid byte-order: %s", byteOrder) +} + +// I64 - no scale +func determineConverterI64(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native", "INT64": + return func(b []byte) interface{} { + return int64(tohost(b)) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return uint64(in) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return float64(in) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U64 - no scale +func determineConverterU64(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "INT64": + return func(b []byte) interface{} { + return int64(tohost(b)) + }, nil + case "native", "UINT64": + return func(b []byte) interface{} { + return tohost(b) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(tohost(b)) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F64 - no scale +func determineConverterF64(outType, byteOrder string) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native", "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + return math.Float64frombits(raw) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I64 - scale +func determineConverterI64Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return int64(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int64(tohost(b)) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U64 - scale +func determineConverterU64Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := tohost(b) + return uint64(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := tohost(b) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := tohost(b) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := tohost(b) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// F64 - scale +func determineConverterF64Scale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + tohost, err := endiannessConverter64(byteOrder) + if err != nil { + return nil, err + } + + switch outType { + case "native", "FLOAT64": + return func(b []byte) interface{} { + raw := tohost(b) + in := math.Float64frombits(raw) + return in * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} diff --git a/plugins/common/modbus_server/type_conversions8.go b/plugins/common/modbus_server/type_conversions8.go new file mode 100644 index 0000000000000..5bf52a5a7b34c --- /dev/null +++ b/plugins/common/modbus_server/type_conversions8.go @@ -0,0 +1,253 @@ +package modbus_server + +import ( + "fmt" +) + +func endiannessIndex8(byteOrder string, low bool) (int, error) { + switch byteOrder { + case "ABCD": // Big endian (Motorola) + if low { + return 1, nil + } + return 0, nil + case "DCBA": // Little endian (Intel) + if low { + return 0, nil + } + return 1, nil + } + return -1, fmt.Errorf("invalid byte-order: %s", byteOrder) +} + +// I8 lower byte - no scale +func determineConverterI8L(outType, byteOrder string) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, true) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return int8(b[idx]) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(int8(b[idx])) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(int8(b[idx])) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(int8(b[idx])) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I8 higher byte - no scale +func determineConverterI8H(outType, byteOrder string) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, false) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return int8(b[idx]) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(int8(b[idx])) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(int8(b[idx])) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(int8(b[idx])) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U8 lower byte - no scale +func determineConverterU8L(outType, byteOrder string) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, true) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return b[idx] + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(b[idx]) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(b[idx]) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(b[idx]) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U8 higher byte - no scale +func determineConverterU8H(outType, byteOrder string) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, false) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return b[idx] + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(b[idx]) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(b[idx]) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(b[idx]) + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I8 lower byte - scale +func determineConverterI8LScale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, true) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := int8(b[idx]) + return int8(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// I8 higher byte - scale +func determineConverterI8HScale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, false) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + in := int8(b[idx]) + return int8(float64(in) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return int64(float64(in) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return uint64(float64(in) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + in := int8(b[idx]) + return float64(in) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U8 lower byte - scale +func determineConverterU8LScale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, true) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return uint8(float64(b[idx]) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(float64(b[idx]) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(float64(b[idx]) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(b[idx]) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} + +// U8 higher byte - scale +func determineConverterU8HScale(outType, byteOrder string, scale float64) (fieldConverterFunc, error) { + idx, err := endiannessIndex8(byteOrder, false) + if err != nil { + return nil, err + } + + switch outType { + case "native": + return func(b []byte) interface{} { + return uint8(float64(b[idx]) * scale) + }, nil + case "INT64": + return func(b []byte) interface{} { + return int64(float64(b[idx]) * scale) + }, nil + case "UINT64": + return func(b []byte) interface{} { + return uint64(float64(b[idx]) * scale) + }, nil + case "FLOAT64": + return func(b []byte) interface{} { + return float64(b[idx]) * scale + }, nil + } + return nil, fmt.Errorf("invalid output data-type: %s", outType) +} diff --git a/plugins/common/modbus_server/type_conversions_bit.go b/plugins/common/modbus_server/type_conversions_bit.go new file mode 100644 index 0000000000000..c3acd05370565 --- /dev/null +++ b/plugins/common/modbus_server/type_conversions_bit.go @@ -0,0 +1,14 @@ +package modbus_server + +func determineConverterBit(byteOrder string, bit uint8) (fieldConverterFunc, error) { + tohost, err := endiannessConverter16(byteOrder) + if err != nil { + return nil, err + } + + return func(b []byte) interface{} { + // Swap the bytes according to endianness + v := tohost(b) + return uint8(v >> bit & 0x01) + }, nil +} diff --git a/plugins/common/modbus_server/type_conversions_string.go b/plugins/common/modbus_server/type_conversions_string.go new file mode 100644 index 0000000000000..8f0b309b60b62 --- /dev/null +++ b/plugins/common/modbus_server/type_conversions_string.go @@ -0,0 +1,61 @@ +package modbus_server + +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 +} + +func determineConverterStringLow(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 & 0xFF)) + } + // Remove everything after null-termination + s, _ := bytes.CutSuffix(buf.Bytes(), []byte{0x00}) + return string(s) + }, nil +} + +func determineConverterStringHigh(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)) + } + // Remove everything after null-termination + s, _ := bytes.CutSuffix(buf.Bytes(), []byte{0x00}) + return string(s) + }, nil +} diff --git a/plugins/inputs/all/modbus_server.go b/plugins/inputs/all/modbus_server.go new file mode 100644 index 0000000000000..a1b64d697daf4 --- /dev/null +++ b/plugins/inputs/all/modbus_server.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.modbus_server + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/modbus_server" // register plugin diff --git a/plugins/inputs/modbus_server/README.md b/plugins/inputs/modbus_server/README.md new file mode 100644 index 0000000000000..4c509a02e77c6 --- /dev/null +++ b/plugins/inputs/modbus_server/README.md @@ -0,0 +1,95 @@ +# Modbus Server Input Plugin + +The `modbus_server` input plugin collects data from a Modbus server. +This plugin supports various data types and allows for flexible +configuration of metrics and their corresponding Modbus registers. + +## Global configuration options + +In addition to the plugin-specific configuration settings, plugins support +additional global and plugin configuration settings. These settings are used to +modify metrics, tags, and field or create aliases and configure ordering, etc. +See the [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins + +## Configuration + +```toml +[[inputs.modbus_server]] + server_address = "tcp://localhost:502" + byte_order = "ABCD" + timeout = 10 + max_clients = 5 + [[inputs.modbus_server.metrics]] + name = "measurement1" + fields = [ + { register = "coil", address = 0, name = "field1"}, + { register = "holding", address = 50000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 50001, name = "bit_field0", type = "BIT", bit = 0}, + { register = "holding", address = 50001, name = "bit_field1", type = "BIT", bit = 1}, + { register = "holding", address = 50001, name = "bit_field2", type = "BIT", bit = 2}, + { register = "holding", address = 50001, name = "bit_field3", type = "BIT", bit = 3}, + { register = "holding", address = 50001, name = "bit_field4", type = "BIT", bit = 4}, + { register = "holding", address = 50001, name = "bit_field5", type = "BIT", bit = 5}, + { register = "holding", address = 50001, name = "bit_field6", type = "BIT", bit = 6}, + { register = "holding", address = 50001, name = "bit_field7", type = "BIT", bit = 7}, + { register = "holding", address = 50001, name = "bit_field8", type = "BIT", bit = 8}, + { register = "holding", address = 50001, name = "bit_field9", type = "BIT", bit = 9}, + { register = "holding", address = 50001, name = "bit_field10", type = "BIT", bit = 10}, + { register = "holding", address = 50001, name = "bit_field11", type = "BIT", bit = 11}, + { register = "holding", address = 50001, name = "bit_field12", type = "BIT", bit = 12}, + { register = "holding", address = 50001, name = "bit_field13", type = "BIT", bit = 13}, + { register = "holding", address = 50001, name = "bit_field14", type = "BIT", bit = 14}, + { register = "holding", address = 50001, name = "bit_field15", type = "BIT", bit = 15}, + ] + [inputs.modbus_server.metrics.tags] + tag1 = "value1" + tag2 = "value2" + [[inputs.modbus_server.metrics]] + name = "measurement2" + fields = [ + { register = "holding", address = 40000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 40002, name = "string_field", type = "STRING", length = 10 }, + ] + [inputs.modbus_server.metrics.tags] + tag3 = "3" +``` + +## Metrics + +The metrics section defines the metrics to be collected from the Modbus server. +Each metric can have multiple fields, each corresponding to a Modbus register. +Metrics are custom and fields can be configured as coils, or holding registers. +Holding registers are required to be configured with the `type` option. + +## Fields + +- register: The type of Modbus register (e.g., "coil", "register"). +- address: The address of the Modbus register. +- name: The name of the field. +- value: The value of the field. +- type: The data type of the field. Supported types are: + - `BIT` + - `UINT16` + - `FLOAT32` + - `INT32` + - `UINT32` + - `INT64` + - `UINT64` + - `FLOAT64` + - `INT8L` + - `INT8H` + - `UINT8L` + - `UINT8H` + - `FLOAT16` + - `STRING` + +Only the field name and values are part of the output metric. +The address and type are used to read the data from the Modbus server. + +## Example Output + +```text +temperature,location=server_room temp_sensor_1=18.5,temp_sensor_2=18.1,airco_on=true 1741084338000000000 +``` diff --git a/plugins/inputs/modbus_server/modbus_server.go b/plugins/inputs/modbus_server/modbus_server.go new file mode 100644 index 0000000000000..519e4d1d31b72 --- /dev/null +++ b/plugins/inputs/modbus_server/modbus_server.go @@ -0,0 +1,229 @@ +//go:generate ../../../tools/readme_config_includer/generator +package modbus_server + +import ( + "context" + _ "embed" + "fmt" + "time" + + "github.com/simonvetter/modbus" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" + "github.com/influxdata/telegraf/plugins/common/modbus_server" + "github.com/influxdata/telegraf/plugins/inputs" +) + +//go:embed sample.conf +var config string + +type MetricSchema struct { + Register string `toml:"register"` + Address uint16 `toml:"address"` + Name string `toml:"name"` + Type string `toml:"type,omitempty"` + Bit uint8 `toml:"bit,omitempty"` + Scale float64 `toml:"scale,omitempty"` + Length uint16 `toml:"length,omitempty"` +} + +type MetricDefinition struct { + Name string `toml:"measurement"` + MetricSchema []MetricSchema `toml:"fields"` + Tags map[string]string `toml:"tags"` +} + +type ModbusServerConfig struct { + ServerAddress string `toml:"server_address"` + ByteOrder string `toml:"byte_order"` + Timeout time.Duration `toml:"timeout"` + MaxClients uint `toml:"max_clients"` + Metrics []MetricDefinition `toml:"metrics"` +} + +type ModbusServer struct { + ModbusServerConfig + server *modbus.ModbusServer + handler *modbus_server.Handler + Log telegraf.Logger `toml:"-"` + ctx context.Context + cancel context.CancelFunc +} + +func (*ModbusServer) SampleConfig() string { + return config +} + +func checkMeasurement(measurement MetricDefinition) error { + memoryLayout := modbus_server.MemoryLayout{} + fields := make(map[string]bool) + for _, field := range measurement.MetricSchema { + // check for duplicate field names + if _, ok := fields[field.Name]; ok { + return fmt.Errorf("duplicate field name: %v", field.Name) + } + fields[field.Name] = true + memoryLayout = append( + memoryLayout, modbus_server.MemoryEntry{ + Register: field.Register, Address: field.Address, Type: field.Type, Bit: field.Bit, Scale: field.Scale, Length: field.Length, + }, + ) + } + + _, overlaps, err := memoryLayout.HasOverlap() + if err != nil { + return err + } + + if len(overlaps) > 0 { + return fmt.Errorf("overlapping addresses: %v in measurement: %v", measurement.Name, overlaps) + } + + return nil +} + +func (m *ModbusServer) checkConfig() (modbus_server.MemoryLayout, []string, error) { + memoryLayout := modbus_server.MemoryLayout{} + + for _, entry := range m.Metrics { + err := checkMeasurement(entry) + if err != nil { + return nil, nil, err + } + + for _, field := range entry.MetricSchema { + memoryLayout = append( + memoryLayout, modbus_server.MemoryEntry{ + Address: field.Address, Type: field.Type, Register: field.Register, Bit: field.Bit, Scale: field.Scale, Length: field.Length, + }, + ) + } + } + + _, overlaps, err := memoryLayout.HasOverlap() + if err != nil { + return nil, overlaps, err + } + + return memoryLayout, overlaps, nil +} + +func (m *ModbusServer) getMetrics(timestamp time.Time) []telegraf.Metric { + coils, coilOffset := m.handler.GetCoilsAndOffset() + registers, registerOffset := m.handler.GetRegistersAndOffset() + + var metrics []telegraf.Metric + metricFields := make(map[string]interface{}) + + for _, entry := range m.Metrics { + for _, field := range entry.MetricSchema { + var err error + metricFields[field.Name], err = modbus_server.ParseMemory( + m.ByteOrder, modbus_server.MemoryEntry{ + Address: field.Address, + Type: field.Type, + Register: field.Register, + Scale: field.Scale, + Bit: field.Bit, + Length: field.Length, + }, coilOffset, registerOffset, coils, registers, + ) + + if err != nil { + m.Log.Errorf("Error parsing memory: %v", err) + continue + } + metrics = append(metrics, metric.New(entry.Name, entry.Tags, metricFields, timestamp)) + } + } + return metrics +} + +func (m *ModbusServer) Init() error { + // create the server object + memLayout, overlaps, err := m.checkConfig() + if err != nil { + m.Log.Errorf("failed to create server: %v\n", err) + return err + } + + if len(overlaps) > 0 { + m.Log.Warnf("Overlapping addresses: %v", overlaps) + } + + coils, registers := memLayout.GetCoilsAndRegisters() + coilOffset, registerOffset := memLayout.GetMemoryOffsets() + + // Initialize the handler + m.handler, err = modbus_server.NewRequestHandler(uint16(len(coils)), coilOffset, uint16(len(registers)), registerOffset, m.Log) + if err != nil { + m.Log.Errorf("failed to create server: %v\n", err) + return err + } + + m.server, err = modbus.NewServer( + &modbus.ServerConfiguration{ + URL: m.ServerAddress, + Timeout: m.Timeout * time.Second, + MaxClients: m.MaxClients, + }, m.handler, + ) + + if err != nil { + m.Log.Errorf("failed to create server: %v\n", err) + return err + } + // Create a cancellable context + m.ctx, m.cancel = context.WithCancel(context.Background()) + + return nil +} + +func (*ModbusServer) Gather(_ telegraf.Accumulator) error { + return nil +} + +func (m *ModbusServer) Start(acc telegraf.Accumulator) error { + err := m.server.Start() + if err != nil { + m.Log.Errorf("Error starting server: %v", err) + return err + } + m.Log.Debug("Server started") + go func() { + for { + select { + case <-m.ctx.Done(): + return + // Check if the channel is empty + case lastEditTimestamp := <-m.handler.LastEdit: + metrics := m.getMetrics(lastEditTimestamp) + m.Log.Infof("Gathered %d metrics", len(metrics)) + for _, modbusMetric := range metrics { + acc.AddMetric(modbusMetric) + } + } + } + }() + return nil +} + +func (m *ModbusServer) Stop() { + err := m.server.Stop() + if err != nil { + m.Log.Errorf("Error stopping server: %v", err) + return + } + m.cancel() + close(m.handler.LastEdit) + m.Log.Debug("Server stopped") +} + +func init() { + inputs.Add( + "modbus_server", func() telegraf.Input { + return &ModbusServer{} + }, + ) +} diff --git a/plugins/inputs/modbus_server/modbus_server_test.go b/plugins/inputs/modbus_server/modbus_server_test.go new file mode 100644 index 0000000000000..2a0b052271fef --- /dev/null +++ b/plugins/inputs/modbus_server/modbus_server_test.go @@ -0,0 +1,558 @@ +package modbus_server + +import ( + "math" + "sync" + "testing" + "time" + + "github.com/simonvetter/modbus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf/plugins/common/modbus_server" + "github.com/influxdata/telegraf/testutil" +) + +func TestInit(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Metrics: nil, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + acc := &testutil.Accumulator{} + require.NoError(t, m.Init()) + require.NoError(t, m.Start(acc)) + require.NoError(t, m.Gather(acc)) + + m.Stop() + require.NoError(t, acc.FirstError()) +} + +func TestSampleConfig(t *testing.T) { + m := &ModbusServer{} + require.NotEmpty(t, m.SampleConfig()) +} + +func TestCheckConfig(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + {Register: "register", Address: 40000, Name: "bit_field0", Type: "BIT", Bit: 0}, + {Register: "register", Address: 40000, Name: "bit_field1", Type: "BIT", Bit: 1}, + {Register: "register", Address: 40000, Name: "bit_field2", Type: "BIT", Bit: 2}, + {Register: "register", Address: 40000, Name: "bit_field3", Type: "BIT", Bit: 3}, + {Register: "register", Address: 40000, Name: "bit_field4", Type: "BIT", Bit: 4}, + {Register: "register", Address: 40000, Name: "bit_field5", Type: "BIT", Bit: 5}, + {Register: "register", Address: 40000, Name: "bit_field6", Type: "BIT", Bit: 6}, + {Register: "register", Address: 40000, Name: "bit_field7", Type: "BIT", Bit: 7}, + {Register: "register", Address: 40000, Name: "bit_field8", Type: "BIT", Bit: 8}, + {Register: "register", Address: 40000, Name: "bit_field9", Type: "BIT", Bit: 9}, + {Register: "register", Address: 40000, Name: "bit_field10", Type: "BIT", Bit: 10}, + {Register: "register", Address: 40000, Name: "bit_field11", Type: "BIT", Bit: 11}, + {Register: "register", Address: 40000, Name: "bit_field12", Type: "BIT", Bit: 12}, + {Register: "register", Address: 40000, Name: "bit_field13", Type: "BIT", Bit: 13}, + {Register: "register", Address: 40000, Name: "bit_field14", Type: "BIT", Bit: 14}, + {Register: "register", Address: 40000, Name: "bit_field15", Type: "BIT", Bit: 15}, + }, + }, + }, + }, + } + memoryLayout, _, err := m.checkConfig() + require.NoError(t, err) + require.NotEmpty(t, memoryLayout) +} + +func TestCheckConfigAddressesOutOfRange(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + {Register: "register", Address: 3, Name: "field3", Type: "UINT16"}, + {Register: "register", Address: 4, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 10, Name: "field5", Type: "UINT16"}, + {Register: "register", Address: 11, Name: "field6", Type: "UINT16"}, + {Register: "register", Address: 12, Name: "field7", Type: "UINT16"}, + }, + }, + }, + }, + } + memoryLayout, _, err := m.checkConfig() + require.NoError(t, err) + require.Len(t, memoryLayout, 7) +} + +func TestGetMetrics(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "coil", Address: 2, Name: "field2"}, + + {Register: "register", Address: 3, Name: "field3", Type: "UINT16"}, + {Register: "register", Address: 4, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 5, Name: "field5", Type: "UINT32"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + var err error + m.handler, err = modbus_server.NewRequestHandler(2, 1, 4, 3, testutil.Logger{}) + require.NoError(t, err) + _, err = m.handler.WriteHoldingRegisters(3, []uint16{123, 321, math.MaxUint16, math.MaxUint16}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(1, []bool{true, false}) + require.NoError(t, err) + _, _, err = m.checkConfig() + require.NoError(t, err) + + metrics := m.getMetrics(time.Now()) + require.Len(t, metrics, 5) + require.Equal(t, "test_metric", metrics[0].Name()) + require.Equal(t, true, metrics[0].Fields()["field1"]) + + require.Equal(t, "test_metric", metrics[1].Name()) + require.Equal(t, false, metrics[1].Fields()["field2"]) + + require.Equal(t, "test_metric", metrics[2].Name()) + require.Equal(t, uint64(123), metrics[2].Fields()["field3"]) + + require.Equal(t, "test_metric", metrics[3].Name()) + require.Equal(t, uint64(321), metrics[3].Fields()["field4"]) + + require.Equal(t, "test_metric", metrics[4].Name()) + require.Equal(t, uint64(math.MaxUint32), metrics[4].Fields()["field5"]) +} + +func TestStartStop(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + acc := &testutil.Accumulator{} + require.NoError(t, m.Init()) + require.NoError(t, m.Start(acc)) + m.Stop() +} + +func TestOverlappingEntries(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + }, + { + Name: "test_metric1", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + require.NoError(t, m.Init()) + memMap, _, err := m.checkConfig() + require.NoError(t, err) + require.NotEmpty(t, memMap) +} + +func TestDuplicateFields(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + {Register: "register", Address: 2, Name: "field1", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + require.Error(t, m.Init()) + memMap, _, err := m.checkConfig() + require.Error(t, err) + require.Empty(t, memMap) +} + +func TestUpdateMemory(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 10, Name: "field1"}, + {Register: "coil", Address: 11, Name: "field2"}, + + {Register: "register", Address: 30, Name: "field3", Type: "UINT16"}, + {Register: "register", Address: 31, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 32, Name: "field5", Type: "UINT32"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + // init + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + coils, registers := memLayout.GetCoilsAndRegisters() + coilOffset, registerOffset := memLayout.GetMemoryOffsets() + m.handler, err = modbus_server.NewRequestHandler(uint16(len(coils)), coilOffset, uint16(len(registers)), registerOffset, testutil.Logger{}) + require.NoError(t, err) + + _, err = m.handler.WriteHoldingRegisters(30, []uint16{123, 321, math.MaxUint16, math.MaxUint16}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(10, []bool{true, false}) + require.NoError(t, err) + + readCoils, err := m.handler.ReadCoils(10, 2) + require.NoError(t, err) + require.Equal(t, []bool{true, false}, readCoils) + + readRegisters, err := m.handler.ReadHoldingRegisters(30, 4) + require.NoError(t, err) + require.Equal(t, []uint16{123, 321, math.MaxUint16, math.MaxUint16}, readRegisters) + + // get metrics + require.NoError(t, err) + metrics := m.getMetrics(time.Now()) + require.Len(t, metrics, 5) + require.Equal(t, "test_metric", metrics[0].Name()) + require.Equal(t, true, metrics[0].Fields()["field1"]) + + require.Equal(t, "test_metric", metrics[1].Name()) + require.Equal(t, false, metrics[1].Fields()["field2"]) + + require.Equal(t, "test_metric", metrics[2].Name()) + require.Equal(t, uint64(123), metrics[2].Fields()["field3"]) + + require.Equal(t, "test_metric", metrics[3].Name()) + require.Equal(t, uint64(321), metrics[3].Fields()["field4"]) + + require.Equal(t, "test_metric", metrics[4].Name()) + require.Equal(t, uint64(math.MaxUint32), metrics[4].Fields()["field5"]) + + // update memory + _, err = m.handler.WriteHoldingRegisters(30, []uint16{111}) + require.NoError(t, err) + _, err = m.handler.WriteHoldingRegisters(31, []uint16{222, 0, 333}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(10, []bool{false, false}) + require.NoError(t, err) + + // check metrics update + metrics = m.getMetrics(time.Now()) + require.Len(t, metrics, 5) + require.Equal(t, "test_metric", metrics[0].Name()) + require.Equal(t, false, metrics[0].Fields()["field1"]) + + require.Equal(t, "test_metric", metrics[1].Name()) + require.Equal(t, false, metrics[1].Fields()["field2"]) + + require.Equal(t, "test_metric", metrics[2].Name()) + require.Equal(t, uint64(111), metrics[2].Fields()["field3"]) + + require.Equal(t, "test_metric", metrics[3].Name()) + require.Equal(t, uint64(222), metrics[3].Fields()["field4"]) + + require.Equal(t, "test_metric", metrics[4].Name()) + require.Equal(t, uint64(333), metrics[4].Fields()["field5"]) +} + +func TestMemoryOverlap(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT32"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + require.Error(t, m.Init()) + memMap, _, err := m.checkConfig() + require.Error(t, err) + require.Empty(t, memMap) +} + +func TestAccumulatedMetrics(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + var err error + m.handler, err = modbus_server.NewRequestHandler(1, 1, 1, 2, testutil.Logger{}) + require.NoError(t, err) + + _, err = m.handler.WriteHoldingRegisters(2, []uint16{123}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(1, []bool{true}) + require.NoError(t, err) + acc := &testutil.Accumulator{} + require.NoError(t, m.Init()) + require.NoError(t, m.Start(acc)) + require.Empty(t, acc.Metrics) + // Update last edit time to trigger more metrics + _, err = m.handler.WriteCoils(1, []bool{true}) + require.NoError(t, err) + + acc.Wait(2) + require.Len(t, acc.Metrics, 2) + // Update last edit time to trigger new metrics + _, err = m.handler.WriteCoils(1, []bool{false}) + require.NoError(t, err) + + acc.Wait(4) + require.Len(t, acc.Metrics, 4) + + m.Stop() +} + +func TestAccumulatedMetricsNoNewUpdates(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:10502", + ByteOrder: "ABCD", + Timeout: 2 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + var err error + m.handler, err = modbus_server.NewRequestHandler(1, 1, 1, 2, testutil.Logger{}) + require.NoError(t, err) + + _, err = m.handler.WriteHoldingRegisters(2, []uint16{123}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(1, []bool{true}) + require.NoError(t, err) + + acc := &testutil.Accumulator{} + require.NoError(t, m.Init()) + require.NoError(t, m.Start(acc)) + require.Empty(t, acc.Metrics) + // Update last edit time to trigger more metrics + m.handler.LastEdit <- time.Now() + acc.Wait(2) + require.Len(t, acc.Metrics, 2) + // No new updates + time.Sleep(1 * time.Millisecond) + require.Len(t, acc.Metrics, 2) + + m.Stop() +} + +func TestModbusServerIntegration(t *testing.T) { + // Create a ModbusServer instance + serverAddr := "tcp://localhost:10502" + server := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: serverAddr, + ByteOrder: "ABCD", + Timeout: 5 * 60 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "measurement1", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "field1"}, + {Register: "coil", Address: 1, Name: "field2"}, + {Register: "coil", Address: 2, Name: "field3"}, + {Register: "holding", Address: 40001, Name: "float_field", Type: "FLOAT32"}, + }, + Tags: map[string]string{ + "tag1": "value1", + "tag2": "value2", + }, + }, + }, + }, + Log: testutil.Logger{}, + } + + // Initialize the server + require.NoError(t, server.Init()) + + // Create a test accumulator + acc := &testutil.Accumulator{} + + // Start the server + require.NoError(t, server.Start(acc)) + defer server.Stop() + + serverCoils, _ := server.handler.GetCoilsAndOffset() + serverRegisters, _ := server.handler.GetRegistersAndOffset() + require.Equal(t, []bool{false, false, false}, serverCoils) + require.Equal(t, []uint16{0, 0}, serverRegisters) + + _, err := server.handler.WriteCoils(0, []bool{false, false, false}) + require.NoError(t, err) + _, err = server.handler.WriteHoldingRegisters(40001, []uint16{0, 0}) + require.NoError(t, err) + + // Add a delay to ensure the server is fully up and running + client, err := modbus.NewClient( + &modbus.ClientConfiguration{ + URL: serverAddr, + Timeout: 10 * time.Second, + }, + ) + + require.NoError(t, err) + + // Create wait group to wait for the client operations to complete + var wg sync.WaitGroup + wg.Add(1) + // Run the client operations in a separate goroutine + go func() { + defer wg.Done() + // Open the client connection + err := client.Open() + assert.NoError(t, err) + + defer func(client *modbus.ModbusClient) { + err := client.Close() + assert.NoError(t, err) + }(client) + // Read 1 coil + coil, err := client.ReadCoil(0) + assert.NoError(t, err) + assert.False(t, coil) + + // Read 1 register + register, err := client.ReadRegister(40001, 0) + assert.NoError(t, err) + assert.Equal(t, uint16(0), register) + + // Write coils + err = client.WriteCoils(0, []bool{true, false, true}) + assert.NoError(t, err) + + // Read coils + coils, err := client.ReadCoils(0, 3) + assert.NoError(t, err) + assert.Equal(t, []bool{true, false, true}, coils) + + // Write holding registers + err = client.WriteRegisters(40001, []uint16{0x3f80, 0x0000}) + assert.NoError(t, err) + + // Read holding registers + registers, err := client.ReadRegisters(40001, 2, 0) + assert.NoError(t, err) + assert.Equal(t, []uint16{0x3f80, 0x0000}, registers) + }() + + // Wait for the client operations to complete + wg.Wait() +} diff --git a/plugins/inputs/modbus_server/sample.conf b/plugins/inputs/modbus_server/sample.conf new file mode 100644 index 0000000000000..a4a01c540a21d --- /dev/null +++ b/plugins/inputs/modbus_server/sample.conf @@ -0,0 +1,38 @@ +[[inputs.modbus_server]] + server_address = "tcp://localhost:502" + byte_order = "ABCD" + timeout = 10 + max_clients = 5 + [[inputs.modbus_server.metrics]] + measurement = "measurement1" + fields = [ + { register = "coil", address = 0, name = "field1"}, + { register = "holding", address = 50000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 50001, name = "bit_field0", type = "BIT", bit = 0}, + { register = "holding", address = 50001, name = "bit_field1", type = "BIT", bit = 1}, + { register = "holding", address = 50001, name = "bit_field2", type = "BIT", bit = 2}, + { register = "holding", address = 50001, name = "bit_field3", type = "BIT", bit = 3}, + { register = "holding", address = 50001, name = "bit_field4", type = "BIT", bit = 4}, + { register = "holding", address = 50001, name = "bit_field5", type = "BIT", bit = 5}, + { register = "holding", address = 50001, name = "bit_field6", type = "BIT", bit = 6}, + { register = "holding", address = 50001, name = "bit_field7", type = "BIT", bit = 7}, + { register = "holding", address = 50001, name = "bit_field8", type = "BIT", bit = 8}, + { register = "holding", address = 50001, name = "bit_field9", type = "BIT", bit = 9}, + { register = "holding", address = 50001, name = "bit_field10", type = "BIT", bit = 10}, + { register = "holding", address = 50001, name = "bit_field11", type = "BIT", bit = 11}, + { register = "holding", address = 50001, name = "bit_field12", type = "BIT", bit = 12}, + { register = "holding", address = 50001, name = "bit_field13", type = "BIT", bit = 13}, + { register = "holding", address = 50001, name = "bit_field14", type = "BIT", bit = 14}, + { register = "holding", address = 50001, name = "bit_field15", type = "BIT", bit = 15}, + ] + [inputs.modbus_server.metrics.tags] + tag1 = "value1" + tag2 = "value2" + [[inputs.modbus_server.metrics]] + measurement = "measurement2" + fields = [ + { register = "holding", address = 40000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 40002, name = "string_field", type = "STRING", length = 10 }, + ] + [inputs.modbus_server.metrics.tags] + tag3 = "3" diff --git a/plugins/outputs/all/modbus_server.go b/plugins/outputs/all/modbus_server.go new file mode 100644 index 0000000000000..ce033cff9692c --- /dev/null +++ b/plugins/outputs/all/modbus_server.go @@ -0,0 +1,5 @@ +//go:build !custom || outputs || outputs.modbus_server + +package all + +import _ "github.com/influxdata/telegraf/plugins/outputs/modbus_server" // register plugin diff --git a/plugins/outputs/modbus_server/README.md b/plugins/outputs/modbus_server/README.md new file mode 100644 index 0000000000000..68737be25893c --- /dev/null +++ b/plugins/outputs/modbus_server/README.md @@ -0,0 +1,85 @@ +# Modbus Server Output Plugin + +The `modbus_server` output plugin sends data to a Modbus server. +This plugin supports various data types and allows for flexible +configuration of metrics and their corresponding Modbus registers. + +## Global configuration options + +In addition to the plugin-specific configuration settings, plugins support +additional global and plugin configuration settings. These settings are used to +modify metrics, tags, and field or create aliases and configure ordering, etc. +See the [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins + +## Configuration + +```toml +[[outputs.modbus_server]] +[[outputs.allseas_modbus_server]] + ## The address of the Modbus server (e.g., "tcp://localhost:502"). + server_address = "tcp://localhost:502" + + ## Byte order of the Modbus registers. Supported values are "ABCD", "BADC", "CDAB", "DCBA". + byte_order = "ABCD" + + ## Timeout for Modbus requests (sec). + timeout = 30 + + ## Maximum number of concurrent clients. + max_clients = 5 + + ## Metrics to send to the Modbus server. + [[outputs.modbus_server.metrics]] + measurement = "metric_name" + tags = { tag1 = "value1", tag2 = "value2" } + + [[outputs.modbus_server.metrics.fields]] + [[outputs.allseas_modbus_server.metrics]] + measurement = "metric_name" + tags = { tag1 = "value1", tag2 = "value2" } + + [[outputs.allseas_modbus_server.metrics.fields]] + register = "coil" + address = 1 + name = "field1" + type = "BIT" + + [[outputs.modbus_server.metrics.fields]] + [[outputs.allseas_modbus_server.metrics.fields]] + register = "register" + address = 2 + name = "field2" + type = "UINT16" +``` + +## Metric Schema + +The metrics section defines the metrics to be collected from the Modbus server. +Each metric can have multiple fields, each corresponding to a Modbus register. + +## Fields + +- register: The type of Modbus register (e.g., "coil", "register"). +- address: The address of the Modbus register. +- name: The name of the field. +- value: The value of the field. +- type: The data type of the field. Supported types are: + - `BIT` + - `UINT16` + - `FLOAT32` + - `INT32` + - `UINT32` + - `INT64` + - `UINT64` + - `FLOAT64` + - `INT8L` + - `INT8H` + - `UINT8L` + - `UINT8H` + - `FLOAT16` + - `STRING` + +Only the tags, field name and values are part of the output metric. +The address and type are used to read the data from the Modbus server. diff --git a/plugins/outputs/modbus_server/hash_id_generator.go b/plugins/outputs/modbus_server/hash_id_generator.go new file mode 100644 index 0000000000000..97944b781cca8 --- /dev/null +++ b/plugins/outputs/modbus_server/hash_id_generator.go @@ -0,0 +1,73 @@ +package modbus_server + +import ( + "hash/maphash" + "sort" + + "github.com/influxdata/telegraf" +) + +func NewHashIDGenerator() *HashIDGenerator { + return &HashIDGenerator{ + hashSeed: maphash.MakeSeed(), + } +} + +type HashIDGenerator struct { + hashSeed maphash.Seed +} + +func (g *HashIDGenerator) GetID( + measurement string, + tags map[string]string, +) uint64 { + taglist := make([]*telegraf.Tag, 0, len(tags)) + for k, v := range tags { + taglist = append( + taglist, + &telegraf.Tag{Key: k, Value: v}, + ) + } + sort.Slice(taglist, func(i, j int) bool { return taglist[i].Key < taglist[j].Key }) + + return genID(g.hashSeed, measurement, taglist) +} + +func genID(seed maphash.Seed, measurement string, taglist []*telegraf.Tag) uint64 { + var mh maphash.Hash + mh.SetSeed(seed) + + _, err := mh.WriteString(measurement) + if err != nil { + return 0 + } + err = mh.WriteByte(0) + if err != nil { + return 0 + } + + for _, tag := range taglist { + _, err = mh.WriteString(tag.Key) + if err != nil { + return 0 + } + err = mh.WriteByte(0) + if err != nil { + return 0 + } + _, err = mh.WriteString(tag.Value) + if err != nil { + return 0 + } + err = mh.WriteByte(0) + if err != nil { + return 0 + } + } + err = mh.WriteByte(0) + if err != nil { + return 0 + } + + return mh.Sum64() +} diff --git a/plugins/outputs/modbus_server/modbus_server.go b/plugins/outputs/modbus_server/modbus_server.go new file mode 100644 index 0000000000000..4aeca3d8fb3af --- /dev/null +++ b/plugins/outputs/modbus_server/modbus_server.go @@ -0,0 +1,251 @@ +//go:generate ../../../tools/readme_config_includer/generator +package modbus_server + +import ( + "context" + _ "embed" + "fmt" + "time" + + "github.com/simonvetter/modbus" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/plugins/common/modbus_server" + "github.com/influxdata/telegraf/plugins/outputs" +) + +//go:embed sample.conf +var config string + +type MetricSchema struct { + Register string `toml:"register"` + Address uint16 `toml:"address"` + Name string `toml:"name"` + CoilInitialValue bool `toml:"coil_initial_value,omitempty"` + Type string `toml:"type,omitempty"` + Bit uint8 `toml:"bit,omitempty"` + Scale float64 `toml:"scale,omitempty"` + Length uint16 `toml:"length,omitempty"` +} + +type MetricDefinition struct { + Name string `toml:"measurement"` + MetricSchema []MetricSchema `toml:"fields"` + Tags map[string]string `toml:"tags"` +} + +type ModbusServerConfig struct { + ServerAddress string `toml:"server_address"` + ByteOrder string `toml:"byte_order"` + Timeout time.Duration `toml:"timeout"` + MaxClients uint `toml:"max_clients"` + Metrics []MetricDefinition `toml:"metrics"` +} + +type ModbusServer struct { + ModbusServerConfig + server *modbus.ModbusServer + handler *modbus_server.Handler + MemoryMap map[uint64]map[string]modbus_server.MemoryEntry + Log telegraf.Logger `toml:"-"` + ctx context.Context + cancel context.CancelFunc + hashGrouper *HashIDGenerator +} + +func (*ModbusServer) SampleConfig() string { + return config +} + +func checkMeasurement(measurement MetricDefinition) error { + memoryLayout := modbus_server.MemoryLayout{} + fields := make(map[string]bool) + + for _, field := range measurement.MetricSchema { + // check for duplicate field names + if _, ok := fields[field.Name]; ok { + return fmt.Errorf("duplicate field name: %v", field.Name) + } + fields[field.Name] = true + memoryLayout = append( + memoryLayout, modbus_server.MemoryEntry{ + Address: field.Address, Type: field.Type, Measurement: measurement.Name, Field: field.Name, Register: field.Register, Bit: field.Bit, + Scale: field.Scale, Length: field.Length, + }, + ) + } + + _, overlaps, err := memoryLayout.HasOverlap() + if err != nil { + return err + } + + if len(overlaps) > 0 { + return fmt.Errorf("overlapping addresses: %v in measurement: %v", measurement.Name, overlaps) + } + + return nil +} + +func (m *ModbusServer) checkConfig() (modbus_server.MemoryLayout, []string, error) { + memoryLayout := modbus_server.MemoryLayout{} + m.hashGrouper = NewHashIDGenerator() + for _, entry := range m.Metrics { + err := checkMeasurement(entry) + if err != nil { + return nil, nil, err + } + for _, field := range entry.MetricSchema { + hashID := m.hashGrouper.GetID(entry.Name, entry.Tags) + memoryLayout = append( + memoryLayout, modbus_server.MemoryEntry{ + Address: field.Address, Type: field.Type, HashID: hashID, Field: field.Name, Register: field.Register, Bit: field.Bit, Scale: field.Scale, + Length: field.Length, CoilInitialValue: field.CoilInitialValue, + }, + ) + } + } + _, overlaps, err := memoryLayout.HasOverlap() + if err != nil { + return nil, overlaps, err + } + + return memoryLayout, overlaps, nil +} + +func (m *ModbusServer) InitCoilValues(memory modbus_server.MemoryLayout) { + for _, entry := range memory { + if entry.CoilInitialValue { + if entry.Register == "coil" { + _, err := m.handler.WriteCoils(entry.Address, []bool{entry.CoilInitialValue}) + if err != nil { + m.Log.Errorf("failed to init coil value: %v", err) + } + } + } + } +} + +func (m *ModbusServer) Init() error { + // create the server object + memLayout, overlaps, err := m.checkConfig() + if err != nil { + m.Log.Errorf("failed to create server: %v\n", err) + return err + } + + if len(overlaps) > 0 { + m.Log.Warnf("Overlapping addresses: %v", overlaps) + } + + coils, registers := memLayout.GetCoilsAndRegisters() + coilOffset, registerOffset := memLayout.GetMemoryOffsets() + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + if err != nil { + return err + } + + m.Log.Debugf("MemoryLayout: %v", memLayout) + m.Log.Debugf("Metrics: %v", m.Metrics) + + m.Log.Debugf("Coils: %v, Registers: %v, CoilOffset: %v, RegisterOffset: %v", coils, registers, coilOffset, registerOffset) + m.Log.Debugf("MemoryMap: %v", m.MemoryMap) + + m.handler, err = modbus_server.NewRequestHandler(uint16(len(coils)), coilOffset, uint16(len(registers)), registerOffset, m.Log) + if err != nil { + m.Log.Errorf("failed to create server: %v", err) + return err + } + + m.server, err = modbus.NewServer( + &modbus.ServerConfiguration{ + URL: m.ServerAddress, + Timeout: m.Timeout * time.Second, + MaxClients: m.MaxClients, + }, m.handler, + ) + + if err != nil { + m.Log.Errorf("failed to create server: %v\n", err) + return err + } + + m.InitCoilValues(memLayout) + + // Create a cancellable context + m.ctx, m.cancel = context.WithCancel(context.Background()) + + return nil +} + +func (m *ModbusServer) Write(metrics []telegraf.Metric) error { + for _, metr := range metrics { + metr.Accept() + m.Log.Debugf("--------------------metric: %v------------------\n", metr.Name()) + m.Log.Debugf("tags: %v\n", metr.Tags()) + m.Log.Debugf("fields: %v\n", metr.FieldList()) + m.Log.Debugf("time: %v\n", metr.Time()) + + hashID := m.hashGrouper.GetID(metr.Name(), metr.Tags()) + memMap, ok := m.MemoryMap[hashID] + if !ok { + m.Log.Errorf("failed to find metric: %v, id %v", metr.Name(), hashID) + return fmt.Errorf("failed to find metric: %v, id %v", metr.Name(), hashID) + } + for _, field := range metr.FieldList() { + memEntry := memMap[field.Key] + if memEntry.Register == "coil" { + _, err := m.handler.WriteCoils(memEntry.Address, []bool{field.Value.(bool)}) + if err != nil { + m.Log.Errorf("failed to write metric: %v", err) + return err + } + } else if memEntry.Type == "BIT" { + bitIndex := memEntry.Bit + bitValue := field.Value.(bool) + _, err := m.handler.WriteBitToHoldingRegister(memEntry.Address, bitValue, bitIndex) + if err != nil { + m.Log.Errorf("failed to write metric: %v", err) + return err + } + } else { + registerValues, err := modbus_server.ParseMetric(m.ByteOrder, field.Value, memEntry.Type, memEntry.Scale) + if err != nil { + m.Log.Errorf("failed to parse metric: %v", err) + return err + } + _, err = m.handler.WriteHoldingRegisters(memEntry.Address, registerValues) + if err != nil { + m.Log.Errorf("failed to write metric: %v", err) + return err + } + } + } + } + return nil +} + +func (m *ModbusServer) Connect() error { + // Create a Modbus TCP client configuration + err := m.server.Start() + if err != nil { + return err + } + return nil +} + +func (m *ModbusServer) Close() error { + err := m.server.Stop() + if err != nil { + return err + } + return nil +} + +func init() { + outputs.Add( + "modbus_server", func() telegraf.Output { + return &ModbusServer{} + }, + ) +} diff --git a/plugins/outputs/modbus_server/modbus_server_test.go b/plugins/outputs/modbus_server/modbus_server_test.go new file mode 100644 index 0000000000000..c74e0fea9746a --- /dev/null +++ b/plugins/outputs/modbus_server/modbus_server_test.go @@ -0,0 +1,646 @@ +package modbus_server + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" + "github.com/influxdata/telegraf/plugins/common/modbus_server" + "github.com/influxdata/telegraf/testutil" +) + +func TestInitOpenClose(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Timeout: 60 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "measurement1", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "field1", Type: "UINT16"}, + {Register: "coil", Address: 1, Name: "field2", Type: "FLOAT32"}, + }, + Tags: map[string]string{"tag1": "value1"}, + }, + }, + }, + Log: testutil.Logger{}, + } + + require.NoError(t, m.Init()) + require.NoError(t, m.Connect()) + require.NoError(t, m.Close()) +} + +func TestSampleConfig(t *testing.T) { + m := &ModbusServer{} + require.NotEmpty(t, m.SampleConfig()) +} + +func TestCheckConfig(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1", Type: "BIT"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + }, + }, + }, + }, + } + memoryLayout, _, err := m.checkConfig() + require.NoError(t, err) + require.NotEmpty(t, memoryLayout) +} + +func TestCheckConfigAddressesOutOfRange(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1", Type: "BIT"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + {Register: "register", Address: 3, Name: "field3", Type: "UINT16"}, + {Register: "register", Address: 4, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 10, Name: "field5", Type: "UINT16"}, + {Register: "register", Address: 11, Name: "field6", Type: "UINT16"}, + {Register: "register", Address: 12, Name: "field7", Type: "UINT16"}, + }, + }, + }, + }, + } + memoryLayout, _, err := m.checkConfig() + require.NoError(t, err) + require.Len(t, memoryLayout, 7) +} + +func TestOverlappingEntries(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Timeout: 60 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + }, + { + Name: "test_metric1", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + require.NoError(t, m.Init()) + memMap, _, err := m.checkConfig() + require.NoError(t, err) + require.NotEmpty(t, memMap) +} + +func TestDuplicateFields(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Timeout: 60 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + {Register: "register", Address: 2, Name: "field1", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + require.Error(t, m.Init()) + memMap, _, err := m.checkConfig() + require.Error(t, err) + require.Empty(t, memMap) +} + +func TestMemoryOverlap(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Timeout: 60 * time.Second, + MaxClients: 5, + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 1, Name: "field1", Type: "UINT32"}, + {Register: "register", Address: 2, Name: "field2", Type: "UINT16"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + require.Error(t, m.Init()) + memMap, _, err := m.checkConfig() + require.Error(t, err) + require.Empty(t, memMap) +} + +func TestCheckMeasurement(t *testing.T) { + tests := []struct { + measurement MetricDefinition + expectError bool + }{ + { + measurement: MetricDefinition{ + Name: "measurement1", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "field1", Type: "UINT16"}, + {Register: "coil", Address: 1, Name: "field2", Type: "FLOAT32"}, + }, + Tags: map[string]string{"tag1": "value1"}, + }, + expectError: false, + }, + { + measurement: MetricDefinition{ + Name: "measurement2", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "field1", Type: "UINT16"}, + {Register: "coil", Address: 0, Name: "field1", Type: "FLOAT32"}, + }, + Tags: map[string]string{"tag1": "value1"}, + }, + expectError: true, + }, + } + + for _, test := range tests { + err := checkMeasurement(test.measurement) + if test.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } +} + +func TestMetricsConversion(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1"}, + {Register: "coil", Address: 2, Name: "field2"}, + + {Register: "register", Address: 3, Name: "field3", Type: "UINT16"}, + {Register: "register", Address: 4, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 5, Name: "field5", Type: "UINT32"}, + }, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(2, 1, 5, 3, testutil.Logger{}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(1, []bool{false, true}) + require.NoError(t, err) + _, err = m.handler.WriteHoldingRegisters(3, []uint16{123, 321, math.MaxUint16, math.MaxUint16}) + require.NoError(t, err) + + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + + require.NoError(t, err) + // generate metrics + var metrics []telegraf.Metric + + // Define sample fields for the metric + field1 := map[string]interface{}{ + "field1": true, + } + field2 := map[string]interface{}{ + "field2": false, + } + field3 := map[string]interface{}{ + "field3": uint16(3), + } + field4 := map[string]interface{}{ + "field4": uint16(4), + } + field5 := map[string]interface{}{ + "field5": uint32(5), + } + // Define tags for the metric + tags := map[string]string{} + + // Create the metric + metrics = append( + metrics, + metric.New("test_metric", tags, field1, time.Now()), + metric.New("test_metric", tags, field2, time.Now()), + metric.New("test_metric", tags, field3, time.Now()), + metric.New("test_metric", tags, field4, time.Now()), + metric.New("test_metric", tags, field5, time.Now()), + ) + + require.NoError(t, m.Write(metrics)) + + coils, err := m.handler.ReadCoils(1, 2) + require.NoError(t, err) + registers, err := m.handler.ReadHoldingRegisters(3, 5) + require.NoError(t, err) + // check if the values are updated + require.True(t, coils[0]) + require.False(t, coils[1]) + require.Equal(t, uint16(3), registers[0]) + require.Equal(t, uint16(4), registers[1]) + require.Equal(t, uint16(0), registers[2]) + require.Equal(t, uint16(5), registers[3]) +} + +func TestMetricsConversionTwoMeasurements(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric1", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 1, Name: "field1", Type: "BIT"}, + {Register: "coil", Address: 2, Name: "field2", Type: "BIT"}, + {Register: "register", Address: 3, Name: "field3", Type: "UINT16"}, + }, + Tags: map[string]string{}, + }, + { + Name: "test_metric2", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 4, Name: "field4", Type: "UINT16"}, + {Register: "register", Address: 5, Name: "field5", Type: "UINT32"}, + }, + Tags: map[string]string{}, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(2, 1, 5, 3, testutil.Logger{}) + require.NoError(t, err) + _, err = m.handler.WriteCoils(1, []bool{false, true}) + require.NoError(t, err) + _, err = m.handler.WriteHoldingRegisters(3, []uint16{123, 321, math.MaxUint16, math.MaxUint16}) + require.NoError(t, err) + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + + require.NoError(t, err) + // generate metrics + var metrics []telegraf.Metric + + // Define sample fields for the metrics + field1 := map[string]interface{}{ + "field1": true, + } + field2 := map[string]interface{}{ + "field2": false, + } + field3 := map[string]interface{}{ + "field3": uint16(3), + } + field4 := map[string]interface{}{ + "field4": uint16(4), + } + field5 := map[string]interface{}{ + "field5": uint32(5), + } + tags := map[string]string{} + + // Create the metrics + metrics = append( + metrics, + metric.New("test_metric1", tags, field1, time.Now()), + metric.New("test_metric1", tags, field2, time.Now()), + metric.New("test_metric1", tags, field3, time.Now()), + metric.New("test_metric2", tags, field4, time.Now()), + metric.New("test_metric2", tags, field5, time.Now()), + ) + + require.NoError(t, m.Write(metrics)) + + coils, err := m.handler.ReadCoils(1, 2) + require.NoError(t, err) + registers, err := m.handler.ReadHoldingRegisters(3, 4) + require.NoError(t, err) + // check if the values are updated + require.True(t, coils[0]) + require.False(t, coils[1]) + require.Equal(t, uint16(3), registers[0]) + require.Equal(t, uint16(4), registers[1]) + require.Equal(t, uint16(0), registers[2]) + require.Equal(t, uint16(5), registers[3]) +} + +func TestSameMetricsDifferentTags(t *testing.T) { + tags1 := map[string]string{"tag1": "value1"} + tags2 := map[string]string{"tag2": "value2"} + + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 0, Name: "field0", Type: "UINT16"}, + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + Tags: tags1, + }, + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 0, Name: "field0", Type: "UINT16"}, + {Register: "register", Address: 1, Name: "field1", Type: "UINT16"}, + }, + Tags: tags2, + }, + }, + }, + Log: testutil.Logger{ + Name: "", + Quiet: false, + }, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(0, 0, 2, 0, testutil.Logger{}) + require.NoError(t, err) + _, err = m.handler.WriteHoldingRegisters(0, []uint16{123, 321}) + require.NoError(t, err) + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + + require.NoError(t, err) + // generate metrics + var metrics []telegraf.Metric + + // Define sample fields for the metrics + field0 := map[string]interface{}{ + "field0": uint16(3), + } + field1 := map[string]interface{}{ + "field1": uint32(4), + } + + // Write the first series + metrics = append( + metrics, + metric.New("test_metric", map[string]string{"tag1": "value1"}, field0, time.Now()), + metric.New("test_metric", map[string]string{"tag1": "value1"}, field1, time.Now()), + ) + + require.NoError(t, m.Write(metrics)) + + res, err := m.handler.ReadHoldingRegisters(0, 2) + require.NoError(t, err) + require.Equal(t, uint16(3), res[0]) + require.Equal(t, uint16(4), res[1]) + + // Reset registers + field0 = map[string]interface{}{ + "field0": uint16(0), + } + field1 = map[string]interface{}{ + "field1": uint32(0), + } + + // Write the second series + metrics = make([]telegraf.Metric, 0) + metrics = append( + metrics, + metric.New("test_metric", map[string]string{"tag2": "value2"}, field0, time.Now()), + metric.New("test_metric", map[string]string{"tag2": "value2"}, field1, time.Now()), + ) + + require.NoError(t, m.Write(metrics)) + res, err = m.handler.ReadHoldingRegisters(0, 2) + require.NoError(t, err) + require.Equal(t, uint16(0), res[0]) + require.Equal(t, uint16(0), res[1]) +} + +func TestWriteBitType(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + Tags: map[string]string{}, + MetricSchema: func() []MetricSchema { + schema := make([]MetricSchema, 16) + for i := 0; i < 16; i++ { + schema[i] = MetricSchema{Register: "register", Address: 0, Name: fmt.Sprintf("field%d", i+1), Type: "BIT", Bit: uint8(i)} + } + return schema + }(), + }, + }, + }, + Log: testutil.Logger{}, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(0, 0, 1, 0, testutil.Logger{}) + require.NoError(t, err) + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + require.NoError(t, err) + + res, err := m.handler.ReadHoldingRegisters(0, 1) + require.NoError(t, err) + require.Equal(t, uint16(0), res[0]) + + // Define sample fields for the metric + fields := map[string]interface{}{ + "field1": true, "field2": false, "field3": true, "field4": false, + "field5": true, "field6": false, "field7": true, "field8": false, + "field9": true, "field10": false, "field11": true, "field12": false, + "field13": true, "field14": false, "field15": true, "field16": false, + } + tags := map[string]string{} + + // Create the metric + metrics := make([]telegraf.Metric, 0, len(fields)) + for k, v := range fields { + metrics = append(metrics, metric.New("test_metric", tags, map[string]interface{}{k: v}, time.Now())) + } + + require.NoError(t, m.Write(metrics)) + + res, err = m.handler.ReadHoldingRegisters(0, 1) + require.NoError(t, err) + // Check if the values are updated 21845 = 0b0101010101010101 + require.Equal(t, uint16(21845), res[0]) + + // Define sample fields for the metric + fields = map[string]interface{}{ + "field1": false, "field2": true, "field3": false, "field4": true, + "field5": false, "field6": true, "field7": false, "field8": true, + "field9": false, "field10": true, "field11": false, "field12": true, + "field13": false, "field14": true, "field15": false, "field16": true, + } + // Create the metric + metrics = make([]telegraf.Metric, 0, len(fields)) + for k, v := range fields { + metrics = append(metrics, metric.New("test_metric", tags, map[string]interface{}{k: v}, time.Now())) + } + + require.NoError(t, m.Write(metrics)) + + res, err = m.handler.ReadHoldingRegisters(0, 1) + require.NoError(t, err) + // Check if the values are updated 43690 = 0b1010101010101010 + require.Equal(t, uint16(43690), res[0]) +} + +func TestWriteStringMetric(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_string_metric", + MetricSchema: []MetricSchema{ + {Register: "register", Address: 0, Name: "field1", Type: "STRING", Length: 3}, + }, + }, + }, + }, + Log: testutil.Logger{}, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(0, 0, 3, 0, testutil.Logger{}) + require.NoError(t, err) + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + m.MemoryMap, err = memLayout.GetMemoryMappedByHashID() + require.NoError(t, err) + + // Define sample fields for the metric + field1 := map[string]interface{}{ + "field1": "Hello", + } + // Define tags for the metric + tags := make(map[string]string) + + // Create the metric + metrics := []telegraf.Metric{ + metric.New("test_string_metric", tags, field1, time.Now()), + } + + require.NoError(t, m.Write(metrics)) + + // Check if the values are updated + expectedRegisters := []uint16{0x4865, 0x6c6c, 0x6f00} // "Hello" in UTF-16 + + res, err := m.handler.ReadHoldingRegisters(0, 3) + require.NoError(t, err) + require.Equal(t, expectedRegisters, res) +} + +func TestInitCoilValues(t *testing.T) { + m := &ModbusServer{ + ModbusServerConfig: ModbusServerConfig{ + ServerAddress: "tcp://localhost:5502", + ByteOrder: "ABCD", + Metrics: []MetricDefinition{ + { + Name: "test_metric", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "field1", CoilInitialValue: true}, + {Register: "coil", Address: 1, Name: "field2", CoilInitialValue: false}, + {Register: "coil", Address: 2, Name: "field3"}, + }, + }, + }, + }, + Log: testutil.Logger{}, + } + + var err error + m.handler, err = modbus_server.NewRequestHandler(3, 0, 0, 0, testutil.Logger{}) + require.NoError(t, err) + memLayout, _, err := m.checkConfig() + require.NoError(t, err) + + m.InitCoilValues(memLayout) + + // Check if the coil value is initialized correctly + coils, err := m.handler.ReadCoils(0, 3) + require.NoError(t, err) + require.True(t, coils[0]) + require.False(t, coils[1]) + require.False(t, coils[2]) +} diff --git a/plugins/outputs/modbus_server/sample.conf b/plugins/outputs/modbus_server/sample.conf new file mode 100644 index 0000000000000..1fe4d26e7db0b --- /dev/null +++ b/plugins/outputs/modbus_server/sample.conf @@ -0,0 +1,38 @@ +[[outputs.modbus_server]] + server_address = "tcp://localhost:502" + byte_order = "ABCD" + timeout = 30 + max_clients = 5 + [[outputs.modbus_server.metrics]] + measurement = "measurement1" + fields = [ + { register = "coil", address = 0, name = "field1"}, + { register = "holding", address = 50000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 50001, name = "bit_field0", type = "BIT", bit = 0}, + { register = "holding", address = 50001, name = "bit_field1", type = "BIT", bit = 1}, + { register = "holding", address = 50001, name = "bit_field2", type = "BIT", bit = 2}, + { register = "holding", address = 50001, name = "bit_field3", type = "BIT", bit = 3}, + { register = "holding", address = 50001, name = "bit_field4", type = "BIT", bit = 4}, + { register = "holding", address = 50001, name = "bit_field5", type = "BIT", bit = 5}, + { register = "holding", address = 50001, name = "bit_field6", type = "BIT", bit = 6}, + { register = "holding", address = 50001, name = "bit_field7", type = "BIT", bit = 7}, + { register = "holding", address = 50001, name = "bit_field8", type = "BIT", bit = 8}, + { register = "holding", address = 50001, name = "bit_field9", type = "BIT", bit = 9}, + { register = "holding", address = 50001, name = "bit_field10", type = "BIT", bit = 10}, + { register = "holding", address = 50001, name = "bit_field11", type = "BIT", bit = 11}, + { register = "holding", address = 50001, name = "bit_field12", type = "BIT", bit = 12}, + { register = "holding", address = 50001, name = "bit_field13", type = "BIT", bit = 13}, + { register = "holding", address = 50001, name = "bit_field14", type = "BIT", bit = 14}, + { register = "holding", address = 50001, name = "bit_field15", type = "BIT", bit = 15}, + ] + [outputs.modbus_server.metrics.tags] + tag1 = "value1" + tag2 = "value2" + [[outputs.modbus_server.metrics]] + measurement = "measurement2" + fields = [ + { register = "holding", address = 40000, name = "float_field", type = "FLOAT32" }, + { register = "holding", address = 40002, name = "string_field", type = "STRING", length = 10 }, + ] + [outputs.modbus_server.metrics.tags] + tag3 = "3" From ad399d1d751ce7bb10314c05d76ed65b8c6cc413 Mon Sep 17 00:00:00 2001 From: Paula Iacoban Date: Thu, 1 May 2025 09:49:48 +0200 Subject: [PATCH 2/2] fix(inputs.modbus_server): Fix measurement-fields mapping bug in metrics --- plugins/inputs/modbus_server/modbus_server.go | 5 +++-- .../modbus_server/modbus_server_test.go | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/plugins/inputs/modbus_server/modbus_server.go b/plugins/inputs/modbus_server/modbus_server.go index 519e4d1d31b72..48c543c2fc5e5 100644 --- a/plugins/inputs/modbus_server/modbus_server.go +++ b/plugins/inputs/modbus_server/modbus_server.go @@ -114,9 +114,10 @@ func (m *ModbusServer) getMetrics(timestamp time.Time) []telegraf.Metric { registers, registerOffset := m.handler.GetRegistersAndOffset() var metrics []telegraf.Metric - metricFields := make(map[string]interface{}) for _, entry := range m.Metrics { + metricFields := make(map[string]interface{}) + for _, field := range entry.MetricSchema { var err error metricFields[field.Name], err = modbus_server.ParseMemory( @@ -199,7 +200,7 @@ func (m *ModbusServer) Start(acc telegraf.Accumulator) error { // Check if the channel is empty case lastEditTimestamp := <-m.handler.LastEdit: metrics := m.getMetrics(lastEditTimestamp) - m.Log.Infof("Gathered %d metrics", len(metrics)) + m.Log.Debugf("Gathered %d metrics", len(metrics)) for _, modbusMetric := range metrics { acc.AddMetric(modbusMetric) } diff --git a/plugins/inputs/modbus_server/modbus_server_test.go b/plugins/inputs/modbus_server/modbus_server_test.go index 2a0b052271fef..c35fc759c4168 100644 --- a/plugins/inputs/modbus_server/modbus_server_test.go +++ b/plugins/inputs/modbus_server/modbus_server_test.go @@ -120,6 +120,12 @@ func TestGetMetrics(t *testing.T) { {Register: "register", Address: 5, Name: "field5", Type: "UINT32"}, }, }, + { + Name: "test_metric2", + MetricSchema: []MetricSchema{ + {Register: "coil", Address: 0, Name: "coil"}, + }, + }, }, }, Log: testutil.Logger{ @@ -128,17 +134,17 @@ func TestGetMetrics(t *testing.T) { }, } var err error - m.handler, err = modbus_server.NewRequestHandler(2, 1, 4, 3, testutil.Logger{}) + m.handler, err = modbus_server.NewRequestHandler(3, 0, 4, 3, testutil.Logger{}) require.NoError(t, err) _, err = m.handler.WriteHoldingRegisters(3, []uint16{123, 321, math.MaxUint16, math.MaxUint16}) require.NoError(t, err) - _, err = m.handler.WriteCoils(1, []bool{true, false}) + _, err = m.handler.WriteCoils(0, []bool{true, true, false}) require.NoError(t, err) _, _, err = m.checkConfig() require.NoError(t, err) metrics := m.getMetrics(time.Now()) - require.Len(t, metrics, 5) + require.Len(t, metrics, 6) require.Equal(t, "test_metric", metrics[0].Name()) require.Equal(t, true, metrics[0].Fields()["field1"]) @@ -153,6 +159,15 @@ func TestGetMetrics(t *testing.T) { require.Equal(t, "test_metric", metrics[4].Name()) require.Equal(t, uint64(math.MaxUint32), metrics[4].Fields()["field5"]) + + for i := 0; i < 5; i++ { + _, exists := metrics[i].Fields()["coil"] + require.False(t, exists, "coil should not exist in test_metric") + } + + require.Equal(t, "test_metric2", metrics[5].Name()) + require.Equal(t, true, metrics[5].Fields()["coil"]) + require.Len(t, metrics[5].Fields(), 1) } func TestStartStop(t *testing.T) {