diff --git a/go.mod b/go.mod index 231188b66..a52cc210c 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,7 @@ require ( github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 github.com/mabunixda/wattpilot v1.8.4 github.com/mitchellh/go-homedir v1.1.0 - github.com/mlnoga/rct v0.1.2-0.20240421173556-1c5b75037e2f + github.com/mlnoga/rct v0.1.2-0.20250123194852-f6c64dfa2f45 github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e github.com/mxschmitt/golang-combinations v1.2.0 github.com/nicksnyder/go-i18n/v2 v2.4.1 @@ -210,4 +210,6 @@ replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20241027151 replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20241230132027-815870498cc3 +replace github.com/mlnoga/rct => github.com/Maschga/rct v0.1.2-0.20250123202746-3bacbc13d633 + replace github.com/hasura/go-graphql-client => github.com/andig/go-graphql-client v0.0.0-20250123081454-14ac4443ffd9 diff --git a/go.sum b/go.sum index 21e65b86f..f804ce644 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Maschga/rct v0.1.2-0.20250123202746-3bacbc13d633 h1:WH5hu3UDfAMfSz8s8AazSBM5Ts2UfQbpH8FDyNbyg0o= +github.com/Maschga/rct v0.1.2-0.20250123202746-3bacbc13d633/go.mod h1:0lfd2mmBnBzIvuzYtdhG+2371u+cUfIxsYErm4P9KRI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= @@ -450,8 +452,6 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mlnoga/rct v0.1.2-0.20240421173556-1c5b75037e2f h1:63hbvjkIpccli0EA60fqvzokvU1fH1ZPiNzlF/vUmvM= -github.com/mlnoga/rct v0.1.2-0.20240421173556-1c5b75037e2f/go.mod h1:0lfd2mmBnBzIvuzYtdhG+2371u+cUfIxsYErm4P9KRI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/meter/rct.go b/meter/rct.go index 8f2170af6..6747b7f61 100644 --- a/meter/rct.go +++ b/meter/rct.go @@ -1,8 +1,10 @@ package meter import ( + "encoding/binary" "errors" "fmt" + "math" "strings" "sync" "time" @@ -52,14 +54,15 @@ func init() { registry.Add("rct", NewRCTFromConfig) } -//go:generate decorate -f decorateRCT -b *RCT -r api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryCapacity,Capacity,func() float64" +//go:generate decorate -f decorateRCT -b *RCT -r api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryController,SetBatteryMode,func(api.BatteryMode) error" -t "api.BatteryCapacity,Capacity,func() float64" // NewRCTFromConfig creates an RCT from generic config func NewRCTFromConfig(other map[string]interface{}) (api.Meter, error) { cc := struct { - capacity `mapstructure:",squash"` - Uri, Usage string - Cache time.Duration + capacity `mapstructure:",squash"` + Uri, Usage string + MinSoc, MaxSoc int + Cache time.Duration }{ Cache: time.Second, } @@ -72,13 +75,13 @@ func NewRCTFromConfig(other map[string]interface{}) (api.Meter, error) { return nil, errors.New("missing usage") } - return NewRCT(cc.Uri, cc.Usage, cc.Cache, cc.capacity.Decorator()) + return NewRCT(cc.Uri, cc.Usage, cc.MinSoc, cc.MaxSoc, cc.Cache, cc.capacity.Decorator()) } var rctMu sync.Mutex // NewRCT creates an RCT meter -func NewRCT(uri, usage string, cache time.Duration, capacity func() float64) (api.Meter, error) { +func NewRCT(uri, usage string, minSoc, maxSoc int, cache time.Duration, capacity func() float64) (api.Meter, error) { rctMu.Lock() defer rctMu.Unlock() @@ -105,11 +108,62 @@ func NewRCT(uri, usage string, cache time.Duration, capacity func() float64) (ap // decorate api.BatterySoc var batterySoc func() (float64, error) + var batteryMode func(api.BatteryMode) error if usage == "battery" { batterySoc = m.batterySoc + + batteryMode = func(mode api.BatteryMode) error { + if mode != api.BatteryNormal { + batStatus, err := m.queryInt32(rct.BatteryBatStatus) + if err != nil { + return err + } + + // see https://github.com/weltenwort/home-assistant-rct-power-integration/issues/264#issuecomment-2124811644 + if batStatus != 0 { + return errors.New("invalid battery operating mode") + } + } + + switch mode { + case api.BatteryNormal: + if err := m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetInternal}); err != nil { + return err + } + + if err := m.conn.Write(rct.BatterySoCTargetMin, m.floatVal(float32(minSoc)/100)); err != nil { + return err + } + + return m.conn.Write(rct.PowerMngBatteryPowerExternW, m.floatVal(float32(0))) + + case api.BatteryHold: + if err := m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetInternal}); err != nil { + return err + } + + return m.conn.Write(rct.BatterySoCTargetMin, m.floatVal(float32(maxSoc)/100)) + + case api.BatteryCharge: + if err := m.conn.Write(rct.PowerMngBatteryPowerExternW, m.floatVal(float32(-10_000))); err != nil { + return err + } + + return m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetExternal}) + + default: + return api.ErrNotAvailable + } + } } - return decorateRCT(m, totalEnergy, batterySoc, capacity), nil + return decorateRCT(m, totalEnergy, batterySoc, batteryMode, capacity), nil +} + +func (m *RCT) floatVal(f float32) []byte { + data := make([]byte, 4) + binary.BigEndian.PutUint32(data, math.Float32bits(f)) + return data } // CurrentPower implements the api.Meter interface @@ -187,3 +241,19 @@ func (m *RCT) queryFloat(id rct.Identifier) (float64, error) { return float64(res), err } + +// queryInt32 adds retry logic of recoverable errors to QueryInt32 +func (m *RCT) queryInt32(id rct.Identifier) (int32, error) { + m.bo.Reset() + + res, err := backoff.RetryWithData(func() (int32, error) { + res, err := m.conn.QueryInt32(id) + if err != nil && !errors.As(err, new(rct.RecoverableError)) { + err = backoff.Permanent(err) + } + + return res, err + }, m.bo) + + return res, err +} diff --git a/meter/rct_decorators.go b/meter/rct_decorators.go index 0f52cafe9..5804034cc 100644 --- a/meter/rct_decorators.go +++ b/meter/rct_decorators.go @@ -6,7 +6,7 @@ import ( "github.com/evcc-io/evcc/api" ) -func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() (float64, error), batteryCapacity func() float64) api.Meter { +func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() (float64, error), batteryController func(api.BatteryMode) error, batteryCapacity func() float64) api.Meter { switch { case battery == nil && meterEnergy == nil: return base @@ -22,7 +22,7 @@ func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() }, } - case battery != nil && batteryCapacity == nil && meterEnergy == nil: + case battery != nil && batteryCapacity == nil && batteryController == nil && meterEnergy == nil: return &struct { *RCT api.Battery @@ -33,7 +33,7 @@ func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() }, } - case battery != nil && batteryCapacity == nil && meterEnergy != nil: + case battery != nil && batteryCapacity == nil && batteryController == nil && meterEnergy != nil: return &struct { *RCT api.Battery @@ -48,7 +48,41 @@ func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() }, } - case battery != nil && batteryCapacity != nil && meterEnergy == nil: + case battery != nil && batteryCapacity == nil && batteryController != nil && meterEnergy == nil: + return &struct { + *RCT + api.Battery + api.BatteryController + }{ + RCT: base, + Battery: &decorateRCTBatteryImpl{ + battery: battery, + }, + BatteryController: &decorateRCTBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity == nil && batteryController != nil && meterEnergy != nil: + return &struct { + *RCT + api.Battery + api.BatteryController + api.MeterEnergy + }{ + RCT: base, + Battery: &decorateRCTBatteryImpl{ + battery: battery, + }, + BatteryController: &decorateRCTBatteryControllerImpl{ + batteryController: batteryController, + }, + MeterEnergy: &decorateRCTMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController == nil && meterEnergy == nil: return &struct { *RCT api.Battery @@ -63,7 +97,7 @@ func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() }, } - case battery != nil && batteryCapacity != nil && meterEnergy != nil: + case battery != nil && batteryCapacity != nil && batteryController == nil && meterEnergy != nil: return &struct { *RCT api.Battery @@ -81,6 +115,48 @@ func decorateRCT(base *RCT, meterEnergy func() (float64, error), battery func() meterEnergy: meterEnergy, }, } + + case battery != nil && batteryCapacity != nil && batteryController != nil && meterEnergy == nil: + return &struct { + *RCT + api.Battery + api.BatteryCapacity + api.BatteryController + }{ + RCT: base, + Battery: &decorateRCTBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateRCTBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateRCTBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController != nil && meterEnergy != nil: + return &struct { + *RCT + api.Battery + api.BatteryCapacity + api.BatteryController + api.MeterEnergy + }{ + RCT: base, + Battery: &decorateRCTBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateRCTBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateRCTBatteryControllerImpl{ + batteryController: batteryController, + }, + MeterEnergy: &decorateRCTMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } } return nil @@ -102,6 +178,14 @@ func (impl *decorateRCTBatteryCapacityImpl) Capacity() float64 { return impl.batteryCapacity() } +type decorateRCTBatteryControllerImpl struct { + batteryController func(api.BatteryMode) error +} + +func (impl *decorateRCTBatteryControllerImpl) SetBatteryMode(p0 api.BatteryMode) error { + return impl.batteryController(p0) +} + type decorateRCTMeterEnergyImpl struct { meterEnergy func() (float64, error) } diff --git a/templates/definition/meter/rct-power.yaml b/templates/definition/meter/rct-power.yaml index a9c6a4d9c..fb1e106f2 100644 --- a/templates/definition/meter/rct-power.yaml +++ b/templates/definition/meter/rct-power.yaml @@ -3,6 +3,7 @@ products: - brand: RCT description: generic: Power +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -10,9 +11,20 @@ params: - name: host - name: capacity advanced: true + # battery control + - name: minsoc + type: int + default: 7 + advanced: true + - name: maxsoc + type: int + default: 97 + advanced: true render: | type: rct uri: {{ .host }} usage: {{ .usage }} cache: 2s capacity: {{ .capacity }} # kWh + minsoc: {{ .minsoc }} + maxsoc: {{ .maxsoc }}