Network: allow configuring external url (BC) (#25285)

This commit is contained in:
andig 2025-11-14 17:32:48 +01:00 • committed by GitHub
parent bb61acc99c
commit b535d2ed07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 195 additions and 126 deletions

View file

@ -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(),
})
}

View file

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

View file

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

View file

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

View file

@ -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')"
>
<template #default="{ values }">
<FormRow
id="networkSchema"
:label="$t('config.network.labelSchema')"
:help="$t('config.network.descriptionSchema')"
>
<div class="btn-group" role="group">
<input
id="networkSchemaHttp"
v-model="values.schema"
type="radio"
class="btn-check"
name="networkSchema"
tabindex="0"
value="http"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="networkSchemaHttp">HTTP</label>
<input
id="networkSchemaHttps"
v-model="values.schema"
type="radio"
class="btn-check"
name="networkSchema"
tabindex="0"
value="https"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="networkSchemaHttps">HTTPS</label>
</div>
</FormRow>
<FormRow
id="networkHost"
:label="$t('config.network.labelHost')"
:help="$t('config.network.descriptionHost')"
example="evcc.local"
>
<input id="networkHost" v-model="values.host" class="form-control" />
</FormRow>
<FormRow
id="networkPort"
:label="$t('config.network.labelPort')"
:help="$t('config.network.descriptionPort')"
example="7070"
>
<input
id="networkPort"
v-model="values.port"
class="form-control w-50 me-2 w-50"
type="number"
placeholder="7070"
required
/>
</FormRow>
<FormRow
id="networkInternalUrl"
:label="$t('config.network.labelInternalUrl')"
:help="$t('config.network.descriptionInternalUrl')"
>
<input
id="networkInternalUrl"
v-model="values.internalUrl"
class="form-control"
type="text"
readonly
tabindex="-1"
/>
</FormRow>
<FormRow
id="networkExternalUrl"
:label="$t('config.network.labelExternalUrl')"
:help="$t('config.network.descriptionExternalUrl')"
example="https://evcc.example.org"
optional
>
<input
id="networkExternalUrl"
v-model="values.externalUrl"
class="form-control"
type="text"
inputmode="url"
autocomplete="off"
spellcheck="false"
/>
</FormRow>
<FormRow
id="networkHost"
:label="$t('config.network.labelHost')"
:help="$t('config.network.descriptionHost')"
optional
>
<input
id="networkHost"
v-model="values.host"
class="form-control"
spellcheck="false"
placeholder="evcc"
/>
</FormRow>
</template>
</JsonModal>
</template>
@ -74,5 +83,13 @@ export default {
name: "NetworkModal",
components: { FormRow, JsonModal },
emits: ["changed"],
methods: {
transformWriteValues(values) {
const payload = { ...values };
delete payload.internalUrl;
return payload;
},
},
};
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View file

@ -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": {

View file

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