diff --git a/README.md b/README.md index 2d29a8227..d850002ab 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - Other commercial: ABL eMH1, go-eCharger, Heidelberg Energy Control, KEBA/BMW, NRGkick, Wallbe, Mobile Charger Connect, EEBUS (experimental) - Build-your-own: Phoenix (includes ESL Walli), [SimpleEVSE](https://www.evse-wifi.de/produkt-schlagwort/simple-evse-wb/) - Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link -- multiple [meters](#meter): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall +- multiple [meters](#meter): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall, LG ESS HOME - wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Ford, Hyundai, Kia, Mini, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo, Tronity - [plugins](#plugins) for integrating with any charger/ meter/ vehicle: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts - status notifications using [Telegram](https://telegram.org), [PushOver](https://pushover.net) and [many more](https://containrrr.dev/shoutrrr/) @@ -269,6 +269,7 @@ EVCC uses positive (+) sign for incoming energy (grid consumption, PV inverter p Available meter implementations are: - `modbus`: ModBus meters as supported by [MBMD](https://github.com/volkszaehler/mbmd#supported-devices). Configuration is similar to the [ModBus plugin](#modbus-readwrite) where `power` and `energy` specify the MBMD measurement value to use. Additionally, `soc` can specify an MBMD measurement value for home battery soc. Typical values are `power: Power`, `energy: Sum` and `soc: ChargeState` where only `power` applied per default. +- `lgess`: LG ESS HOME meter. Use `usage` to choose meter type: `grid`/`pv`/`battery`. Use `uri` to configure the URI of the LG ESS HOME. Use `password` to configure the password required to access the LG ESS HOME. `uri` and `password` only need to be provided once if multiple usages are defined. - `openwb`: OpenWB meters. Use `usage` to choose meter type: `grid`/`pv`/`battery`. - `sma`: SMA Home Manager 2.0, SMA Energy Meter and Inverters via SMA Speedwire. - `tesla`: Tesla PowerWall meter. Use `usage` to choose meter type: `grid`/`pv`/`battery`. diff --git a/meter/lgess.go b/meter/lgess.go new file mode 100644 index 000000000..4503c881f --- /dev/null +++ b/meter/lgess.go @@ -0,0 +1,143 @@ +package meter + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/lgpcs" + "github.com/evcc-io/evcc/util" +) + +/* +This meter supports the LGESS HOME 8 and LGESS HOME 10 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: lgess + usage: grid + uri: https://192.168.1.23 + password: "DE200....." +- name: PvMeter + type: lgess + usage: pv +- name: BatteryMeter + type: lgess + 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 + lp *lgpcs.Com // communication with the lgpcs device +} + +func init() { + registry.Add("lgess", NewLgEssFromConfig) +} + +//go:generate go run ../cmd/tools/decorate.go -f decorateLgEss -b api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.Battery,SoC,func() (float64, error)" + +// NewLgEssFromConfig creates an LgEss Meter from generic config +func NewLgEssFromConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + URI, Usage, Password string + Cache time.Duration + }{ + Cache: 2 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Usage == "" { + return nil, errors.New("missing usage") + } + + return NewLgEss(cc.URI, cc.Usage, cc.Password, cc.Cache) +} + +// NewLgEss creates an LgEss Meter +func NewLgEss(uri, usage, password string, cache time.Duration) (api.Meter, error) { + lp, err := lgpcs.GetInstance(uri, password, cache) + if err != nil { + return nil, err + } + + m := &LgEss{ + usage: strings.ToLower(usage), + lp: lp, + } + + // decorate api.MeterEnergy + var totalEnergy func() (float64, error) + if m.usage == "grid" { + totalEnergy = m.totalEnergy + } + + // decorate api.BatterySoC + var batterySoC func() (float64, error) + if usage == "battery" { + batterySoC = m.batterySoC + } + + return decorateLgEss(m, totalEnergy, batterySoC), nil +} + +// CurrentPower implements the api.Meter interface +func (m *LgEss) CurrentPower() (float64, error) { + data, err := m.lp.Data() + if err != nil { + return 0, err + } + + switch m.usage { + case "grid": + return data.GridPower, nil + case "pv": + return data.PvTotalPower, nil + case "battery": + return data.BatConvPower, nil + default: + return 0, fmt.Errorf("invalid usage: %s", m.usage) + } +} + +// totalEnergy implements the api.MeterEnergy interface +func (m *LgEss) totalEnergy() (float64, error) { + data, err := m.lp.Data() + if err != nil { + return 0, err + } + + switch m.usage { + case "grid": + return data.CurrentGridFeedInEnergy / 1e3, nil + default: + return 0, fmt.Errorf("invalid usage: %s", m.usage) + } +} + +// batterySoC implements the api.Battery interface +func (m *LgEss) batterySoC() (float64, error) { + data, err := m.lp.Data() + if err != nil { + return 0, err + } + + return data.BatUserSoc, nil +} diff --git a/meter/lgess_decorators.go b/meter/lgess_decorators.go new file mode 100644 index 000000000..ec9967322 --- /dev/null +++ b/meter/lgess_decorators.go @@ -0,0 +1,69 @@ +package meter + +// Code generated by github.com/andig/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateLgEss(base api.Meter, meterEnergy func() (float64, error), battery func() (float64, error)) api.Meter { + switch { + case battery == nil && meterEnergy == nil: + return base + + case battery == nil && meterEnergy != nil: + return &struct { + api.Meter + api.MeterEnergy + }{ + Meter: base, + MeterEnergy: &decorateLgEssMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + + case battery != nil && meterEnergy == nil: + return &struct { + api.Meter + api.Battery + }{ + Meter: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + } + + case battery != nil && meterEnergy != nil: + return &struct { + api.Meter + api.Battery + api.MeterEnergy + }{ + Meter: base, + Battery: &decorateLgEssBatteryImpl{ + battery: battery, + }, + MeterEnergy: &decorateLgEssMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + } + + return nil +} + +type decorateLgEssBatteryImpl struct { + battery func() (float64, error) +} + +func (impl *decorateLgEssBatteryImpl) SoC() (float64, error) { + return impl.battery() +} + +type decorateLgEssMeterEnergyImpl struct { + meterEnergy func() (float64, error) +} + +func (impl *decorateLgEssMeterEnergyImpl) TotalEnergy() (float64, error) { + return impl.meterEnergy() +} diff --git a/meter/lgpcs/lgpcs.go b/meter/lgpcs/lgpcs.go new file mode 100644 index 000000000..3e625a2fb --- /dev/null +++ b/meter/lgpcs/lgpcs.go @@ -0,0 +1,186 @@ +// Package lgpcs implements access to the LG pcs device (aka inverter). +// Pcs is the LG power conditioning system that converts the PV (or battery) - DC current into AC current (and controls the batteries) +package lgpcs + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/evcc-io/evcc/provider" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +// URIs +const ( + LoginURI = "/v1/login" + MeterURI = "/v1/user/essinfo/home" +) + +type MeterResponse struct { + Statistics EssData + Direction struct { + IsGridSelling int `json:"is_grid_selling_,string"` + IsBatteryDischarging int `json:"is_battery_discharging_,string"` + } +} + +type EssData struct { + GridPower float64 `json:"grid_power,string"` + PvTotalPower float64 `json:"pcs_pv_total_power,string"` + BatConvPower float64 `json:"batconv_power,string"` + BatUserSoc float64 `json:"bat_user_soc,string"` + CurrentGridFeedInEnergy float64 `json:"current_grid_feed_in_energy,string"` + CurrentPvGenerationSum float64 `json:"current_pv_generation_sum,string"` +} + +type Com struct { + *request.Helper + uri string // URI address of the LG ESS inverter - e.g. "https://192.168.1.28" + 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 + cachedData func() (interface{}, error) +} + +var once sync.Once +var instance *Com + +// GetInstance implements the singleton pattern to handle the access via the authkey to the PCS of the LG ESS HOME system +func GetInstance(uri, password string, cache time.Duration) (*Com, error) { + const emptyUri = "https:" + uri = util.DefaultScheme(strings.TrimSuffix(uri, "/"), "https") + + once.Do(func() { + log := util.NewLogger("lgess") + instance = &Com{ + Helper: request.NewHelper(log), + uri: uri, + password: password, + } + + // ignore the self signed certificate + instance.Client.Transport = request.NewTripper(log, request.InsecureTransport()) + + // 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.cachedData = provider.NewCached(func() (interface{}, error) { + return instance.refreshData() + }, cache).InterfaceGetter() + }) + + // it is sufficient to provide the uri once ... if not provided yet set uri now + if instance.uri == emptyUri { + instance.uri = uri + } + + // check if different uris are provided + if uri != emptyUri && instance.uri != uri { + return nil, fmt.Errorf("uri mismatch: %s vs %s", instance.uri, uri) + } + + // it is sufficient to provide the password once ... if not provided yet set password now + if instance.password == "" { + instance.password = password + } + + // check if different passwords are provided + if password != "" && instance.password != password { + return nil, errors.New("password mismatch") + } + + // do first login if no authKey exists and uri and password exist + var err error + if instance.authKey == "" && instance.uri != emptyUri && instance.password != "" { + err = instance.Login() + } + + return instance, err +} + +// Login calls login and stores the returned authorization key +func (m *Com) Login() error { + data := map[string]interface{}{ + "password": m.password, + } + + req, err := request.New(http.MethodPut, m.uri+LoginURI, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return err + } + + // read auth_key from response body + var res struct { + Status string `json:"status,omitempty"` + AuthKey string `json:"auth_key"` + } + + if err := m.DoJSON(req, &res); err != nil { + return fmt.Errorf("login failed: %w", err) + } + + // check login response status + if res.Status != "success" { + return fmt.Errorf("login failed: %s", res.Status) + } + + // read auth_key from response + m.authKey = res.AuthKey + + return nil +} + +// Data gives the data read from the pcs. +func (m *Com) Data() (EssData, error) { + res, err := m.cachedData() + return res.(EssData), err +} + +// refreshData reads data from lgess pcs. Tries to re-login if "405" auth_key expired is returned +func (m *Com) refreshData() (EssData, error) { + data := map[string]interface{}{ + "auth_key": m.authKey, + } + + req, err := request.New(http.MethodPost, m.uri+MeterURI, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return EssData{}, err + } + + var resp MeterResponse + + if err = m.DoJSON(req, &resp); err != nil { + // re-login if request returns 405-error + if err2, ok := err.(request.StatusError); ok && err2.HasStatus(http.StatusMethodNotAllowed) { + err = m.Login() + + if err == nil { + data["auth_key"] = m.authKey + req, err = request.New(http.MethodPost, m.uri+MeterURI, request.MarshalJSON(data), request.JSONEncoding) + } + + if err == nil { + err = m.DoJSON(req, &resp) + } + } + } + + if err != nil { + return EssData{}, err + } + + res := resp.Statistics + if resp.Direction.IsGridSelling > 0 { + res.GridPower = -res.GridPower + } + + // discharge battery: batPower is positive, charge battery: batPower is negative + if resp.Direction.IsBatteryDischarging == 0 { + res.BatConvPower = -res.BatConvPower + } + + return res, nil +}