Add GhostEEBus charger (#28573)

This commit is contained in:
Andreas Linde 2026-04-04 10:19:40 +02:00 • committed by GitHub
parent 3c10d114af
commit 70b17d09d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 941 additions and 34 deletions

View file

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

View 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
View 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)
})
}

View 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
View 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"`
}

View file

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

View file

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

View file

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

View file

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