Add remote access (#28688)

This commit is contained in:
Michael Geers 2026-04-13 22:21:33 +02:00 • committed by GitHub
parent 16f2f89d42
commit 9b8ac11777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1778 additions and 14 deletions

View file

@ -175,11 +175,21 @@ export default {
},
methods: {
valueClasses(entry) {
return {
"value--error": !!entry.error,
"value--warning": entry.warning,
"value--muted": entry.muted || entry.value === false,
};
if (entry.error) {
return "value--error";
}
if (entry.warning) {
return "value--warning";
}
if (
entry.muted ||
entry.value === false ||
entry.value === null ||
entry.value === undefined
) {
return "value--muted";
}
return "";
},
fmtDeviceValue(entry) {
const { name, value } = entry;
@ -221,7 +231,9 @@ export default {
case "singlePhase":
case "enabled":
case "configured":
case "connected":
case "dimmed":
case "loginBlocked":
return value
? this.$t("config.deviceValue.yes")
: this.$t("config.deviceValue.no");

View file

@ -0,0 +1,97 @@
<template>
<div>
<p>{{ $t("config.remote.addClientDescription") }}</p>
<form @submit.prevent="submit">
<div class="row">
<div class="col-md-6">
<FormRow
id="newClientDeviceName"
:label="$t('config.remote.deviceName')"
example="blue-iphone"
>
<input
id="newClientDeviceName"
ref="deviceNameInput"
v-model="username"
name="deviceName"
type="text"
class="form-control border"
pattern="[^:]+"
autocomplete="off"
data-lpignore="true"
data-1p-ignore
data-form-type="other"
required
/>
</FormRow>
</div>
<div class="col-md-6">
<FormRow id="newClientExpiration" :label="$t('config.remote.expiration')">
<select
id="newClientExpiration"
v-model="expiresIn"
class="form-select border"
>
<option
v-for="opt in expirationOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</FormRow>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-outline-secondary" @click="$emit('cancel')">
{{ $t("config.general.cancel") }}
</button>
<button type="submit" class="btn btn-primary">
{{ $t("config.remote.createClient") }}
</button>
</div>
</form>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "../FormRow.vue";
import formatter from "@/mixins/formatter";
export default defineComponent({
name: "RemoteClientCreate",
components: { FormRow },
mixins: [formatter],
emits: ["cancel", "submit"],
data() {
return {
username: "",
expiresIn: 0,
};
},
computed: {
expirationOptions() {
const HOUR = 60 * 60;
const DAY = 24 * HOUR;
return [
{ value: 0, name: this.$t("config.remote.expirationNone") },
{ value: HOUR, name: this.fmtDurationParts({ hours: 1 }) },
{ value: DAY, name: this.fmtDurationParts({ days: 1 }) },
{ value: 7 * DAY, name: this.fmtDurationParts({ weeks: 1 }) },
];
},
},
mounted() {
(this.$refs["deviceNameInput"] as HTMLInputElement | undefined)?.focus();
},
methods: {
submit() {
const username = this.username.trim();
if (!username) return;
this.$emit("submit", { username, expiresIn: this.expiresIn });
},
},
});
</script>

View file

@ -0,0 +1,102 @@
<template>
<div>
<h6 class="mb-3">{{ $t("config.remote.clients") }}</h6>
<div
v-for="client in clients"
:key="client.username"
data-testid="remote-client"
class="mb-2"
>
<div
class="d-flex align-items-center justify-content-between py-2 ps-3 pe-2 border rounded"
:class="{ 'opacity-50': isExpired(client) }"
>
<div class="flex-grow-1 fw-semibold">{{ client.username }}</div>
<div class="text-end">
<small v-if="expiryLabel(client)" class="text-muted ms-2 me-2 text-end">
{{ expiryLabel(client) }}
</small>
<span v-if="isActive(client)" class="badge bg-success ms-2 me-2">
{{ $t("config.remote.active") }}
</span>
<small
v-else-if="activityLabel(client)"
class="text-muted ms-2 me-2 text-end d-block"
>
{{ activityLabel(client) }}
</small>
</div>
<button
type="button"
class="btn btn-sm btn-outline-secondary border-0"
:aria-label="$t('config.remote.removeClient')"
@click="$emit('remove', client.username)"
>
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
</button>
</div>
</div>
<div v-if="!clients.length" class="text-muted small mb-2">
{{ $t("config.remote.noClients") }}
</div>
<div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-outline-secondary" @click="$emit('add')">
{{ $t("config.remote.addClient") }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import "@h2d2/shopicons/es/regular/trash";
import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker";
import type { RemoteClient } from "@/types/evcc";
import { isRemoteClientActive } from "@/utils/remote";
export default defineComponent({
name: "RemoteClientList",
mixins: [formatter, minuteTicker],
props: {
clients: { type: Array as PropType<RemoteClient[]>, required: true },
lastSeen: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
connected: Boolean,
},
emits: ["add", "remove"],
methods: {
lastSeenMs(client: RemoteClient): number | null {
void this.everyMinute;
const seen = this.lastSeen[client.username];
if (!seen) return null;
return Date.now() - new Date(seen).getTime();
},
isActive(client: RemoteClient): boolean {
return this.connected && isRemoteClientActive(this.lastSeen, client.username);
},
activityLabel(client: RemoteClient): string {
const ms = this.lastSeenMs(client);
if (ms === null) return "";
return this.$t("config.remote.lastActive", { time: this.fmtTimeAgo(-ms) });
},
expiresInMs(client: RemoteClient): number | null {
// reference everyMinute so dependents re-evaluate on each tick
void this.everyMinute;
if (!client.expiresAt) return null;
return new Date(client.expiresAt).getTime() - Date.now();
},
isExpired(client: RemoteClient): boolean {
const ms = this.expiresInMs(client);
return ms !== null && ms <= 0;
},
expiryLabel(client: RemoteClient): string {
const ms = this.expiresInMs(client);
if (ms === null) return "";
if (ms <= 0) return this.$t("config.remote.expired");
return this.$t("config.remote.expiresIn", { time: this.fmtTimeAgo(ms) });
},
},
});
</script>

View file

@ -0,0 +1,125 @@
<template>
<div>
<ol class="text-muted mb-2">
<li>
<i18n-t keypath="config.remote.qrInstall" scope="global">
<template #ios>
<a href="https://apps.apple.com/app/evcc-io/id6478510176" target="_blank">
iOS
</a>
</template>
<template #android>
<a
href="https://play.google.com/store/apps/details?id=io.evcc.android"
target="_blank"
>
Android
</a>
</template>
</i18n-t>
</li>
<li>{{ $t("config.remote.qrScan") }}</li>
</ol>
<div class="text-center my-3">
<a v-if="qrDataUrl" :href="appUrl" class="d-inline-block">
<img :src="qrDataUrl" alt="QR Code" class="qr-code" />
</a>
</div>
<hr class="my-4" />
<p>
<i18n-t keypath="config.remote.manualLogin" scope="global">
<template #url>
<a :href="serverUrl" target="_blank" rel="noopener">{{ serverUrl }}</a>
</template>
</i18n-t>
</p>
<FormRow id="revealUsername" :label="$t('config.remote.username')">
<input
id="revealUsername"
type="text"
class="form-control border"
:value="client.username"
readonly
/>
</FormRow>
<FormRow id="revealPassword" :label="$t('config.remote.password')">
<input
id="revealPassword"
type="text"
class="form-control border font-monospace"
:value="client.password"
readonly
/>
<CopyLink :text="client.password" />
</FormRow>
<div class="mt-4 small text-muted">
<strong class="text-evcc">{{ $t("general.note") }}</strong>
{{ $t("config.remote.passwordOnce") }}
</div>
<div class="d-flex justify-content-end mt-3">
<button type="button" class="btn btn-primary" @click="$emit('done')">
{{ $t("config.remote.done") }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import QRCode from "qrcode";
import FormRow from "../FormRow.vue";
import CopyLink from "../../Helper/CopyLink.vue";
import type { RemoteClientCreated } from "@/types/evcc";
export default defineComponent({
name: "RemoteClientReveal",
components: { FormRow, CopyLink },
props: {
client: { type: Object as PropType<RemoteClientCreated>, required: true },
serverUrl: { type: String, required: true },
},
emits: ["done"],
data() {
return {
qrDataUrl: null as string | null,
};
},
computed: {
appUrl(): string {
if (!this.client || !this.serverUrl) return "";
const params = new URLSearchParams({
url: this.serverUrl,
username: this.client.username,
password: this.client.password,
});
return `evcc://server?${params.toString()}`;
},
},
watch: {
appUrl: {
immediate: true,
async handler(url: string) {
if (!url) return;
try {
this.qrDataUrl = await QRCode.toDataURL(url, {
width: 200,
margin: 1,
});
} catch {
this.qrDataUrl = null;
}
},
},
},
});
</script>
<style scoped>
.qr-code {
image-rendering: pixelated;
}
</style>

View file

@ -0,0 +1,211 @@
<template>
<GenericModal
id="remoteModal"
:title="modalTitle"
config-modal-name="remote"
data-testid="remote-modal"
@open="onOpen"
>
<div class="alert alert-warning">
Development preview. Not ready for general use. Use with caution and monitor your system
closely. Feedback welcome!
</div>
<SponsorTokenRequired v-if="!isSponsor" feature class="mt-0" />
<ErrorMessage :error="error" />
<template v-if="view === 'list'">
<p>{{ $t("config.remote.description") }}</p>
<div class="form-check form-switch my-3">
<input
id="remoteEnabled"
:checked="config.enabled"
class="form-check-input"
type="checkbox"
role="switch"
:disabled="!isSponsor"
@change="change"
/>
<div class="form-check-label">
<label for="remoteEnabled">{{ $t("config.remote.enableLabel") }}</label>
<div v-if="status.url">
<span v-if="status.connected" class="text-primary">
{{ $t("config.remote.connected") }}
</span>
<span v-else class="text-muted small">
{{ $t("config.remote.disconnected") }}
</span>
</div>
</div>
</div>
<div v-if="status.url" class="mt-4">
<FormRow id="remoteServerUrl" :label="$t('config.remote.url')">
<input
id="remoteServerUrl"
type="text"
class="form-control border"
:value="status.url"
readonly
/>
</FormRow>
<hr class="my-4" />
<div v-if="status.loginBlocked" class="alert alert-danger">
{{ $t("config.remote.loginBlocked") }}
</div>
<RemoteClientList
:clients="clients"
:last-seen="status.lastSeen"
:connected="status.connected"
@add="view = 'create'"
@remove="removeClient"
/>
</div>
</template>
<RemoteClientCreate
v-else-if="view === 'create'"
@cancel="view = 'list'"
@submit="submitCreate"
/>
<RemoteClientReveal
v-else-if="view === 'reveal' && createdClient && status.url"
:client="createdClient"
:server-url="status.url"
@done="finishReveal"
/>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../../Helper/GenericModal.vue";
import ErrorMessage from "../../Helper/ErrorMessage.vue";
import FormRow from "../FormRow.vue";
import SponsorTokenRequired from "../DeviceModal/SponsorTokenRequired.vue";
import RemoteClientList from "./RemoteClientList.vue";
import RemoteClientCreate from "./RemoteClientCreate.vue";
import RemoteClientReveal from "./RemoteClientReveal.vue";
import api from "@/api";
import type {
Remote,
RemoteConfig,
RemoteStatus,
RemoteClient,
RemoteClientCreated,
} from "@/types/evcc";
import type { AxiosError } from "axios";
type View = "list" | "create" | "reveal";
export default defineComponent({
name: "RemoteModal",
components: {
GenericModal,
ErrorMessage,
FormRow,
SponsorTokenRequired,
RemoteClientList,
RemoteClientCreate,
RemoteClientReveal,
},
props: {
remote: { type: Object as () => Remote | undefined, default: undefined },
isSponsor: Boolean,
},
data() {
return {
error: null as string | null,
view: "list" as View,
clients: [] as RemoteClient[],
createdClient: null as RemoteClientCreated | null,
};
},
computed: {
config(): RemoteConfig {
return this.remote?.config ?? { enabled: false };
},
status(): RemoteStatus {
return this.remote?.status ?? { connected: false, loginBlocked: false };
},
modalTitle(): string {
switch (this.view) {
case "create":
return this.$t("config.remote.addClientTitle");
case "reveal":
return this.$t("config.remote.clientCreated");
default:
return `${this.$t("config.remote.title")} 🧪`;
}
},
},
methods: {
async onOpen() {
this.error = null;
this.view = "list";
this.createdClient = null;
await this.loadClients();
},
async loadClients() {
if (!this.status.url) {
this.clients = [];
return;
}
try {
const res = await api.get("config/remote/clients");
this.clients = res.data || [];
} catch (err) {
this.handleError(err);
}
},
async change(e: Event) {
const target = e.target as HTMLInputElement;
const checked = target.checked;
try {
this.error = null;
await api.post(`config/remote/${checked}`);
if (checked) {
await this.loadClients();
}
} catch (err) {
target.checked = !checked;
this.handleError(err);
}
},
async submitCreate(payload: { username: string; expiresIn: number }) {
try {
this.error = null;
const res = await api.post("config/remote/clients", payload);
this.createdClient = res.data as RemoteClientCreated;
this.view = "reveal";
} catch (err) {
this.handleError(err);
}
},
async finishReveal() {
this.createdClient = null;
await this.loadClients();
this.view = "list";
},
async removeClient(username: string) {
if (!window.confirm(this.$t("config.remote.confirmDelete"))) {
return;
}
try {
this.error = null;
await api.delete("config/remote/clients", { params: { username } });
await this.loadClients();
} catch (err) {
this.handleError(err);
}
},
handleError(err: unknown) {
const axiosErr = err as AxiosError<{ error: string }>;
this.error = axiosErr.response?.data?.error || axiosErr.message || String(err);
},
},
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6.5 20q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.4 0 4.238 1.4T18.725 9q.125.425-.112.75t-.588.425t-.725-.075t-.55-.675q-.5-1.5-1.8-2.463T12 6Q9.925 6 8.463 7.463T7 11h-.5q-1.45 0-2.475 1.025T3 14.5t1.025 2.475T6.5 18H13q.425 0 .713.288T14 19t-.288.713T13 20zM17 20q-.425 0-.712-.288T16 19v-3q0-.425.288-.712T17 15v-1q0-.825.588-1.412T19 12t1.413.588T21 14v1q.425 0 .713.288T22 16v3q0 .425-.288.713T21 20zm1-5h2v-1q0-.425-.288-.712T19 13t-.712.288T18 14z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "RemoteAccessIcon",
mixins: [icon],
});
</script>

View file

@ -180,6 +180,10 @@ export default defineComponent({
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style });
return formatter.format({ minutes, hours });
},
fmtDurationParts(parts: Record<string, number>) {
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types
return new Intl.DurationFormat(this.$i18n?.locale, { style: "long" }).format(parts);
},
fmtDayString(date: Date) {
const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0");

View file

@ -93,6 +93,7 @@ export interface State {
shm?: ShmConfig;
sponsor?: ConfigStatus<unknown, SponsorStatus>;
eebus?: ConfigStatus<EebusConfig, EebusStatus>;
remote?: Remote;
modbusproxy?: ModbusProxy[];
messaging?: ConfigStatus<unknown, unknown>;
messagingEvents?: MessagingEvents;
@ -484,6 +485,29 @@ export type Certificate = {
private: string;
};
export type Remote = ConfigStatus<RemoteConfig, RemoteStatus>;
export type RemoteConfig = {
enabled: boolean;
};
export type RemoteStatus = {
connected: boolean;
url?: string;
loginBlocked: boolean;
lastSeen?: Record<string, string>;
};
export type RemoteClient = {
username: string;
createdAt: string;
expiresAt?: string;
};
export type RemoteClientCreated = RemoteClient & {
password: string;
};
export type Eebus = ConfigStatus<EebusConfig, EebusStatus>;
export type EebusConfig = {

10
assets/js/utils/remote.ts Normal file
View file

@ -0,0 +1,10 @@
const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
export function isRemoteClientActive(
lastSeen: Record<string, string> | undefined,
username: string
): boolean {
const seen = lastSeen?.[username];
if (!seen) return false;
return Date.now() - new Date(seen).getTime() < ACTIVE_THRESHOLD_MS;
}

View file

@ -321,6 +321,19 @@
<DeviceTags :tags="hemsTags" />
</template>
</DeviceCard>
<DeviceCard
v-if="remote"
:title="$t('config.remote.title')"
editable
:unconfigured="isUnconfigured(remoteTags)"
data-testid="remote-access"
@edit="openModal('remote')"
>
<template #icon><RemoteAccessIcon /></template>
<template #tags>
<DeviceTags :tags="remoteTags" />
</template>
</DeviceCard>
<DeviceCard
v-if="experimental"
:title="`${$t('config.optimizer.title')} 🧪`"
@ -417,6 +430,7 @@
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<OptimizerModal />
<ExperimentalModal :experimental="experimental" />
<RemoteModal :remote="remote" :is-sponsor="isSponsor" />
<TitleModal @changed="loadDirty" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
<CircuitsModal :gridMeter="gridMeter" :extMeters="extMeters" @changed="loadDirty" />
@ -473,6 +487,9 @@ import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
import MqttIcon from "../components/MaterialIcon/Mqtt.vue";
import MqttModal from "../components/Config/MqttModal.vue";
import RemoteAccessIcon from "../components/MaterialIcon/RemoteAccess.vue";
import RemoteModal from "../components/Config/Remote/RemoteModal.vue";
import { isRemoteClientActive } from "@/utils/remote";
import NetworkModal from "../components/Config/NetworkModal.vue";
import NotificationIcon from "../components/MaterialIcon/Notification.vue";
import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue";
@ -504,6 +521,7 @@ import type {
SiteConfig,
DeviceType,
Notification,
Remote,
} from "@/types/evcc";
import { CURRENCY, GRID_CONTROL } from "@/types/evcc";
import { circuitTree } from "@/utils/circuits";
@ -555,6 +573,8 @@ export default defineComponent({
ModbusProxyModal,
MqttIcon,
MqttModal,
RemoteAccessIcon,
RemoteModal,
NetworkModal,
NotificationIcon,
OptimizerIcon,
@ -763,6 +783,30 @@ export default defineComponent({
return result;
},
remote(): Remote | undefined {
return store.state?.remote;
},
remoteTags(): DeviceTags {
const remote = this.remote;
if (!remote?.status?.url) {
return { configured: { value: false } };
}
const tags: DeviceTags = {
enabled: { value: remote.config?.enabled },
connected: { value: remote.status?.connected },
};
if (remote.status?.loginBlocked) {
tags["loginBlocked"] = { value: true, error: true };
}
if (remote.status?.connected) {
const lastSeen = remote.status?.lastSeen;
const count = lastSeen
? Object.keys(lastSeen).filter((u) => isRemoteClientActive(lastSeen, u)).length
: 0;
tags["activeClients"] = { value: count };
}
return tags;
},
sponsor() {
return store.state?.sponsor;
},

View file

@ -22,6 +22,7 @@ import (
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/server/mcp"
"github.com/evcc-io/evcc/server/network"
"github.com/evcc-io/evcc/server/remote"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
@ -237,6 +238,12 @@ func runRoot(cmd *cobra.Command, args []string) {
// publish to UI
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
// remote access tunnel
var remoteAccess *remote.Remote
if remoteHost := os.Getenv("EVCC_REMOTE_ACCESS"); remoteHost != "" {
remoteAccess = remote.New(remoteHost, httpd.Router(), valueChan)
}
// signal ui listening
valueChan <- util.Param{Key: keys.StartupCompleted, Val: false}
@ -385,6 +392,11 @@ func runRoot(cmd *cobra.Command, args []string) {
YamlSource: yamlSource.tariffs,
}}
// publish remote access status
if remoteAccess != nil {
valueChan <- util.Param{Key: keys.Remote, Val: remoteAccess.ConfigStatus()}
}
// publish system infos
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}
valueChan <- util.Param{Key: keys.Config, Val: viper.ConfigFileUsed()}
@ -429,7 +441,7 @@ func runRoot(cmd *cobra.Command, args []string) {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
once.Do(func() { close(stopC) }) // signal loop to end
}, viper.ConfigFileUsed())
}, viper.ConfigFileUsed(), remoteAccess)
// show and check version, reduce api load during development
if util.Version != util.DevVersion {

View file

@ -30,6 +30,9 @@ const (
Telemetry = "telemetry"
Optimizer = "optimizer"
DemoMode = "demoMode"
Remote = "remote"
RemoteClients = "remoteClients"
RemoteLastSeen = "remoteLastSeen"
AuthDisabled = "authDisabled"
AuthProviders = "authProviders"
)

2
go.mod
View file

@ -54,6 +54,7 @@ require (
github.com/gregdel/pushover v1.4.0
github.com/grid-x/modbus v0.0.0-20260325140807-cf9e1b9daae0
github.com/hashicorp/go-version v1.9.0
github.com/hashicorp/yamux v0.1.2
github.com/hasura/go-graphql-client v0.16.0
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/influxdata/influxdb-client-go/v2 v2.14.0
@ -85,6 +86,7 @@ require (
github.com/robertkrimen/otto v0.5.1
github.com/samber/lo v1.53.0
github.com/sandrolain/httpcache v1.4.0
github.com/sethvargo/go-password v0.3.1
github.com/sirupsen/logrus v1.9.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/smallnest/chanx v1.2.0

4
go.sum
View file

@ -391,6 +391,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/hasura/go-graphql-client v0.16.0 h1:DQLfp+djj4j5NPdJkGYym8J55hpm5etML1zqgco78Qc=
github.com/hasura/go-graphql-client v0.16.0/go.mod h1:z/sO2T0zI+HnPNIevQcs+7xA6/gDOc8hgHMrNBzfL2c=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@ -686,6 +688,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU=
github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=

View file

@ -113,6 +113,7 @@
"title": "Währung"
},
"deviceValue": {
"activeClients": "Aktive Clients",
"amount": "Anzahl",
"broker": "Broker",
"bucket": "Bucket",
@ -144,6 +145,7 @@
"hemsActiveLimit": "Aktives Limit",
"hemsType": "Kommunikation",
"identifier": "RFID-Kennung",
"loginBlocked": "Login-Limit erreicht",
"max": "max",
"messengers": "Dienste",
"no": "Nein",
@ -631,6 +633,38 @@
"titleAdd": "PV-Anlage hinzufügen",
"titleEdit": "PV-Zähler bearbeiten"
},
"remote": {
"active": "Aktiv",
"addClient": "Client hinzufügen",
"addClientDescription": "Zugangsdaten werden ausschließlich lokal auf deiner evcc-Instanz gespeichert und überprüft.",
"addClientTitle": "Remote-Client hinzufügen",
"clientCreated": "Client erstellt",
"clients": "Clients",
"confirmDelete": "Client löschen?",
"connected": "Verbunden",
"createClient": "Client erstellen",
"description": "Externer Zugriff auf die evcc-Installation über die evcc-App. Ohne Portfreigabe oder VPN.",
"deviceName": "Gerätename",
"disconnected": "Nicht verbunden",
"done": "Fertig",
"enableLabel": "Remotezugriff aktivieren",
"expiration": "Ablauf",
"expirationNone": "Nie",
"expired": "abgelaufen",
"expiresIn": "läuft {time} ab",
"lastActive": "aktiv {time}",
"loginBlocked": "Remote-Logins sind wegen zu vieler fehlgeschlagener Anmeldeversuche für eine Minute gesperrt.",
"manualLogin": "Oder melde dich manuell unter {url} in deinem Browser mit diesen Zugangsdaten an:",
"noClients": "Noch keine Clients. Niemand kann sich verbinden.",
"password": "Passwort",
"passwordOnce": "Dieses Passwort wird nur einmal angezeigt. QR-Code scannen oder jetzt kopieren. Es wird später nicht mehr angezeigt.",
"qrInstall": "Installiere die evcc-App für {ios} oder {android}.",
"qrScan": "Scanne den Code mit der Kamera deines Smartphones, um dich zu verbinden. Klicke ihn an, wenn du bereits dein Handy nutzt.",
"removeClient": "Client entfernen",
"title": "Remotezugriff",
"url": "Öffentliche URL",
"username": "Benutzername"
},
"section": {
"additionalMeter": "Zusätzliche Zähler",
"general": "Allgemein",

View file

@ -113,6 +113,7 @@
"title": "Currency"
},
"deviceValue": {
"activeClients": "Active clients",
"amount": "Amount",
"broker": "Broker",
"bucket": "Bucket",
@ -126,6 +127,7 @@
"chargedEnergy": "Charged",
"co2": "Grid CO₂",
"configured": "Configured",
"connected": "Connected",
"connections": "Connections",
"controllable": "Controllable",
"currency": "Currency",
@ -144,6 +146,7 @@
"hemsActiveLimit": "Active limit",
"hemsType": "Communication",
"identifier": "RFID-Identifier",
"loginBlocked": "Login limit reached",
"max": "max",
"messengers": "Services",
"no": "no",
@ -631,6 +634,38 @@
"titleAdd": "Add Solar Meter",
"titleEdit": "Edit Solar Meter"
},
"remote": {
"active": "Active",
"addClient": "Add client",
"addClientDescription": "Credentials are stored and verified only locally on your evcc instance.",
"addClientTitle": "Add Remote Client",
"clientCreated": "Client created",
"clients": "Clients",
"confirmDelete": "Delete client?",
"connected": "Connected",
"createClient": "Create client",
"description": "Access your evcc installation from anywhere using the evcc mobile app. No port forwarding or VPN required.",
"deviceName": "Device name",
"disconnected": "Disconnected",
"done": "Done",
"enableLabel": "Enable remote access",
"expiration": "Expiration",
"expirationNone": "Never",
"expired": "expired",
"expiresIn": "expires {time}",
"lastActive": "active {time}",
"loginBlocked": "Remote logins are blocked for one minute due to too many failed login attempts.",
"manualLogin": "Or sign in manually at {url} in your browser using these credentials:",
"noClients": "No clients yet. No one can connect yet.",
"password": "Password",
"passwordOnce": "This password is shown only once. Scan the QR code or copy it now. You won't be able to see it again.",
"qrInstall": "Install the evcc app for {ios} or {android}.",
"qrScan": "Scan the code with your phone's camera to connect. Click it, if you're already using your phone.",
"removeClient": "Remove client",
"title": "Remote Access",
"url": "Public URL",
"username": "Username"
},
"section": {
"additionalMeter": "Additional meters",
"general": "General",

255
package-lock.json generated
View file

@ -22,6 +22,7 @@
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
"vue": "^3.5.31",
@ -40,6 +41,7 @@
"@types/bootstrap": "^5.2.10",
"@types/canvas-confetti": "^1.9.0",
"@types/kill-port": "^2.0.3",
"@types/qrcode": "^1.5.6",
"@types/smoothscroll-polyfill": "^0.3.4",
"@types/wait-on": "^5.3.4",
"@types/ws": "^8.18.1",
@ -3435,6 +3437,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
@ -4439,7 +4451,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4449,7 +4460,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -5115,6 +5125,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
@ -5324,7 +5343,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -5337,7 +5355,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@ -5655,6 +5672,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -5790,6 +5816,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -6993,7 +7025,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -7686,7 +7717,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -9165,6 +9195,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -9215,7 +9254,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -9346,6 +9384,15 @@
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -9702,6 +9749,173 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@ -9997,6 +10211,21 @@
"regjsparser": "bin/parser"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -10298,6 +10527,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -12470,6 +12705,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",

View file

@ -35,6 +35,7 @@
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"monaco-editor": "0.52.2",
"qrcode": "^1.5.4",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
"vue": "^3.5.31",
@ -62,6 +63,7 @@
"@types/bootstrap": "^5.2.10",
"@types/canvas-confetti": "^1.9.0",
"@types/kill-port": "^2.0.3",
"@types/qrcode": "^1.5.6",
"@types/smoothscroll-polyfill": "^0.3.4",
"@types/wait-on": "^5.3.4",
"@types/ws": "^8.18.1",

View file

@ -15,6 +15,7 @@ import (
"github.com/evcc-io/evcc/hems/shm"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/server/remote"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
@ -225,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
}
// RegisterSystemHandler provides system level handlers
func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string) {
func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *util.ParamCache, auth auth.Auth, shutdown func(), configFile string, remoteAccess *remote.Remote) {
router := s.Server.Handler.(*mux.Router)
// api
@ -299,6 +300,13 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
}
if remoteAccess != nil {
routes["remote"] = route{"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)}
routes["remoteclients"] = route{"GET", "/remote/clients", remoteClientsHandler(remoteAccess)}
routes["createremoteclient"] = route{"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)}
routes["deleteremoteclient"] = route{"DELETE", "/remote/clients", deleteRemoteClientHandler(remoteAccess)}
}
// yaml handlers
for key, fun := range map[string]func() (any, any){
keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} },

View file

@ -0,0 +1,62 @@
package server
import (
"encoding/json"
"net/http"
"time"
"github.com/evcc-io/evcc/server/remote"
)
// remoteClientsHandler returns the list of remote tunnel clients.
func remoteClientsHandler(r *remote.Remote) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
jsonWrite(w, r.Clients())
}
}
// createRemoteClientHandler creates a new tunnel client and returns the
// cleartext password (shown to the user only once).
func createRemoteClientHandler(r *remote.Remote) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var body struct {
Username string `json:"username"`
ExpiresIn int64 `json:"expiresIn"` // seconds; 0 = never
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
client, password, err := r.CreateClient(body.Username, time.Duration(body.ExpiresIn)*time.Second)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonWrite(w, struct {
Username string `json:"username"`
Password string `json:"password"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}{
Username: client.Username,
Password: password,
CreatedAt: client.CreatedAt,
ExpiresAt: client.ExpiresAt,
})
}
}
// deleteRemoteClientHandler removes a tunnel client by username.
// Username is passed as a query parameter to allow arbitrary characters.
func deleteRemoteClientHandler(r *remote.Remote) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
username := req.URL.Query().Get("username")
if err := r.DeleteClient(username); err != nil {
jsonError(w, http.StatusNotFound, err)
return
}
jsonWrite(w, true)
}
}

166
server/remote/clients.go Normal file
View file

@ -0,0 +1,166 @@
package remote
import (
"crypto/rand"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/samber/lo"
"github.com/sethvargo/go-password/password"
"golang.org/x/crypto/bcrypt"
)
// dummyHash is a bcrypt hash of a random value, used to make the
// "unknown user" path take the same time as a real password check and
// prevent username enumeration via timing side channels.
var dummyHash []byte
func init() {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost)
if err != nil {
panic(err)
}
dummyHash = h
}
// Client is a single tunnel basic-auth credential used by a remote client.
type Client struct {
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type persistedClient struct {
Client
Hash string `json:"hash"`
}
// loadClients reads the persisted client list.
func loadClients() []persistedClient {
var res []persistedClient
_ = settings.Json(keys.RemoteClients, &res)
return res
}
// saveClients persists the given client list.
func saveClients(list []persistedClient) error {
return settings.SetJson(keys.RemoteClients, list)
}
// generatePassword returns a crypto-random alphanumeric password
// with 20 characters including 4 digits (~96 bits of entropy).
func generatePassword() (string, error) {
return password.Generate(20, 4, 0, false, false)
}
// Clients returns the list of configured clients (without password hashes).
func (r *Remote) Clients() []Client {
return lo.Map(loadClients(), func(c persistedClient, _ int) Client {
return c.Client
})
}
// CreateClient creates a new client with an auto-generated password.
// expiresIn <= 0 means the client never expires.
// Returns the cleartext password (shown to the user only once).
func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) {
username = strings.TrimSpace(username)
if username == "" {
return Client{}, "", errors.New("username required")
}
// RFC 7617: ":" is the basic-auth separator; reject control chars too.
for _, r := range username {
if r == ':' || r < 0x20 || r == 0x7f {
return Client{}, "", errors.New("username contains invalid characters")
}
}
var expires *time.Time
if expiresIn > 0 {
expires = new(time.Now().Add(expiresIn))
}
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
for _, c := range list {
if c.Username == username {
return Client{}, "", fmt.Errorf("client %q already exists", username)
}
}
password, err := generatePassword()
if err != nil {
return Client{}, "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return Client{}, "", err
}
c := persistedClient{
Client: Client{
Username: username,
CreatedAt: time.Now(),
ExpiresAt: expires,
},
Hash: string(hash),
}
list = append(list, c)
if err := saveClients(list); err != nil {
return Client{}, "", err
}
return c.Client, password, nil
}
// DeleteClient removes a client by username.
func (r *Remote) DeleteClient(username string) error {
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
idx := slices.IndexFunc(list, func(c persistedClient) bool {
return c.Username == username
})
if idx == -1 {
return fmt.Errorf("client %s not found", username)
}
return saveClients(slices.Delete(list, idx, idx+1))
}
// Authenticate validates basic-auth credentials. Always runs bcrypt
// (against a dummy hash on miss) to prevent username enumeration via timing.
func (r *Remote) Authenticate(username, password string) bool {
hash := dummyHash
var found *persistedClient
for _, c := range loadClients() {
if c.Username == username {
found = &c
hash = []byte(c.Hash)
break
}
}
valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
if !valid || found == nil {
return false
}
if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) {
return false
}
return true
}

View file

@ -0,0 +1,51 @@
package remote
import (
"sync"
"time"
)
// authRateLimiter tracks failed authentication attempts in a sliding window.
// When the failure count exceeds the threshold, further attempts are blocked
// to prevent brute-force attacks.
type authRateLimiter struct {
mu sync.Mutex
failures []time.Time
window time.Duration
max int
now func() time.Time
}
func newAuthRateLimiter() *authRateLimiter {
return &authRateLimiter{
window: time.Minute,
max: 10,
now: time.Now,
}
}
// allow checks whether an authentication attempt should proceed.
func (rl *authRateLimiter) allow() bool {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := rl.now().Add(-rl.window)
// prune old entries
valid := rl.failures[:0]
for _, t := range rl.failures {
if t.After(cutoff) {
valid = append(valid, t)
}
}
rl.failures = valid
return len(rl.failures) < rl.max
}
// fail records a failed authentication attempt.
func (rl *authRateLimiter) fail() {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.failures = append(rl.failures, rl.now())
}

View file

@ -0,0 +1,70 @@
package remote
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuthRateLimiter(t *testing.T) {
t.Run("allows requests under threshold", func(t *testing.T) {
rl := newAuthRateLimiter()
for range rl.max {
assert.True(t, rl.allow())
rl.fail()
}
})
t.Run("blocks after threshold", func(t *testing.T) {
rl := newAuthRateLimiter()
for range rl.max {
rl.fail()
}
assert.False(t, rl.allow())
})
t.Run("recovers after window expires", func(t *testing.T) {
now := time.Now()
var mu sync.Mutex
rl := newAuthRateLimiter()
rl.now = func() time.Time {
mu.Lock()
defer mu.Unlock()
return now
}
for range rl.max {
rl.fail()
}
assert.False(t, rl.allow())
// advance past window
mu.Lock()
now = now.Add(rl.window + time.Second)
mu.Unlock()
assert.True(t, rl.allow())
})
t.Run("successful auth does not count as failure", func(t *testing.T) {
rl := newAuthRateLimiter()
// fill up to max-1 failures
for range rl.max - 1 {
rl.fail()
}
// allow should still work (no fail() call = successful auth)
assert.True(t, rl.allow())
// still under threshold
assert.True(t, rl.allow())
})
}

228
server/remote/remote.go Normal file
View file

@ -0,0 +1,228 @@
package remote
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
)
// Settings is the persisted remote access configuration.
type Settings struct {
Enabled bool `json:"enabled"`
URL string `json:"url,omitempty"`
Token string `json:"token,omitempty"`
TunnelURL string `json:"tunnelUrl,omitempty"`
}
// Remote manages the remote access tunnel lifecycle.
type Remote struct {
mu sync.Mutex
cloudHost string
settings Settings
tunnel *Tunnel
httpHandler http.Handler
log *util.Logger
publisher chan<- util.Param
lastSeen map[string]time.Time // persisted: username → last activity
connected map[string]int // in-memory: active connection count per user
}
// New creates a new Remote manager, loads persisted settings, and connects if enabled.
func New(cloudHost string, httpHandler http.Handler, valueChan chan<- util.Param) *Remote {
r := &Remote{
cloudHost: cloudHost,
httpHandler: httpHandler,
log: util.NewLogger("remote"),
publisher: valueChan,
lastSeen: make(map[string]time.Time),
connected: make(map[string]int),
}
// load saved settings
_ = settings.Json(keys.Remote, &r.settings)
_ = settings.Json(keys.RemoteLastSeen, &r.lastSeen)
if r.settings.Enabled && r.settings.Token != "" {
go r.connect()
}
shutdown.Register(r.disconnect)
go func() {
for range time.Tick(time.Minute) {
r.publish()
}
}()
return r
}
// Enable enables or disables remote access. When enabling for the first time,
// it registers with the cloud to obtain a URL and token.
func (r *Remote) Enable(enable bool) error {
r.mu.Lock()
r.settings.Enabled = enable
r.saveSettings()
r.mu.Unlock()
if enable {
// TODO why do we need a go routine for this?
go r.connect()
} else {
r.disconnect()
}
r.publish()
return nil
}
// Enabled returns whether remote access is enabled.
func (r *Remote) Enabled() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.settings.Enabled
}
func (r *Remote) connect() {
r.mu.Lock()
token := r.settings.Token
r.mu.Unlock()
if token == "" {
if err := r.register(); err != nil {
r.log.ERROR.Printf("registration failed: %v", err)
return
}
}
r.log.INFO.Printf("remote access via %s", r.settings.URL)
tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish)
r.mu.Lock()
r.tunnel = tunnel
r.mu.Unlock()
// blocks until disconnected
tunnel.run()
}
func (r *Remote) disconnect() {
r.mu.Lock()
defer r.mu.Unlock()
if r.tunnel != nil {
r.tunnel.Close()
r.tunnel = nil
}
}
type registerRequest struct {
SponsorToken string `json:"sponsorToken"`
}
type registerResponse struct {
URL string `json:"url"`
Token string `json:"token"`
TunnelURL string `json:"tunnelUrl"`
}
// register calls the cloud registration endpoint and persists the result.
func (r *Remote) register() error {
uri := fmt.Sprintf("https://%s/api/register", r.cloudHost)
data := registerRequest{SponsorToken: sponsor.Token}
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
var res registerResponse
client := request.NewHelper(r.log)
if err := client.DoJSON(req, &res); err != nil {
return err
}
r.mu.Lock()
r.settings.URL = res.URL
r.settings.Token = res.Token
r.settings.TunnelURL = res.TunnelURL
r.saveSettings()
r.mu.Unlock()
r.log.INFO.Printf("registered as %s", res.URL)
return nil
}
// TrackActivity tracks remote client connections and disconnections.
func (r *Remote) TrackActivity(username string, active bool) {
r.mu.Lock()
defer r.mu.Unlock()
if active {
r.lastSeen[username] = time.Now()
r.connected[username]++
} else if r.connected[username] > 0 {
r.connected[username]--
}
}
// saveSettings persists the current settings. Must be called with mu held.
func (r *Remote) saveSettings() {
if err := settings.SetJson(keys.Remote, r.settings); err != nil {
r.log.ERROR.Println(err)
}
}
// ConfigStatus returns the current remote access config and status.
func (r *Remote) ConfigStatus() globalconfig.ConfigStatus {
r.mu.Lock()
defer r.mu.Unlock()
connected := r.tunnel != nil && r.tunnel.IsConnected()
loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked()
return globalconfig.ConfigStatus{
Config: struct {
Enabled bool `json:"enabled"`
}{
Enabled: r.settings.Enabled,
},
Status: struct {
Connected bool `json:"connected"`
URL string `json:"url,omitempty"`
LoginBlocked bool `json:"loginBlocked"`
LastSeen map[string]time.Time `json:"lastSeen,omitempty"`
}{
Connected: connected,
URL: r.settings.URL,
LoginBlocked: loginBlocked,
LastSeen: r.lastSeen,
},
}
}
// publish sends the current status to the UI via the value channel.
func (r *Remote) publish() {
if r.publisher == nil {
return
}
// refresh lastSeen for open connections (auth only fires once)
r.mu.Lock()
now := time.Now()
for user, count := range r.connected {
if count > 0 {
r.lastSeen[user] = now
}
}
_ = settings.SetJson(keys.RemoteLastSeen, r.lastSeen)
r.mu.Unlock()
r.publisher <- util.Param{Key: keys.Remote, Val: r.ConfigStatus()}
}

199
server/remote/tunnel.go Normal file
View file

@ -0,0 +1,199 @@
package remote
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/coder/websocket"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/hashicorp/yamux"
)
// Tunnel manages a WebSocket+yamux tunnel to the cloud proxy.
type Tunnel struct {
tunnelURL string
token string
httpHandler http.Handler
authenticate func(user, pass string) bool
trackActivity func(username string, active bool)
log *util.Logger
cancel func()
onStateChange func()
rateLimiter *authRateLimiter
mu sync.Mutex
session *yamux.Session
}
// NewTunnel creates a new tunnel client.
func NewTunnel(tunnelURL, token string, httpHandler http.Handler, authenticate func(user, pass string) bool, trackActivity func(string, bool), log *util.Logger, onStateChange func()) *Tunnel {
return &Tunnel{
tunnelURL: tunnelURL,
token: token,
httpHandler: httpHandler,
authenticate: authenticate,
trackActivity: trackActivity,
log: log,
onStateChange: onStateChange,
rateLimiter: newAuthRateLimiter(),
}
}
// run establishes the tunnel and reconnects on failure.
func (t *Tunnel) run() {
bo := backoff.NewExponentialBackOff(
backoff.WithInitialInterval(time.Second),
backoff.WithMaxInterval(60*time.Second),
backoff.WithMaxElapsedTime(0),
)
ctx, cancel := context.WithCancel(context.Background())
t.cancel = cancel
for {
ok, err := t.connect(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
t.log.ERROR.Printf("tunnel: %v", err)
}
// reset backoff after successful connection
if ok {
bo.Reset()
}
select {
case <-ctx.Done():
return
case <-time.After(bo.NextBackOff()):
}
}
}
func (t *Tunnel) connect(ctx context.Context) (bool, error) {
conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{
HTTPHeader: http.Header{
"Authorization": []string{"Bearer " + t.token},
"X-Sponsor-Token": []string{sponsor.Token},
},
})
if err != nil {
return false, fmt.Errorf("websocket dial: %w", err)
}
netConn := websocket.NetConn(ctx, conn, websocket.MessageBinary)
config := yamux.DefaultConfig()
config.LogOutput = t.log.TRACE.Writer()
session, err := yamux.Client(netConn, config)
if err != nil {
netConn.Close() // closes the underlying socket connection
return false, fmt.Errorf("yamux client: %w", err)
}
t.changeState(session, nil)
// accept streams from the proxy
srv := &http.Server{
Handler: t.basicAuthMiddleware(t.httpHandler),
}
if err := srv.Serve(session); err != nil {
t.changeState(nil, err)
}
return true, nil
}
func (t *Tunnel) changeState(session *yamux.Session, err error) {
t.mu.Lock()
t.session = session
t.mu.Unlock()
if t.onStateChange != nil {
t.onStateChange()
}
if session != nil {
t.log.INFO.Println("tunnel connected")
} else {
if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) {
t.log.INFO.Println("tunnel disconnected")
} else {
t.log.INFO.Println("tunnel disconnected:", err)
}
}
}
// IsConnected returns whether the tunnel is currently connected.
func (t *Tunnel) IsConnected() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.session != nil
}
// LoginBlocked returns whether login attempts are currently blocked by the rate limiter.
func (t *Tunnel) LoginBlocked() bool {
return !t.rateLimiter.allow()
}
// Close tears down the tunnel.
func (t *Tunnel) Close() {
t.mu.Lock()
defer t.mu.Unlock()
// close websocket; produces io.EOF in yamux which it handles silently
if t.session != nil {
t.session.Close() // closes the underlying socket connection
}
if t.cancel != nil {
t.cancel()
t.cancel = nil
}
}
// basicAuthMiddleware wraps a handler with HTTP basic auth, validating
// credentials against the given authenticate function per request.
// It rate-limits failed attempts to prevent brute-force attacks.
func (t *Tunnel) basicAuthMiddleware(next http.Handler) http.Handler {
rejectAuth := func(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="evcc"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || t.authenticate == nil {
rejectAuth(w)
return
}
if !t.rateLimiter.allow() {
t.log.INFO.Printf("login blocked for %q (rate limited)", user)
http.Error(w, "Too many failed login attempts. Try again in 1 minute.", http.StatusTooManyRequests)
return
}
if !t.authenticate(user, pass) {
t.rateLimiter.fail()
t.log.INFO.Printf("failed login attempt for %q", user)
rejectAuth(w)
return
}
if t.trackActivity != nil {
t.trackActivity(user, true)
defer t.trackActivity(user, false) // long-running requests (ws)
}
next.ServeHTTP(w, r)
})
}