Add LG ESS HOME 8/10 support (#1414)

This commit is contained in:
texperiri 2021-08-26 19:29:21 +02:00 • committed by andig
parent a2c5fc0217
commit 65b2faef10
4 changed files with 400 additions and 1 deletions

View file

@ -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`.

143
meter/lgess.go Normal file
View file

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

69
meter/lgess_decorators.go Normal file
View file

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

186
meter/lgpcs/lgpcs.go Normal file
View file

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