diff --git a/charger/eebus.go b/charger/eebus.go index 41bc3f81e..269cdac84 100644 --- a/charger/eebus.go +++ b/charger/eebus.go @@ -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 { diff --git a/charger/ghosteebus.go b/charger/ghosteebus.go new file mode 100644 index 000000000..31e6f2991 --- /dev/null +++ b/charger/ghosteebus.go @@ -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 +} diff --git a/charger/ghosteebus_decorators.go b/charger/ghosteebus_decorators.go new file mode 100644 index 000000000..bfaa947f9 --- /dev/null +++ b/charger/ghosteebus_decorators.go @@ -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) +} diff --git a/charger/ghosteebus_test.go b/charger/ghosteebus_test.go new file mode 100644 index 000000000..b4cd44177 --- /dev/null +++ b/charger/ghosteebus_test.go @@ -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) + }) +} diff --git a/charger/ghostone/identity.go b/charger/ghostone/identity.go new file mode 100644 index 000000000..2ec74a543 --- /dev/null +++ b/charger/ghostone/identity.go @@ -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() +} diff --git a/charger/ghostone/types.go b/charger/ghostone/types.go new file mode 100644 index 000000000..e7425400c --- /dev/null +++ b/charger/ghostone/types.go @@ -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"` +} diff --git a/core/loadpoint.go b/core/loadpoint.go index abe402b63..00a51a489 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 diff --git a/templates/definition/charger/elli-2.yaml b/templates/definition/charger/elli-2.yaml index d70a53ccf..5225a8afc 100644 --- a/templates/definition/charger/elli-2.yaml +++ b/templates/definition/charger/elli-2.yaml @@ -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 }} diff --git a/templates/definition/charger/ghost.yaml b/templates/definition/charger/ghost.yaml index 856585093..782690876 100644 --- a/templates/definition/charger/ghost.yaml +++ b/templates/definition/charger/ghost.yaml @@ -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 }} diff --git a/templates/definition/charger/porsche-wallbox.yaml b/templates/definition/charger/porsche-wallbox.yaml index ebe7c4d9b..6805a7490 100644 --- a/templates/definition/charger/porsche-wallbox.yaml +++ b/templates/definition/charger/porsche-wallbox.yaml @@ -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 }}