LG ESS: add battery control (#19630)

This commit is contained in:
Andrei Zaitsev 2025-03-11 17:58:11 +01:00 • committed by GitHub
parent b18b7964a8
commit 5ed022aa91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 237 additions and 95 deletions

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -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)
}