Skip to content

Commit d164988

Browse files
webalexeuclaude
andcommitted
feat: Add OCPP forwarder
Forward OCPP calls from chargers to an external upstream server (billing platform, DSO, etc.) in addition to evcc. - Per-rule configuration: station ID (wildcard "*" supported), upstream WebSocket URL, optional upstream station ID override, and optional HTTP Basic Auth password - Live connection status badge per rule (Connected / Disconnected / No charger) with descriptive hover tooltip - Forwarder card moved to the integrations section of the Config UI - Store key and HTTP endpoint: ocppforwarder / /config/ocppforwarder - Callback registration ordering fixed so session connect/disconnect events are pushed to the UI immediately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a2e8358 commit d164988

16 files changed

Lines changed: 942 additions & 14 deletions

File tree

api/globalconfig/types.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ type All struct {
4848
Interval time.Duration
4949
Database DB
5050
Mqtt Mqtt
51-
ModbusProxy []ModbusProxy
51+
ModbusProxy []ModbusProxy
52+
OcppForwarder []ocpp.ForwarderRule `mapstructure:"-"` // db-only, not from yaml
5253
Javascript []Javascript
5354
Go []Go
5455
Influx Influx
@@ -82,6 +83,7 @@ type ModbusProxy struct {
8283
modbus.Settings `mapstructure:",squash" yaml:",inline,omitempty" json:"settings,omitempty"`
8384
}
8485

86+
8587
var _ api.Redactor = (*Hems)(nil)
8688

8789
type Hems config.Typed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<template>
2+
<JsonModal
3+
name="ocppforwarder"
4+
:title="$t('config.ocppforwarder.title')"
5+
:description="$t('config.ocppforwarder.description')"
6+
endpoint="/config/ocppforwarder"
7+
state-key="ocppforwarder"
8+
store-values-in-array
9+
disable-remove
10+
@changed="$emit('changed')"
11+
>
12+
<template #default="{ values }: { values: OcppForwarderRule[] }">
13+
<div
14+
v-for="(rule, index) in values"
15+
:key="index"
16+
class="mb-3"
17+
data-testid="ocppforwarder-rule"
18+
>
19+
<hr v-if="index > 0" class="my-4" />
20+
<FormRow
21+
:id="`ocppforwarderStationId-${index}`"
22+
:label="$t('config.ocppforwarder.stationId')"
23+
:help="$t('config.ocppforwarder.stationIdHelp')"
24+
>
25+
<input
26+
:id="`ocppforwarderStationId-${index}`"
27+
v-model="rule.stationId"
28+
type="text"
29+
class="form-control"
30+
placeholder="*"
31+
spellcheck="false"
32+
autocomplete="off"
33+
required
34+
/>
35+
</FormRow>
36+
<FormRow
37+
:id="`ocppforwarderUpstreamUrl-${index}`"
38+
:label="$t('config.ocppforwarder.upstreamUrl')"
39+
:help="$t('config.ocppforwarder.upstreamUrlHelp')"
40+
>
41+
<div class="d-flex align-items-center gap-2">
42+
<input
43+
:id="`ocppforwarderUpstreamUrl-${index}`"
44+
v-model="rule.upstreamUrl"
45+
type="text"
46+
class="form-control"
47+
inputmode="url"
48+
spellcheck="false"
49+
autocomplete="off"
50+
required
51+
/>
52+
<span
53+
v-if="rule.upstreamUrl"
54+
class="badge flex-shrink-0"
55+
:class="upstreamStatusClass(rule.upstreamUrl)"
56+
:title="$t(`config.ocppforwarder.${upstreamStatusKey(rule.upstreamUrl)}Help`)"
57+
data-bs-toggle="tooltip"
58+
>
59+
{{ $t(`config.ocppforwarder.${upstreamStatusKey(rule.upstreamUrl)}`) }}
60+
</span>
61+
</div>
62+
</FormRow>
63+
<FormRow
64+
:id="`ocppforwarderUpstreamStationId-${index}`"
65+
:label="$t('config.ocppforwarder.upstreamStationId')"
66+
:help="$t('config.ocppforwarder.upstreamStationIdHelp')"
67+
>
68+
<input
69+
:id="`ocppforwarderUpstreamStationId-${index}`"
70+
v-model="rule.upstreamStationId"
71+
type="text"
72+
class="form-control"
73+
:placeholder="rule.stationId"
74+
spellcheck="false"
75+
autocomplete="off"
76+
/>
77+
</FormRow>
78+
<FormRow
79+
:id="`ocppforwarderPassword-${index}`"
80+
:label="$t('config.ocppforwarder.password')"
81+
:help="$t('config.ocppforwarder.passwordHelp')"
82+
>
83+
<input
84+
:id="`ocppforwarderPassword-${index}`"
85+
v-model="rule.password"
86+
type="password"
87+
class="form-control"
88+
autocomplete="new-password"
89+
/>
90+
</FormRow>
91+
<button
92+
type="button"
93+
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray ms-auto"
94+
:aria-label="$t('config.general.remove')"
95+
tabindex="0"
96+
@click="values.splice(index, 1)"
97+
>
98+
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
99+
{{ $t("config.general.remove") }}
100+
</button>
101+
</div>
102+
103+
<hr class="my-4" />
104+
105+
<button
106+
type="button"
107+
class="d-flex btn btn-sm align-items-center gap-2 mb-3"
108+
:class="values.length === 0 ? 'btn-secondary' : 'btn-outline-secondary border-0 evcc-gray'"
109+
data-testid="ocppforwarder-add"
110+
tabindex="0"
111+
@click="values.push({ stationId: '', upstreamUrl: '' })"
112+
>
113+
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
114+
{{ $t("config.ocppforwarder.add") }}
115+
</button>
116+
</template>
117+
</JsonModal>
118+
</template>
119+
120+
<script lang="ts">
121+
import "@h2d2/shopicons/es/regular/plus";
122+
import "@h2d2/shopicons/es/regular/trash";
123+
import { defineComponent } from "vue";
124+
import JsonModal from "./JsonModal.vue";
125+
import FormRow from "./FormRow.vue";
126+
import type { OcppForwarderSession } from "@/types/evcc";
127+
import store from "@/store";
128+
129+
export default defineComponent({
130+
name: "OcppForwarderModal",
131+
components: { JsonModal, FormRow },
132+
emits: ["changed"],
133+
computed: {
134+
sessions(): OcppForwarderSession[] {
135+
return store.state?.ocppforwarderstatus || [];
136+
},
137+
},
138+
methods: {
139+
// Returns true if any active session has an upstream connection to this URL.
140+
upstreamStatusKey(url: string): string {
141+
if (!this.sessions.length) return "upstreamIdle";
142+
const base = url.replace(/\/$/, "");
143+
const connected = this.sessions.some(
144+
(s) => s.upstreamUrl === base && s.upstreamConnected
145+
);
146+
return connected ? "upstreamConnected" : "upstreamDisconnected";
147+
},
148+
upstreamStatusClass(url: string): string {
149+
if (!this.sessions.length) return "bg-secondary";
150+
const base = url.replace(/\/$/, "");
151+
const connected = this.sessions.some(
152+
(s) => s.upstreamUrl === base && s.upstreamConnected
153+
);
154+
return connected ? "bg-success" : "bg-warning";
155+
},
156+
},
157+
});
158+
</script>

assets/js/components/Config/OcppModal.vue

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,8 @@ export default defineComponent({
9898
},
9999
},
100100
computed: {
101-
status() {
102-
return this.ocpp.status;
103-
},
104-
stations() {
105-
return this.status.stations;
101+
stations(): OcppStationStatus[] {
102+
return this.ocpp.status.stations;
106103
},
107104
ocppUrl(): string {
108105
return getOcppUrl(this.ocpp);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<template>
2+
<svg :style="svgStyle" viewBox="0 0 24 24">
3+
<path
4+
fill="currentColor"
5+
d="M3 16v-3H2q-.425 0-.712-.288T1 12t.288-.712T2 11h1V8q0-1.25.875-2.125T6 5h1q0-.425.288-.712T8 4t.713.288T9 5v14q0 .425-.288.713T8 20t-.712-.288T7 19H6q-1.25 0-2.125-.875T3 16m3 1h1V7H6q-.425 0-.712.288T5 8v8q0 .425.288.713T6 17m8.5-5l3.5-3.5V11h3v2h-3v2.5zm3.5 5v-2h-4v-2h4v-2l3 3z"
6+
/>
7+
</svg>
8+
</template>
9+
10+
<script lang="ts">
11+
import { defineComponent } from "vue";
12+
import icon from "@/mixins/icon";
13+
14+
export default defineComponent({
15+
name: "OcppForwarder",
16+
mixins: [icon],
17+
});
18+
</script>

assets/js/types/evcc.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export interface State {
113113
config?: string;
114114
database?: string;
115115
ocpp?: Ocpp;
116+
ocppforwarder?: OcppForwarderRule[];
117+
ocppforwarderstatus?: OcppForwarderSession[];
116118
optimizer?: boolean;
117119
}
118120

@@ -128,6 +130,19 @@ export interface OcppConfig {
128130
port: number;
129131
}
130132

133+
export interface OcppForwarderRule {
134+
stationId: string;
135+
upstreamUrl: string;
136+
password?: string;
137+
upstreamStationId?: string;
138+
}
139+
140+
export interface OcppForwarderSession {
141+
chargerId: string;
142+
upstreamUrl: string;
143+
upstreamConnected: boolean;
144+
}
145+
131146
export interface OcppStatus {
132147
externalUrl?: string;
133148
stations: OcppStationStatus[];

assets/js/views/Config.vue

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,18 @@
308308
<DeviceTags :tags="modbusproxyTags" />
309309
</template>
310310
</DeviceCard>
311+
<DeviceCard
312+
:title="$t('config.ocppforwarder.title')"
313+
editable
314+
:unconfigured="isUnconfigured(ocppforwarderTags)"
315+
data-testid="ocppforwarder"
316+
@edit="openModal('ocppforwarder')"
317+
>
318+
<template #icon><OcppForwarderIcon /></template>
319+
<template #tags>
320+
<DeviceTags :tags="ocppforwarderTags" />
321+
</template>
322+
</DeviceCard>
311323
<DeviceCard
312324
:title="$t('config.hems.title')"
313325
editable
@@ -440,6 +452,7 @@
440452
@changed="loadDirty"
441453
/>
442454
<OcppModal :ocpp="ocpp" />
455+
<OcppForwarderModal @changed="loadDirty" />
443456
<BackupRestoreModal v-bind="backupRestoreProps" />
444457
<PasswordModal update-mode />
445458
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
@@ -467,6 +480,8 @@ import EebusIcon from "../components/MaterialIcon/Eebus.vue";
467480
import EebusModal from "../components/Config/EebusModal.vue";
468481
import OcppIcon from "../components/MaterialIcon/Ocpp.vue";
469482
import OcppModal from "../components/Config/OcppModal.vue";
483+
import OcppForwarderIcon from "../components/MaterialIcon/OcppForwarder.vue";
484+
import OcppForwarderModal from "../components/Config/OcppForwarderModal.vue";
470485
import formatter from "../mixins/formatter";
471486
import GeneralConfig from "../components/Config/GeneralConfig.vue";
472487
import HemsIcon from "../components/MaterialIcon/Hems.vue";
@@ -555,6 +570,8 @@ export default defineComponent({
555570
EebusModal,
556571
OcppIcon,
557572
OcppModal,
573+
OcppForwarderIcon,
574+
OcppForwarderModal,
558575
GeneralConfig,
559576
HemsIcon,
560577
HemsModal,
@@ -836,6 +853,13 @@ export default defineComponent({
836853
}
837854
return { configured: { value: false } };
838855
},
856+
ocppforwarderTags(): DeviceTags {
857+
const rules = store.state?.ocppforwarder || [];
858+
if (rules.length > 0) {
859+
return { amount: { value: rules.length } };
860+
}
861+
return { configured: { value: false } };
862+
},
839863
messagingTags(): DeviceTags {
840864
if (this.messagingUiConfigured) {
841865
const events = store.state?.messagingEvents || [];

0 commit comments

Comments
 (0)