Add LG ESS HOME 8/10 support (#1414)
This commit is contained in:
parent
a2c5fc0217
commit
65b2faef10
4 changed files with 400 additions and 1 deletions
|
|
@ -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
143
meter/lgess.go
Normal 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
69
meter/lgess_decorators.go
Normal 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
186
meter/lgpcs/lgpcs.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue