Skip to content

Commit e1b23e0

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. Architecture: sidecar model. Chargers connect directly to evcc on the normal OCPP port. For each charger with a matching rule a WebSocket sidecar connection is dialled to the upstream server. - Raw Call frames (type 2) from the charger are mirrored to upstream so the upstream maintains a full OCPP session view. CallResult / CallError responses to evcc's own requests are not forwarded to avoid confusing upstream servers. - Calls from upstream are injected back into the charger; CallResults from upstream are discarded (evcc already replied to the charger). - Read-only mode: upstream can observe but cannot send commands; incoming calls are answered with a SecurityError. - Pre-connection buffer ensures BootNotification and other early frames reach upstream even when the sidecar dial races the charger connect. - UI: two-box layout (Charger → Upstream) matching the Modbus proxy modal, with SelectGroup for the read-only option. - Status badge on the upstream URL reflects live sidecar session state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0918c62 commit e1b23e0

14 files changed

Lines changed: 1056 additions & 45 deletions

File tree

api/globalconfig/types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type All struct {
4949
Database DB
5050
Mqtt Mqtt
5151
ModbusProxy []ModbusProxy
52+
OcppForwarder []OcppForwarder
5253
Javascript []Javascript
5354
Go []Go
5455
Influx Influx
@@ -66,6 +67,9 @@ type All struct {
6667
Circuits []config.Named
6768
}
6869

70+
// OcppForwarder is an alias for ocpp.ForwarderRule used in YAML/DB configuration.
71+
type OcppForwarder = ocpp.ForwarderRule
72+
6973
type Javascript struct {
7074
VM string
7175
Script string
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
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+
size="xl"
11+
@changed="$emit('changed')"
12+
>
13+
<template #default="{ values }: { values: OcppForwarderRule[] }">
14+
<div class="mb-3">
15+
<div v-for="(rule, index) in values" :key="index" data-testid="ocppforwarder-rule">
16+
<div class="d-block">
17+
<hr class="mt-5" />
18+
<h5>
19+
<div class="inner mb-4">
20+
{{ $t("config.ocppforwarder.rule", { number: index + 1 }) }}
21+
</div>
22+
</h5>
23+
</div>
24+
<div class="row d-inline d-lg-flex mb-3">
25+
<div class="col-lg-5" data-testid="charger-box">
26+
<div class="border rounded px-3 pt-4 pb-3">
27+
<div class="d-lg-block">
28+
<h5 class="box-heading">
29+
<div class="inner">
30+
{{ $t("config.ocppforwarder.charger") }}
31+
</div>
32+
</h5>
33+
</div>
34+
<FormRow
35+
:id="`ocppforwarderStationId-${index}`"
36+
:label="$t('config.ocppforwarder.stationId')"
37+
:help="$t('config.ocppforwarder.stationIdHelp')"
38+
>
39+
<input
40+
:id="`ocppforwarderStationId-${index}`"
41+
v-model="rule.stationId"
42+
type="text"
43+
class="form-control"
44+
placeholder="*"
45+
spellcheck="false"
46+
autocomplete="off"
47+
required
48+
/>
49+
</FormRow>
50+
</div>
51+
</div>
52+
<div
53+
class="col-lg-2 d-none d-lg-flex justify-content-center evcc-gray"
54+
style="padding-top: 2.5rem"
55+
>
56+
<shopicon-regular-arrowright
57+
size="l"
58+
class="flex-shrink-0"
59+
></shopicon-regular-arrowright>
60+
</div>
61+
<div class="col d-flex d-lg-none justify-content-center evcc-gray my-3">
62+
<shopicon-regular-arrowdown
63+
size="l"
64+
class="flex-shrink-0"
65+
></shopicon-regular-arrowdown>
66+
</div>
67+
<div class="col-lg-5" data-testid="upstream-box">
68+
<div class="border rounded px-3 pt-4 pb-3">
69+
<div class="d-lg-block">
70+
<h5 class="box-heading">
71+
<div class="inner">
72+
{{ $t("config.ocppforwarder.upstream") }}
73+
</div>
74+
</h5>
75+
</div>
76+
<FormRow
77+
:id="`ocppforwarderUpstreamUrl-${index}`"
78+
:label="$t('config.ocppforwarder.upstreamUrl')"
79+
:help="$t('config.ocppforwarder.upstreamUrlHelp')"
80+
>
81+
<div class="d-flex align-items-center gap-2">
82+
<input
83+
:id="`ocppforwarderUpstreamUrl-${index}`"
84+
v-model="rule.upstreamUrl"
85+
type="text"
86+
class="form-control"
87+
inputmode="url"
88+
spellcheck="false"
89+
autocomplete="off"
90+
required
91+
/>
92+
<span
93+
v-if="rule.upstreamUrl"
94+
class="badge flex-shrink-0"
95+
:class="upstreamStatusClass(rule.upstreamUrl)"
96+
:title="
97+
$t(
98+
`config.ocppforwarder.${upstreamStatusKey(rule.upstreamUrl)}Help`
99+
)
100+
"
101+
data-bs-toggle="tooltip"
102+
>
103+
{{
104+
$t(
105+
`config.ocppforwarder.${upstreamStatusKey(rule.upstreamUrl)}`
106+
)
107+
}}
108+
</span>
109+
</div>
110+
</FormRow>
111+
<FormRow
112+
:id="`ocppforwarderUpstreamStationId-${index}`"
113+
:label="$t('config.ocppforwarder.upstreamStationId')"
114+
:help="$t('config.ocppforwarder.upstreamStationIdHelp')"
115+
>
116+
<input
117+
:id="`ocppforwarderUpstreamStationId-${index}`"
118+
v-model="rule.upstreamStationId"
119+
type="text"
120+
class="form-control"
121+
:placeholder="rule.stationId"
122+
spellcheck="false"
123+
autocomplete="off"
124+
/>
125+
</FormRow>
126+
<FormRow
127+
:id="`ocppforwarderPassword-${index}`"
128+
:label="$t('config.ocppforwarder.password')"
129+
:help="$t('config.ocppforwarder.passwordHelp')"
130+
>
131+
<input
132+
:id="`ocppforwarderPassword-${index}`"
133+
v-model="rule.password"
134+
type="password"
135+
class="form-control"
136+
autocomplete="new-password"
137+
/>
138+
</FormRow>
139+
<FormRow
140+
:id="`ocppforwarderInsecure-${index}`"
141+
:label="$t('config.ocppforwarder.labelInsecure')"
142+
>
143+
<div class="d-flex">
144+
<input
145+
:id="`ocppforwarderInsecure-${index}`"
146+
v-model="rule.insecure"
147+
class="form-check-input"
148+
type="checkbox"
149+
/>
150+
<label
151+
class="form-check-label ms-2"
152+
:for="`ocppforwarderInsecure-${index}`"
153+
>
154+
{{ $t("config.ocppforwarder.labelCheckInsecure") }}
155+
</label>
156+
</div>
157+
</FormRow>
158+
<PropertyCollapsible>
159+
<template #advanced>
160+
<FormRow
161+
:id="`ocppforwarderCaCert-${index}`"
162+
:label="$t('config.ocppforwarder.labelCaCert')"
163+
optional
164+
>
165+
<PropertyCertField
166+
:id="`ocppforwarderCaCert-${index}`"
167+
v-model="rule.caCert"
168+
/>
169+
</FormRow>
170+
</template>
171+
</PropertyCollapsible>
172+
<FormRow
173+
:id="`ocppforwarderReadOnly-${index}`"
174+
:label="$t('config.ocppforwarder.readOnly.label')"
175+
:help="getReadOnlyHelp(rule.readOnly)"
176+
>
177+
<SelectGroup
178+
:id="`ocppforwarderReadOnly-${index}`"
179+
:model-value="rule.readOnly ? 'true' : 'false'"
180+
class="w-100"
181+
:options="readOnlyOptions"
182+
transparent
183+
@update:model-value="rule.readOnly = $event === 'true'"
184+
/>
185+
</FormRow>
186+
</div>
187+
</div>
188+
</div>
189+
<button
190+
type="button"
191+
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray ms-auto"
192+
:aria-label="$t('config.general.remove')"
193+
tabindex="0"
194+
@click="values.splice(index, 1)"
195+
>
196+
<shopicon-regular-trash
197+
size="s"
198+
class="flex-shrink-0"
199+
></shopicon-regular-trash>
200+
{{ $t("config.general.remove") }}
201+
</button>
202+
</div>
203+
204+
<hr class="my-5" />
205+
206+
<button
207+
type="button"
208+
class="d-flex btn btn-sm align-items-center gap-2 mb-5"
209+
:class="
210+
values.length === 0
211+
? 'btn-secondary'
212+
: 'btn-outline-secondary border-0 evcc-gray'
213+
"
214+
data-testid="ocppforwarder-add"
215+
tabindex="0"
216+
@click="addRule(values)"
217+
>
218+
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
219+
{{ $t("config.ocppforwarder.add") }}
220+
</button>
221+
</div>
222+
</template>
223+
</JsonModal>
224+
</template>
225+
226+
<script lang="ts">
227+
import "@h2d2/shopicons/es/regular/arrowright";
228+
import "@h2d2/shopicons/es/regular/arrowdown";
229+
import "@h2d2/shopicons/es/regular/plus";
230+
import "@h2d2/shopicons/es/regular/trash";
231+
import { defineComponent } from "vue";
232+
import JsonModal from "./JsonModal.vue";
233+
import FormRow from "./FormRow.vue";
234+
import SelectGroup from "@/components/Helper/SelectGroup.vue";
235+
import PropertyCollapsible from "./PropertyCollapsible.vue";
236+
import PropertyCertField from "./PropertyCertField.vue";
237+
import type { OcppForwarderRule, OcppForwarderSession } from "@/types/evcc";
238+
import store from "@/store";
239+
240+
export default defineComponent({
241+
name: "OcppForwarderModal",
242+
components: { JsonModal, FormRow, SelectGroup, PropertyCollapsible, PropertyCertField },
243+
emits: ["changed"],
244+
computed: {
245+
sessions(): OcppForwarderSession[] {
246+
return store.state?.ocppforwarderstatus || [];
247+
},
248+
readOnlyOptions() {
249+
return ["false", "true"].map((value) => ({
250+
value,
251+
name: this.$t(`config.ocppforwarder.option.${value}`),
252+
}));
253+
},
254+
},
255+
methods: {
256+
getReadOnlyHelp(readOnly?: boolean): string {
257+
return this.$t(`config.ocppforwarder.readOnly.help.${readOnly ? "true" : "false"}`);
258+
},
259+
// Returns true if any active session has an upstream connection to this URL.
260+
upstreamStatusKey(url: string): string {
261+
if (!this.sessions.length) return "upstreamIdle";
262+
const base = url.replace(/\/$/, "");
263+
const connected = this.sessions.some(
264+
(s) => s.upstreamUrl === base && s.upstreamConnected
265+
);
266+
return connected ? "upstreamConnected" : "upstreamDisconnected";
267+
},
268+
upstreamStatusClass(url: string): string {
269+
if (!this.sessions.length) return "bg-secondary";
270+
const base = url.replace(/\/$/, "");
271+
const connected = this.sessions.some(
272+
(s) => s.upstreamUrl === base && s.upstreamConnected
273+
);
274+
return connected ? "bg-success" : "bg-warning";
275+
},
276+
addRule(values: OcppForwarderRule[]) {
277+
values.push({ stationId: "", upstreamUrl: "" });
278+
},
279+
},
280+
});
281+
</script>
282+
283+
<style scoped>
284+
h5 {
285+
position: relative;
286+
display: flex;
287+
top: -25px;
288+
margin-bottom: -0.5rem;
289+
padding: 0 0.5rem;
290+
justify-content: center;
291+
}
292+
h5.box-heading {
293+
top: -34px;
294+
margin-bottom: -24px;
295+
}
296+
h5 .inner {
297+
padding: 0 0.5rem;
298+
background-color: var(--evcc-box);
299+
font-weight: normal;
300+
color: var(--evcc-gray);
301+
text-align: center;
302+
}
303+
</style>

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: 18 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,22 @@ 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+
insecure?: boolean;
139+
caCert?: string;
140+
readOnly?: boolean;
141+
}
142+
143+
export interface OcppForwarderSession {
144+
chargerId: string;
145+
upstreamUrl: string;
146+
upstreamConnected: boolean;
147+
}
148+
131149
export interface OcppStatus {
132150
externalUrl?: string;
133151
stations: OcppStationStatus[];

0 commit comments

Comments
 (0)