evcc-io/meter/lgpcs/lgpcs.go
2026-02-12 15:56:06 +01:00

219 lines
6 KiB
Go

// 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"
"maps"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"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"
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 (
instances map[string]*Com = map[string]*Com{}
mu sync.Mutex = sync.Mutex{}
)
// GetInstance retrives a singleton per uri from a map to handle the access via the authkey to the PCS of the LG ESS HOME system
func GetInstance(uri, registration, password string, cache time.Duration, essType Model) (*Com, error) {
mu.Lock()
defer mu.Unlock()
uri = util.DefaultScheme(strings.TrimSuffix(uri, "/"), "https")
instance, found := instances[uri]
if found {
return instance, nil
}
log := util.NewLogger(fmt.Sprintf("lgess-%s", strings.TrimPrefix("https://", uri)))
instance = &Com{uri: uri,
Helper: request.NewHelper(log),
registration: registration,
password: password,
essType: essType,
log: log,
}
// put instance into the cache map
instances[uri] = instance
// ignore the self signed certificate
instance.Client.Transport = request.NewTripper(log, transport.Insecure())
// 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.essInfo, cache)
// do first login if no authKey exists and uri and password exist
if instance.authKey == "" && instance.uri != "" && (instance.password != "" || instance.registration != "") {
if err := instance.Login(); err != nil {
return nil, err
}
return instance, nil
}
return nil, errors.New("missing credentials")
}
// 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]any{
"password": m.password,
}
uri := fmt.Sprintf("%s/v1/user/setting/login", m.uri)
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
}
// 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 cached data read from the pcs.
func (m *Com) Data() (EssData, error) {
return m.dataG()
}
// 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.request(f, nil, &res)
return res, err
}
var res MeterResponse15
err := m.request(f, nil, &res)
return res, err
}
func (m *Com) GetSystemInfo() (SystemInfoResponse, error) {
f := func(payload any) (*http.Request, error) {
uri := fmt.Sprintf("%s/v1/user/setting/systeminfo", m.uri)
return request.New(http.MethodPost, uri, request.MarshalJSON(payload), request.JSONEncoding)
}
var res SystemInfoResponse
err := m.request(f, nil, &res)
return res, err
}
func (m *Com) GetFirmwareVersion() (int, error) {
systemInfo, err := m.GetSystemInfo()
if err != nil {
return 0, err
}
// extract the patch number behind a dot that is always followed by at least 4 digits
if match := regexp.MustCompile(`\.(\d{4})$`).FindStringSubmatch(systemInfo.Version.PMSVersion); len(match) > 1 {
return strconv.Atoi(match[1])
}
return 0, errors.New("firmware version not found")
}
// 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 := f(data)
if err != nil {
return err
}
err = m.DoJSON(req, &res)
if err == nil {
return nil
}
// re-login if request returns 405-error
if se, ok := errors.AsType[*request.StatusError](err); ok && se.StatusCode() != http.StatusMethodNotAllowed {
return err
}
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)
}