diff --git a/go.mod b/go.mod index ad37684d99..ed19251166 100644 --- a/go.mod +++ b/go.mod @@ -77,6 +77,7 @@ require ( github.com/samber/lo v1.39.0 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/smallnest/chanx v1.2.0 + github.com/spali/go-rscp v0.2.0-beta4 github.com/spf13/cast v1.6.0 github.com/spf13/cobra v1.8.0 github.com/spf13/jwalterweatherman v1.1.0 @@ -108,9 +109,11 @@ require ( github.com/ahmetb/go-linq/v3 v3.2.0 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cstockton/go-conv v1.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.16.0 // indirect @@ -172,8 +175,9 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.3 github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spali/go-slicereader v0.0.0-20201122145524-8e262e1a5127 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/teivah/onecontext v1.3.0 // indirect diff --git a/go.sum b/go.sum index 0d73a3caf1..1eaddfabe0 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.51.21 h1:UrT6JC9R9PkYYXDZBV0qDKTualMr+bfK2eboTknMgbs= github.com/aws/aws-sdk-go v1.51.21/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b h1:/2dABok/UswXOj5rjbR5bZ411ApGBq1pAEZdy5rvFrY= +github.com/azihsoyn/rijndael256 v0.0.0-20200316065338-d14eefa2b66b/go.mod h1:ef+2vMUkiKcy2Tz7HykB01KbgUnkK4gQKq4ZeR4RYVs= github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw= github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw= github.com/basvdlei/gotsmart v0.0.3 h1:7hrI6btBc8dmFzzHRDkr9Xl87PvrrOT43DzbsTFqMk8= @@ -94,6 +96,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cstockton/go-conv v1.0.0 h1:zj/q/0MpQ/97XfiC9glWiohO8lhgR4TTnHYZifLTv6I= +github.com/cstockton/go-conv v1.0.0/go.mod h1:HuiHkkRgOA0IoBNPC7ysG7kNpjDYlgM7Kj62yQPxjy4= github.com/dave/jennifer v1.6.1 h1:T4T/67t6RAA5AIV6+NP8Uk/BIsXgDoqEowgycdQQLuk= github.com/dave/jennifer v1.6.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -186,6 +190,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 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/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA= github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -606,6 +612,10 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spali/go-rscp v0.2.0-beta4 h1:ct9YZTCmTW2IMg74O16nJu0QntGF26dxY5ZejRvl280= +github.com/spali/go-rscp v0.2.0-beta4/go.mod h1:yPHx7clunJmpCLFDc60XL04/lp8p/DrrhfeBqM3J8cc= +github.com/spali/go-slicereader v0.0.0-20201122145524-8e262e1a5127 h1:YDqvwAH/l3S4ZULmKlUYszPyLBjHq73CLuUPU+2jJeE= +github.com/spali/go-slicereader v0.0.0-20201122145524-8e262e1a5127/go.mod h1:nf5bOq6n8UugtmQiD3l0BzkE5VP4NvyngFZVkH3ZzgM= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= diff --git a/meter/e3dc.go b/meter/e3dc.go new file mode 100644 index 0000000000..016cf453f9 --- /dev/null +++ b/meter/e3dc.go @@ -0,0 +1,301 @@ +package meter + +import ( + "errors" + "net" + "slices" + "strconv" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/templates" + "github.com/samber/lo" + "github.com/sirupsen/logrus" + "github.com/spali/go-rscp/rscp" + "github.com/spf13/cast" +) + +type E3dc struct { + capacity float64 + dischargeLimit uint32 + usage templates.Usage // TODO check if we really want to depend on templates + conn *rscp.Client +} + +func init() { + registry.Add("e3dc-rscp", NewE3dcFromConfig) +} + +//go:generate go run ../cmd/tools/decorate.go -f decorateE3dc -b *E3dc -r api.Meter -t "api.BatteryCapacity,Capacity,func() float64" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryController,SetBatteryMode,func(api.BatteryMode) error" + +func NewE3dcFromConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + Usage templates.Usage + Uri string + User string + Password string + Key string + Battery uint16 // battery id + DischargeLimit uint32 + Timeout time.Duration + }{ + Timeout: request.Timeout, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + host, port_, err := net.SplitHostPort(util.DefaultPort(cc.Uri, 5033)) + if err != nil { + return nil, err + } + + port, _ := strconv.Atoi(port_) + + cfg := rscp.ClientConfig{ + Address: host, + Port: uint16(port), + Username: cc.User, + Password: cc.Password, + Key: cc.Key, + ConnectionTimeout: cc.Timeout, + SendTimeout: cc.Timeout, + ReceiveTimeout: cc.Timeout, + } + + return NewE3dc(cfg, cc.Usage, cc.Battery, cc.DischargeLimit) +} + +var e3dcOnce sync.Once + +func NewE3dc(cfg rscp.ClientConfig, usage templates.Usage, batteryId uint16, dischargeLimit uint32) (api.Meter, error) { + e3dcOnce.Do(func() { + log := util.NewLogger("e3dc") + rscp.Log.SetLevel(logrus.DebugLevel) + rscp.Log.SetOutput(log.TRACE.Writer()) + }) + + conn, err := rscp.NewClient(cfg) + if err != nil { + return nil, err + } + + m := &E3dc{ + usage: usage, + conn: conn, + dischargeLimit: dischargeLimit, + } + + // decorate api.BatterySoc + var ( + batterySoc func() (float64, error) + batteryCapacity func() float64 + batteryMode func(api.BatteryMode) error + ) + + if usage == templates.UsageBattery { + batterySoc = m.batterySoc + batteryCapacity = m.batteryCapacity + batteryMode = m.setBatteryMode + + res, err := m.conn.Send(rscp.Message{ + Tag: rscp.BAT_REQ_DATA, + DataType: rscp.Container, + Value: []rscp.Message{ + { + Tag: rscp.BAT_INDEX, + DataType: rscp.UInt16, + Value: batteryId, + }, + { + Tag: rscp.BAT_REQ_SPECIFICATION, + DataType: rscp.None, + }, + }, + }) + if err != nil { + return nil, err + } + + batSpec, err := rscpContains(res, rscp.BAT_SPECIFICATION) + if err != nil { + return nil, err + } + + batCap, err := rscpContains(&batSpec, rscp.BAT_SPECIFIED_CAPACITY) + if err != nil { + return nil, err + } + + cap, err := rscpValue(batCap, cast.ToFloat64E) + if err != nil { + return nil, err + } + + m.capacity = cap / 1e3 + } + + return decorateE3dc(m, batteryCapacity, batterySoc, batteryMode), nil +} + +func (m *E3dc) CurrentPower() (float64, error) { + switch m.usage { + case templates.UsageGrid: + res, err := m.conn.Send(*rscp.NewMessage(rscp.EMS_REQ_POWER_GRID, nil)) + if err != nil { + return 0, err + } + return rscpValue(*res, cast.ToFloat64E) + + case templates.UsagePV: + res, err := m.conn.SendMultiple([]rscp.Message{ + *rscp.NewMessage(rscp.EMS_REQ_POWER_PV, nil), + *rscp.NewMessage(rscp.EMS_REQ_POWER_ADD, nil), + }) + if err != nil { + return 0, err + } + + values, err := rscpValues(res, cast.ToFloat64E) + if err != nil { + return 0, err + } + + return lo.Sum(values), nil + + case templates.UsageBattery: + res, err := m.conn.Send(*rscp.NewMessage(rscp.EMS_REQ_POWER_BAT, nil)) + if err != nil { + return 0, err + } + pwr, err := rscpValue(*res, cast.ToFloat64E) + if err != nil { + return 0, err + } + + return -pwr, nil + + default: + return 0, api.ErrNotAvailable + } +} + +func (m *E3dc) batteryCapacity() float64 { + return m.capacity +} + +func (m *E3dc) batterySoc() (float64, error) { + res, err := m.conn.Send(*rscp.NewMessage(rscp.EMS_REQ_BAT_SOC, nil)) + if err != nil { + return 0, err + } + return rscpValue(*res, cast.ToFloat64E) +} + +func (m *E3dc) setBatteryMode(mode api.BatteryMode) error { + var ( + res []rscp.Message + err error + ) + + switch mode { + case api.BatteryNormal: + res, err = m.conn.SendMultiple([]rscp.Message{ + e3dcDischargeBatteryLimit(false, 0), + e3dcBatteryCharge(0), + }) + + case api.BatteryHold: + res, err = m.conn.SendMultiple([]rscp.Message{ + e3dcDischargeBatteryLimit(true, m.dischargeLimit), + e3dcBatteryCharge(0), + }) + + case api.BatteryCharge: + res, err = m.conn.SendMultiple([]rscp.Message{ + e3dcDischargeBatteryLimit(false, 0), + e3dcBatteryCharge(10000), // 10kWh + }) + + default: + return api.ErrNotAvailable + } + + if err == nil { + err = rscpError(res...) + } + return err +} + +func e3dcDischargeBatteryLimit(active bool, limit uint32) rscp.Message { + contents := []rscp.Message{ + *rscp.NewMessage(rscp.EMS_POWER_LIMITS_USED, active), + } + + if active { + contents = append(contents, *rscp.NewMessage(rscp.EMS_MAX_DISCHARGE_POWER, limit)) + } + + return *rscp.NewMessage(rscp.EMS_REQ_SET_POWER_SETTINGS, contents) +} + +func e3dcBatteryCharge(amount uint32) rscp.Message { + return *rscp.NewMessage(rscp.EMS_REQ_START_MANUAL_CHARGE, amount) +} + +func rscpError(msg ...rscp.Message) error { + var errs []error + for _, m := range msg { + if m.DataType == rscp.Error { + errs = append(errs, errors.New(rscp.RscpError(cast.ToUint32(m.Value)).String())) + } + } + return errors.Join(errs...) +} + +func rscpContains(msg *rscp.Message, tag rscp.Tag) (rscp.Message, error) { + var zero rscp.Message + + slice, ok := msg.Value.([]rscp.Message) + if !ok { + return zero, errors.New("not a slice looking for " + tag.String()) + } + + idx := slices.IndexFunc(slice, func(m rscp.Message) bool { + return m.Tag == tag + }) + if idx < 0 { + return zero, errors.New("missing " + tag.String()) + } + + res := slice[idx] + return res, rscpError(res) +} + +func rscpValue[T any](msg rscp.Message, fun func(any) (T, error)) (T, error) { + var zero T + if err := rscpError(msg); err != nil { + return zero, err + } + + return fun(msg.Value) +} + +func rscpValues[T any](msg []rscp.Message, fun func(any) (T, error)) ([]T, error) { + res := make([]T, 0, len(msg)) + + for _, m := range msg { + v, err := rscpValue(m, fun) + if err != nil { + return nil, err + } + + res = append(res, v) + } + + return res, nil +} diff --git a/meter/e3dc_decorators.go b/meter/e3dc_decorators.go new file mode 100644 index 0000000000..5e7e3e478d --- /dev/null +++ b/meter/e3dc_decorators.go @@ -0,0 +1,137 @@ +package meter + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateE3dc(base *E3dc, batteryCapacity func() float64, battery func() (float64, error), batteryController func(api.BatteryMode) error) api.Meter { + switch { + case battery == nil && batteryCapacity == nil && batteryController == nil: + return base + + case battery == nil && batteryCapacity != nil && batteryController == nil: + return &struct { + *E3dc + api.BatteryCapacity + }{ + E3dc: base, + BatteryCapacity: &decorateE3dcBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + } + + case battery != nil && batteryCapacity == nil && batteryController == nil: + return &struct { + *E3dc + api.Battery + }{ + E3dc: base, + Battery: &decorateE3dcBatteryImpl{ + battery: battery, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController == nil: + return &struct { + *E3dc + api.Battery + api.BatteryCapacity + }{ + E3dc: base, + Battery: &decorateE3dcBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateE3dcBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + } + + case battery == nil && batteryCapacity == nil && batteryController != nil: + return &struct { + *E3dc + api.BatteryController + }{ + E3dc: base, + BatteryController: &decorateE3dcBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery == nil && batteryCapacity != nil && batteryController != nil: + return &struct { + *E3dc + api.BatteryCapacity + api.BatteryController + }{ + E3dc: base, + BatteryCapacity: &decorateE3dcBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateE3dcBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity == nil && batteryController != nil: + return &struct { + *E3dc + api.Battery + api.BatteryController + }{ + E3dc: base, + Battery: &decorateE3dcBatteryImpl{ + battery: battery, + }, + BatteryController: &decorateE3dcBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController != nil: + return &struct { + *E3dc + api.Battery + api.BatteryCapacity + api.BatteryController + }{ + E3dc: base, + Battery: &decorateE3dcBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateE3dcBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateE3dcBatteryControllerImpl{ + batteryController: batteryController, + }, + } + } + + return nil +} + +type decorateE3dcBatteryImpl struct { + battery func() (float64, error) +} + +func (impl *decorateE3dcBatteryImpl) Soc() (float64, error) { + return impl.battery() +} + +type decorateE3dcBatteryCapacityImpl struct { + batteryCapacity func() float64 +} + +func (impl *decorateE3dcBatteryCapacityImpl) Capacity() float64 { + return impl.batteryCapacity() +} + +type decorateE3dcBatteryControllerImpl struct { + batteryController func(api.BatteryMode) error +} + +func (impl *decorateE3dcBatteryControllerImpl) SetBatteryMode(p0 api.BatteryMode) error { + return impl.batteryController(p0) +} diff --git a/templates/definition/meter/e3dc.yaml b/templates/definition/meter/e3dc-modbus.yaml similarity index 98% rename from templates/definition/meter/e3dc.yaml rename to templates/definition/meter/e3dc-modbus.yaml index 80614fdee1..8cccec9baf 100644 --- a/templates/definition/meter/e3dc.yaml +++ b/templates/definition/meter/e3dc-modbus.yaml @@ -1,4 +1,5 @@ template: e3dc +deprecated: true products: - brand: E3/DC params: diff --git a/templates/definition/meter/e3dc-rscp.yaml b/templates/definition/meter/e3dc-rscp.yaml new file mode 100644 index 0000000000..c855653f2f --- /dev/null +++ b/templates/definition/meter/e3dc-rscp.yaml @@ -0,0 +1,34 @@ +template: e3dc-rscp +products: + - brand: E3/DC +params: + - name: usage + choice: ["grid", "pv", "battery"] + allinone: true + - name: host + - name: port + default: 5033 + - name: user + - name: password + - name: key + - name: battery + type: number + advanced: true + - name: dischargelimit + description: + de: Entladelimit in W + en: Discharge limit in W + help: + de: Limitiert die Entladeleistung im 'Halten' Batteriemodus + en: Limits discharge power in 'Hold' battery mode + type: number + advanced: true +render: | + type: e3dc-rscp + usage: {{ .usage }} + uri: {{ .host }}:{{ .port }} + user: {{ .user }} + password: {{ .password }} + key: {{ .key }} + battery: {{ .battery }} + dischargelimit: {{ .dischargelimit }} diff --git a/util/templates/types.go b/util/templates/types.go index 42c7158e78..52510e1c47 100644 --- a/util/templates/types.go +++ b/util/templates/types.go @@ -9,17 +9,6 @@ import ( "dario.cat/mergo" ) -type Usage int - -//go:generate enumer -type Usage -trimprefix Usage -transform=lower -const ( - UsageGrid Usage = iota - UsagePV - UsageBattery - UsageCharge - UsageAux -) - const ( ParamUsage = "usage" ParamModbus = "modbus" diff --git a/util/templates/usage.go b/util/templates/usage.go new file mode 100644 index 0000000000..1981429959 --- /dev/null +++ b/util/templates/usage.go @@ -0,0 +1,12 @@ +package templates + +type Usage int + +//go:generate enumer -type Usage -trimprefix Usage -transform=lower -text +const ( + UsageGrid Usage = iota + UsagePV + UsageBattery + UsageCharge + UsageAux +) diff --git a/util/templates/usage_enumer.go b/util/templates/usage_enumer.go index 8655c6581f..c6c4b605b9 100644 --- a/util/templates/usage_enumer.go +++ b/util/templates/usage_enumer.go @@ -1,4 +1,4 @@ -// Code generated by "enumer -type Usage -trimprefix Usage -transform=lower"; DO NOT EDIT. +// Code generated by "enumer -type Usage -trimprefix Usage -transform=lower -text"; DO NOT EDIT. package templates @@ -88,3 +88,15 @@ func (i Usage) IsAUsage() bool { } return false } + +// MarshalText implements the encoding.TextMarshaler interface for Usage +func (i Usage) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for Usage +func (i *Usage) UnmarshalText(text []byte) error { + var err error + *i, err = UsageString(string(text)) + return err +}