From b535d2ed07cb83f1ea95e48f2ad276c1068a07aa Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 14 Nov 2025 17:32:48 +0100 Subject: [PATCH] Network: allow configuring external url (BC) (#25285) --- api/globalconfig/types.go | 48 +++++++-- assets/css/app.css | 4 +- assets/js/components/Config/GeneralConfig.vue | 3 +- assets/js/components/Config/JsonModal.vue | 8 +- assets/js/components/Config/NetworkModal.vue | 97 +++++++++++-------- cmd/configure/configure.tpl | 2 +- cmd/root.go | 4 +- cmd/setup.go | 21 ++-- cmd/token_tronity.go | 2 +- evcc.dist.yaml | 10 +- hems/shm/shm.go | 57 +++-------- i18n/de.json | 10 +- i18n/en.json | 10 +- tests/config.spec.ts | 45 ++++++++- 14 files changed, 195 insertions(+), 126 deletions(-) diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index ed4f3c575..a25bd21ab 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -1,9 +1,11 @@ package globalconfig import ( - "fmt" + "encoding/json" "net" + "os" "strconv" + "strings" "time" "github.com/evcc-io/evcc/api" @@ -11,6 +13,7 @@ import ( "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/modbus" ) @@ -152,18 +155,45 @@ type Tariffs struct { } type Network struct { - Schema string `json:"schema"` - Host string `json:"host"` - Port int `json:"port"` + Schema_ string `json:"schema,omitempty"` // TODO deprecated + ExternalUrl string `json:"externalUrl"` + Host string `json:"host"` + Port int `json:"port"` } func (c Network) HostPort() string { - if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 { - return c.Host + host := "localhost" + if h, err := os.Hostname(); err == nil { + host = h } - return net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) + if ips := util.LocalIPs(); len(ips) > 0 { + host = ips[0].IP.String() + } + if c.Port == 80 { + return host + } + return net.JoinHostPort(host, strconv.Itoa(c.Port)) } -func (c Network) URI() string { - return fmt.Sprintf("%s://%s", c.Schema, c.HostPort()) +func (c Network) InternalURL() string { + return "http://" + c.HostPort() +} + +func (c Network) ExternalURL() string { + if c.ExternalUrl != "" { + return strings.TrimRight(c.ExternalUrl, "/") + } + return c.InternalURL() +} + +// MarshalJSON includes the computed InternalUrl field in JSON output +func (c Network) MarshalJSON() ([]byte, error) { + type networkAlias Network + return json.Marshal(struct { + networkAlias + InternalUrl string `json:"internalUrl"` + }{ + networkAlias: networkAlias(c), + InternalUrl: c.InternalURL(), + }) } diff --git a/assets/css/app.css b/assets/css/app.css index 89e32828c..fa1d2ed02 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -576,8 +576,8 @@ input.form-control:read-only, color: var(--evcc-default-text); } -.dark .form-control::placeholder { - color: var(--bs-gray-medium); +.form-control::placeholder { + opacity: 0.5 !important; } input[type="time"]::-webkit-calendar-picker-indicator { diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue index f8863e343..ef2d85d3c 100644 --- a/assets/js/components/Config/GeneralConfig.vue +++ b/assets/js/components/Config/GeneralConfig.vue @@ -97,8 +97,7 @@ export default { return settings.hiddenFeatures === true; }, networkStatus() { - const { host, port } = store.state?.network || {}; - return host ? `${host}:${port}` : `${port || ""}`; + return store.state?.network?.port || ""; }, controlStatus() { const sec = store.state?.interval; diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue index f2981f1b1..e3338bf55 100644 --- a/assets/js/components/Config/JsonModal.vue +++ b/assets/js/components/Config/JsonModal.vue @@ -83,6 +83,7 @@ export default { disableRemove: Boolean, noButtons: Boolean, transformReadValues: Function, + transformWriteValues: Function, stateKey: String, saveMethod: { type: String, default: "post" }, storeValuesInArray: Boolean, @@ -134,8 +135,11 @@ export default { this.saving = true; this.error = ""; try { - const values = this.trimValues(this.values); - const res = await api[this.saveMethod](this.endpoint, values, { + const trimmedValues = this.trimValues(deepClone(this.values)); + const payload = this.transformWriteValues + ? this.transformWriteValues(trimmedValues) + : trimmedValues; + const res = await api[this.saveMethod](this.endpoint, payload, { validateStatus: (code) => [200, 202, 400].includes(code), }); if (res.status === 200 || res.status === 202) { diff --git a/assets/js/components/Config/NetworkModal.vue b/assets/js/components/Config/NetworkModal.vue index 31631ce75..d0719ccea 100644 --- a/assets/js/components/Config/NetworkModal.vue +++ b/assets/js/components/Config/NetworkModal.vue @@ -4,64 +4,73 @@ :title="$t('config.network.title')" endpoint="/config/network" state-key="network" + :transform-write-values="transformWriteValues" disable-remove data-testid="network-modal" @changed="$emit('changed')" > @@ -74,5 +83,13 @@ export default { name: "NetworkModal", components: { FormRow, JsonModal }, emits: ["changed"], + methods: { + transformWriteValues(values) { + const payload = { ...values }; + delete payload.internalUrl; + + return payload; + }, + }, }; diff --git a/cmd/configure/configure.tpl b/cmd/configure/configure.tpl index b642bb0be..cd32b4e43 100644 --- a/cmd/configure/configure.tpl +++ b/cmd/configure/configure.tpl @@ -1,7 +1,7 @@ # open evcc at http://evcc.local:7070 network: schema: http - host: evcc.local # .local suffix announces the hostname on MDNS + host: evcc # announces the hostname on mDNS port: 7070 log: debug diff --git a/cmd/root.go b/cmd/root.go index d38852c07..e1564e288 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,13 +261,13 @@ func runRoot(cmd *cobra.Command, args []string) { } // announce on mDNS - if err == nil && strings.HasSuffix(conf.Network.Host, ".local") { + if err == nil { err = configureMDNS(conf.Network) } // start SHM server if err == nil { - err = wrapErrorWithClass(ClassSHM, configureSHM(&conf.SHM, site, httpd)) + err = wrapErrorWithClass(ClassSHM, configureSHM(&conf.SHM, conf.Network.ExternalURL(), site, httpd)) } // start HEMS server diff --git a/cmd/setup.go b/cmd/setup.go index 59c450e2e..76639da56 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -63,9 +63,8 @@ var conf = globalconfig.All{ Interval: 10 * time.Second, Log: "info", Network: globalconfig.Network{ - Schema: "http", - Host: "evcc.local", - Port: 7070, + Host: "", + Port: 7070, }, Mqtt: globalconfig.Mqtt{ Topic: "evcc", @@ -676,14 +675,14 @@ func configureMqtt(conf *globalconfig.Mqtt) error { } // setup SHM -func configureSHM(conf *shm.Config, site *core.Site, httpd *server.HTTPd) error { +func configureSHM(conf *shm.Config, externalUrl string, site *core.Site, httpd *server.HTTPd) error { if settings.Exists(keys.Shm) { if err := settings.Json(keys.Shm, &conf); err != nil { return err } } - if err := shm.NewFromConfig(*conf, site, httpd.Addr, httpd.Router()); err != nil { + if err := shm.NewFromConfig(*conf, externalUrl, site, httpd.Addr, httpd.Router()); err != nil { return fmt.Errorf("failed configuring shm: %w", err) } @@ -751,8 +750,18 @@ func networkSettings(conf *globalconfig.Network) error { // setup MDNS func configureMDNS(conf globalconfig.Network) error { host := strings.TrimSuffix(conf.Host, ".local") + if host == "" { + host = "evcc" + } - zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, []string{"path=/"}, nil) + internalURL := conf.InternalURL() + text := []string{"path=/", "internal_url=" + internalURL} + + if externalURL := conf.ExternalURL(); externalURL != internalURL { + text = append(text, "external_url="+externalURL) + } + + zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, text, nil) if err != nil { return fmt.Errorf("mDNS announcement: %w", err) } diff --git a/cmd/token_tronity.go b/cmd/token_tronity.go index 209535da9..ed339e9f5 100644 --- a/cmd/token_tronity.go +++ b/cmd/token_tronity.go @@ -120,7 +120,7 @@ func tronityToken(conf globalconfig.All, vehicleConf config.Named) (*oauth2.Toke } if oc.RedirectURL = cc.RedirectURI; oc.RedirectURL == "" { - oc.RedirectURL = fmt.Sprintf("%s/auth/tronity", conf.Network.URI()) + oc.RedirectURL = fmt.Sprintf("%s/auth/tronity", conf.Network.ExternalURL()) } return tronityAuthorize(conf.Network.HostPort(), oc) diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 07a5f035f..0d6f2efc3 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -1,14 +1,12 @@ network: - # schema is the HTTP schema - # setting to `https` does not enable https, it only changes the way URLs are generated - schema: http # host is the hostname or IP address - # if the host name contains a `.local` suffix, the name will be announced on MDNS - # docker: MDNS announcements don't work. host must be set to the docker host's name. - host: evcc.local + # for mDNS announcements. note: mDNS announcements don't work in docker. host must be set to the docker host's name. + host: evcc # port is the listening port for UI and api # evcc will listen on all available interfaces port: 7070 + # externalurl is the user-configurable public url from outside + externalurl: https://behind-reverse-proxy interval: 30s # control cycle interval. Interval <30s can lead to unexpected behavior, see https://docs.evcc.io/docs/reference/configuration/interval diff --git a/hems/shm/shm.go b/hems/shm/shm.go index 1f6ab010f..74f159c4c 100644 --- a/hems/shm/shm.go +++ b/hems/shm/shm.go @@ -5,10 +5,7 @@ import ( "encoding/hex" "encoding/xml" "fmt" - "net" "net/http" - "os" - "strconv" "strings" "time" @@ -23,8 +20,6 @@ import ( ) const ( - sempController = "Sunny Home Manager" - sempBaseURLEnv = "SEMP_BASE_URL" sempGateway = "urn:schemas-simple-energy-management-protocol:device:Gateway:1" sempDeviceId = "F-%s-%.12x-00" // 6 bytes sempSerialNumber = "%s-%d" @@ -37,13 +32,12 @@ var serverName = "EVCC SEMP Server " + util.Version // SEMP is the SMA SEMP server type SEMP struct { - log *util.Logger - vid string - did []byte - uid string - hostURI string - port int - site site.API + log *util.Logger + vid string + did []byte + uid string + uri string + site site.API } type Config struct { @@ -53,7 +47,7 @@ type Config struct { } // NewFromConfig creates a new SEMP instance from configuration and starts it -func NewFromConfig(cfg Config, site site.API, addr string, router *mux.Router) error { +func NewFromConfig(cfg Config, hostUri string, site site.API, addr string, router *mux.Router) error { vendorId := cfg.VendorId if vendorId == "" { vendorId = "28081973" @@ -87,20 +81,9 @@ func NewFromConfig(cfg Config, site site.API, addr string, router *mux.Router) e uid: uid.String(), vid: vendorId, did: did, + uri: hostUri, } - // find external port - // TODO refactor network config - _, port, err := net.SplitHostPort(addr) - if err == nil { - s.port, err = strconv.Atoi(port) - } - if err != nil { - return err - } - - s.hostURI = s.callbackURI() - s.handlers(router) go s.run() @@ -108,7 +91,7 @@ func NewFromConfig(cfg Config, site site.API, addr string, router *mux.Router) e } func (s *SEMP) advertise(st, usn string) (*ssdp.Advertiser, error) { - descriptor := s.hostURI + basePath + "/description.xml" + descriptor := s.uri + basePath + "/description.xml" return ssdp.Advertise(st, usn, descriptor, serverName, maxAge) } @@ -139,24 +122,6 @@ func (s *SEMP) run() { } } -func (s *SEMP) callbackURI() string { - if uri := os.Getenv(sempBaseURLEnv); uri != "" { - return strings.TrimSuffix(uri, "/") - } - - ip := "localhost" - ips := util.LocalIPs() - if len(ips) > 0 { - ip = ips[0].IP.String() - } else { - s.log.ERROR.Printf("couldn't determine ip address- specify %s to override", sempBaseURLEnv) - } - - uri := fmt.Sprintf("http://%s:%d", ip, s.port) - - return uri -} - func (s *SEMP) handlers(router *mux.Router) { sempRouter := router.PathPrefix(basePath).Subrouter() getRouter := sempRouter.Methods(http.MethodGet).Subrouter() @@ -198,11 +163,11 @@ func (s *SEMP) gatewayDescription(w http.ResponseWriter, r *http.Request) { FriendlyName: "evcc", Manufacturer: "evcc.io", ModelName: serverName, - PresentationURL: s.hostURI, + PresentationURL: s.uri, UDN: uid, ServiceDefinition: ServiceDefinition{ Xmlns: urnSEMPService, - Server: s.hostURI, + Server: s.uri, BasePath: basePath, Transport: "HTTP/Pull", ExchangeFormat: "XML", diff --git a/i18n/de.json b/i18n/de.json index 53a07798b..1105d5cda 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -426,12 +426,14 @@ "title": "MQTT" }, "network": { - "descriptionHost": "Verwende den .local-Suffix, um mDNS zu aktivieren. Wird zur Erkennung der mobilen App und einiger OCPP-Wallboxen benötigt.", + "descriptionExternalUrl": "Adresse, mit der sich andere Geräte mit evcc verbinden und für die Autodiscovery der evcc-App.", + "descriptionHost": "Wird verwendet, um evcc in deinem lokalen Netzwerk anzukündigen.", + "descriptionInternalUrl": "Lokale Netzwerkadresse von evcc.", "descriptionPort": "Port für die Web-Oberfläche und API. Du musst deine Browser-URL aktualisieren, wenn du dies änderst.", - "descriptionSchema": "Beeinflusst nur die URL-Generierung. Die Auswahl von HTTPS aktiviert keine Verschlüsselung.", - "labelHost": "Hostname", + "labelExternalUrl": "Externe URL", + "labelHost": "mDNS-Hostname", + "labelInternalUrl": "Interne URL", "labelPort": "Port", - "labelSchema": "Schema", "title": "Netzwerk" }, "options": { diff --git a/i18n/en.json b/i18n/en.json index 07fba2a4e..f5640e399 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -427,12 +427,14 @@ "title": "MQTT" }, "network": { - "descriptionHost": "Use .local suffix to enable mDNS. Relevant for discovery of the mobile app and some OCPP chargers.", + "descriptionExternalUrl": "Address for other devices that want to connect to evcc and for autodiscovery of the evcc app.", + "descriptionHost": "Used to announce evcc in your local network.", + "descriptionInternalUrl": "Local network address of evcc.", "descriptionPort": "Port for the web interface and API. You'll need to update your browser URL if you change this.", - "descriptionSchema": "Only affects how URLs are generated. Selecting HTTPS will not enable encryption.", - "labelHost": "Hostname", + "labelExternalUrl": "External URL", + "labelHost": "mDNS Hostname", + "labelInternalUrl": "Internal URL", "labelPort": "Port", - "labelSchema": "Schema", "title": "Network" }, "options": { diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 34cc64501..ea52ce682 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { start, stop, baseUrl } from "./evcc"; +import { start, stop, restart, baseUrl } from "./evcc"; import { enableExperimental, expectModalHidden, @@ -9,6 +9,8 @@ import { } from "./utils"; const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; +const NETWORK_HOST = "somehostname.local"; +const NETWORK_EXTERNAL_URL = "https://ext.evcc.example"; test.use({ baseURL: baseUrl() }); @@ -63,3 +65,44 @@ test.describe("general", async () => { await expect(page.getByRole("heading", { name: "Ahoy World" })).toBeVisible(); }); }); + +test.describe("network modal", async () => { + test("persists host and external url across restart", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, false); + + const networkEntry = page.getByTestId("generalconfig-network"); + await expect(networkEntry).toBeVisible(); + await networkEntry.getByRole("button", { name: "edit" }).click(); + + const modal = page.getByTestId("network-modal"); + await expectModalVisible(modal); + + const portValue = await modal.getByLabel("Port").inputValue(); + await modal.getByLabel("External URL").fill(NETWORK_EXTERNAL_URL); + await modal.getByLabel("mDNS Hostname").fill(NETWORK_HOST); + + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + // values immediatelly visible + await expect(networkEntry).toContainText(portValue); + + // check persistance + await restart(CONFIG_GRID_ONLY); + await page.reload(); + + await expect(networkEntry).toBeVisible(); + await networkEntry.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(modal); + + await expect(modal.getByLabel("mDNS Hostname")).toHaveValue(NETWORK_HOST); + await expect(modal.getByLabel("Port")).toHaveValue(portValue); + await expect(modal.getByLabel("External URL")).toHaveValue(NETWORK_EXTERNAL_URL); + const internalUrl = await modal.getByLabel("Internal URL").inputValue(); + expect(internalUrl).toContain(`:${portValue}`); + + await modal.getByRole("button", { name: "Cancel" }).click(); + await expectModalHidden(modal); + }); +});