Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add modbus proxy #4981

Merged
merged 1 commit into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/evcc-io/evcc/server"
autoauth "github.com/evcc-io/evcc/server/auth"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/vehicle"
"github.com/evcc-io/evcc/vehicle/wrapper"
"github.com/gorilla/handlers"
Expand Down Expand Up @@ -47,13 +48,14 @@ type config struct {
Network networkConfig
Log string
SponsorToken string
Telemetry bool
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Mqtt mqttConfig
ModbusProxy []proxyConfig
Database dbConfig
Javascript map[string]interface{}
Influx server.InfluxConfig
Expand All @@ -73,6 +75,12 @@ type mqttConfig struct {
Topic string
}

type proxyConfig struct {
Port int
ReadOnly bool
modbus.Settings `mapstructure:",squash"`
}

type dbConfig struct {
Type string
Dsn string
Expand Down
10 changes: 10 additions & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/modbus"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/locale"
Expand Down Expand Up @@ -106,6 +107,15 @@ func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
err = configureMQTT(conf.Mqtt)
}

// setup modbus proxy listeners
if err == nil {
for _, cfg := range conf.ModbusProxy {
if err = modbus.StartProxy(cfg.Port, cfg.Settings, cfg.ReadOnly); err != nil {
break
}
}
}

// setup javascript VMs
if err == nil {
err = configureJavascript(conf.Javascript)
Expand Down
8 changes: 8 additions & 0 deletions evcc.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ levels:
cache: error
db: error

# modbus proxy for allowing external programs to reuse the evcc modbus connection
# each entry will start a proxy instance at the given port speaking Modbus TCP and
# relaying to the given modbus downstream device (either TCP or RTU, RS485 or TCP)
modbusproxy:
# - port: 5200
# uri: localhost:502
# readonly: true

# meter definitions
# name can be freely chosen and is used as reference when assigning meters to site and loadpoints
# for documentation see https://docs.evcc.io/docs/devices/meters
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/PuerkitoBio/goquery v1.8.0
github.com/andig/gosunspec v0.0.0-20211108155140-af2e73b86e71
github.com/andig/mbserver v0.0.0-20221101145037-fcaa2dfef9fb
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
github.com/avast/retry-go/v3 v3.1.1
github.com/aws/aws-sdk-go v1.44.127
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ github.com/andig/go-powerwall v0.2.1-0.20220205120646-e5220ad9a9a0 h1:9fQ/afZNwq
github.com/andig/go-powerwall v0.2.1-0.20220205120646-e5220ad9a9a0/go.mod h1:NA12RXBKXFQFx3Nb9xMTQjHIphu1Fl64i/gQMuRdMZc=
github.com/andig/gosunspec v0.0.0-20211108155140-af2e73b86e71 h1:tnjVNZjuz+CK6fdc7ohJpMHjcEGFI5APp0l5T5Ocr/Y=
github.com/andig/gosunspec v0.0.0-20211108155140-af2e73b86e71/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k=
github.com/andig/mbserver v0.0.0-20221101145037-fcaa2dfef9fb h1:cw8kFzV0kbG4E4Bv4UOmbgNkhCEZ0p7frNCFO4LKzm0=
github.com/andig/mbserver v0.0.0-20221101145037-fcaa2dfef9fb/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM=
github.com/andig/rct v0.0.0-20221101081802-96d01efdc68c h1:iXNLsesR2rTRPmr+QbjGgRTAOue8QpIkibakicNR7Qg=
github.com/andig/rct v0.0.0-20221101081802-96d01efdc68c/go.mod h1:0lfd2mmBnBzIvuzYtdhG+2371u+cUfIxsYErm4P9KRI=
github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY=
Expand Down Expand Up @@ -292,6 +294,7 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
Expand Down Expand Up @@ -774,6 +777,7 @@ github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/simonvetter/modbus v1.6.0 h1:RDHJevtc7LDIVoHAbhDun8fy+QwnGe+ZU+sLm9ZZzjc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
Expand Down
141 changes: 141 additions & 0 deletions server/modbus/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package modbus

import (
"encoding/binary"
"errors"

"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
gridx "github.com/grid-x/modbus"
)

type handler struct {
log *util.Logger
readOnly bool
mbserver.RequestHandler
conn *modbus.Connection
}

func bytesAsUint16(b []byte) []uint16 {
u := make([]uint16, 0, len(b)/2)
for i := 0; i < len(b)/2; i++ {
u = append(u, binary.BigEndian.Uint16(b[2*i:]))
}
return u
}

func asBytes(u []uint16) []byte {
b := make([]byte, 2*len(u))
for i, u := range u {
binary.BigEndian.PutUint16(b[2*i:], u)
}
return b
}

func bytesAsBool(b []byte) []bool {
var res []bool
for _, c := range bytesAsUint16(b) {
if c != 0 {
res = append(res, true)
continue
}
res = append(res, false)
}
return res
}

func boolAsBytes(b []bool) []byte {
res := make([]byte, 2*len(b))
for i, bb := range b {
if bb {
binary.BigEndian.PutUint16(res[2*i:], 0xFF00)
}
}
return res
}

func (h *handler) logResult(op string, b []byte, err error) {
if err == nil {
h.log.TRACE.Printf(op+" response: %0x", b)
} else {
h.log.TRACE.Printf(op+" response: %v", err)
}
}

func (h *handler) exceptionToUint16AndError(op string, b []byte, err error) ([]uint16, error) {
h.logResult(op, b, err)

var modbusError *gridx.Error
if errors.As(err, &modbusError) {
err = mbserver.MapExceptionCodeToError(modbusError.ExceptionCode)
}

return bytesAsUint16(b), err
}

func (h *handler) exceptionToBoolAndError(op string, b []byte, err error) ([]bool, error) {
h.logResult(op, b, err)

var modbusError *gridx.Error
if errors.As(err, &modbusError) {
err = mbserver.MapExceptionCodeToError(modbusError.ExceptionCode)
}

return bytesAsBool(b), err
}

func (h *handler) HandleCoils(req *mbserver.CoilsRequest) ([]bool, error) {
if req.IsWrite {
if h.readOnly {
return nil, mbserver.ErrIllegalFunction
}

if req.Quantity == 1 {
h.log.TRACE.Printf("write coil: id: %d addr: %d val: %t", req.UnitId, req.Addr, req.Args[0])
var u uint16
if req.Args[0] {
u = 0xFF00
}

b, err := h.conn.WriteSingleCoilWithSlave(req.UnitId, req.Addr, u)
return h.exceptionToBoolAndError("write coil", b, err)
}

h.log.TRACE.Printf("write multiple coils: id: %d addr: %d qty: %d val: %v", req.UnitId, req.Addr, req.Quantity, req.Args)
b, err := h.conn.WriteMultipleCoilsWithSlave(req.UnitId, req.Addr, req.Quantity, boolAsBytes(req.Args))
return h.exceptionToBoolAndError("write multiple coils", b, err)
}

h.log.TRACE.Printf("read coil: id: %d addr: %d qty: %d", req.UnitId, req.Addr, req.Quantity)
b, err := h.conn.ReadCoilsWithSlave(req.UnitId, req.Addr, req.Quantity)
return h.exceptionToBoolAndError("read coil", b, err)
}

func (h *handler) HandleInputRegisters(req *mbserver.InputRegistersRequest) (res []uint16, err error) {
h.log.TRACE.Printf("read input: id: %d addr: %d qty: %d", req.UnitId, req.Addr, req.Quantity)
b, err := h.conn.ReadInputRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
return h.exceptionToUint16AndError("read input", b, err)
}

func (h *handler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) (res []uint16, err error) {
if req.IsWrite {
if h.readOnly {
return nil, mbserver.ErrIllegalFunction
}

if req.Quantity == 1 {
h.log.TRACE.Printf("write holding: id: %d addr: %d val: %0x", req.UnitId, req.Addr, req.Args[0])
b, err := h.conn.WriteSingleRegisterWithSlave(req.UnitId, req.Addr, req.Args[0])
return h.exceptionToUint16AndError("write holding", b, err)
}

h.log.TRACE.Printf("write multiple holding: id: %d addr: %d qty: %d val: %0x", req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
b, err := h.conn.WriteMultipleRegistersWithSlave(req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
return h.exceptionToUint16AndError("write multiple holding", b, err)
}

h.log.TRACE.Printf("read holding: id: %d addr: %d qty: %d", req.UnitId, req.Addr, req.Quantity)
b, err := h.conn.ReadHoldingRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
return h.exceptionToUint16AndError("read holding", b, err)
}
45 changes: 45 additions & 0 deletions server/modbus/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package modbus

import (
"fmt"
"net"

"github.com/andig/mbserver"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/sponsor"
)

func StartProxy(port int, config modbus.Settings, readOnly bool) error {
conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, modbus.ProtocolFromRTU(config.RTU), config.ID)
if err != nil {
return err
}

if !sponsor.IsAuthorized() {
return api.ErrSponsorRequired
}

h := &handler{
log: util.NewLogger(fmt.Sprintf("proxy-%d", port)),
readOnly: readOnly,
RequestHandler: new(mbserver.DummyHandler),
conn: conn,
}

l, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return err
}

h.log.DEBUG.Printf("modbus proxy for %s listening at :%d", config.String(), port)

srv, err := mbserver.New(h)

if err == nil {
err = srv.Start(l)
}

return err
}
70 changes: 70 additions & 0 deletions server/modbus/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package modbus

import (
"encoding/binary"
"math/rand"
"net"
"sync"
"testing"
"time"

"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util/modbus"
"github.com/stretchr/testify/assert"
)

func TestProxyRead(t *testing.T) {
l, err := net.Listen("tcp", ":0")
assert.NoError(t, err)
defer l.Close()

t.Log(l.Addr().String())

conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, 1)
assert.NoError(t, err)

h := &echoHandler{
id: 0,
RequestHandler: new(mbserver.DummyHandler),
conn: conn,
}

srv, _ := mbserver.New(h)
assert.NoError(t, srv.Start(l))
defer func() { _ = srv.Stop() }()

var wg sync.WaitGroup

for i := 1; i <= 10; i++ {
wg.Add(1)

go func(id int) {
for i := 0; i < 50; i++ {
addr := uint16(rand.Int31n(200) + 1)

b, err := conn.ReadInputRegistersWithSlave(uint8(id), addr, 1)
assert.NoError(t, err)

if err == nil {
assert.Equal(t, addr^uint16(id), binary.BigEndian.Uint16(b))
}

time.Sleep(time.Duration(rand.Int31n(1000)) * time.Microsecond)
}

wg.Done()
}(i)
}

wg.Wait()
}

type echoHandler struct {
id int
mbserver.RequestHandler
conn *modbus.Connection
}

func (h *echoHandler) HandleInputRegisters(req *mbserver.InputRegistersRequest) (res []uint16, err error) {
return []uint16{req.Addr ^ uint16(req.UnitId)}, err
}
Loading