diff --git a/meter/lgess.go b/meter/lgess.go index 4c7c8a74d..d359c6e74 100644 --- a/meter/lgess.go +++ b/meter/lgess.go @@ -11,38 +11,6 @@ import ( "github.com/evcc-io/evcc/util" ) -/* -This meter supports the LGESS HOME 8, LGESS HOME 10 and LGESS HOME 15 systems from LG with / without battery. - - -** Usages ** -The following usages are supported: -- grid ... for reading the power imported or exported to the grid -- pv ... for reading the power produced by the pv -- battery ... for reading the power imported or exported to the battery - -** Example configuration ** -meters: -- name: GridMeter - type: template - template: lg-ess-home-15 - usage: grid - uri: https://192.168.1.23 - password: "DE200....." -- name: PvMeter - type: template - template: lg-ess-home-15 - usage: pv -- name: BatteryMeter - type: template - template: lg-ess-home-15 - usage: battery - -** Limitations ** -It is not allowed to provide different URIs or passwords for different lgess meters since always the -same hardware instance is accessed with the different usages. -*/ - // LgEss implements the api.Meter interface type LgEss struct { usage string // grid, pv, battery @@ -54,7 +22,7 @@ func init() { registry.Add("lgess15", NewLgEss15FromConfig) } -//go:generate go tool decorate -f decorateLgEss -b *LgEss -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 go tool decorate -f decorateLgEss -b *LgEss -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" func NewLgEss8FromConfig(other map[string]interface{}) (api.Meter, error) { return NewLgEssFromConfig(other, lgpcs.LgEss8) @@ -70,8 +38,13 @@ func NewLgEssFromConfig(other map[string]interface{}, essType lgpcs.Model) (api. capacity `mapstructure:",squash"` URI, Usage string Registration, Password string + battery `mapstructure:",squash"` Cache time.Duration }{ + battery: battery{ + MinSoc: 20, + MaxSoc: 95, + }, Cache: time.Second, } @@ -83,11 +56,11 @@ func NewLgEssFromConfig(other map[string]interface{}, essType lgpcs.Model) (api. return nil, errors.New("missing usage") } - return NewLgEss(cc.URI, cc.Usage, cc.Registration, cc.Password, cc.Cache, cc.capacity.Decorator(), essType) + return NewLgEss(cc.URI, cc.Usage, cc.Registration, cc.Password, cc.Cache, cc.capacity.Decorator(), cc.battery, essType) } // NewLgEss creates an LgEss Meter -func NewLgEss(uri, usage, registration, password string, cache time.Duration, capacity func() float64, essType lgpcs.Model) (api.Meter, error) { +func NewLgEss(uri, usage, registration, password string, cache time.Duration, capacity func() float64, battery battery, essType lgpcs.Model) (api.Meter, error) { conn, err := lgpcs.GetInstance(uri, registration, password, cache, essType) if err != nil { return nil, err @@ -106,11 +79,13 @@ func NewLgEss(uri, usage, registration, password string, cache time.Duration, ca // decorate api.BatterySoc var batterySoc func() (float64, error) + var setBatteryMode func(api.BatteryMode) error if usage == "battery" { batterySoc = m.batterySoc + setBatteryMode = m.batteryMode(battery) } - return decorateLgEss(m, totalEnergy, batterySoc, capacity), nil + return decorateLgEss(m, totalEnergy, batterySoc, setBatteryMode, capacity), nil } // CurrentPower implements the api.Meter interface @@ -156,3 +131,33 @@ func (m *LgEss) batterySoc() (float64, error) { return data.GetBatUserSoc(), nil } + +// batteryMode implements the api.BatteryController interface +func (m *LgEss) batteryMode(battery battery) func(api.BatteryMode) error { + return func(mode api.BatteryMode) error { + switch mode { + case api.BatteryNormal: + // firmeware bug: battery not discharging after hold mode + // if battery is sleeping, wake up with charging for 10sec + m.conn.BatteryMode("on", 100, true) + time.Sleep(10 * time.Second) + // now turn Battery discharge on + return m.conn.BatteryMode("on", int(battery.MinSoc), true) + case api.BatteryHold: + soc, err := m.batterySoc() + if err != nil { + return err + } + // soc needs to be the next higher int value to stop discharging immediately + // example: batterySoc=50.7 -> set 51 + if int(soc)+1 < 100 { + soc++ + } + return m.conn.BatteryMode("on", int(soc), true) + case api.BatteryCharge: + return m.conn.BatteryMode("on", int(battery.MaxSoc), true) + default: + return api.ErrNotAvailable + } + } +} diff --git a/meter/lgess_decorators.go b/meter/lgess_decorators.go index 83c12eba6..54db10bcf 100644 --- a/meter/lgess_decorators.go +++ b/meter/lgess_decorators.go @@ -6,7 +6,7 @@ import ( "github.com/evcc-io/evcc/api" ) -func decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery func() (float64, error), batteryCapacity func() float64) api.Meter { +func decorateLgEss(base *LgEss, 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 decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery fun }, } - case battery != nil && batteryCapacity == nil && meterEnergy == nil: + case battery != nil && batteryCapacity == nil && batteryController == nil && meterEnergy == nil: return &struct { *LgEss api.Battery @@ -33,7 +33,7 @@ func decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery fun }, } - case battery != nil && batteryCapacity == nil && meterEnergy != nil: + case battery != nil && batteryCapacity == nil && batteryController == nil && meterEnergy != nil: return &struct { *LgEss api.Battery @@ -48,7 +48,41 @@ func decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery fun }, } - case battery != nil && batteryCapacity != nil && meterEnergy == nil: + case battery != nil && batteryCapacity == nil && batteryController != nil && meterEnergy == nil: + return &struct { + *LgEss + api.Battery + api.BatteryController + }{ + LgEss: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + BatteryController: &decorateLgEssBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity == nil && batteryController != nil && meterEnergy != nil: + return &struct { + *LgEss + api.Battery + api.BatteryController + api.MeterEnergy + }{ + LgEss: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + BatteryController: &decorateLgEssBatteryControllerImpl{ + batteryController: batteryController, + }, + MeterEnergy: &decorateLgEssMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController == nil && meterEnergy == nil: return &struct { *LgEss api.Battery @@ -63,7 +97,7 @@ func decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery fun }, } - case battery != nil && batteryCapacity != nil && meterEnergy != nil: + case battery != nil && batteryCapacity != nil && batteryController == nil && meterEnergy != nil: return &struct { *LgEss api.Battery @@ -81,6 +115,48 @@ func decorateLgEss(base *LgEss, meterEnergy func() (float64, error), battery fun meterEnergy: meterEnergy, }, } + + case battery != nil && batteryCapacity != nil && batteryController != nil && meterEnergy == nil: + return &struct { + *LgEss + api.Battery + api.BatteryCapacity + api.BatteryController + }{ + LgEss: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateLgEssBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateLgEssBatteryControllerImpl{ + batteryController: batteryController, + }, + } + + case battery != nil && batteryCapacity != nil && batteryController != nil && meterEnergy != nil: + return &struct { + *LgEss + api.Battery + api.BatteryCapacity + api.BatteryController + api.MeterEnergy + }{ + LgEss: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + BatteryCapacity: &decorateLgEssBatteryCapacityImpl{ + batteryCapacity: batteryCapacity, + }, + BatteryController: &decorateLgEssBatteryControllerImpl{ + batteryController: batteryController, + }, + MeterEnergy: &decorateLgEssMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } } return nil @@ -102,6 +178,14 @@ func (impl *decorateLgEssBatteryCapacityImpl) Capacity() float64 { return impl.batteryCapacity() } +type decorateLgEssBatteryControllerImpl struct { + batteryController func(api.BatteryMode) error +} + +func (impl *decorateLgEssBatteryControllerImpl) SetBatteryMode(p0 api.BatteryMode) error { + return impl.batteryController(p0) +} + type decorateLgEssMeterEnergyImpl struct { meterEnergy func() (float64, error) } diff --git a/meter/lgpcs/lgpcs.go b/meter/lgpcs/lgpcs.go index b3d00e0c1..29aaf5979 100644 --- a/meter/lgpcs/lgpcs.go +++ b/meter/lgpcs/lgpcs.go @@ -5,7 +5,9 @@ package lgpcs import ( "errors" "fmt" + "maps" "net/http" + "strconv" "strings" "sync" "time" @@ -13,23 +15,18 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" -) - -// URIs -const ( - LoginURI = "/v1/login" - UserLoginURI = "/v1/user/setting/login" - MeterURI = "/v1/user/essinfo/home" + "github.com/spf13/cast" ) type Com struct { *request.Helper - uri string // URI address of the LG ESS inverter - e.g. "https://192.168.1.28" - authPath string - password string // registration number of the LG ESS Inverter - e.g. "DE2001..." - authKey string // auth_key returned during login and renewed with new login after expiration - essType Model // currently the LG Ess Home 8/10 and Home 15 are supported - dataG func() (EssData, error) + uri string // URI address of the LG ESS inverter - e.g. "https://192.168.1.28" + password string // user password, usually MAC address of the LG ESS in lowercase without colons + registration string // registration number of the LG ESS Inverter - e.g. "DE2001..." + authKey string // auth_key returned during login and renewed with new login after expiration + essType Model // currently the LG Ess Home 8/10 and Home 15 are supported + dataG func() (EssData, error) + log *util.Logger } var ( @@ -45,16 +42,12 @@ func GetInstance(uri, registration, password string, cache time.Duration, essTyp once.Do(func() { log := util.NewLogger("lgess") instance = &Com{ - Helper: request.NewHelper(log), - uri: uri, - authPath: UserLoginURI, - password: password, - essType: essType, - } - - if registration != "" { - instance.authPath = LoginURI - instance.password = registration + Helper: request.NewHelper(log), + uri: uri, + registration: registration, + password: password, + essType: essType, + log: log, } // ignore the self signed certificate @@ -62,24 +55,24 @@ func GetInstance(uri, registration, password string, cache time.Duration, essTyp // caches the data access for the "cache" time duration // sends a new request to the pcs if the cache is expired and Data() requested - instance.dataG = util.Cached(instance.refreshData, cache) + instance.dataG = util.Cached(instance.essInfo, cache) // do first login if no authKey exists and uri and password exist - if instance.authKey == "" && instance.uri != "" && instance.password != "" { + if instance.authKey == "" && instance.uri != "" && (instance.password != "" || instance.registration != "") { err = instance.Login() } }) - // check if different uris are provided - if password != "" && registration != "" { - return nil, errors.New("cannot have registration and password") - } - // check if different uris are provided if uri != "" && instance.uri != uri { return nil, fmt.Errorf("uri mismatch: %s vs %s", instance.uri, uri) } + // check if different registrations are provided + if registration != "" && instance.registration != registration { + return nil, errors.New("registration mismatch") + } + // check if different passwords are provided if password != "" && instance.password != password { return nil, errors.New("password mismatch") @@ -90,11 +83,27 @@ func GetInstance(uri, registration, password string, cache time.Duration, essTyp // Login calls login and stores the returned authorization key func (m *Com) Login() error { + // check if at least one of password and registration is provided + if m.password == "" && m.registration == "" { + return errors.New("neither registration nor password provided - at least one needed") + } + data := map[string]interface{}{ "password": m.password, } + uri := fmt.Sprintf("%s/v1/user/setting/login", m.uri) - req, err := request.New(http.MethodPut, m.uri+m.authPath, request.MarshalJSON(data), request.JSONEncoding) + if m.password == "" { // use installer login + m.log.DEBUG.Println("installer login") + uri = fmt.Sprintf("%s/v1/login", m.uri) + data["password"] = m.registration + } + + if m.authKey != "" { + m.log.DEBUG.Println("re-login") + } + + req, err := request.New(http.MethodPut, uri, request.MarshalJSON(data), request.JSONEncoding) if err != nil { return err } @@ -125,47 +134,67 @@ func (m *Com) Data() (EssData, error) { return m.dataG() } -// refreshData reads data from lgess pcs. Tries to re-login if "405" auth_key expired is returned -func (m *Com) refreshData() (EssData, error) { +// essInfo reads essinfo/home +func (m *Com) essInfo() (EssData, error) { + f := func(payload any) (*http.Request, error) { + uri := fmt.Sprintf("%s/v1/user/essinfo/home", m.uri) + return request.New(http.MethodPost, uri, request.MarshalJSON(payload), request.JSONEncoding) + } + if m.essType == LgEss8 { var res MeterResponse8 - err := m.update(&res) + err := m.request(f, nil, &res) return res, err } var res MeterResponse15 - err := m.update(&res) + err := m.request(f, nil, &res) return res, err } -func (m *Com) update(meterData any) error { - data := map[string]interface{}{ +// BatteryMode sets the battery mode +func (m *Com) BatteryMode(mode string, soc int, autocharge bool) error { + var res struct{} + return m.request(func(payload any) (*http.Request, error) { + uri := fmt.Sprintf("%s/v1/user/setting/batt", m.uri) + return request.New(http.MethodPut, uri, request.MarshalJSON(payload), request.JSONEncoding) + }, map[string]string{ + "backupmode": mode, + "backup_soc": strconv.Itoa(soc), + "autocharge": strconv.Itoa(cast.ToInt(autocharge)), + }, &res) +} + +func (m *Com) request(f func(any) (*http.Request, error), payload map[string]string, res any) error { + data := map[string]string{ "auth_key": m.authKey, } + maps.Copy(data, payload) - req, err := request.New(http.MethodPost, m.uri+MeterURI, request.MarshalJSON(data), request.JSONEncoding) + req, err := f(data) if err != nil { return err } - if err := m.DoJSON(req, meterData); err != nil { - // re-login if request returns 405-error - if se := new(request.StatusError); errors.As(err, &se) && se.StatusCode() == http.StatusMethodNotAllowed { - if err := m.Login(); err != nil { - return err - } - - data["auth_key"] = m.authKey - req, err := request.New(http.MethodPost, m.uri+MeterURI, request.MarshalJSON(data), request.JSONEncoding) - if err != nil { - return err - } - - return m.DoJSON(req, &meterData) - } + err = m.DoJSON(req, &res) + if err == nil { + return nil + } + // re-login if request returns 405-error + if se := new(request.StatusError); errors.As(err, &se) && se.StatusCode() != http.StatusMethodNotAllowed { return err } - return nil + if err := m.Login(); err != nil { + return err + } + + data["auth_key"] = m.authKey + req, err = f(data) + if err != nil { + return err + } + + return m.DoJSON(req, &res) } diff --git a/templates/definition/meter/lg-ess-home-15.yaml b/templates/definition/meter/lg-ess-home-15.yaml index c94f8eed2..e38991d24 100644 --- a/templates/definition/meter/lg-ess-home-15.yaml +++ b/templates/definition/meter/lg-ess-home-15.yaml @@ -3,6 +3,7 @@ products: - brand: LG description: generic: ESS Home 15 +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -24,6 +25,13 @@ params: de: Registriernummer des LG ESS HOME Wechselrichters. - name: capacity advanced: true + # battery control + - name: minsoc + type: int + advanced: true + - name: maxsoc + type: int + advanced: true render: | type: lgess15 usage: {{ .usage }} @@ -35,4 +43,8 @@ render: | {{- if .registration }} registration: {{ .registration }} {{- end }} + {{- if eq .usage "battery" }} capacity: {{ .capacity }} # kWh + minsoc: {{ .minsoc }} + maxsoc: {{ .maxsoc }} + {{- end }} diff --git a/templates/definition/meter/lg-ess-home-8-10.yaml b/templates/definition/meter/lg-ess-home-8-10.yaml index 39a8afbb2..ffd61d150 100644 --- a/templates/definition/meter/lg-ess-home-8-10.yaml +++ b/templates/definition/meter/lg-ess-home-8-10.yaml @@ -3,6 +3,7 @@ products: - brand: LG description: generic: ESS Home 8/10 +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -24,6 +25,13 @@ params: de: Registriernummer des LG ESS HOME Wechselrichters. - name: capacity advanced: true + # battery control + - name: minsoc + type: int + advanced: true + - name: maxsoc + type: int + advanced: true render: | type: lgess8 usage: {{ .usage }} @@ -35,4 +43,8 @@ render: | {{- if .registration }} registration: {{ .registration }} {{- end }} + {{- if eq .usage "battery" }} capacity: {{ .capacity }} # kWh + minsoc: {{ .minsoc }} + maxsoc: {{ .maxsoc }} + {{- end }}