diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index e45ea26b3..3756f2f8c 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -9,6 +9,7 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/charger/ocpp" "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/server/eebus" @@ -19,6 +20,7 @@ import ( type All struct { Network Network + Ocpp ocpp.Config Log string SponsorToken string Plant string // telemetry plant id diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue index ced532338..975607da9 100644 --- a/assets/js/components/Config/ChargerModal.vue +++ b/assets/js/components/Config/ChargerModal.vue @@ -16,20 +16,67 @@ :apply-custom-defaults="applyCustomDefaults" :custom-fields="customFields" :get-product-name="getProductName" + :hide-template-fields="hideTemplateFields" @added="$emit('added', $event)" @updated="$emit('updated')" @removed="$emit('removed')" @close="$emit('close')" + @reset="reset" > - @@ -56,7 +103,8 @@ import switchsocketHeaterYaml from "./defaultYaml/switchsocketHeater.yaml?raw"; import switchsocketChargerYaml from "./defaultYaml/switchsocketCharger.yaml?raw"; import sgreadyYaml from "./defaultYaml/sgready.yaml?raw"; import sgreadyRelayYaml from "./defaultYaml/sgreadyRelay.yaml?raw"; -import { LOADPOINT_TYPE, type LoadpointType } from "@/types/evcc"; +import { LOADPOINT_TYPE, type LoadpointType, type Ocpp } from "@/types/evcc"; +import { getOcppUrl, getOcppUrlWithStationId } from "@/utils/ocpp"; const initialValues = { type: ConfigType.Template, @@ -78,6 +126,10 @@ export default defineComponent({ id: Number, loadpointType: { type: String as PropType, default: null }, fade: String as PropType, + ocpp: { + type: Object as PropType, + default: () => ({ config: { port: 0 }, status: { stations: [] } }), + }, isSponsor: Boolean, }, emits: ["added", "updated", "removed", "close"], @@ -85,6 +137,7 @@ export default defineComponent({ return { initialValues, customFields: CUSTOM_FIELDS, + ocppNextStepConfirmed: false, currentTemplate: null as Template | null, currentValues: {} as DeviceValues, }; @@ -102,14 +155,28 @@ export default defineComponent({ isHeating(): boolean { return this.loadpointType === LOADPOINT_TYPE.HEATING; }, - ocppUrl(): string | null { - const isOcpp = + isOcpp(): boolean { + return ( + this.currentTemplate !== null && this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "connector") && - this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid"); - if (isOcpp && this.currentValues) { - return `ws://${window.location.hostname}:8887/${this.currentValues.stationid || ""}`; + this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid") + ); + }, + ocppUrl(): string | null { + return this.isOcpp ? getOcppUrl(this.ocpp) : null; + }, + ocppUrlWithStationId(): string | null { + return this.isOcpp ? getOcppUrlWithStationId(this.ocpp) : null; + }, + ocppStationIdDetected(): string | undefined { + if (!this.isOcpp) { + return undefined; } - return null; + const stations = this.ocpp.status.stations; + return stations.find((station) => station.status === "unknown")?.id; + }, + hideTemplateFields(): boolean { + return this.isOcpp && !this.ocppNextStepConfirmed; }, }, methods: { @@ -244,6 +311,16 @@ export default defineComponent({ // For template types, use the standard logic (deviceProduct or templateName) return values.deviceProduct || templateName || ""; }, + confirmOcppNextStep() { + if (window.confirm(this.$t("config.charger.ocppConfirmContinue"))) { + this.ocppNextStepConfirmed = true; + } + }, + reset() { + this.ocppNextStepConfirmed = false; + this.currentTemplate = null; + this.currentValues = {} as DeviceValues; + }, }, }); diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index ca9c0f488..87ad05579 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -43,7 +43,9 @@

{{ $t("config.general.templateLoading") }}

- + + +
- +
+ - + - - - - + + + + +
@@ -252,8 +256,18 @@ export default defineComponent({ onConfigurationLoaded: Function as PropType<(values: DeviceValues) => void>, // Optional: external template selection control (for parent to reset template) externalTemplate: String as PropType, + // Optional: hide template fields, e.g. because ocpp step was not completed + hideTemplateFields: { type: Boolean, default: false }, }, - emits: ["added", "updated", "removed", "close", "template-changed", "update:externalTemplate"], + emits: [ + "added", + "updated", + "removed", + "close", + "template-changed", + "update:externalTemplate", + "reset", + ], data() { return { isModalVisible: false, @@ -367,7 +381,19 @@ export default defineComponent({ return !this.isNew; }, showActions() { - return (this.templateName && !this.authRequired) || this.showYamlInput; + // explicitly hide template fields (ocpp step 1) + if (this.hideTemplateFields) { + return false; + } + // yaml input type + if (this.showYamlInput) { + return true; + } + // template selected and no auth prerequisit + if (this.templateName && !this.authRequired) { + return true; + } + return false; }, showYamlInput() { return this.isYamlInputTypeByValue(this.values.type); @@ -499,6 +525,7 @@ export default defineComponent({ this.values = { ...this.initialValues } as DeviceValues; this.test = initialTestState(); this.resetAuthStatus(); + this.$emit("reset"); }, async loadConfiguration() { try { diff --git a/assets/js/components/Config/OcppModal.vue b/assets/js/components/Config/OcppModal.vue new file mode 100644 index 000000000..fc7aa529e --- /dev/null +++ b/assets/js/components/Config/OcppModal.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/assets/js/components/MaterialIcon/Ocpp.vue b/assets/js/components/MaterialIcon/Ocpp.vue new file mode 100644 index 000000000..ca229d140 --- /dev/null +++ b/assets/js/components/MaterialIcon/Ocpp.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 129e8e1bd..bb124f603 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -92,6 +92,32 @@ export interface State { authDisabled?: boolean; config?: string; database?: string; + ocpp?: Ocpp; +} + +export interface OcppConfig { + port: number; +} + +export interface OcppStatus { + externalUrl?: string; + stations: OcppStationStatus[]; +} + +export interface Ocpp { + config: OcppConfig; + status: OcppStatus; +} + +export interface OcppStationStatus { + id: string; + status: OCPP_STATION_STATUS; +} + +export enum OCPP_STATION_STATUS { + UNKNOWN = "unknown", + CONFIGURED = "configured", + CONNECTED = "connected", } export interface Config { diff --git a/assets/js/utils/ocpp.ts b/assets/js/utils/ocpp.ts new file mode 100644 index 000000000..79915c015 --- /dev/null +++ b/assets/js/utils/ocpp.ts @@ -0,0 +1,13 @@ +import type { Ocpp } from "@/types/evcc"; + +export function getOcppUrl(ocpp: Ocpp): string { + // User specified url, e.g., for reverse proxy setups + if (ocpp.status.externalUrl) { + return ocpp.status.externalUrl; + } + return `ws://${window.location.hostname}:${ocpp.config.port}/`; +} + +export function getOcppUrlWithStationId(ocpp: Ocpp): string { + return `${getOcppUrl(ocpp)}`; +} diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 45710ba83..d14c998b5 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -234,6 +234,18 @@ + + + + + @@ -404,6 +418,8 @@ import DeviceCard from "../components/Config/DeviceCard.vue"; import DeviceTags from "../components/Config/DeviceTags.vue"; import EebusIcon from "../components/MaterialIcon/Eebus.vue"; import EebusModal from "../components/Config/EebusModal.vue"; +import OcppIcon from "../components/MaterialIcon/Ocpp.vue"; +import OcppModal from "../components/Config/OcppModal.vue"; import formatter from "../mixins/formatter"; import GeneralConfig from "../components/Config/GeneralConfig.vue"; import HemsIcon from "../components/MaterialIcon/Hems.vue"; @@ -469,6 +485,8 @@ export default defineComponent({ DeviceTags, EebusIcon, EebusModal, + OcppIcon, + OcppModal, ExperimentalBanner, AuthSuccessBanner, GeneralConfig, @@ -665,6 +683,9 @@ export default defineComponent({ isSponsor(): boolean { return !!this.sponsor?.status.name; }, + ocpp() { + return store.state?.ocpp; + }, telemetry() { // @ts-expect-error: telemetry property exists but not in TypeScript definitions return store.state?.telemetry === true; @@ -672,6 +693,28 @@ export default defineComponent({ eebusTags() { return { configured: { value: store.state?.eebus || false } }; }, + ocppTags() { + const ocpp = store.state?.ocpp; + const stations = ocpp?.status?.stations || []; + if (stations.length === 0) { + return { configured: { value: false } }; + } + + const connected = stations.filter((s) => s.status === "connected").length; + const configured = stations.filter((s) => s.status === "configured").length; + const detected = stations.filter((s) => s.status === "unknown").length; + const total = connected + configured; + + const tags: Record = { + connections: { value: `${connected}/${total}` }, + }; + + if (detected > 0) { + tags["detected"] = { value: detected }; + } + + return tags; + }, modbusproxyTags() { const config = store.state?.modbusproxy || []; if (config.length > 0) { diff --git a/charger/ocpp/cs.go b/charger/ocpp/cs.go index 533da9bf3..2131daa5c 100644 --- a/charger/ocpp/cs.go +++ b/charger/ocpp/cs.go @@ -24,10 +24,62 @@ func newRegistration() *registration { type CS struct { ocpp16.CentralSystem - mu sync.Mutex - log *util.Logger - regs map[string]*registration // guarded by mu mutex - txnId atomic.Int64 + mu sync.Mutex + log *util.Logger + regs map[string]*registration // guarded by mu mutex + txnId atomic.Int64 + publishFunc func() +} + +type stationStatus struct { + ID string `json:"id"` + Status StationStatus `json:"status"` +} + +// Status represents the runtime OCPP status +type Status struct { + ExternalUrl string `json:"externalUrl,omitempty"` + Stations []stationStatus `json:"stations"` +} + +// status returns the OCPP runtime status +func (cs *CS) status() Status { + cs.mu.Lock() + defer cs.mu.Unlock() + + stations := []stationStatus{} + + for id, reg := range cs.regs { + if id == "" { + continue // skip anonymous registrations + } + + state := StationStatusUnknown + if cp := reg.cp; cp != nil { + if cp.Connected() { + state = StationStatusConnected + } else { + state = StationStatusConfigured + } + } + + stations = append(stations, stationStatus{ + ID: id, + Status: state, + }) + } + + return Status{ + ExternalUrl: ExternalUrl(), + Stations: stations, + } +} + +// SetUpdated sets a callback function that is called when the status changes +func (cs *CS) SetUpdated(f func()) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.publishFunc = f } // errorHandler logs error channel @@ -37,6 +89,13 @@ func (cs *CS) errorHandler(errC <-chan error) { } } +// publish triggers the publish callback if set +func (cs *CS) publish() { + if cs.publishFunc != nil { + cs.publishFunc() + } +} + func (cs *CS) ChargepointByID(id string) (*CP, error) { cs.mu.Lock() defer cs.mu.Unlock() @@ -76,6 +135,7 @@ func (cs *CS) RegisterChargepoint(id string, newfun func() *CP, init func(*CP) e } cs.mu.Unlock() + cs.publish() // serialise on chargepoint id reg.setup.Lock() @@ -112,23 +172,22 @@ func (cs *CS) RegisterChargepoint(id string, newfun func() *CP, init func(*CP) e // NewChargePoint implements ocpp16.ChargePointConnectionHandler func (cs *CS) NewChargePoint(chargePoint ocpp16.ChargePointConnection) { cs.mu.Lock() - defer cs.mu.Unlock() // check for configured charge point reg, ok := cs.regs[chargePoint.ID()] if ok { cs.log.DEBUG.Printf("charge point connected: %s", chargePoint.ID()) - // trigger initial connection if charge point is already setup + // trigger initial connection if cp := reg.cp; cp != nil { cp.connect(true) } + cs.mu.Unlock() + cs.publish() return } - cs.log.WARN.Printf("unknown charge point connected: %s", chargePoint.ID()) - // check for configured anonymous charge point reg, ok = cs.regs[""] if ok && reg.cp != nil { @@ -142,12 +201,17 @@ func (cs *CS) NewChargePoint(chargePoint ocpp16.ChargePointConnection) { cp.connect(true) + cs.mu.Unlock() + cs.publish() return } // register unknown charge point - // when charge point setup is complete, it will eventually be associated with the connected id cs.regs[chargePoint.ID()] = newRegistration() + cs.log.WARN.Printf("unknown charge point connected: %s", chargePoint.ID()) + + cs.mu.Unlock() + cs.publish() } // ChargePointDisconnected implements ocpp16.ChargePointConnectionHandler @@ -157,4 +221,6 @@ func (cs *CS) ChargePointDisconnected(chargePoint ocpp16.ChargePointConnection) if cp, err := cs.ChargepointByID(chargePoint.ID()); err == nil { cp.connect(false) } + + cs.publish() } diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go index 025a56e56..f7ce50a67 100644 --- a/charger/ocpp/instance.go +++ b/charger/ocpp/instance.go @@ -1,7 +1,10 @@ package ocpp import ( + "fmt" "net/http" + "net/url" + "strings" "sync" "time" @@ -16,11 +19,49 @@ import ( "github.com/lorenzodonini/ocpp-go/ws" ) +type Config struct { + Port int `json:"port"` +} + var ( - once sync.Once - instance *CS + once sync.Once + instance *CS + port = 8887 + externalUrl string ) +// GetStatus returns the OCPP runtime status +func GetStatus() Status { + if instance == nil { + return Status{} + } + return instance.status() +} + +// ExternalUrl returns the auto-generated OCPP external URL based on network external URL +func ExternalUrl() string { + if externalUrl == "" { + return "" + } + + u, err := url.Parse(externalUrl) + if err != nil { + return "" + } + + // Replace protocol: http -> ws, https -> wss + u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1) + u.Host = fmt.Sprintf("%s:%d", strings.Split(u.Host, ":")[0], 8887) // deliberately fixed, port configurability only for testing + + return u.String() +} + +// Init initializes the OCPP server +func Init(cfg Config, networkExternalUrl string) { + port = cfg.Port + externalUrl = networkExternalUrl +} + func Instance() *CS { once.Do(func() { log := util.NewLogger("ocpp") @@ -55,7 +96,7 @@ func Instance() *CS { cs.SetChargePointDisconnectedHandler(instance.ChargePointDisconnected) go instance.errorHandler(cs.Errors()) - go cs.Start(8887, "/{ws}") + go cs.Start(port, "/{ws}") // wait for server to start for range time.Tick(10 * time.Millisecond) { diff --git a/charger/ocpp/instance_test.go b/charger/ocpp/instance_test.go new file mode 100644 index 000000000..9f8a62d7d --- /dev/null +++ b/charger/ocpp/instance_test.go @@ -0,0 +1,27 @@ +package ocpp + +import ( + "testing" +) + +func TestExternalUrl(t *testing.T) { + tests := []struct{ input, expected string }{ + {"", ""}, + {"http://example.com:7070", "ws://example.com:8887"}, + {"https://example.com:443", "wss://example.com:8887"}, + {"http://example.com", "ws://example.com:8887"}, + {"https://example.com", "wss://example.com:8887"}, + {"http://10.20.30.40:7070/path", "ws://10.20.30.40:8887/path"}, + {"https://example.com/path", "wss://example.com:8887/path"}, + {"ws://example.com", "ws://example.com:8887"}, + {"wss://example.com:9000", "wss://example.com:8887"}, + {"strange://example.com", "strange://example.com:8887"}, + } + + for _, tt := range tests { + externalUrl = tt.input + if result := ExternalUrl(); result != tt.expected { + t.Errorf("ExternalUrl(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/charger/ocpp/stationstatus.go b/charger/ocpp/stationstatus.go new file mode 100644 index 000000000..a0693a06d --- /dev/null +++ b/charger/ocpp/stationstatus.go @@ -0,0 +1,10 @@ +package ocpp + +type StationStatus int + +//go:generate go tool enumer -type StationStatus -trimprefix StationStatus -transform=lower -json +const ( + StationStatusUnknown StationStatus = iota + StationStatusConfigured + StationStatusConnected +) diff --git a/charger/ocpp/stationstatus_enumer.go b/charger/ocpp/stationstatus_enumer.go new file mode 100644 index 000000000..e1b326730 --- /dev/null +++ b/charger/ocpp/stationstatus_enumer.go @@ -0,0 +1,100 @@ +// Code generated by "enumer -type StationStatus -trimprefix StationStatus -transform=lower -json"; DO NOT EDIT. + +package ocpp + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _StationStatusName = "unknownconfiguredconnected" + +var _StationStatusIndex = [...]uint8{0, 7, 17, 26} + +const _StationStatusLowerName = "unknownconfiguredconnected" + +func (i StationStatus) String() string { + if i < 0 || i >= StationStatus(len(_StationStatusIndex)-1) { + return fmt.Sprintf("StationStatus(%d)", i) + } + return _StationStatusName[_StationStatusIndex[i]:_StationStatusIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _StationStatusNoOp() { + var x [1]struct{} + _ = x[StationStatusUnknown-(0)] + _ = x[StationStatusConfigured-(1)] + _ = x[StationStatusConnected-(2)] +} + +var _StationStatusValues = []StationStatus{StationStatusUnknown, StationStatusConfigured, StationStatusConnected} + +var _StationStatusNameToValueMap = map[string]StationStatus{ + _StationStatusName[0:7]: StationStatusUnknown, + _StationStatusLowerName[0:7]: StationStatusUnknown, + _StationStatusName[7:17]: StationStatusConfigured, + _StationStatusLowerName[7:17]: StationStatusConfigured, + _StationStatusName[17:26]: StationStatusConnected, + _StationStatusLowerName[17:26]: StationStatusConnected, +} + +var _StationStatusNames = []string{ + _StationStatusName[0:7], + _StationStatusName[7:17], + _StationStatusName[17:26], +} + +// StationStatusString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func StationStatusString(s string) (StationStatus, error) { + if val, ok := _StationStatusNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _StationStatusNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to StationStatus values", s) +} + +// StationStatusValues returns all values of the enum +func StationStatusValues() []StationStatus { + return _StationStatusValues +} + +// StationStatusStrings returns a slice of all String values of the enum +func StationStatusStrings() []string { + strs := make([]string, len(_StationStatusNames)) + copy(strs, _StationStatusNames) + return strs +} + +// IsAStationStatus returns "true" if the value is listed in the enum definition. "false" otherwise +func (i StationStatus) IsAStationStatus() bool { + for _, v := range _StationStatusValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for StationStatus +func (i StationStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for StationStatus +func (i *StationStatus) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("StationStatus should be a string, got %s", data) + } + + var err error + *i, err = StationStatusString(s) + return err +} diff --git a/cmd/root.go b/cmd/root.go index db9ea881e..7960646e2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "time" "github.com/evcc-io/evcc/api/globalconfig" + "github.com/evcc-io/evcc/charger/ocpp" "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/core/keys" hemsapi "github.com/evcc-io/evcc/hems/hems" @@ -171,6 +172,23 @@ func runRoot(cmd *cobra.Command, args []string) { valueChan := make(chan util.Param, 64) go tee.Run(valueChan) + // start OCPP server + ocppCS := ocpp.Instance() + ocppCS.SetUpdated(func() { + // republish when OCPP state updates + valueChan <- util.Param{Key: keys.Ocpp, Val: struct { + Config ocpp.Config `json:"config"` + Status ocpp.Status `json:"status"` + }{ + Config: conf.Ocpp, + Status: ocpp.GetStatus(), + }} + }) + log.INFO.Printf("OCPP local: ws://127.0.0.1:%d/", conf.Ocpp.Port) + if ocpp.ExternalUrl() != "" { + log.INFO.Printf("OCPP external: %s/", ocpp.ExternalUrl()) + } + // value cache cache := util.NewParamCache() go cache.Run(pipe.NewDropper(ignoreLogs...).Pipe(tee.Attach())) @@ -186,7 +204,10 @@ func runRoot(cmd *cobra.Command, args []string) { os.Exit(1) } }() - log.INFO.Printf("UI listening at :%d", conf.Network.Port) + log.INFO.Printf("UI local: http://127.0.0.1:%d", conf.Network.Port) + if conf.Network.ExternalUrl != "" { + log.INFO.Printf("UI external: %s", conf.Network.ExternalURL()) + } // publish to UI go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache) @@ -316,6 +337,13 @@ func runRoot(cmd *cobra.Command, args []string) { valueChan <- util.Param{Key: keys.ModbusProxy, Val: conf.ModbusProxy} valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt} valueChan <- util.Param{Key: keys.Network, Val: conf.Network} + valueChan <- util.Param{Key: keys.Ocpp, Val: struct { + Config ocpp.Config `json:"config"` + Status ocpp.Status `json:"status"` + }{ + Config: conf.Ocpp, + Status: ocpp.GetStatus(), + }} valueChan <- util.Param{Key: keys.Sponsor, Val: struct { Status sponsor.Status `json:"status"` FromYaml bool `json:"fromYaml"` diff --git a/cmd/setup.go b/cmd/setup.go index 898996f83..6e82a013b 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -17,6 +17,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/charger" + "github.com/evcc-io/evcc/charger/ocpp" "github.com/evcc-io/evcc/cmd/shutdown" "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/core/circuit" @@ -65,6 +66,9 @@ var conf = globalconfig.All{ Host: "", Port: 7070, }, + Ocpp: ocpp.Config{ + Port: 8887, + }, Mqtt: globalconfig.Mqtt{ Topic: "evcc", }, @@ -547,6 +551,11 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) error { err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt)) } + // setup OCPP server + if err == nil { + configureOCPP(&conf.Ocpp, conf.Network.ExternalUrl) + } + // setup EEBus server if err == nil { err = wrapErrorWithClass(ClassEEBus, configureEEBus(&conf.EEBus)) @@ -758,6 +767,11 @@ func configureMDNS(conf globalconfig.Network) error { return nil } +// setup OCPP +func configureOCPP(cfg *ocpp.Config, externalUrl string) { + ocpp.Init(*cfg, externalUrl) +} + // setup EEBus func configureEEBus(conf *eebus.Config) error { // migrate settings diff --git a/core/keys/global.go b/core/keys/global.go index c028984ce..55ca9bf38 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -13,6 +13,7 @@ const ( Shm = "shm" Messaging = "messaging" ModbusProxy = "modbusproxy" + Ocpp = "ocpp" Tariffs = "tariffs" Version = "version" Config = "config" diff --git a/i18n/bg.json b/i18n/bg.json index f9967545c..1601f5d67 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -40,7 +40,7 @@ "chargers": "Зарядни станции", "generic": "Общи интеграции", "heatingdevices": "Загревателни устройства", - "ocppHelp": "Копирай този адрес в конфигурацията на твоите зарядни станции.", + "ocppHelp": "Копирайте този URL адрес в конфигурацията на вашето зарядно устройство. Проверете ръководството на производителя за подробности. Зарядното устройство автоматично ще добави своя уникален идентификатор (ID на станцията) към URL адреса. В редки случаи може да се наложи ръчно да посочите идентификатора. Пример: `{url}`", "ocppLabel": "Адрес на OCPP сървъра", "switchsockets": "Контакти с прекъсвач", "template": "Производител", diff --git a/i18n/cs.json b/i18n/cs.json index dd795f491..5aaabcc04 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -40,7 +40,7 @@ "chargers": "EV nabíječky", "generic": "Obecné integrace", "heatingdevices": "Zařízení pro vytápění", - "ocppHelp": "Zkopíruj tuto adresu do konfiguračního nástroje nabíječky.", + "ocppHelp": "Zkopírujte tuto URL adresu do konfigurace vaší nabíječky. Podrobnosti naleznete v manuálu výrobce. Nabíječka automaticky připojí svůj unikátní identifikátor (ID stanice) k URL. Ve vzácných případech může být nutné identifikátor zadat ručně. Příklad: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "Přepínatelné zásuvky", "template": "Výrobce", diff --git a/i18n/da.json b/i18n/da.json index 1948afde1..cbed4e720 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -40,7 +40,7 @@ "chargers": "EV opladere", "generic": "Generisk integration", "heatingdevices": "Varmeenheder", - "ocppHelp": "Kopier denne adresse til opladerens konfiguration.", + "ocppHelp": "Kopier denne URL til konfigurationen af din oplader. Se producentens manual for detaljer. Opladeren vil automatisk tilføje sin unikke identifikator (stations-ID) til URL'en. I sjældne tilfælde skal du muligvis angive identifikatoren manuelt. Eksempel: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "Omskiftelige stikkontakter", "template": "Fabrikant", diff --git a/i18n/de.json b/i18n/de.json index bde27cc73..0c62bc8c2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -58,8 +58,16 @@ "chargers": "Wallboxen", "generic": "Generische Integrationen", "heatingdevices": "Wärmeerzeuger", - "ocppHelp": "Kopiere diese Adresse in die Konfiguration der Wallbox.", + "ocppConfirmContinue": "Deine Wallbox hat sich noch nicht mit evcc verbunden. Möchtest du wirklich fortfahren?", + "ocppConnected": "Verbunden!", + "ocppDescription": "evcc hat einen eingebauten OCPP-Server. Folge diesen Schritten:", + "ocppHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`", "ocppLabel": "OCPP-Server URL", + "ocppNextStep": "Nächster Schritt", + "ocppStep1": "Konfiguriere deine Wallbox, um evcc als OCPP-Server zu verwenden.", + "ocppStep2": "Warte darauf, dass deine Wallbox sich mit evcc verbindet.", + "ocppStep3": "Fahre fort und schließe die Konfiguration ab.", + "ocppWaiting": "Warte auf Verbindung", "switchsockets": "Schaltbare Steckdosen", "template": "Hersteller", "titleAdd": { @@ -109,10 +117,12 @@ "chargedEnergy": "Geladen", "co2": "Netz-CO₂", "configured": "Konfiguriert", + "connections": "Verbindungen", "controllable": "Steuerbar", "currency": "Währung", "current": "Strom", "currentRange": "Strom", + "detected": "Erkannt", "dimmed": "Gedimmt", "enabled": "Ladebereit", "energy": "Energie", @@ -472,6 +482,23 @@ "labelPort": "Port", "title": "Netzwerk" }, + "ocpp": { + "connectedChargers": "Verbundene Wallboxen", + "connectionStatus": "Konfigurierte Station-IDs", + "connectionStatusHelp": "Verbindungsstatus der konfigurierten Wallboxen.", + "detectedChargers": "Erkannte Station-IDs", + "detectedHelp": "Diese Wallboxen haben versucht, sich mit evcc zu verbinden. Um eine Wallbox zu verwenden, erstelle einen Ladepunkt mit ihrer Station-ID.", + "noChargers": "Keine OCPP-Wallboxen erkannt.", + "noStations": "Keine Stationen verbunden", + "status": { + "configured": "Nicht verbunden", + "connected": "Verbunden", + "unknown": "Unbekannt" + }, + "title": "OCPP Server", + "url": "Server-URL", + "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`" + }, "options": { "boolean": { "no": "nein", diff --git a/i18n/el.json b/i18n/el.json index 626dcb98b..a6e106648 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -40,7 +40,7 @@ "chargers": "Φορτιστές οχημάτων", "generic": "Γενικές ενσωματώσεις", "heatingdevices": "Συσκευές θέρμανσης", - "ocppHelp": "Αντιγράψτε αυτή τη διεύθυνση στη διαμόρφωση των φορτιστών σας.", + "ocppHelp": "Αντιγράψτε αυτό το URL στη διαμόρφωση του φορτιστή σας. Ελέγξτε το εγχειρίδιο του κατασκευαστή για λεπτομέρειες. Ο φορτιστής θα προσθέσει αυτόματα το μοναδικό αναγνωριστικό του (ID σταθμού) στο URL. Σε σπάνιες περιπτώσεις, ίσως χρειαστεί να καθορίσετε το αναγνωριστικό χειροκίνητα. Παράδειγμα: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "Πρίζες με δυνατότητα εναλλαγής", "template": "Κατασκευαστής", diff --git a/i18n/en.json b/i18n/en.json index dbf47bcee..f91ee5c6b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -58,8 +58,16 @@ "chargers": "EV chargers", "generic": "Generic integrations", "heatingdevices": "Heating devices", - "ocppHelp": "Copy this address into your chargers configuration.", + "ocppConfirmContinue": "Your charger has not connected to evcc yet. Are you sure you want to continue?", + "ocppConnected": "Connected!", + "ocppDescription": "evcc has a built-in OCPP server. Follow these steps:", + "ocppHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`", "ocppLabel": "OCPP-Server URL", + "ocppNextStep": "Next step", + "ocppStep1": "Configure your charger to use evcc as OCPP server.", + "ocppStep2": "Wait for your charger to connect to evcc.", + "ocppStep3": "Proceed and complete the configuration.", + "ocppWaiting": "Waiting for connection", "switchsockets": "Switchable sockets", "template": "Manufacturer", "titleAdd": { @@ -109,10 +117,12 @@ "chargedEnergy": "Charged", "co2": "Grid CO₂", "configured": "Configured", + "connections": "Connections", "controllable": "Controllable", "currency": "Currency", "current": "Current", "currentRange": "Current", + "detected": "Detected", "dimmed": "Dimmed", "enabled": "Enabled", "energy": "Energy", @@ -472,6 +482,23 @@ "labelPort": "Port", "title": "Network" }, + "ocpp": { + "connectedChargers": "Connected chargers", + "connectionStatus": "Configured station IDs", + "connectionStatusHelp": "Connection status of configured chargers.", + "detectedChargers": "Detected station IDs", + "detectedHelp": "These chargers have tried to connect to evcc. To use a charger, create a loadpoint with its station ID.", + "noChargers": "No OCPP chargers detected.", + "noStations": "No stations connected", + "status": { + "configured": "Not connected", + "connected": "Connected", + "unknown": "Unknown" + }, + "title": "OCPP Server", + "url": "Server URL", + "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`" + }, "options": { "boolean": { "no": "no", diff --git a/i18n/es.json b/i18n/es.json index b440472c3..a766a1b2c 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -30,7 +30,7 @@ "charger": { "generic": "Integraciones genéricas", "heatingdevices": "Dispositivos de calefacción", - "ocppHelp": "Copia esta dirección en la configuración de cargadores.", + "ocppHelp": "Copia esta URL en la configuración de tu cargador. Consulta el manual del fabricante para más detalles. El cargador añadirá automáticamente su identificador único (ID de estación) a la URL. En casos excepcionales, es posible que debas especificar el identificador manualmente. Ejemplo: `{url}`", "ocppLabel": "URL del servidor OCPP", "switchsockets": "Tomas conmutables", "template": "Fabricante", diff --git a/i18n/fi.json b/i18n/fi.json index 251361e28..ef178051f 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -40,7 +40,7 @@ "chargers": "Sähköauton latausasemat", "generic": "Yleiset integraatiot", "heatingdevices": "Lämmityslaitteet", - "ocppHelp": "Kopioi tämä osoite laturien määrityksiin.", + "ocppHelp": "Kopioi tämä URL-osoite laturisi asetuksiin. Katso lisätietoja valmistajan käsikirjasta. Laturi lisää automaattisesti yksilöllisen tunnisteensa (aseman tunniste) URL-osoitteeseen. Harvinaisissa tapauksissa saatat joutua määrittämään tunnisteen manuaalisesti. Esimerkki: `{url}`", "ocppLabel": "OCPP-palvelimen URL", "switchsockets": "Kytkettävät pistorasiat", "template": "Valmistaja", diff --git a/i18n/fr.json b/i18n/fr.json index 96568dc89..33a9e7533 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -40,7 +40,7 @@ "chargers": "Chargeurs VE", "generic": "Intégrations génériques", "heatingdevices": "Dispositif de chauffage", - "ocppHelp": "Copier cette adresse dans votre configuration de chargeurs.", + "ocppHelp": "Copiez cette URL dans la configuration de votre chargeur. Consultez le manuel du fabricant pour plus de détails. Le chargeur ajoutera automatiquement son identifiant unique (ID de station) à l'URL. Dans de rares cas, vous devrez peut-être spécifier l'identifiant manuellement. Exemple : `{url}`", "ocppLabel": "URL du serveur OCPP", "switchsockets": "Prises commutables", "template": "Fabricant", diff --git a/i18n/hr.json b/i18n/hr.json index 24f33a381..958ed5e41 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -40,7 +40,7 @@ "chargers": "EV punjači", "generic": "Opće integracije", "heatingdevices": "Uređaji za grijanje", - "ocppHelp": "Unesite ovu adresu u konfiguraciju punjača.", + "ocppHelp": "Kopirajte ovaj URL u konfiguraciju svog punjača. Provjerite upute proizvođača za detalje. Punjač će automatski dodati svoj jedinstveni identifikator (ID stanice) na URL. U rijetkim slučajevima možda ćete morati ručno odrediti identifikator. Primjer: `{url}`", "ocppLabel": "URL OCPP poslužitelja", "switchsockets": "Upravljive utičnice", "template": "Proizvođač", diff --git a/i18n/hu.json b/i18n/hu.json index 9561003e8..7af003e47 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -40,7 +40,7 @@ "chargers": "EV töltők", "generic": "Általános integrációk", "heatingdevices": "Fűtőberendezések", - "ocppHelp": "Másolja be ezt a címet a töltő konfigurációjába.", + "ocppHelp": "Másolja ezt az URL-t a töltő konfigurációjába. A részletekért tekintse meg a gyártó kézikönyvét. A töltő automatikusan hozzáfűzi egyedi azonosítóját (állomás azonosító) az URL-hez. Ritka esetekben előfordulhat, hogy az azonosítót manuálisan kell megadnia. Példa: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "Kapcsolható dugaljak", "template": "Gyártó", diff --git a/i18n/it.json b/i18n/it.json index 0d7aedb9e..3fae00387 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -40,7 +40,7 @@ "chargers": "Caricatori EV", "generic": "Integrazioni generiche", "heatingdevices": "Dispositivi di riscaldamento", - "ocppHelp": "Copia questo indirizzo nella configurazione dei tuoi caricatori", + "ocppHelp": "Copia questo URL nella configurazione del tuo caricatore. Consulta il manuale del produttore per i dettagli. Il caricatore aggiungerà automaticamente il suo identificatore univoco (ID stazione) all'URL. In rari casi, potrebbe essere necessario specificare manualmente l'identificatore. Esempio: `{url}`", "ocppLabel": "URL Server OCPP", "switchsockets": "Prese commutabili", "template": "Produttore", diff --git a/i18n/lb.json b/i18n/lb.json index a565be571..91229d3c5 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -40,7 +40,7 @@ "chargers": "Wallboxen", "generic": "Generesch Integratiounen", "heatingdevices": "Heizungsgerät", - "ocppHelp": "Kopéier dës Adress an d'Konfiguratioun vun der Wallbox.", + "ocppHelp": "Kopéier dës URL an d'Konfiguratioun vun dengem Luedapparat. Kuck am Handbuch vum Hiersteller fir Detailer. De Luedapparat setzt automatesch säin eenzegaartegen Identifikator (Statiounen-ID) un d'URL. A seltenen Fäll muss de vläicht den Identifikator manuell uginn. Beispill: `{url}`", "ocppLabel": "URL vum OCPP-Server", "switchsockets": "Schaltbar Steckdousen", "template": "Hiersteller", diff --git a/i18n/lt.json b/i18n/lt.json index 1979d6ce9..3990b0904 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -40,7 +40,7 @@ "chargers": "Elektromobilių įkrovikliai", "generic": "Tipinės integracijos", "heatingdevices": "Šildymo prietaisai", - "ocppHelp": "Nukopijuokite šį adresą į jūsų įkroviklio nustatymus.", + "ocppHelp": "Nukopijuokite šį URL į savo įkroviklio konfigūraciją. Išsamesnės informacijos ieškokite gamintojo vadove. Įkroviklis automatiškai pridės savo unikalų identifikatorių (stoties ID) prie URL. Retais atvejais gali tekti rankiniu būdu nurodyti identifikatorių. Pavyzdys: `{url}`", "ocppLabel": "OCPP-Serverio URL", "switchsockets": "Išmanios rozetės", "template": "Gamintojas", diff --git a/i18n/nl.json b/i18n/nl.json index 002e1aeb2..4c810a5e0 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -40,7 +40,7 @@ "chargers": "EV laders", "generic": "Algemene integraties", "heatingdevices": "Warmtepompen", - "ocppHelp": "Kopieer dit adres in de configuratie van jouw lader.", + "ocppHelp": "Kopieer deze URL naar de configuratie van je lader. Raadpleeg de handleiding van de fabrikant voor details. De lader voegt automatisch zijn unieke identificatie (station-ID) toe aan de URL. In zeldzame gevallen moet je de identificatie mogelijk handmatig opgeven. Voorbeeld: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "schakelbare stopcontacten", "template": "Fabrikant", diff --git a/i18n/pl.json b/i18n/pl.json index 528bf1a2a..8cac9ecd9 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -40,7 +40,7 @@ "chargers": "Ładowarki EV", "generic": "Ogólne integracje", "heatingdevices": "Urządzenia grzewcze", - "ocppHelp": "Skopiuj ten adres do konfiguracji twojej ładowarki.", + "ocppHelp": "Skopiuj ten adres URL do konfiguracji swojej ładowarki. Sprawdź instrukcję producenta, aby uzyskać szczegóły. Ładowarka automatycznie doda swój unikalny identyfikator (ID stacji) do adresu URL. W rzadkich przypadkach może być konieczne ręczne określenie identyfikatora. Przykład: `{url}`", "ocppLabel": "Adres URL serwera OCPP", "switchsockets": "Gniazdka przełączalne", "template": "Producent", diff --git a/i18n/pt.json b/i18n/pt.json index 174177e7d..c3c3e051f 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -40,7 +40,7 @@ "chargers": "Carregadores de Veículos Eléctricos", "generic": "Integrações genéricas", "heatingdevices": "Equipamentos de aquecimento", - "ocppHelp": "Copie este endereço para a configuração do seu carregador.", + "ocppHelp": "Copie este URL para a configuração do seu carregador. Consulte o manual do fabricante para obter detalhes. O carregador adicionará automaticamente seu identificador único (ID da estação) ao URL. Em casos raros, pode ser necessário especificar o identificador manualmente. Exemplo: `{url}`", "ocppLabel": "URL do servidor OCPP", "switchsockets": "Tomadas comandadas", "template": "Fabricante", diff --git a/i18n/sl.json b/i18n/sl.json index 7425365df..f465ca699 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -40,7 +40,7 @@ "chargers": "polnilnice za EV", "generic": "Splošne integracije", "heatingdevices": "Ogrevalne naprave", - "ocppHelp": "Kopirajte ta naslov v konfiguracijo vaše polnilnice.", + "ocppHelp": "Kopirajte ta URL v konfiguracijo vašega polnilnika. Za podrobnosti preverite priročnik proizvajalca. Polnilnik bo samodejno dodal svoj edinstveni identifikator (ID postaje) k URL-ju. V redkih primerih boste morda morali identifikator določiti ročno. Primer: `{url}`", "ocppLabel": "URL strežnika OCPP", "switchsockets": "Preklopne vtičnice", "template": "Proizvajalec", diff --git a/i18n/sv.json b/i18n/sv.json index 1ff2d801b..c2c82663e 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -40,7 +40,7 @@ "chargers": "Elbilsladdare", "generic": "Generisk integration", "heatingdevices": "Värmeenheter", - "ocppHelp": "Kopiera in denna adress i din laddkonfiguration.", + "ocppHelp": "Kopiera denna URL till din laddares konfiguration. Kontrollera tillverkarens manual för detaljer. Laddaren kommer automatiskt att lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`", "ocppLabel": "OCPP-Server URL", "switchsockets": "Byt kontakt", "template": "Tillverkare", diff --git a/i18n/ta.json b/i18n/ta.json index 6a89a451f..f4117f4e4 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -40,7 +40,7 @@ "chargers": "ஈ.வி. சார்சர்ச்", "generic": "பொதுவான ஒருங்கிணைப்புகள்", "heatingdevices": "வெப்ப சாதனங்கள்", - "ocppHelp": "இந்த முகவரியை உங்கள் சார்சர்ச் உள்ளமைவில் நகலெடுக்கவும்.", + "ocppHelp": "இந்த URL-ஐ உங்கள் சார்ஜரின் உள்ளமைவில் நகலெடுக்கவும். விவரங்களுக்கு உற்பத்தியாளரின் கையேட்டைப் பார்க்கவும். சார்ஜர் தானாகவே அதன் தனித்துவமான அடையாளங்காட்டியை (நிலைய ID) URL-க்கு இணைக்கும். அரிதான சந்தர்ப்பங்களில், நீங்கள் அடையாளங்காட்டியை கைமுறையாக குறிப்பிட வேண்டியிருக்கலாம். எடுத்துக்காட்டு: `{url}`", "ocppLabel": "OCPP-SERVER முகவரி", "switchsockets": "மாறக்கூடிய சாக்கெட்டுகள்", "template": "உற்பத்தியாளர்", diff --git a/i18n/tr.json b/i18n/tr.json index d27af8f9e..85e837ad6 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -40,7 +40,7 @@ "chargers": "Doldurma cihazları", "generic": "Genel entegrasyon", "heatingdevices": "Isıtma cihazları", - "ocppHelp": "Bu adresi doldurma cihazının yapılandırmasına kopyala.", + "ocppHelp": "Bu URL'yi şarj cihazınızın yapılandırmasına kopyalayın. Ayrıntılar için üreticinin kılavuzuna bakın. Şarj cihazı, benzersiz tanımlayıcısını (istasyon kimliği) URL'ye otomatik olarak ekleyecektir. Nadir durumlarda tanımlayıcıyı manuel olarak belirtmeniz gerekebilir. Örnek: `{url}`", "ocppLabel": "OCPP sunucusu URL'si", "switchsockets": "“Açılıp kapanabilen prizler”", "template": "Üretici", diff --git a/i18n/uk.json b/i18n/uk.json index 3f9ab667f..b6907a8e8 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -40,7 +40,7 @@ "chargers": "Зарядні пристрої для електромобілів", "generic": "Загальні інтеграції", "heatingdevices": "Нагрівальні прилади", - "ocppHelp": "Скопіюйте цю адресу в конфігурацію зарядних пристроїв.", + "ocppHelp": "Скопіюйте цей URL у конфігурацію вашого зарядного пристрою. Перевірте посібник виробника для отримання деталей. Зарядний пристрій автоматично додасть свій унікальний ідентифікатор (ID станції) до URL. У рідкісних випадках може знадобитися вказати ідентифікатор вручну. Приклад: `{url}`", "ocppLabel": "URL-адреса OCPP-сервера", "switchsockets": "Перемикаються розетки", "template": "Виробник", diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index 73e8400b3..33d9d2f57 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -40,7 +40,7 @@ "chargers": "电动汽车充电桩", "generic": "通用集成", "heatingdevices": "加热设备", - "ocppHelp": "将此地址复制到您的充电桩配置中。", + "ocppHelp": "将此URL复制到您的充电器配置中。详细信息请查看制造商的手册。充电器将自动将其唯一标识符(站点ID)附加到URL。在极少数情况下,您可能需要手动指定标识符。示例:`{url}`", "ocppLabel": "OCPP 服务器 URL", "switchsockets": "可切换插座", "template": "制造商", diff --git a/package-lock.json b/package-lock.json index 7cb16ecb8..f65709d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@types/kill-port": "^2.0.3", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", + "@types/ws": "^8.18.1", "@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/compiler-sfc": "^3.2.30", @@ -78,7 +79,8 @@ "vue-eslint-parser": "^10.2.0", "vue-hot-reload-api": "^2.3.4", "vue-tsc": "^3.1.5", - "wait-on": "^9.0.1" + "wait-on": "^9.0.1", + "ws": "^8.18.3" }, "engines": { "node": ">=22.0.0", diff --git a/package.json b/package.json index dd24a1fa7..b133c4cd6 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@types/kill-port": "^2.0.3", "@types/smoothscroll-polyfill": "^0.3.4", "@types/wait-on": "^5.3.4", + "@types/ws": "^8.18.1", "@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-vue": "^6.0.2", "@vue/compiler-sfc": "^3.2.30", @@ -99,6 +100,7 @@ "vue-eslint-parser": "^10.2.0", "vue-hot-reload-api": "^2.3.4", "vue-tsc": "^3.1.5", - "wait-on": "^9.0.1" + "wait-on": "^9.0.1", + "ws": "^8.18.3" } } diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index 1ba858fcb..2a3a8405f 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -664,12 +664,12 @@ test.describe("sponsor token", () => { await page.goto("/#/config"); await enableExperimental(page); - // add loadpoint with OCPP charger - await newLoadpoint(page, "OCPP Test Charger"); + // add loadpoint with Peblar charger + await newLoadpoint(page, "Peblar Test Charger"); await page.getByTestId("loadpoint-modal").getByRole("button", { name: "Add charger" }).click(); const chargerModal = page.getByTestId("charger-modal"); await expectModalVisible(chargerModal); - await chargerModal.getByLabel("Manufacturer").selectOption({ label: "OCPP 1.6J compatible" }); + await chargerModal.getByLabel("Manufacturer").selectOption({ label: "Peblar Business" }); // verify disabled save button await expect(chargerModal.getByRole("button", { name: "Save" })).toBeDisabled(); diff --git a/tests/config-ocpp.spec.ts b/tests/config-ocpp.spec.ts new file mode 100644 index 000000000..e435bdd22 --- /dev/null +++ b/tests/config-ocpp.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { startSimulator, stopSimulator, simulatorUrl } from "./simulator"; +import { expectModalVisible, enableExperimental } from "./utils"; +import axios from "axios"; + +test.use({ baseURL: baseUrl() }); + +const OCPP_STATION_ID = "test-station-001"; + +test.beforeEach(async () => { + await startSimulator(); + await start(); +}); + +test.afterEach(async () => { + await stop(); + await stopSimulator(); +}); + +test.describe("ocpp", () => { + test("ocpp modal", async ({ page }) => { + // Navigate to config page + await page.goto("/#/config"); + await enableExperimental(page, true); + + // Open OCPP card + const ocppCard = page.getByTestId("ocpp"); + await expect(ocppCard).toContainText(["Configured", "no"].join("")); + await ocppCard.getByRole("button", { name: "edit" }).click(); + + const ocppModal = page.getByTestId("ocpp-modal"); + await expectModalVisible(ocppModal); + const serverUrl = await ocppModal.getByLabel("Server URL").inputValue(); + await expect(ocppModal).toContainText("No OCPP chargers detected."); + + // Connect OCPP client via simulator UI + await page.goto(simulatorUrl()); + + // Fill in OCPP connection details + const addClientCard = page.getByTestId("ocpp-add-client"); + await addClientCard.getByLabel("Server URL").fill(serverUrl); + await addClientCard.getByLabel("Station ID").fill(OCPP_STATION_ID); + await addClientCard.getByRole("button", { name: "Connect" }).click(); + + // Navigate back to evcc config page + await page.goto("/#/config"); + await expect(ocppCard).toContainText(["Connections", "0/0", "Detected", "1"].join("")); + await ocppCard.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(ocppModal); + await expect(ocppModal).toContainText("Detected station IDs"); + await expect(ocppModal).toContainText([OCPP_STATION_ID, "Unknown"].join("")); + await expect(ocppModal).not.toContainText("No OCPP chargers detected."); + }); + + test("create ocpp charger", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, true); + + // Open loadpoint modal and select charging point type + await page.getByRole("button", { name: "Add charger or heater" }).click(); + const lpModal = page.getByTestId("loadpoint-modal"); + await expectModalVisible(lpModal); + await lpModal.getByRole("button", { name: "Charging point" }).click(); + await lpModal.getByLabel("Title").fill("OCPP Test Charger"); + + // Open charger modal and select OCPP 1.6J + await lpModal.getByRole("button", { name: "Add charger" }).click(); + const chargerModal = page.getByTestId("charger-modal"); + await expectModalVisible(chargerModal); + await chargerModal.getByLabel("Manufacturer").selectOption("OCPP 1.6J compatible"); + + // Verify waiting for connection state and extract server URL + const serverUrl = await chargerModal.getByLabel("OCPP-Server URL").inputValue(); + const waitingButton = chargerModal.getByRole("button", { name: "Waiting for connection" }); + await expect(waitingButton).toBeVisible(); + + // Connect OCPP client via simulator REST API + await axios.post(`${simulatorUrl()}/api/ocpp/connect`, { + stationId: OCPP_STATION_ID, + serverUrl: serverUrl, + }); + + // Verify connection successful + await expect(chargerModal).toContainText("Connected!"); + await expect(waitingButton).not.toBeVisible(); + + // Proceed to validation step + await chargerModal.getByRole("button", { name: "Next step" }).click(); + await expect(chargerModal.getByLabel("Station ID")).toHaveValue(OCPP_STATION_ID); + + // Validate and verify sponsor token error + const testResult = chargerModal.getByTestId("test-result"); + await testResult.getByRole("link", { name: "Validate" }).click(); + await expect(testResult).toContainText("No sponsor token configured."); + }); +}); diff --git a/tests/evcc.ts b/tests/evcc.ts index 9af5bc45a..71ce7f194 100644 --- a/tests/evcc.ts +++ b/tests/evcc.ts @@ -20,6 +20,11 @@ function workerPort() { return 11000 + index; } +function ocppPort() { + const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0); + return 12000 + index; +} + function logPrefix() { return `[worker:${process.env["TEST_WORKER_INDEX"]}]`; } @@ -99,14 +104,20 @@ async function _restoreDatabase(sqlDumps: string) { async function _start(config?: string, flags: string | string[] = []) { const configArgs = config ? ["--config", config.includes("/") ? config : `tests/${config}`] : []; const port = workerPort(); + const ocpp = ocppPort(); log(`wait until port ${port} is available`); // wait for port to be available await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED }); + log(`starting evcc on ports ${port} (HTTP) and ${ocpp} (OCPP)`); const additionalFlags = typeof flags === "string" ? [flags] : flags; additionalFlags.push("--log", "debug,httpd:trace"); - log("starting evcc", { config, port, additionalFlags }); + log("starting evcc", { config, port, ocpp, additionalFlags }); const instance = spawn(BINARY, [...configArgs, ...additionalFlags], { - env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() }, + env: { + EVCC_NETWORK_PORT: port.toString(), + EVCC_OCPP_PORT: ocpp.toString(), + EVCC_DATABASE_DSN: dbPath(), + }, stdio: ["pipe", "pipe", "pipe"], }); const steamLog = createSteamLog(); @@ -137,7 +148,7 @@ async function _stop(instance?: ChildProcess) { if (instance) { log("shutting down evcc hard", { port }); instance.kill("SIGKILL"); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 }); log("evcc is down", { port }); return; } @@ -165,7 +176,7 @@ async function _stop(instance?: ChildProcess) { } } log(`wait until port ${port} is closed`); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED }); + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 }); log("evcc is down", { port }); } diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts index 35df7fd20..e878435c8 100644 --- a/tests/logs.spec.ts +++ b/tests/logs.spec.ts @@ -47,7 +47,7 @@ test.describe("opening logs", async () => { test.describe("features", async () => { test("content", async ({ page }) => { await page.goto("/#/log"); - await page.getByTestId("log-search").fill("listening at"); - await expect(page.getByTestId("log-content")).toContainText("listening at"); + await page.getByTestId("log-search").fill("UI local"); + await expect(page.getByTestId("log-content")).toContainText("UI local"); }); }); diff --git a/tests/simulator.ts b/tests/simulator.ts index a990b639d..bdc0078a0 100644 --- a/tests/simulator.ts +++ b/tests/simulator.ts @@ -11,7 +11,7 @@ const LOG_ENABLED = !process.env["GITHUB_ACTIONS"]; function workerPort() { const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1"); - return 12000 + index; + return 13000 + index; } function logPrefix() { diff --git a/tests/simulator/api.ts b/tests/simulator/api.ts index 16cb4cb04..4938b89b7 100644 --- a/tests/simulator/api.ts +++ b/tests/simulator/api.ts @@ -1,6 +1,9 @@ import bodyParser from "body-parser"; import type { Connect, ViteDevServer } from "vite"; import type { ServerResponse } from "http"; +import { OcppClient } from "./ocppClient"; + +const ocppClients = new Map(); let state = { site: { @@ -11,6 +14,9 @@ let state = { loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }], vehicles: [{ soc: 0, range: 0 }], hems: { relay: false }, + ocpp: { + clients: [] as { stationId: string; serverUrl: string; connected: boolean }[], + }, }; const loggingMiddleware = ( @@ -34,6 +40,10 @@ const stateApiMiddleware = ( res.end(); } else if (req.method === "POST" && req.originalUrl === "/api/shutdown") { console.log("[simulator] POST /api/shutdown"); + for (const client of ocppClients.values()) { + client.disconnect(); + } + ocppClients.clear(); res.end(); process.exit(); } else if (req.originalUrl === "/api/state") { @@ -117,6 +127,80 @@ const shellyMiddleware = ( } }; +const updateOcppState = () => { + state.ocpp.clients = Array.from(ocppClients.entries()).map(([stationId, client]) => ({ + stationId, + serverUrl: client.getServerUrl(), + connected: client.isConnected(), + })); +}; + +const ocppMiddleware = ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction +) => { + if (req.method === "POST" && req.originalUrl === "/api/ocpp/connect") { + console.log("[simulator] POST /api/ocpp/connect"); + // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' + const { stationId, serverUrl } = req.body; + + if (!stationId || !serverUrl) { + res.statusCode = 400; + res.end(JSON.stringify({ error: "stationId and serverUrl required" })); + return; + } + + if (ocppClients.has(stationId)) { + res.statusCode = 400; + res.end(JSON.stringify({ error: `Client ${stationId} already exists` })); + return; + } + + const client = new OcppClient(stationId, serverUrl); + ocppClients.set(stationId, client); + + client + .connect() + .then(() => client.bootNotification()) + .then((response) => { + console.log("[simulator] OCPP BootNotification response:", response); + updateOcppState(); + res.end(JSON.stringify({ status: "connected", stationId, response })); + }) + .catch((error) => { + console.error("[simulator] OCPP connection error:", error); + ocppClients.delete(stationId); + updateOcppState(); + res.statusCode = 500; + res.end(JSON.stringify({ error: error.message })); + }); + } else if (req.method === "POST" && req.originalUrl === "/api/ocpp/disconnect") { + console.log("[simulator] POST /api/ocpp/disconnect"); + // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' + const { stationId } = req.body; + + if (!stationId) { + res.statusCode = 400; + res.end(JSON.stringify({ error: "stationId required" })); + return; + } + + const client = ocppClients.get(stationId); + if (client) { + client.disconnect(); + ocppClients.delete(stationId); + updateOcppState(); + res.end(JSON.stringify({ status: "disconnected", stationId })); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: `Client ${stationId} not found` })); + } + } else { + next(); + } +}; + export default () => ({ name: "api", enforce: "pre", @@ -129,6 +213,7 @@ export default () => ({ server.middlewares.use(openemsMiddleware); server.middlewares.use(teslaloggerMiddleware); server.middlewares.use(shellyMiddleware); + server.middlewares.use(ocppMiddleware); }; }, }); diff --git a/tests/simulator/ocppClient.ts b/tests/simulator/ocppClient.ts new file mode 100644 index 000000000..eae7b22e1 --- /dev/null +++ b/tests/simulator/ocppClient.ts @@ -0,0 +1,221 @@ +import WebSocket from "ws"; + +export class OcppClient { + private ws: WebSocket | null = null; + private messageId = 0; + private pendingCallbacks = new Map void>(); + private connected = false; + private stationId: string; + private serverUrl: string; + + constructor(stationId: string, serverUrl: string) { + this.stationId = stationId; + this.serverUrl = serverUrl; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + const url = `${this.serverUrl}${this.stationId}`; + console.log(`[OCPP Client] Connecting to ${url}`); + + this.ws = new WebSocket(url, ["ocpp1.6"]); + + this.ws.on("open", () => { + console.log(`[OCPP Client] Connected as ${this.stationId}`); + this.connected = true; + resolve(); + }); + + this.ws.on("message", (data: WebSocket.Data) => { + const message = JSON.parse(data.toString()); + this.handleMessage(message); + }); + + this.ws.on("error", (error) => { + console.error(`[OCPP Client] Error:`, error); + reject(error); + }); + + this.ws.on("close", () => { + console.log(`[OCPP Client] Disconnected`); + this.connected = false; + }); + + // Timeout after 5 seconds + setTimeout(() => { + if (!this.connected) { + this.disconnect(); + reject(new Error("Connection timeout")); + } + }, 5000); + }); + } + + private handleMessage(message: any[]) { + const messageType = message[0]; + + // Handle CallResult (3) - response to our calls + if (messageType === 3) { + const messageId = message[1]; + const payload = message[2]; + const callback = this.pendingCallbacks.get(messageId); + if (callback) { + callback(payload); + this.pendingCallbacks.delete(messageId); + } + } + // Handle Call (2) - requests from server + else if (messageType === 2) { + const messageId = message[1]; + const action = message[2]; + const payload = message[3]; + console.log(`[OCPP Client] Received ${action}:`, payload); + + // Auto-respond to common server requests + this.handleServerRequest(messageId, action, payload); + } + } + + private handleServerRequest(messageId: string, action: string, payload: any) { + let response: any = {}; + + switch (action) { + case "GetConfiguration": + response = { + configurationKey: [ + { key: "NumberOfConnectors", readonly: true, value: "1" }, + { + key: "MeterValuesSampledData", + readonly: false, + value: "Energy.Active.Import.Register,Power.Active.Import", + }, + ], + }; + break; + case "ChangeAvailability": + response = { status: "Accepted" }; + break; + case "RemoteStartTransaction": + response = { status: "Accepted" }; + break; + case "RemoteStopTransaction": + response = { status: "Accepted" }; + break; + case "TriggerMessage": + // Handle trigger and send the requested message + if (payload.requestedMessage === "StatusNotification") { + this.statusNotification(1, "Available"); + } + response = { status: "Accepted" }; + break; + default: + console.log(`[OCPP Client] Unknown action ${action}, sending empty response`); + } + + // Send CallResult + const responseMessage = [3, messageId, response]; + this.send(responseMessage); + } + + private send(message: any): void { + if (this.ws && this.connected) { + const data = JSON.stringify(message); + console.log(`[OCPP Client] Sending:`, data); + this.ws.send(data); + } + } + + private async call(action: string, payload: any): Promise { + return new Promise((resolve, reject) => { + const messageId = (++this.messageId).toString(); + const message = [2, messageId, action, payload]; + + // Setup callback for response + this.pendingCallbacks.set(messageId, resolve); + + // Send message + this.send(message); + + // Timeout after 10 seconds + setTimeout(() => { + if (this.pendingCallbacks.has(messageId)) { + this.pendingCallbacks.delete(messageId); + reject(new Error(`Timeout waiting for response to ${action}`)); + } + }, 10000); + }); + } + + async bootNotification(model = "Simulator", vendor = "evcc-test"): Promise { + return this.call("BootNotification", { + chargePointModel: model, + chargePointVendor: vendor, + chargePointSerialNumber: this.stationId, + firmwareVersion: "1.0.0", + }); + } + + async statusNotification( + connectorId: number, + status: string, + errorCode = "NoError" + ): Promise { + return this.call("StatusNotification", { + connectorId, + status, + errorCode, + timestamp: new Date().toISOString(), + }); + } + + async meterValues(connectorId: number, power: number, energy: number): Promise { + return this.call("MeterValues", { + connectorId, + meterValue: [ + { + timestamp: new Date().toISOString(), + sampledValue: [ + { + value: power.toString(), + context: "Sample.Periodic", + format: "Raw", + measurand: "Power.Active.Import", + phase: null, + location: "Outlet", + unit: "W", + }, + { + value: energy.toString(), + context: "Sample.Periodic", + format: "Raw", + measurand: "Energy.Active.Import.Register", + phase: null, + location: "Outlet", + unit: "Wh", + }, + ], + }, + ], + }); + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + this.connected = false; + } + } + + isConnected(): boolean { + return this.connected; + } + + getStationId(): string { + return this.stationId; + } + + getServerUrl(): string { + return this.serverUrl; + } +} diff --git a/tests/simulator/src/Simulator.vue b/tests/simulator/src/Simulator.vue index a6d0618f3..22e5fe698 100644 --- a/tests/simulator/src/Simulator.vue +++ b/tests/simulator/src/Simulator.vue @@ -250,6 +250,80 @@ + +

OCPP work in progress, connect only

+ + +
+
+
+
{{ client.stationId }}
+ + {{ client.connected ? "Connected" : "Disconnected" }} + +
+

{{ client.serverUrl }}

+ +
+
+ + +
+
+
OCPP Client
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
@@ -278,7 +352,13 @@ export default defineComponent({ }[]; vehicles: { soc: number; range: number }[]; hems: { relay: boolean }; + ocpp: { + clients: { stationId: string; serverUrl: string; connected: boolean }[]; + }; } | null, + ocppServerUrl: "ws://127.0.0.1:8887/", + ocppStationId: "", + connecting: false, }; }, mounted() { @@ -292,6 +372,33 @@ export default defineComponent({ const response = await axios.get("/api/state"); this.state = response.data; }, + async connectOcpp() { + this.connecting = true; + try { + await axios.post("/api/ocpp/connect", { + stationId: this.ocppStationId, + serverUrl: this.ocppServerUrl, + }); + // Reload state to get updated ocpp status + await this.load(); + this.ocppStationId = ""; + } catch (error) { + console.error("Failed to connect OCPP client:", error); + alert("Failed to connect OCPP client"); + } finally { + this.connecting = false; + } + }, + async disconnectOcpp(stationId: string) { + try { + await axios.post("/api/ocpp/disconnect", { stationId }); + // Reload state to get updated ocpp status + await this.load(); + } catch (error) { + console.error("Failed to disconnect OCPP client:", error); + alert("Failed to disconnect OCPP client"); + } + }, addVehicle() { // push a duplacate of the last entry const vehicles = this.state?.vehicles; @@ -327,6 +434,7 @@ input[type="number"]::-webkit-inner-spin-button { margin: 0; } input[type="number"] { + appearance: textfield; -moz-appearance: textfield; } input[type="number"] { diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 45b1870ee..6de60dc4f 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -596,8 +596,8 @@ presets: description: generic: Station ID help: - en: "Station ID of the charging point. Note: In exceptional cases, it may be necessary to manually append this ID to the OCPP URL of the charging station in the form `ws://:8887/`. Most charging stations automatically add the ID internally." - de: "Station ID des Ladepunktes. Hinweis: In Ausnahmefällen kann es erforderlich sein, diese ID manuell an die OCPP-URL der Ladestation in der Form `ws://:8887/` anzuhängen. Die meisten Ladestationen fügen die ID intern automatisch hinzu." + en: Unique identifier of the charger. evcc auto-detects this once the charger connects. + de: Eindeutige Wallbox-Kennung. evcc erkennt diese automatisch, sobald sich die Wallbox verbindet. example: EVB-P12354 - name: connector description: