diff --git a/cmd/commons.go b/cmd/commons.go index 15f741f8..51716b95 100644 --- a/cmd/commons.go +++ b/cmd/commons.go @@ -60,6 +60,14 @@ func meterHelp() string { s += fmt.Sprintf("\n %s", "TCP") s += fmt.Sprintf("\n %-10s%s", "SUNS", "Sunspec-compatible MODBUS TCP device (SMA, SolarEdge, KOSTAL, etc)") + s += fmt.Sprintf("\n %s", "UDP") + udpTypes := []string{"VM3P"} + for _, t := range udpTypes { + if p, ok := rs485.Producers[t]; ok { + s += fmt.Sprintf("\n %-10s%s", t, p().Description()) + } + } + return s } diff --git a/cmd/confighandler.go b/cmd/confighandler.go index 284debb9..aee1b049 100644 --- a/cmd/confighandler.go +++ b/cmd/confighandler.go @@ -50,6 +50,7 @@ type InfluxConfig struct { type AdapterConfig struct { Device string RTU bool + UDP bool Baudrate int Comset string } @@ -77,12 +78,16 @@ func NewDeviceConfigHandler() *DeviceConfigHandler { return conf } -// createConnection parses adapter string to create TCP or RTU connection -func createConnection(device string, rtu bool, baudrate int, comset string, timeout time.Duration) (res meters.Connection) { +// createConnection parses adapter string to create TCP, UDP or RTU connection +func createConnection(device string, rtu bool, udp bool, baudrate int, comset string, timeout time.Duration) (res meters.Connection) { if device == "mock" { res = meters.NewMock(device) // mocked connection } else if tcp, _ := regexp.MatchString(":[0-9]+$", device); tcp { - if rtu { + if udp { + // special case: RTU over UDP + log.Printf("config: creating RTU over UDP connection for %s", device) + res = meters.NewRTUOverUDP(device) // udp connection + } else if rtu { // special case: RTU over TCP log.Printf("config: creating RTU over TCP connection for %s", device) res = meters.NewRTUOverTCP(device) // tcp connection @@ -106,10 +111,10 @@ func createConnection(device string, rtu bool, baudrate int, comset string, time } // ConnectionManager returns connection manager from cache or creates new connection wrapped by manager -func (conf *DeviceConfigHandler) ConnectionManager(connSpec string, rtu bool, baudrate int, comset string, timeout time.Duration) *meters.Manager { +func (conf *DeviceConfigHandler) ConnectionManager(connSpec string, rtu bool, udp bool, baudrate int, comset string, timeout time.Duration) *meters.Manager { manager, ok := conf.Managers[connSpec] if !ok { - conn := createConnection(connSpec, rtu, baudrate, comset, timeout) + conn := createConnection(connSpec, rtu, udp, baudrate, comset, timeout) manager = meters.NewManager(conn) conf.Managers[connSpec] = manager } @@ -222,9 +227,9 @@ func (conf *DeviceConfigHandler) CreateDeviceFromSpec(deviceDef string, timeout log.Fatalf("Error parsing device id %s: %v. See -h for help.", devID, err) } - // If this is an RTU over TCP device, a default RTU over TCP should already - // have been created of the --rtu flag was specified. We'll not re-check this here. - manager := conf.ConnectionManager(connSpec, false, 0, "", timeout) + // If this is an RTU over TCP/UDP device, a default connection should already + // have been created if the --rtu or --udp flag was specified. We'll not re-check this here. + manager := conf.ConnectionManager(connSpec, false, false, 0, "", timeout) meter := conf.createDeviceForManager(manager, meterType, subdevice) if err := manager.Add(uint8(id), meter); err != nil { diff --git a/cmd/inspect.go b/cmd/inspect.go index 3d52c3fb..963cef52 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -143,7 +143,7 @@ func inspect(cmd *cobra.Command, args []string) { defaultDevice := viper.GetString("adapter") if defaultDevice != "" { confHandler.DefaultDevice = defaultDevice - confHandler.ConnectionManager(defaultDevice, viper.GetBool("rtu"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) + confHandler.ConnectionManager(defaultDevice, viper.GetBool("rtu"), viper.GetBool("udp"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) } // create devices from command line diff --git a/cmd/read.go b/cmd/read.go index 45d9aefa..5d9ccecf 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -56,7 +56,7 @@ func modbusClient() (meters.Connection, modbus.Client) { } // connection - conn := createConnection(adapter, viper.GetBool("rtu"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) + conn := createConnection(adapter, viper.GetBool("rtu"), viper.GetBool("udp"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) client := conn.ModbusClient() // raw log diff --git a/cmd/root.go b/cmd/root.go index 07f293b6..8a436755 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,6 +65,13 @@ Only applicable if the default adapter is an RTU device`, `Use RTU over TCP for default adapter. Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection`, + ) + rootCmd.PersistentFlags().Bool( + "udp", + false, + `Use RTU over UDP for default adapter. +Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). +Only applicable if the default adapter is a UDP address`, ) rootCmd.PersistentFlags().BoolP( "help", "h", diff --git a/cmd/run.go b/cmd/run.go index 9acceb94..ebf2cf16 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -212,7 +212,7 @@ func run(cmd *cobra.Command, args []string) { defaultDevice := viper.GetString("adapter") if defaultDevice != "" { confHandler.DefaultDevice = defaultDevice - confHandler.ConnectionManager(defaultDevice, viper.GetBool("rtu"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) + confHandler.ConnectionManager(defaultDevice, viper.GetBool("rtu"), viper.GetBool("udp"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) } // create devices from command line @@ -239,7 +239,7 @@ func run(cmd *cobra.Command, args []string) { if len(devices) == 0 { // add adapters from configuration for _, a := range conf.Adapters { - confHandler.ConnectionManager(a.Device, a.RTU, a.Baudrate, a.Comset, viper.GetDuration("timeout")) + confHandler.ConnectionManager(a.Device, a.RTU, a.UDP, a.Baudrate, a.Comset, viper.GetDuration("timeout")) } // add devices from configuration diff --git a/cmd/scan.go b/cmd/scan.go index bf3d72fd..8e7096b9 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -68,7 +68,7 @@ func scan(cmd *cobra.Command, args []string) { log.Fatal("missing adapter configuration") } - conn := createConnection(adapter, viper.GetBool("rtu"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) + conn := createConnection(adapter, viper.GetBool("rtu"), viper.GetBool("udp"), viper.GetInt("baudrate"), viper.GetString("comset"), viper.GetDuration("timeout")) client := conn.ModbusClient() // raw log diff --git a/docs/mbmd.md b/docs/mbmd.md index 983065e4..412b2da4 100644 --- a/docs/mbmd.md +++ b/docs/mbmd.md @@ -22,6 +22,9 @@ Easily read and distribute data from ModBus meters and grid inverters Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_completion.md b/docs/mbmd_completion.md index 1db7418e..3e507e14 100644 --- a/docs/mbmd_completion.md +++ b/docs/mbmd_completion.md @@ -24,6 +24,9 @@ See each sub-command's help for details on how to use the generated script. Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_completion_bash.md b/docs/mbmd_completion_bash.md index 1621a760..4d0477e2 100644 --- a/docs/mbmd_completion_bash.md +++ b/docs/mbmd_completion_bash.md @@ -52,6 +52,9 @@ mbmd completion bash Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_completion_fish.md b/docs/mbmd_completion_fish.md index d7ecc8b8..459e1fd0 100644 --- a/docs/mbmd_completion_fish.md +++ b/docs/mbmd_completion_fish.md @@ -43,6 +43,9 @@ mbmd completion fish [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_completion_powershell.md b/docs/mbmd_completion_powershell.md index 852e9b28..d1a515e5 100644 --- a/docs/mbmd_completion_powershell.md +++ b/docs/mbmd_completion_powershell.md @@ -40,6 +40,9 @@ mbmd completion powershell [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_completion_zsh.md b/docs/mbmd_completion_zsh.md index f19233aa..95de57e0 100644 --- a/docs/mbmd_completion_zsh.md +++ b/docs/mbmd_completion_zsh.md @@ -54,6 +54,9 @@ mbmd completion zsh [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_inspect.md b/docs/mbmd_inspect.md index 56b6e3e5..be6d3933 100644 --- a/docs/mbmd_inspect.md +++ b/docs/mbmd_inspect.md @@ -48,11 +48,14 @@ mbmd inspect [flags] SDM72 Eastron SDM72 SDM72V2 Eastron SDM72 v2 SEMTR SolarEdge SE-MTR-3Y + VM3P Victron VM-3P75CT/VM-3P5A WAGO87930 Wago 879-30XX WS100 B+G e-tech WS100 X961A Eastron SMART X96-1A TCP SUNS Sunspec-compatible MODBUS TCP device (SMA, SolarEdge, KOSTAL, etc) + UDP + VM3P Victron VM-3P75CT/VM-3P5A To use an adapter different from default, append RTU device or TCP address separated by @. If the adapter is a TCP connection (identified by :port), the device type (SUNS) is ignored and any type is considered valid. @@ -75,6 +78,9 @@ mbmd inspect [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_read.md b/docs/mbmd_read.md index d5703f7c..fed6c0c9 100644 --- a/docs/mbmd_read.md +++ b/docs/mbmd_read.md @@ -36,6 +36,9 @@ mbmd read [flags] register length Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_run.md b/docs/mbmd_run.md index bb58d8b2..37186427 100644 --- a/docs/mbmd_run.md +++ b/docs/mbmd_run.md @@ -43,11 +43,14 @@ mbmd run [flags] SDM72 Eastron SDM72 SDM72V2 Eastron SDM72 v2 SEMTR SolarEdge SE-MTR-3Y + VM3P Victron VM-3P75CT/VM-3P5A WAGO87930 Wago 879-30XX WS100 B+G e-tech WS100 X961A Eastron SMART X96-1A TCP SUNS Sunspec-compatible MODBUS TCP device (SMA, SolarEdge, KOSTAL, etc) + UDP + VM3P Victron VM-3P75CT/VM-3P5A To use an adapter different from default, append RTU device or TCP address separated by @. If the adapter is a TCP connection (identified by :port), the device type (SUNS) is ignored and any type is considered valid. @@ -86,6 +89,9 @@ mbmd run [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_scan.md b/docs/mbmd_scan.md index eaa93686..db2500fe 100644 --- a/docs/mbmd_scan.md +++ b/docs/mbmd_scan.md @@ -32,6 +32,9 @@ mbmd scan [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_version.md b/docs/mbmd_version.md index af3424f7..3c9cfe49 100644 --- a/docs/mbmd_version.md +++ b/docs/mbmd_version.md @@ -22,6 +22,9 @@ mbmd version [flags] Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/docs/mbmd_write.md b/docs/mbmd_write.md index c8f3d06e..9e861896 100644 --- a/docs/mbmd_write.md +++ b/docs/mbmd_write.md @@ -35,6 +35,9 @@ mbmd write [flags] register length value Typically used with RS485 to Ethernet adapters that don't perform protocol conversion (e.g. USR-TCP232). Only applicable if the default adapter is a TCP connection --timeout duration Timeout for MODBUS communication (default 300ms) + --udp Use RTU over UDP for default adapter. + Applicable for devices that use Modbus/UDP (e.g. Victron VM-3P75CT, VM-3P5A). + Only applicable if the default adapter is a UDP address -v, --verbose Verbose mode ``` diff --git a/meters/rs485/victron.go b/meters/rs485/victron.go new file mode 100644 index 00000000..876b26dd --- /dev/null +++ b/meters/rs485/victron.go @@ -0,0 +1,134 @@ +package rs485 + +import . "github.com/volkszaehler/mbmd/meters" + +func init() { + Register("VM3P", NewVM3PProducer) +} + +// VM3PProducer implements the Producer interface for Victron Energy Meters +// VM-3P75CT (product ID 0xa1b1) and VM-3P5A (product ID 0xa1b2). +// Both models share an identical Modbus register map. +// Register map derived from: +// https://github.com/victronenergy/dbus-modbus-client/blob/master/victron_em.py +// +// The meters communicate via Modbus/UDP using Holding Registers (FC=3). +// All multi-word values are big-endian (most-significant word first). +type VM3PProducer struct { + Opcodes +} + +// NewVM3PProducer creates a new VM3PProducer. +func NewVM3PProducer() Producer { + /** + * Opcodes for Victron Energy Meter VM-3P75CT and VM-3P5A. + * Register map: https://github.com/victronenergy/dbus-modbus-client/blob/master/victron_em.py + * + * Uses Holding Registers (Function Code 0x03), big-endian word order. + * Per-phase base address: 0x3040 + 8*(n-1) + * Per-phase power address: 0x3082 + 4*(n-1) + */ + ops := Opcodes{ + Frequency: 0x3032, // u16 /100 → Hz + Import: 0x3034, // u32 /100 → kWh + Export: 0x3036, // u32 /100 → kWh + Power: 0x3080, // s32 /1 → W + Cosphi: 0x303a, // s16 /1000 + + VoltageL1: 0x3040, // s16 /100 → V + CurrentL1: 0x3041, // s16 /100 → A + ImportL1: 0x3042, // u32 /100 → kWh + ExportL1: 0x3044, // u32 /100 → kWh + CosphiL1: 0x3047, // s16 /1000 + PowerL1: 0x3082, // s32 /1 → W + + VoltageL2: 0x3048, // s16 /100 → V + CurrentL2: 0x3049, // s16 /100 → A + ImportL2: 0x304a, // u32 /100 → kWh + ExportL2: 0x304c, // u32 /100 → kWh + CosphiL2: 0x304f, // s16 /1000 + PowerL2: 0x3086, // s32 /1 → W + + VoltageL3: 0x3050, // s16 /100 → V + CurrentL3: 0x3051, // s16 /100 → A + ImportL3: 0x3052, // u32 /100 → kWh + ExportL3: 0x3054, // u32 /100 → kWh + CosphiL3: 0x3057, // s16 /1000 + PowerL3: 0x308a, // s32 /1 → W + } + return &VM3PProducer{Opcodes: ops} +} + +// Description implements Producer interface. +func (p *VM3PProducer) Description() string { + return "Victron VM-3P75CT/VM-3P5A" +} + +// snip creates a Holding Register operation for the given measurement. +func (p *VM3PProducer) snip(iec Measurement, readlen uint16, transform RTUTransform) Operation { + return Operation{ + FuncCode: ReadHoldingReg, + OpCode: p.Opcode(iec), + ReadLen: readlen, + IEC61850: iec, + Transform: transform, + } +} + +// snip16s creates an operation for a signed 16-bit register with divisor. +func (p *VM3PProducer) snip16s(iec Measurement, divisor float64) Operation { + return p.snip(iec, 1, MakeScaledTransform(RTUInt16ToFloat64, divisor)) +} + +// snip16u creates an operation for an unsigned 16-bit register with divisor. +func (p *VM3PProducer) snip16u(iec Measurement, divisor float64) Operation { + return p.snip(iec, 1, MakeScaledTransform(RTUUint16ToFloat64, divisor)) +} + +// snip32s creates an operation for a signed 32-bit register. +func (p *VM3PProducer) snip32s(iec Measurement) Operation { + return p.snip(iec, 2, RTUInt32ToFloat64) +} + +// snip32u creates an operation for an unsigned 32-bit register with divisor. +func (p *VM3PProducer) snip32u(iec Measurement, divisor float64) Operation { + return p.snip(iec, 2, MakeScaledTransform(RTUUint32ToFloat64, divisor)) +} + +// Probe implements Producer interface. +func (p *VM3PProducer) Probe() Operation { + return p.snip16s(VoltageL1, 100) +} + +// Produce implements Producer interface. +func (p *VM3PProducer) Produce() (res []Operation) { + // Voltage: s16 /100 → V + for _, op := range []Measurement{VoltageL1, VoltageL2, VoltageL3} { + res = append(res, p.snip16s(op, 100)) + } + + // Current: s16 /100 → A + for _, op := range []Measurement{CurrentL1, CurrentL2, CurrentL3} { + res = append(res, p.snip16s(op, 100)) + } + + // Active power: s32 /1 → W + for _, op := range []Measurement{Power, PowerL1, PowerL2, PowerL3} { + res = append(res, p.snip32s(op)) + } + + // Power factor: s16 /1000 + for _, op := range []Measurement{Cosphi, CosphiL1, CosphiL2, CosphiL3} { + res = append(res, p.snip16s(op, 1000)) + } + + // Frequency: u16 /100 → Hz + res = append(res, p.snip16u(Frequency, 100)) + + // Energy counters: u32 /100 → kWh + for _, op := range []Measurement{Import, ImportL1, ImportL2, ImportL3, Export, ExportL1, ExportL2, ExportL3} { + res = append(res, p.snip32u(op, 100)) + } + + return res +}