Add GhostEEBus charger (#28573)
This commit is contained in:
parent
3c10d114af
commit
70b17d09d7
10 changed files with 941 additions and 34 deletions
|
|
@ -64,15 +64,16 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger,
|
|||
}
|
||||
|
||||
// default true
|
||||
hasChargedEnergy := cc.ChargedEnergy != nil && *cc.ChargedEnergy
|
||||
hasChargedEnergy := cc.ChargedEnergy == nil || *cc.ChargedEnergy
|
||||
|
||||
return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy)
|
||||
}
|
||||
|
||||
//go:generate go tool decorate -f decorateEEBus -b *EEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater
|
||||
|
||||
// NewEEBus creates EEBus charger
|
||||
func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) {
|
||||
// newEEBus creates and initializes a raw *EEBus charger.
|
||||
// It registers the device with the EEBus instance and waits for the connection.
|
||||
func newEEBus(ctx context.Context, ski, ip string) (*EEBus, error) {
|
||||
if eebus.Instance == nil {
|
||||
return nil, errors.New("eebus not configured")
|
||||
}
|
||||
|
|
@ -101,6 +102,16 @@ func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bo
|
|||
eebus.Instance.UnregisterDevice(ski, c)
|
||||
}()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewEEBus creates EEBus charger
|
||||
func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) {
|
||||
c, err := newEEBus(ctx, ski, ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hasMeter {
|
||||
var energyG func() (float64, error)
|
||||
if hasChargedEnergy {
|
||||
|
|
|
|||
218
charger/ghosteebus.go
Normal file
218
charger/ghosteebus.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package charger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/enbility/spine-go/model"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"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)
|
||||
}
|
||||
|
||||
//go:generate go tool decorate -f decorateGhostEEBus -b *GhostEEBus -r api.Charger -t api.Meter,api.PhaseCurrents,api.ChargeRater,api.PhaseSwitcher,api.PhaseGetter
|
||||
|
||||
// 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
|
||||
var phasesS func(int) error
|
||||
var phasesG func() (int, error)
|
||||
|
||||
if ip != "" && user != "" && password != "" {
|
||||
ts, err := ghostone.TokenSource(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.GetJSON(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.GetJSON(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
|
||||
phasesS = wb.phases1p3p
|
||||
phasesG = wb.getPhases
|
||||
wb.hasRFID = true
|
||||
}
|
||||
|
||||
// EEBus meter capabilities
|
||||
var meter func() (float64, error)
|
||||
var phaseCurrents func() (float64, float64, float64, error)
|
||||
var chargeRater func() (float64, error)
|
||||
|
||||
if hasMeter {
|
||||
meter = eb.currentPower
|
||||
phaseCurrents = eb.currents
|
||||
if hasChargedEnergy {
|
||||
chargeRater = eb.chargedEnergy
|
||||
}
|
||||
}
|
||||
|
||||
return decorateGhostEEBus(wb, meter, phaseCurrents, chargeRater, phasesS, phasesG), nil
|
||||
}
|
||||
|
||||
var _ api.Identifier = (*GhostEEBus)(nil)
|
||||
|
||||
// Identify implements api.Identifier, preferring RFID over EEBUS identification
|
||||
func (wb *GhostEEBus) Identify() (string, error) {
|
||||
if wb.hasRFID {
|
||||
if id, err := wb.identify(); err == nil && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return wb.EEBus.Identify()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
return 0, errors.New(msg)
|
||||
}
|
||||
|
||||
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 res.UUID, err
|
||||
}
|
||||
89
charger/ghosteebus_decorators.go
Normal file
89
charger/ghosteebus_decorators.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package charger
|
||||
|
||||
// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
)
|
||||
|
||||
func decorateGhostEEBus(base *GhostEEBus, meter func() (float64, error), phaseCurrents func() (float64, float64, float64, error), chargeRater func() (float64, error), phaseSwitcher func(int) error, phaseGetter func() (int, error)) api.Charger {
|
||||
caps := make(map[reflect.Type]any)
|
||||
|
||||
if meter != nil {
|
||||
caps[reflect.TypeFor[api.Meter]()] = &decorateGhostEEBusMeterImpl{meter: meter}
|
||||
}
|
||||
|
||||
if phaseCurrents != nil {
|
||||
caps[reflect.TypeFor[api.PhaseCurrents]()] = &decorateGhostEEBusPhaseCurrentsImpl{phaseCurrents: phaseCurrents}
|
||||
}
|
||||
|
||||
if chargeRater != nil {
|
||||
caps[reflect.TypeFor[api.ChargeRater]()] = &decorateGhostEEBusChargeRaterImpl{chargeRater: chargeRater}
|
||||
}
|
||||
|
||||
if phaseSwitcher != nil {
|
||||
caps[reflect.TypeFor[api.PhaseSwitcher]()] = &decorateGhostEEBusPhaseSwitcherImpl{phaseSwitcher: phaseSwitcher}
|
||||
}
|
||||
|
||||
if phaseGetter != nil {
|
||||
caps[reflect.TypeFor[api.PhaseGetter]()] = &decorateGhostEEBusPhaseGetterImpl{phaseGetter: phaseGetter}
|
||||
}
|
||||
|
||||
if len(caps) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
return &decorateGhostEEBusCapable{GhostEEBus: base, caps: caps}
|
||||
}
|
||||
|
||||
type decorateGhostEEBusCapable struct {
|
||||
*GhostEEBus
|
||||
caps map[reflect.Type]any
|
||||
}
|
||||
|
||||
func (d *decorateGhostEEBusCapable) Capability(typ reflect.Type) (any, bool) {
|
||||
c, ok := d.caps[typ]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
type decorateGhostEEBusChargeRaterImpl struct {
|
||||
chargeRater func() (float64, error)
|
||||
}
|
||||
|
||||
func (impl *decorateGhostEEBusChargeRaterImpl) ChargedEnergy() (float64, error) {
|
||||
return impl.chargeRater()
|
||||
}
|
||||
|
||||
type decorateGhostEEBusMeterImpl struct {
|
||||
meter func() (float64, error)
|
||||
}
|
||||
|
||||
func (impl *decorateGhostEEBusMeterImpl) CurrentPower() (float64, error) {
|
||||
return impl.meter()
|
||||
}
|
||||
|
||||
type decorateGhostEEBusPhaseCurrentsImpl struct {
|
||||
phaseCurrents func() (float64, float64, float64, error)
|
||||
}
|
||||
|
||||
func (impl *decorateGhostEEBusPhaseCurrentsImpl) Currents() (float64, float64, float64, error) {
|
||||
return impl.phaseCurrents()
|
||||
}
|
||||
|
||||
type decorateGhostEEBusPhaseGetterImpl struct {
|
||||
phaseGetter func() (int, error)
|
||||
}
|
||||
|
||||
func (impl *decorateGhostEEBusPhaseGetterImpl) GetPhases() (int, error) {
|
||||
return impl.phaseGetter()
|
||||
}
|
||||
|
||||
type decorateGhostEEBusPhaseSwitcherImpl struct {
|
||||
phaseSwitcher func(int) error
|
||||
}
|
||||
|
||||
func (impl *decorateGhostEEBusPhaseSwitcherImpl) Phases1p3p(p0 int) error {
|
||||
return impl.phaseSwitcher(p0)
|
||||
}
|
||||
422
charger/ghosteebus_test.go
Normal file
422
charger/ghosteebus_test.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package charger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
ucapi "github.com/enbility/eebus-go/usecases/api"
|
||||
"github.com/enbility/eebus-go/usecases/mocks"
|
||||
spinemocks "github.com/enbility/spine-go/mocks"
|
||||
"github.com/enbility/spine-go/model"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/charger/ghostone"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestGhostEEBusREST creates a GhostEEBus with a minimal EEBus (no EV connected), for pure REST tests.
|
||||
func newTestGhostEEBusREST(t *testing.T) *GhostEEBus {
|
||||
t.Helper()
|
||||
return &GhostEEBus{
|
||||
EEBus: &EEBus{
|
||||
log: util.NewLogger("test"),
|
||||
cem: &eebus.CustomerEnergyManagement{},
|
||||
},
|
||||
Helper: request.NewHelper(util.NewLogger("test")),
|
||||
uri: "https://wallbox.local/api/v2",
|
||||
}
|
||||
}
|
||||
|
||||
// newTestGhostEEBusWithEEBus creates a GhostEEBus with mocked EEBUS dependencies.
|
||||
func newTestGhostEEBusWithEEBus(t *testing.T) (*GhostEEBus, *mocks.CemEVCCInterface, *spinemocks.EntityRemoteInterface) {
|
||||
t.Helper()
|
||||
|
||||
evccMock := mocks.NewCemEVCCInterface(t)
|
||||
evEntity := spinemocks.NewEntityRemoteInterface(t)
|
||||
|
||||
eb := &EEBus{
|
||||
cem: &eebus.CustomerEnergyManagement{
|
||||
EvCC: evccMock,
|
||||
},
|
||||
ev: evEntity,
|
||||
log: util.NewLogger("test"),
|
||||
}
|
||||
|
||||
wb := &GhostEEBus{
|
||||
EEBus: eb,
|
||||
Helper: request.NewHelper(util.NewLogger("test")),
|
||||
uri: "https://wallbox.local/api/v2",
|
||||
}
|
||||
|
||||
return wb, evccMock, evEntity
|
||||
}
|
||||
|
||||
const ghostEEBusRelaisStateURL = "https://wallbox.local/api/v2/system/relais-switch/state"
|
||||
|
||||
func TestGhostEEBus_PhaseSwitchISO15118(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connected bool
|
||||
comStandard model.DeviceConfigurationKeyValueStringType
|
||||
comErr error
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "no_ev_connected",
|
||||
connected: false,
|
||||
},
|
||||
{
|
||||
name: "iec61851_allowed",
|
||||
connected: true,
|
||||
comStandard: model.DeviceConfigurationKeyValueStringTypeIEC61851,
|
||||
},
|
||||
{
|
||||
name: "iso15118_ed1_blocked",
|
||||
connected: true,
|
||||
comStandard: model.DeviceConfigurationKeyValueStringTypeISO151182ED1,
|
||||
wantErr: api.ErrNotAvailable,
|
||||
},
|
||||
{
|
||||
name: "iso15118_ed2_blocked",
|
||||
connected: true,
|
||||
comStandard: model.DeviceConfigurationKeyValueStringTypeISO151182ED2,
|
||||
wantErr: api.ErrNotAvailable,
|
||||
},
|
||||
{
|
||||
name: "com_standard_error",
|
||||
connected: true,
|
||||
comErr: errors.New("data not available"),
|
||||
wantErr: api.ErrNotAvailable,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t)
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
evccMock.EXPECT().EVConnected(evEntity).Return(tc.connected)
|
||||
if tc.connected {
|
||||
if tc.comErr != nil {
|
||||
evccMock.EXPECT().CommunicationStandard(evEntity).Return(
|
||||
model.DeviceConfigurationKeyValueStringType(""), tc.comErr,
|
||||
)
|
||||
} else {
|
||||
evccMock.EXPECT().CommunicationStandard(evEntity).Return(tc.comStandard, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.wantErr == nil {
|
||||
// mock PUT + GET for successful phase switch
|
||||
httpmock.RegisterResponder(http.MethodPut, ghostEEBusRelaisStateURL,
|
||||
httpmock.NewStringResponder(200, ""),
|
||||
)
|
||||
body, _ := json.Marshal(ghostone.RelaisSwitchStateRead{
|
||||
Value: ghostone.RelaisStateOnePhase, CurrentState: ghostone.RelaisStateOnePhase,
|
||||
})
|
||||
httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL,
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
}
|
||||
|
||||
err := wb.phases1p3p(1)
|
||||
|
||||
if tc.wantErr != nil {
|
||||
assert.ErrorIs(t, err, tc.wantErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostEEBus_PhaseSwitch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phases int
|
||||
wantValue string
|
||||
readBack ghostone.RelaisSwitchStateRead
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "switch_to_1p",
|
||||
phases: 1,
|
||||
wantValue: ghostone.RelaisStateOnePhase,
|
||||
readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateSwitchInProgress, CurrentState: ghostone.RelaisStateThreePhase},
|
||||
},
|
||||
{
|
||||
name: "switch_to_3p",
|
||||
phases: 3,
|
||||
wantValue: ghostone.RelaisStateThreePhase,
|
||||
readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateSwitchInProgress, CurrentState: ghostone.RelaisStateOnePhase},
|
||||
},
|
||||
{
|
||||
name: "already_in_target_state",
|
||||
phases: 1,
|
||||
wantValue: ghostone.RelaisStateOnePhase,
|
||||
readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateOnePhase, CurrentState: ghostone.RelaisStateOnePhase},
|
||||
},
|
||||
{
|
||||
name: "denied_not_possible",
|
||||
phases: 1,
|
||||
wantValue: ghostone.RelaisStateOnePhase,
|
||||
readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateNotPossible, LimitationReason: "hlcLimitation"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "denied_not_available",
|
||||
phases: 3,
|
||||
wantValue: ghostone.RelaisStateThreePhase,
|
||||
readBack: ghostone.RelaisSwitchStateRead{Value: ghostone.RelaisStateNotAvailable},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb := newTestGhostEEBusREST(t)
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
// mock PUT (phase switch command)
|
||||
var capturedBody ghostone.RelaisSwitchStateWrite
|
||||
httpmock.RegisterResponder(http.MethodPut, ghostEEBusRelaisStateURL,
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(req.Body).Decode(&capturedBody); err != nil {
|
||||
return httpmock.NewStringResponse(400, ""), nil
|
||||
}
|
||||
return httpmock.NewStringResponse(200, ""), nil
|
||||
},
|
||||
)
|
||||
|
||||
// mock GET (read-after-write verification)
|
||||
body, _ := json.Marshal(tc.readBack)
|
||||
httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL,
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
|
||||
err := wb.phases1p3p(tc.phases)
|
||||
|
||||
assert.Equal(t, tc.wantValue, capturedBody.Value)
|
||||
assert.Equal(t, 2, httpmock.GetTotalCallCount(), "expected PUT + GET")
|
||||
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostEEBus_GetPhases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentState string
|
||||
value string
|
||||
wantPhases int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "one_phase",
|
||||
currentState: ghostone.RelaisStateOnePhase,
|
||||
value: ghostone.RelaisStateOnePhase,
|
||||
wantPhases: 1,
|
||||
},
|
||||
{
|
||||
name: "three_phase",
|
||||
currentState: ghostone.RelaisStateThreePhase,
|
||||
value: ghostone.RelaisStateThreePhase,
|
||||
wantPhases: 3,
|
||||
},
|
||||
{
|
||||
name: "switch_in_progress",
|
||||
currentState: ghostone.RelaisStateOnePhase,
|
||||
value: ghostone.RelaisStateSwitchInProgress,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown_state",
|
||||
currentState: "foo",
|
||||
value: "foo",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb := newTestGhostEEBusREST(t)
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
resp := ghostone.RelaisSwitchStateRead{
|
||||
Value: tc.value,
|
||||
CurrentState: tc.currentState,
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
httpmock.RegisterResponder(http.MethodGet, ghostEEBusRelaisStateURL,
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
|
||||
phases, err := wb.getPhases()
|
||||
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantPhases, phases)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostEEBus_Decorator(t *testing.T) {
|
||||
wb := newTestGhostEEBusREST(t)
|
||||
|
||||
// with phase switching
|
||||
decorated := decorateGhostEEBus(wb, nil, nil, nil, wb.phases1p3p, wb.getPhases)
|
||||
assert.True(t, api.HasCap[api.PhaseSwitcher](decorated), "expected PhaseSwitcher")
|
||||
assert.True(t, api.HasCap[api.PhaseGetter](decorated), "expected PhaseGetter")
|
||||
|
||||
// without phase switching
|
||||
decorated = decorateGhostEEBus(wb, nil, nil, nil, nil, nil)
|
||||
assert.False(t, api.HasCap[api.PhaseSwitcher](decorated), "unexpected PhaseSwitcher")
|
||||
}
|
||||
|
||||
func TestGhostEEBus_Config(t *testing.T) {
|
||||
// test that config decoding works (fails on missing eebus instance, not on decoding)
|
||||
ctx := context.Background()
|
||||
config := map[string]any{
|
||||
"ski": "test-ski",
|
||||
"ip": "10.0.1.30",
|
||||
"user": "technician",
|
||||
"password": "secret",
|
||||
}
|
||||
|
||||
_, err := NewGhostEEBusFromConfig(ctx, config)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "eebus not configured")
|
||||
}
|
||||
|
||||
func TestGhostEEBus_ConfigMissingCredentials(t *testing.T) {
|
||||
// without credentials, falls back to pure EEBUS (no REST features)
|
||||
// fails on missing eebus instance, not on credentials
|
||||
ctx := context.Background()
|
||||
config := map[string]any{
|
||||
"ski": "test-ski",
|
||||
"ip": "10.0.1.30",
|
||||
"meter": true,
|
||||
}
|
||||
|
||||
_, err := NewGhostEEBusFromConfig(ctx, config)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "eebus not configured")
|
||||
}
|
||||
|
||||
func TestGhostEEBus_Identify(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "with_uuid",
|
||||
uuid: "ABC123",
|
||||
wantID: "ABC123",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
uuid: "",
|
||||
wantID: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb := newTestGhostEEBusREST(t)
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
resp := ghostone.RfidCardLastRead{UUID: tc.uuid}
|
||||
body, _ := json.Marshal(resp)
|
||||
httpmock.RegisterResponder(http.MethodGet,
|
||||
"https://wallbox.local/api/v2/rfid-cards/last-read",
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
|
||||
id, err := wb.identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantID, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostEEBus_IdentifyFallback(t *testing.T) {
|
||||
t.Run("rfid_disabled_falls_back_to_eebus", func(t *testing.T) {
|
||||
wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t)
|
||||
wb.hasRFID = false
|
||||
|
||||
// EEBus identification returns MAC
|
||||
evccMock.EXPECT().EVConnected(evEntity).Return(true)
|
||||
evccMock.EXPECT().Identifications(evEntity).Return([]ucapi.IdentificationItem{{Value: "MAC-001"}}, nil)
|
||||
|
||||
id, err := wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "MAC-001", id)
|
||||
})
|
||||
|
||||
t.Run("rfid_returns_id", func(t *testing.T) {
|
||||
wb, _, _ := newTestGhostEEBusWithEEBus(t)
|
||||
wb.hasRFID = true
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
resp := ghostone.RfidCardLastRead{UUID: "RFID-42"}
|
||||
body, _ := json.Marshal(resp)
|
||||
httpmock.RegisterResponder(http.MethodGet,
|
||||
"https://wallbox.local/api/v2/rfid-cards/last-read",
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
|
||||
id, err := wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "RFID-42", id)
|
||||
})
|
||||
|
||||
t.Run("rfid_empty_falls_back_to_eebus", func(t *testing.T) {
|
||||
wb, evccMock, evEntity := newTestGhostEEBusWithEEBus(t)
|
||||
wb.hasRFID = true
|
||||
|
||||
httpmock.ActivateNonDefault(wb.Client)
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
// RFID returns empty
|
||||
resp := ghostone.RfidCardLastRead{UUID: ""}
|
||||
body, _ := json.Marshal(resp)
|
||||
httpmock.RegisterResponder(http.MethodGet,
|
||||
"https://wallbox.local/api/v2/rfid-cards/last-read",
|
||||
httpmock.NewBytesResponder(200, body),
|
||||
)
|
||||
|
||||
// Falls back to EEBus
|
||||
evccMock.EXPECT().EVConnected(evEntity).Return(true)
|
||||
evccMock.EXPECT().Identifications(evEntity).Return([]ucapi.IdentificationItem{{Value: "MAC-001"}}, nil)
|
||||
|
||||
id, err := wb.Identify()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "MAC-001", id)
|
||||
})
|
||||
}
|
||||
93
charger/ghostone/identity.go
Normal file
93
charger/ghostone/identity.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package ghostone
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/oauth"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/transport"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type tokenSource struct {
|
||||
*request.Helper
|
||||
oauth2.TokenSource
|
||||
uri string
|
||||
user string
|
||||
password string
|
||||
}
|
||||
|
||||
// TokenSource creates a JWT token source for the ghost REST API
|
||||
func TokenSource(log *util.Logger, uri, user, password string) (oauth2.TokenSource, error) {
|
||||
c := &tokenSource{
|
||||
Helper: request.NewHelper(log),
|
||||
uri: uri + "/jwt/login",
|
||||
user: user,
|
||||
password: password,
|
||||
}
|
||||
|
||||
c.Client.Transport = transport.Insecure()
|
||||
|
||||
token, err := c.login()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.TokenSource = oauth.RefreshTokenSource(token, c.refresh)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *tokenSource) login() (*oauth2.Token, error) {
|
||||
data := url.Values{
|
||||
"user": {c.user},
|
||||
"pass": {c.password},
|
||||
}
|
||||
|
||||
req, err := request.New(http.MethodPost, c.uri, strings.NewReader(data.Encode()), request.URLEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := request.ResponseError(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := resp.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
return nil, errors.New("missing authorization header")
|
||||
}
|
||||
|
||||
// strip "Bearer " prefix if present
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
// extract expiry from JWT claims
|
||||
expiry := time.Now().Add(10 * time.Minute) // fallback
|
||||
var claims jwt.RegisteredClaims
|
||||
if _, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(token, &claims); err == nil && claims.ExpiresAt != nil {
|
||||
expiry = claims.ExpiresAt.Time
|
||||
}
|
||||
|
||||
return &oauth2.Token{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *tokenSource) refresh(_ *oauth2.Token) (*oauth2.Token, error) {
|
||||
// re-login to get new token
|
||||
return c.login()
|
||||
}
|
||||
44
charger/ghostone/types.go
Normal file
44
charger/ghostone/types.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package ghostone
|
||||
|
||||
// Relais switch state values
|
||||
const (
|
||||
RelaisStateOnePhase = "onePhase"
|
||||
RelaisStateThreePhase = "threePhase"
|
||||
RelaisStateNotAvailable = "notAvailable"
|
||||
RelaisStateSwitchInProgress = "switchInProgress"
|
||||
RelaisStateNotPossible = "notPossible"
|
||||
)
|
||||
|
||||
// PV optimization mode values
|
||||
const (
|
||||
PvModeNone = "noPV"
|
||||
)
|
||||
|
||||
// Enabled is the response from GET /system/relais-switch/enabled
|
||||
type Enabled struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// RelaisSwitchStateRead is the response from GET /system/relais-switch/state
|
||||
type RelaisSwitchStateRead struct {
|
||||
Value string `json:"value"` // onePhase, threePhase, notAvailable, switchInProgress, notPossible
|
||||
Mode string `json:"mode"` // manual, automatic
|
||||
LimitationReason string `json:"limitationReason"` // plcLimitation, hlcLimitation, gridLimitation
|
||||
CurrentState string `json:"currentState"` // onePhase, threePhase
|
||||
}
|
||||
|
||||
// RelaisSwitchStateWrite is the request body for PUT /system/relais-switch/state
|
||||
type RelaisSwitchStateWrite struct {
|
||||
Value string `json:"value"` // onePhase, threePhase, automatic
|
||||
}
|
||||
|
||||
// PvOptimizationMode is the response from GET /charging/pvoptimization/mode
|
||||
type PvOptimizationMode struct {
|
||||
Value string `json:"value"` // noPV, pvOnly, pvPlus
|
||||
}
|
||||
|
||||
// RfidCardLastRead is the response from GET /rfid-cards/last-read
|
||||
type RfidCardLastRead struct {
|
||||
UUID string `json:"uuid"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
|
@ -1235,7 +1235,7 @@ func (lp *Loadpoint) scalePhasesRequired() bool {
|
|||
return lp.hasPhaseSwitching() && lp.phasesConfigured != 0 && lp.phasesConfigured != lp.GetPhases()
|
||||
}
|
||||
|
||||
// scalePhasesIfAvailable scales if api.PhaseSwitcher is available
|
||||
// scalePhasesIfAvailable scales if api.PhaseSwitcher is available and allowed
|
||||
func (lp *Loadpoint) scalePhasesIfAvailable(phases int) error {
|
||||
if lp.phasesConfigured != 0 {
|
||||
phases = lp.phasesConfigured
|
||||
|
|
|
|||
|
|
@ -36,24 +36,34 @@ products:
|
|||
- brand: Cupra
|
||||
description:
|
||||
generic: Charger Pro Eichrecht 2
|
||||
capabilities: ["iso151182", "mA"]
|
||||
capabilities: ["iso151182", "1p3p", "rfid", "mA"]
|
||||
requirements:
|
||||
evcc: ["eebus"]
|
||||
description:
|
||||
de: |
|
||||
Die Wallbox muss im normalen Lademodus betrieben werden.
|
||||
Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus).
|
||||
|
||||
Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich.
|
||||
|
||||
Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich.
|
||||
Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen).
|
||||
en: |
|
||||
The device has to be used in the normal charging mode.
|
||||
The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off).
|
||||
|
||||
The usage of phase switching is currently not possible in evcc.
|
||||
|
||||
The identification of a vehicle using the RFID card is not possible.
|
||||
Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings).
|
||||
params:
|
||||
- preset: eebus
|
||||
- name: ip
|
||||
help:
|
||||
de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: IP address of the wallbox. Required for phase switching and RFID identification.
|
||||
- name: password
|
||||
help:
|
||||
de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: Technician password. Required for phase switching and RFID identification.
|
||||
render: |
|
||||
{{ include "eebus" . }}
|
||||
type: ghosteebus
|
||||
ski: {{ .ski }}
|
||||
ip: {{ .ip }}
|
||||
meter: true
|
||||
{{- if .password }}
|
||||
user: technician
|
||||
password: {{ .password }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,34 @@ products:
|
|||
- brand: Kontron Solar
|
||||
description:
|
||||
generic: Charger
|
||||
capabilities: ["iso151182", "mA"]
|
||||
capabilities: ["iso151182", "1p3p", "rfid", "mA"]
|
||||
requirements:
|
||||
evcc: ["eebus"]
|
||||
description:
|
||||
de: |
|
||||
Die Wallbox muss im normalen Lademodus betrieben werden.
|
||||
Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus).
|
||||
|
||||
Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich.
|
||||
|
||||
Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich.
|
||||
Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen).
|
||||
en: |
|
||||
The device has to be used in the normal charging mode.
|
||||
The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off).
|
||||
|
||||
The usage of phase switching is currently not possible in evcc.
|
||||
|
||||
The identification of a vehicle using the RFID card is not possible.
|
||||
Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings).
|
||||
params:
|
||||
- preset: eebus
|
||||
- name: ip
|
||||
help:
|
||||
de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: IP address of the wallbox. Required for phase switching and RFID identification.
|
||||
- name: password
|
||||
help:
|
||||
de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: Technician password. Required for phase switching and RFID identification.
|
||||
render: |
|
||||
{{ include "eebus" . }}
|
||||
type: ghosteebus
|
||||
ski: {{ .ski }}
|
||||
ip: {{ .ip }}
|
||||
meter: true
|
||||
{{- if .password }}
|
||||
user: technician
|
||||
password: {{ .password }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -3,24 +3,34 @@ products:
|
|||
- brand: Porsche
|
||||
description:
|
||||
generic: Wallbox
|
||||
capabilities: ["iso151182", "mA"]
|
||||
capabilities: ["iso151182", "1p3p", "rfid", "mA"]
|
||||
requirements:
|
||||
evcc: ["eebus"]
|
||||
description:
|
||||
de: |
|
||||
Die Wallbox muss im normalen Lademodus betrieben werden.
|
||||
Das PV-Überschussladen der Wallbox muss deaktiviert sein (Ladeverwaltung -> Ladeeinstellungen -> PV-Überschussladen aus).
|
||||
|
||||
Die Nutzung der Phasenumschaltung ist in evcc momentan noch nicht möglich.
|
||||
|
||||
Eine Identifikation des Fahrzeugs über die RFID Karte ist nicht möglich.
|
||||
Für Phasenumschaltung und RFID-Identifikation werden IP-Adresse und Techniker-Passwort benötigt. Die Phasenumschaltung sollte in der Wallbox aktiviert werden (Ladeverwaltung -> Ladeeinstellungen).
|
||||
en: |
|
||||
The device has to be used in the normal charging mode.
|
||||
The wallbox PV surplus charging must be disabled (Charging management -> Charging settings -> PV surplus charging off).
|
||||
|
||||
The usage of phase switching is currently not possible in evcc.
|
||||
|
||||
The identification of a vehicle using the RFID card is not possible.
|
||||
Phase switching and RFID identification require the IP address and technician password. Phase switching should be enabled in the wallbox settings (Charging management -> Charging settings).
|
||||
params:
|
||||
- preset: eebus
|
||||
- name: ip
|
||||
help:
|
||||
de: IP-Adresse der Wallbox. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: IP address of the wallbox. Required for phase switching and RFID identification.
|
||||
- name: password
|
||||
help:
|
||||
de: Techniker-Passwort. Erforderlich für Phasenumschaltung und RFID-Identifikation.
|
||||
en: Technician password. Required for phase switching and RFID identification.
|
||||
render: |
|
||||
{{ include "eebus" . }}
|
||||
type: ghosteebus
|
||||
ski: {{ .ski }}
|
||||
ip: {{ .ip }}
|
||||
meter: true
|
||||
{{- if .password }}
|
||||
user: technician
|
||||
password: {{ .password }}
|
||||
{{- end }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue