evcc-io/charger/ghosteebus.go

231 lines
6.8 KiB
Go

package charger
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"github.com/enbility/spine-go/model"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/charger/ghostone"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
// GhostEEBus charger implementation combining EEBus protocol for EV communication
// with Ghost platform REST API for phase switching and RFID identification.
type GhostEEBus struct {
*EEBus
*request.Helper
uri string // REST API base URL, e.g. "https://10.0.1.30/api/v2"
hasRFID bool
}
func init() {
registry.AddCtx("ghosteebus", NewGhostEEBusFromConfig)
}
// NewGhostEEBusFromConfig creates a GhostEEBus charger from generic config
func NewGhostEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
var cc struct {
Ski string
Ip string
Meter bool
ChargedEnergy *bool
User string
Password string
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
// default true
hasChargedEnergy := cc.ChargedEnergy == nil || *cc.ChargedEnergy
return NewGhostEEBus(ctx, cc.Ski, cc.Ip, cc.User, cc.Password, cc.Meter, hasChargedEnergy)
}
// NewGhostEEBus creates a GhostEEBus charger combining EEBus with Ghost REST API
func NewGhostEEBus(ctx context.Context, ski, ip, user, password string, hasMeter, hasChargedEnergy bool) (api.Charger, error) {
eb, err := newEEBus(ctx, ski, ip)
if err != nil {
return nil, err
}
log := util.NewLogger("ghost-eebus").Redact(user, password)
uri := "https://" + ip + "/api/v2"
wb := &GhostEEBus{
EEBus: eb,
Helper: request.NewHelper(log),
uri: uri,
}
// REST API features require IP and credentials
if ip != "" && user != "" && password != "" {
ts, err := ghostone.TokenSource(ctx, log, wb.uri, user, password)
if err != nil {
return nil, err
}
wb.Client.Transport = &oauth2.Transport{
Source: ts,
Base: transport.Insecure(),
}
// warn if PV optimization is active
var pvMode ghostone.PvOptimizationMode
if err := wb.getJSONCtx(ctx, wb.uri+"/charging/pvoptimization/mode", &pvMode); err == nil && pvMode.Value != ghostone.PvModeNone {
log.WARN.Printf("wallbox PV optimization is active (%s), should be disabled when using evcc", pvMode.Value)
}
// warn if phase switching is disabled
var relaisEnabled ghostone.Enabled
if err := wb.getJSONCtx(ctx, wb.uri+"/system/relais-switch/enabled", &relaisEnabled); err == nil && !relaisEnabled.Enabled {
log.WARN.Println("phase switching is disabled, enable it in the wallbox settings to use 1p3p switching")
}
// always wire up phase switching and RFID - the wallbox will reject
// operations at runtime if the feature is disabled or not possible
implement.Has(wb, implement.PhaseSwitcher(wb.phases1p3p))
implement.Has(wb, implement.PhaseGetter(wb.getPhases))
wb.hasRFID = true
}
// EEBus meter capabilities
if hasMeter {
implement.Has(wb, implement.Meter(eb.currentPower))
implement.Has(wb, implement.PhaseCurrents(eb.currents))
if hasChargedEnergy {
implement.Has(wb, implement.ChargeRater(eb.chargedEnergy))
}
}
return wb, nil
}
var _ api.Identifier = (*GhostEEBus)(nil)
// Identify implements api.Identifier, reporting RFID before EEBUS identification
func (wb *GhostEEBus) Identify() ([]string, error) {
var res []string
if wb.hasRFID {
if ids, err := wb.identify(); err == nil {
res = append(res, ids...)
}
}
ids, err := wb.EEBus.Identify()
if err != nil {
return nil, err
}
return slices.DeleteFunc(append(res, ids...), func(id string) bool { return id == "" }), nil
}
// getJSONCtx executes a context-aware GET request and decodes the JSON response.
func (wb *GhostEEBus) getJSONCtx(ctx context.Context, url string, res any) error {
req, err := request.New(http.MethodGet, url, nil, request.AcceptJSON)
if err != nil {
return err
}
return wb.DoJSON(req.WithContext(ctx), res)
}
// putJSON sends a PUT request with JSON body to the REST API.
func (wb *GhostEEBus) putJSON(url string, data any) error {
req, err := request.New(http.MethodPut, url, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
_, err = wb.DoBody(req)
}
return err
}
// phases1p3p implements phase switching via REST API.
// Returns api.ErrNotAvailable when the EV communicates via ISO 15118,
// as relay switching would violate the high-level power contract.
func (wb *GhostEEBus) phases1p3p(phases int) error {
evEntity, connected := wb.EEBus.isEvConnected()
if connected {
comStandard, err := wb.EEBus.cem.EvCC.CommunicationStandard(evEntity)
if err != nil {
return api.ErrNotAvailable
}
if comStandard != model.DeviceConfigurationKeyValueStringTypeIEC61851 {
return api.ErrNotAvailable
}
}
val := ghostone.RelaisStateOnePhase
if phases == 3 {
val = ghostone.RelaisStateThreePhase
}
if err := wb.putJSON(wb.uri+"/system/relais-switch/state", ghostone.RelaisSwitchStateWrite{Value: val}); err != nil {
return err
}
// verify the switch was accepted by reading back the state
var res ghostone.RelaisSwitchStateRead
if err := wb.GetJSON(wb.uri+"/system/relais-switch/state", &res); err != nil {
return err
}
switch res.Value {
case ghostone.RelaisStateNotPossible, ghostone.RelaisStateNotAvailable:
msg := fmt.Sprintf("phase switching denied: %s", res.Value)
if res.LimitationReason != "" {
msg += fmt.Sprintf(" (%s)", res.LimitationReason)
}
return errors.New(msg)
}
return nil
}
// getPhases implements phase reading via REST API.
func (wb *GhostEEBus) getPhases() (int, error) {
var res ghostone.RelaisSwitchStateRead
err := wb.GetJSON(wb.uri+"/system/relais-switch/state", &res)
if err != nil {
return 0, err
}
// check for transient or error states
// "notPossible" is also returned if the EV is connected via ISO 15118, as phase switching is not allowed in that case
switch res.Value {
case ghostone.RelaisStateSwitchInProgress, ghostone.RelaisStateNotAvailable, ghostone.RelaisStateNotPossible:
msg := fmt.Sprintf("phase switch state: %s", res.Value)
if res.LimitationReason != "" {
msg += fmt.Sprintf(" (%s)", res.LimitationReason)
}
// the phase state cannot be read - signal api.ErrNotAvailable so
// callers treat it as a benign "not available" rather than an error
wb.log.TRACE.Println(msg)
return 0, api.ErrNotAvailable
}
switch res.CurrentState {
case ghostone.RelaisStateOnePhase:
return 1, nil
case ghostone.RelaisStateThreePhase:
return 3, nil
default:
return 0, fmt.Errorf("unknown phase state: %s", res.CurrentState)
}
}
// identify implements RFID identification via REST API.
func (wb *GhostEEBus) identify() ([]string, error) {
var res ghostone.RfidCardLastRead
err := wb.GetJSON(wb.uri+"/rfid-cards/last-read", &res)
return []string{res.UUID}, err
}