Config UI: visualize Modbusproxy (#24015)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2025-11-13 09:44:39 +01:00 • committed by GitHub
parent 7924922d4b
commit 07bcca2925
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 666 additions and 119 deletions

View file

@ -56,9 +56,9 @@ type Go struct {
}
type ModbusProxy struct {
Port int
ReadOnly string `yaml:",omitempty" json:",omitempty"`
modbus.Settings `mapstructure:",squash" yaml:",inline,omitempty" json:",omitempty"`
Port int `json:"port"`
ReadOnly string `yaml:",omitempty" json:"readonly,omitempty"`
modbus.Settings `mapstructure:",squash" yaml:",inline,omitempty" json:"settings,omitempty"`
}
var _ api.Redactor = (*Hems)(nil)

View file

@ -4,41 +4,41 @@
id="modbusTcpIp"
:label="$t('config.modbus.connection')"
:help="
connection === 'tcpip'
connection === MODBUS_CONNECTION.TCPIP
? $t('config.modbus.connectionHintTcpip')
: $t('config.modbus.connectionHintSerial')
"
>
<div class="btn-group" role="group">
<input
id="modbusTcpIp"
:id="formId('modbusTcpIp')"
v-model="connection"
type="radio"
class="btn-check"
name="modbusConnection"
:name="formId('modbusConnection')"
value="tcpip"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusTcpIp">
<label class="btn btn-outline-primary" :for="formId('modbusTcpIp')">
{{ $t("config.modbus.connectionValueTcpip") }}
</label>
<input
id="modbusSerial"
:id="formId('modbusSerial')"
v-model="connection"
type="radio"
class="btn-check"
name="modbusConnection"
:name="formId('modbusConnection')"
value="serial"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusSerial">
<label class="btn btn-outline-primary" :for="formId('modbusSerial')">
{{ $t("config.modbus.connectionValueSerial") }}
</label>
</div>
</FormRow>
<FormRow id="modbusId" :label="$t('config.modbus.id')">
<FormRow v-if="!hideModbusId" id="modbusId" :label="$t('config.modbus.id')">
<PropertyField
id="modbusId"
property="id"
@ -49,14 +49,14 @@
@change="$emit('update:id', $event.target.value)"
/>
</FormRow>
<div v-if="connection === 'tcpip'">
<div v-if="connection === MODBUS_CONNECTION.TCPIP">
<FormRow
id="modbusHost"
:id="formId('modbusHost')"
:label="$t('config.modbus.host')"
:help="$t('config.modbus.hostHint')"
>
<PropertyField
id="modbusHost"
:id="formId('modbusHost')"
property="host"
type="String"
class="me-2"
@ -65,9 +65,9 @@
@change="$emit('update:host', $event.target.value)"
/>
</FormRow>
<FormRow id="modbusPort" :label="$t('config.modbus.port')">
<FormRow :id="formId('modbusPort')" :label="$t('config.modbus.port')">
<PropertyField
id="modbusPort"
:id="formId('modbusPort')"
property="port"
type="Int"
class="me-2 w-50"
@ -78,7 +78,7 @@
</FormRow>
<FormRow
v-if="showProtocolOptions"
id="modbusTcp"
:id="formId('modbusTcp')"
:label="$t('config.modbus.protocol')"
:help="
protocol === 'tcp'
@ -88,29 +88,29 @@
>
<div class="btn-group" role="group">
<input
id="modbusTcp"
:id="formId('modbusTcp')"
v-model="protocol"
type="radio"
class="btn-check"
name="modbusProtocol"
:name="formId('modbusProtocol')"
value="tcp"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusTcp">
<label class="btn btn-outline-primary" :for="formId('modbusTcp')">
{{ $t("config.modbus.protocolValueTcp") }}
</label>
<input
id="modbusRtu"
:id="formId('modbusRtu')"
v-model="protocol"
type="radio"
class="btn-check"
name="modbusProtocol"
:name="formId('modbusProtocol')"
value="rtu"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusRtu">
<label class="btn btn-outline-primary" :for="formId('modbusRtu')">
{{ $t("config.modbus.protocolValueRtu") }}
</label>
</div>
@ -118,12 +118,12 @@
</div>
<div v-else>
<FormRow
id="modbusDevice"
:id="formId('modbusDevice')"
:label="$t('config.modbus.device')"
:help="$t('config.modbus.deviceHint')"
>
<PropertyField
id="modbusDevice"
:id="formId('modbusDevice')"
property="device"
type="String"
class="me-2"
@ -132,21 +132,21 @@
@change="$emit('update:device', $event.target.value)"
/>
</FormRow>
<FormRow id="modbusBaudrate" :label="$t('config.modbus.baudrate')">
<FormRow :id="formId('modbusBaudrate')" :label="$t('config.modbus.baudrate')">
<PropertyField
id="modbusBaudrate"
:id="formId('modbusBaudrate')"
property="baudrate"
type="Choice"
class="me-2 w-50"
:choice="baudrateOptions"
required
:model-value="baudrate || defaultBaudrate"
@change="$emit('update:baudrate', $event.target.value)"
@change="$emit('update:baudrate', parseInt($event.target.value))"
/>
</FormRow>
<FormRow id="modbusComset" :label="$t('config.modbus.comset')">
<FormRow :id="formId('modbusComset')" :label="$t('config.modbus.comset')">
<PropertyField
id="modbusComset"
:id="formId('modbusComset')"
property="comset"
type="Choice"
class="me-2 w-50"
@ -165,9 +165,13 @@ import FormRow from "../FormRow.vue";
import PropertyField from "../PropertyField.vue";
import type { PropType } from "vue";
import type { ModbusCapability } from "./index";
type Modbus = "rs485serial" | "rs485tcpip" | "tcpip";
type ConnectionOption = "tcpip" | "serial";
type ProtocolOption = "tcp" | "rtu";
import {
MODBUS_BAUDRATE,
MODBUS_COMSET,
MODBUS_CONNECTION,
MODBUS_PROTOCOL,
MODBUS_TYPE,
} from "@/types/evcc";
export default defineComponent({
name: "Modbus",
@ -177,7 +181,7 @@ export default defineComponent({
type: Array as PropType<ModbusCapability[]>,
default: () => [],
},
modbus: String as PropType<Modbus>,
modbus: String as PropType<MODBUS_TYPE>,
host: String,
port: [Number, String],
id: [Number, String],
@ -188,6 +192,7 @@ export default defineComponent({
defaultId: Number,
defaultComset: String,
defaultBaudrate: Number,
hideModbusId: Boolean,
},
emits: [
"update:modbus",
@ -198,45 +203,53 @@ export default defineComponent({
"update:baudrate",
"update:comset",
],
data(): { connection: ConnectionOption; protocol: ProtocolOption } {
data() {
return {
connection: "tcpip",
protocol: "tcp",
connection: MODBUS_CONNECTION.TCPIP as MODBUS_CONNECTION,
protocol: MODBUS_PROTOCOL.TCP as MODBUS_PROTOCOL,
MODBUS_PROTOCOL,
MODBUS_CONNECTION,
};
},
computed: {
selectedModbus(): Modbus {
if (this.connection === "serial") {
return "rs485serial";
selectedModbus(): MODBUS_TYPE {
if (this.connection === MODBUS_CONNECTION.SERIAL) {
return MODBUS_TYPE.RS485_SERIAL;
}
return this.protocol === "rtu" ? "rs485tcpip" : "tcpip";
return this.protocol === MODBUS_PROTOCOL.RTU
? MODBUS_TYPE.RS485_TCPIP
: MODBUS_TYPE.TCPIP;
},
showConnectionOptions() {
return this.capabilities.includes("rs485");
},
showProtocolOptions() {
return this.connection === "tcpip" && this.capabilities.includes("rs485");
return (
this.connection === MODBUS_CONNECTION.TCPIP && this.capabilities.includes("rs485")
);
},
comsetOptions() {
return ["8N1", "8E1", "8N2"].map((v) => {
return Object.values(MODBUS_COMSET).map((v) => {
return { key: v, name: v };
});
},
baudrateOptions() {
return [1200, 9600, 19200, 38400, 57600, 115200].map((v) => {
return Object.values(MODBUS_BAUDRATE)
.filter((v) => typeof v === "number")
.map((v) => {
return { key: v, name: `${v}` };
});
},
},
watch: {
selectedModbus(newValue: Modbus) {
selectedModbus(newValue: MODBUS_TYPE) {
this.$emit("update:modbus", newValue);
},
options(newValue: ModbusCapability[]) {
this.setProtocolByCapabilities(newValue);
this.$emit("update:modbus", this.selectedModbus);
},
modbus(newValue: Modbus) {
modbus(newValue: MODBUS_TYPE) {
if (newValue) {
this.setConnectionAndProtocolByModbus(newValue);
}
@ -248,24 +261,29 @@ export default defineComponent({
},
methods: {
setProtocolByCapabilities(capabilities: ModbusCapability[]) {
this.protocol = capabilities.includes("tcpip") ? "tcp" : "rtu";
this.protocol = capabilities.includes("tcpip")
? MODBUS_PROTOCOL.TCP
: MODBUS_PROTOCOL.RTU;
},
setConnectionAndProtocolByModbus(modbus?: Modbus) {
setConnectionAndProtocolByModbus(modbus?: MODBUS_TYPE) {
switch (modbus) {
case "rs485serial":
this.connection = "serial";
this.protocol = "rtu";
case MODBUS_TYPE.RS485_SERIAL:
this.connection = MODBUS_CONNECTION.SERIAL;
this.protocol = MODBUS_PROTOCOL.RTU;
break;
case "rs485tcpip":
this.connection = "tcpip";
this.protocol = "rtu";
case MODBUS_TYPE.RS485_TCPIP:
this.connection = MODBUS_CONNECTION.TCPIP;
this.protocol = MODBUS_PROTOCOL.RTU;
break;
case "tcpip":
this.connection = "tcpip";
this.protocol = "tcp";
case MODBUS_TYPE.TCPIP:
this.connection = MODBUS_CONNECTION.TCPIP;
this.protocol = MODBUS_PROTOCOL.TCP;
break;
}
},
formId(name: string): string {
return `${name}-${this.id}`;
},
},
});
</script>

View file

@ -5,7 +5,7 @@
</a>
</div>
<div v-else class="alert alert-warning my-4">
{{ $t("config.sponsor.tokenRequired") }}
{{ $t(feature ? "config.sponsor.tokenRequiredFeature" : "config.sponsor.tokenRequired") }}
<a href="#" role="button" class="text-warning" @click.prevent="openModal">
{{ $t("config.sponsor.tokenRequiredLearnMore") }}
</a>
@ -19,6 +19,7 @@ export default {
name: "SponsorTokenRequired",
props: {
compact: Boolean,
feature: Boolean,
},
methods: {
openModal() {

View file

@ -1,4 +1,4 @@
import type { DeviceType, MeterTemplateUsage } from "@/types/evcc";
import type { DeviceType, MODBUS_COMSET, MeterTemplateUsage } from "@/types/evcc";
import { ConfigType } from "@/types/evcc";
import api from "@/api";
@ -31,7 +31,7 @@ export type ModbusCapability = "rs485" | "tcpip";
export type ModbusParam = TemplateParam & {
ID?: string;
Comset?: string;
Comset?: MODBUS_COMSET;
Baudrate?: number;
Port?: number;
};

View file

@ -1,5 +1,5 @@
<template>
<GenericModal ref="modal" :title="title" @open="open">
<GenericModal ref="modal" :title="title" :size="size" @open="open">
<p v-if="description || docsLink">
<span v-if="description">{{ description + " " }}</span>
<a v-if="docsLink" :href="docsLink" target="_blank">
@ -68,6 +68,7 @@ import GenericModal from "../Helper/GenericModal.vue";
import api from "@/api";
import { docsPrefix } from "@/i18n";
import store from "@/store";
import deepClone from "@/utils/deepClone";
export default {
name: "JsonModal",
@ -84,6 +85,8 @@ export default {
transformReadValues: Function,
stateKey: String,
saveMethod: { type: String, default: "post" },
storeValuesInArray: Boolean,
size: { type: String },
},
emits: ["changed", "open"],
data() {
@ -91,8 +94,8 @@ export default {
saving: false,
removing: false,
error: "",
values: {},
serverValues: {},
values: this.storeValuesInArray ? [] : {},
serverValues: this.storeValuesInArray ? [] : {},
};
},
computed: {
@ -121,7 +124,11 @@ export default {
if (this.transformReadValues) {
this.serverValues = this.transformReadValues(this.serverValues);
}
this.values = { ...this.serverValues };
// Handle null/undefined values when expecting an array or object
if (this.serverValues == null) {
this.serverValues = this.storeValuesInArray ? [] : {};
}
this.values = deepClone(this.serverValues);
},
async save() {
this.saving = true;
@ -163,13 +170,19 @@ export default {
this.removing = false;
},
trimValues(values) {
// extend to recursive when needed in the future
if (Array.isArray(values)) {
for (let index = 0; index < values.length; index++) {
values[index] = this.trimValues(values[index]);
}
return values;
} else {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
typeof value === "string" ? value.trim() : value,
])
);
}
},
},
};

View file

@ -1,27 +1,266 @@
<template>
<YamlModal
<JsonModal
id="modbusProxyModal"
:title="$t('config.modbusproxy.title')"
:description="$t('config.modbusproxy.description')"
docs="/docs/reference/configuration/modbusproxy"
:defaultYaml="defaultYaml"
endpoint="/config/modbusproxy"
removeKey="modbusproxy"
size="md"
state-key="modbusproxy"
store-values-in-array
disable-remove
data-testid="modbusproxy-modal"
size="xl"
@changed="$emit('changed')"
>
<template #default="{ values }: { values: ModbusProxy[] }">
<div class="mb-3">
<SponsorTokenRequired v-if="!isSponsor" feature />
<pre class="text-monospace my-5">{{ ASCII_DIAGRAM }}</pre>
<div v-for="(c, index) in values" :key="index" data-testid="modbusproxy-connection">
<div class="d-block">
<hr class="mt-5" />
<h5>
<div class="inner mb-4">
{{ $t("config.modbusproxy.connection", { number: index + 1 }) }}
</div>
</h5>
</div>
<div class="row d-inline d-lg-flex mb-3">
<div class="col-lg-5" data-testid="evcc-box">
<div class="border rounded px-3 pt-4 pb-3">
<div class="d-lg-block">
<h5 class="box-heading">
<div class="inner">evcc</div>
</h5>
</div>
<FormRow
:id="formId(index, 'sourcePort')"
:label="$t('config.modbus.port')"
:help="$t('config.modbusproxy.sourcePortHelp')"
>
<PropertyField
:id="formId(index, 'sourcePort')"
v-model="c.port"
property="port"
type="Int"
class="w-50"
required
/>
</FormRow>
<FormRow
:id="formId(index, 'readonly')"
:label="$t('config.modbusproxy.readonly.label')"
:help="getReadonlyHelp(c.readonly)"
>
<SelectGroup
:id="formId(index, 'readonly')"
v-model="c.readonly"
class="w-100"
:options="readonlyOptions"
transparent
/>
</FormRow>
</div>
</div>
<div
class="col-lg-2 d-none d-lg-flex justify-content-center evcc-gray"
style="padding-top: 2.5rem"
>
<shopicon-regular-arrowright
size="l"
class="flex-shrink-0"
></shopicon-regular-arrowright>
</div>
<div class="col d-flex d-lg-none justify-content-center evcc-gray my-3">
<shopicon-regular-arrowdown
size="l"
class="flex-shrink-0"
></shopicon-regular-arrowdown>
</div>
<div class="col-lg-5" data-testid="device-box">
<div class="border rounded px-3 pt-4 pb-3">
<div class="d-lg-block">
<h5 class="box-heading">
<div class="inner">
{{ $t("config.modbusproxy.device") }}
</div>
</h5>
</div>
<Modbus
:id="index"
v-model:baudrate="c.settings.baudrate"
v-model:comset="c.settings.comset"
v-model:device="c.settings.device"
:modbus="getModbus(c.settings)"
:host="getHost(c.settings.uri)"
:port="getPort(c.settings.uri)"
:capabilities="['rs485', 'tcpip']"
hide-modbus-id
@update:host="(host) => updateHost(host, c.settings)"
@update:port="(port) => updatePort(port, c.settings)"
@update:modbus="(modbus) => updateModbus(c.settings, modbus)"
/>
</div>
</div>
</div>
<button
type="button"
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray ms-auto"
:aria-label="$t('config.general.remove')"
tabindex="0"
@click="values.splice(index, 1)"
>
<shopicon-regular-trash
size="s"
class="flex-shrink-0"
></shopicon-regular-trash>
{{ $t("config.general.remove") }}
</button>
</div>
<hr class="my-5" />
<button
type="button"
class="d-flex btn btn-sm align-items-center gap-2 mb-5"
:class="
values.length === 0
? 'btn-secondary'
: 'btn-outline-secondary border-0 evcc-gray'
"
data-testid="networkconnection-add"
tabindex="0"
@click="addConnection(values)"
>
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
{{ $t("config.modbusproxy.add") }}
</button>
</div>
</template>
</JsonModal>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/modbusproxy.yaml?raw";
<script lang="ts">
import "@h2d2/shopicons/es/regular/arrowright";
import "@h2d2/shopicons/es/regular/arrowdown";
import "@h2d2/shopicons/es/regular/plus";
import "@h2d2/shopicons/es/regular/trash";
import JsonModal from "./JsonModal.vue";
import {
MODBUS_COMSET,
MODBUS_CONNECTION,
MODBUS_PROTOCOL,
MODBUS_PROXY_READONLY,
MODBUS_TYPE,
type ModbusProxy,
type ModbusProxySettings,
} from "@/types/evcc";
import ASCII_DIAGRAM from "./modbus-diagram.txt?raw";
import Modbus from "./DeviceModal/Modbus.vue";
import PropertyField from "./PropertyField.vue";
import FormRow from "./FormRow.vue";
import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue";
import SelectGroup from "@/components/Helper/SelectGroup.vue";
import { defineComponent } from "vue";
export default {
export default defineComponent({
name: "ModbusProxyModal",
components: { YamlModal },
components: { JsonModal, Modbus, FormRow, PropertyField, SponsorTokenRequired, SelectGroup },
props: {
isSponsor: Boolean,
},
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
return {
ASCII_DIAGRAM,
MODBUS_PROXY_READONLY,
MODBUS_CONNECTION,
MODBUS_PROTOCOL,
MODBUS_TYPE,
};
},
};
computed: {
readonlyOptions() {
return Object.values(MODBUS_PROXY_READONLY).map((value) => ({
value,
name: this.$t(`config.modbusproxy.option.${value}`),
}));
},
},
methods: {
formId(index: number, name: string) {
return `modbusproxy-connection-${index}-${name}`;
},
getModbus(s: ModbusProxySettings) {
if (s.device) {
return MODBUS_TYPE.RS485_SERIAL;
}
return s.rtu ? MODBUS_TYPE.RS485_TCPIP : MODBUS_TYPE.TCPIP;
},
getReadonlyHelp(readonly = MODBUS_PROXY_READONLY.FALSE): string {
return this.$t(`config.modbusproxy.readonly.help.${readonly}`);
},
addConnection(values: ModbusProxy[]) {
const highestPort = values.length > 0 ? Math.max(...values.map((c) => c.port)) : 1501;
values.push({
port: highestPort + 1,
readonly: MODBUS_PROXY_READONLY.FALSE,
settings: {
uri: ":502",
baudrate: 9600,
comset: "8N1" as MODBUS_COMSET,
},
});
},
getHost(uri?: string) {
return uri?.split(":")[0] || "";
},
getPort(uri?: string) {
return uri?.split(":")[1] || "";
},
updateHost(newHost: string, settings: ModbusProxySettings) {
const port = this.getPort(settings.uri);
settings.uri = `${newHost}:${port}`;
},
updatePort(newPort: string | number, settings: ModbusProxySettings) {
const host = this.getHost(settings.uri);
settings.uri = `${host}:${newPort}`;
},
updateModbus(settings: ModbusProxySettings, modbus: MODBUS_TYPE) {
switch (modbus) {
case MODBUS_TYPE.RS485_SERIAL:
settings.uri = undefined;
settings.rtu = undefined;
break;
case MODBUS_TYPE.RS485_TCPIP:
case MODBUS_TYPE.TCPIP:
settings.device = undefined;
settings.baudrate = undefined;
settings.comset = undefined;
settings.rtu = modbus === MODBUS_TYPE.RS485_TCPIP;
break;
}
},
},
});
</script>
<style scoped>
h5 {
position: relative;
display: flex;
top: -25px;
margin-bottom: -0.5rem;
padding: 0 0.5rem;
justify-content: center;
}
h5.box-heading {
top: -34px;
margin-bottom: -24px;
}
h5 .inner {
padding: 0 0.5rem;
background-color: var(--evcc-box);
font-weight: normal;
color: var(--evcc-gray);
text-align: center;
}
</style>

View file

@ -1,7 +0,0 @@
#- port: 5021 # proxy port for incoming ModbusTCP requests
# uri: 192.0.2.2:502 # device address for downstream requests
# rtu: true # optional: use RTU for downstream requests to device
#- port: 5022 # proxy port for incoming ModbusTCP requests
# device: /dev/ttyUSB0 # local device for downstream requests
# baudrate: 9600
# comset: "8N1"

View file

@ -0,0 +1,7 @@
┌────────┐
┌─ network ─> │ device │
┌────────┐ ┌──────┐ └────────┘
│ client │ --port--> │ evcc │
└────────┘ └──────┘ ┌────────┐
└─ serial --> │ device │
└────────┘

View file

@ -72,7 +72,7 @@ export interface State {
shm?: ShmConfig;
sponsor?: Sponsor;
eebus?: any;
modbusproxy?: [];
modbusproxy?: ModbusProxy[];
messaging?: any;
interval?: number;
circuits?: Record<string, Circuit>;
@ -352,6 +352,57 @@ export interface Sponsor {
fromYaml: boolean;
}
export enum MODBUS_BAUDRATE {
_1200 = 1200,
_9600 = 9600,
_19200 = 19200,
_38400 = 38400,
_57600 = 57600,
_115200 = 115200,
}
export enum MODBUS_TYPE {
RS485_SERIAL = "rs485serial",
RS485_TCPIP = "rs485tcpip",
TCPIP = "tcpip",
}
export enum MODBUS_COMSET {
_8N1 = "8N1",
_8E1 = "8E1",
_8N2 = "8N2",
}
export enum MODBUS_PROXY_READONLY {
FALSE = "false",
TRUE = "true",
DENY = "deny",
}
export enum MODBUS_CONNECTION {
TCPIP = "tcpip",
SERIAL = "serial",
}
export enum MODBUS_PROTOCOL {
TCP = "tcp",
RTU = "rtu",
}
export type ModbusProxy = {
port: number;
readonly: MODBUS_PROXY_READONLY;
settings: ModbusProxySettings;
};
export interface ModbusProxySettings {
uri?: string;
rtu?: boolean;
device?: string;
baudrate?: MODBUS_BAUDRATE;
comset?: MODBUS_COMSET;
}
export interface Notification {
message: string;
time: Date;

View file

@ -354,13 +354,12 @@
<MqttModal @changed="loadDirty" />
<NetworkModal @changed="loadDirty" />
<ControlModal @changed="loadDirty" />
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
<HemsModal @changed="yamlChanged" />
<ShmModal @changed="loadDirty" />
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<ModbusProxyModal @changed="yamlChanged" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
<CircuitsModal
:gridMeter="gridMeter"
:extMeters="extMeters"
@ -369,6 +368,7 @@
<EebusModal @changed="yamlChanged" />
<BackupRestoreModal v-bind="backupRestoreProps" />
<PasswordModal update-mode />
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
</div>
</div>
</div>

View file

@ -951,12 +951,32 @@ func configureDevices(conf globalconfig.All) error {
return joinErrors(errs...)
}
func configureModbusProxy(conf *[]globalconfig.ModbusProxy) error {
// migrate settings
if settings.Exists(keys.ModbusProxy) {
*conf = []globalconfig.ModbusProxy{}
if err := settings.Yaml(keys.ModbusProxy, new([]map[string]any), &conf); err != nil {
// migrateYamlToJson converts a settings value from yaml to json if needed
func migrateYamlToJson[T any](key string) error {
var err error
if settings.IsJson(key) {
// already JSON, nothing to do
return nil
}
var data T
if err := settings.Yaml(key, new(T), &data); err == nil {
settings.SetJson(key, data)
log.INFO.Printf("migrated %s setting to JSON", key)
}
return err
}
func configureModbusProxy(conf *[]globalconfig.ModbusProxy) error {
if settings.Exists(keys.ModbusProxy) {
// TODO: delete if not needed any more
if err := migrateYamlToJson[[]globalconfig.ModbusProxy](keys.ModbusProxy); err != nil {
return err
}
if err := settings.Json(keys.ModbusProxy, &conf); err != nil {
return fmt.Errorf("failed to read modbusproxy setting: %w", err)
}
}

View file

@ -387,7 +387,24 @@
"protocolValueTcp": "TCP"
},
"modbusproxy": {
"description": "Erlaubt mehreren Clients den Zugriff auf ein einzelnes Modbus-Gerät.",
"add": "Proxy-Verbindung hinzufügen",
"connection": "Verbindung #{number}",
"description": "Manche Modbus-Geräte unterstützen nur eine oder sehr wenige Verbindungen. evcc kann als Proxy fungieren und ermöglicht so simultanen Zugriff für mehrere Clients (Hausautomation, Skripte, etc.).",
"device": "Gerät",
"option": {
"deny": "Fehler",
"false": "nein",
"true": "still"
},
"readonly": {
"help": {
"deny": "Schreibzugriff wird mit Modbus-Fehler blockiert.",
"false": "Schreibzugriff wird weitergeleitet.",
"true": "Schreibzugriff wird ohne Antwort blockiert."
},
"label": "Nur lesen"
},
"sourcePortHelp": "Port für eingehende Client-Verbindungen. Muss verfügbar sein.",
"title": "Modbus-Proxy"
},
"mqtt": {
@ -477,6 +494,7 @@
"labelToken": "Sponsortoken",
"title": "Sponsoring",
"tokenRequired": "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst.",
"tokenRequiredFeature": "Diese Funktion erfordert ein Sponsortoken.",
"tokenRequiredLearnMore": "Mehr erfahren.",
"tokenRequiredShort": "Kein Sponsortoken konfiguriert.",
"trialToken": "Testtoken",

View file

@ -388,7 +388,24 @@
"protocolValueTcp": "TCP"
},
"modbusproxy": {
"description": "Allow multiple clients to access a single Modbus device.",
"add": "Add proxy connection",
"connection": "Connection #{number}",
"description": "Some Modbus devices only support a single or very few connections. evcc can act as a proxy, enabling simultaneous access for multiple clients (home automation, scripts, etc.).",
"device": "Device",
"option": {
"deny": "error",
"false": "no",
"true": "silent"
},
"readonly": {
"help": {
"deny": "Write access is blocked with a Modbus error.",
"false": "Write access is forwarded.",
"true": "Write access is blocked without response."
},
"label": "Readonly"
},
"sourcePortHelp": "Port for incoming client connections. Must be available.",
"title": "Modbus Proxy"
},
"mqtt": {
@ -478,6 +495,7 @@
"labelToken": "Sponsor token",
"title": "Sponsorship",
"tokenRequired": "You must configure a sponsor token before you can create this device.",
"tokenRequiredFeature": "This feature requires a sponsor token.",
"tokenRequiredLearnMore": "Learn more.",
"tokenRequiredShort": "No sponsor token configured.",
"trialToken": "trial token",

View file

@ -242,6 +242,11 @@ func Yaml(key string, other, res any) error {
return DecodeOtherSliceOrMap(other, res)
}
func IsJson(key string) bool {
s, err := String(key)
return err == nil && json.Unmarshal([]byte(s), &json.RawMessage{}) == nil
}
// wrapping Settings into a struct for better decoupling
type Settings struct{}

View file

@ -296,7 +296,6 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
keys.Hems: func() (any, any) { return map[string]any{}, config.Typed{} },
keys.Tariffs: func() (any, any) { return map[string]any{}, globalconfig.Tariffs{} },
keys.Messaging: func() (any, any) { return map[string]any{}, globalconfig.Messaging{} }, // has default
keys.ModbusProxy: func() (any, any) { return []map[string]any{}, []globalconfig.ModbusProxy{} }, // slice
keys.Circuits: func() (any, any) { return []map[string]any{}, []config.Named{} }, // slice
} {
other, struc := fun()
@ -309,6 +308,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
for key, fun := range map[string]func() any{
keys.Network: func() any { return new(globalconfig.Network) }, // has default
keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default
keys.ModbusProxy: func() any { return new([]globalconfig.ModbusProxy) }, // slice
keys.Shm: func() any { return new(shm.Config) },
keys.Influx: func() any { return new(globalconfig.Influx) },
} {

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
@ -83,11 +84,14 @@ func settingsSetJsonHandler(key string, valueChan chan<- util.Param, newStruc fu
oldStruc := newStruc()
if err := settings.Json(key, &oldStruc); err == nil {
// Skip merge for slices - they should be replaced entirely
if reflect.ValueOf(struc).Elem().Kind() != reflect.Slice {
if err := mergeMaskedAny(oldStruc, struc); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
}
}
settings.SetJson(key, struc)
setConfigDirty()

View file

@ -0,0 +1,19 @@
BEGIN;
CREATE TABLE `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
INSERT INTO settings("key", value) VALUES('modbusproxy', '- port: 5021
uri: 192.0.2.2:502
- port: 5022
device: /dev/ttyUSB0
baudrate: 9600
comset: "8N1"
- port: 5023
uri: 192.0.2.3:502
rtu: true');
COMMIT;

View file

@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl, restart } from "./evcc";
import { expectModalVisible, enableExperimental, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
const CONFIG_MODBUSPROXY_MIGRATE = "config-modbusproxy-migrate.sql";
test.afterEach(async () => {
await stop();
});
test.describe("modbusproxy", async () => {
test("modbusproxy not configured", async ({ page }) => {
await start();
await page.goto("/#/config");
await enableExperimental(page, false);
const modbusproxyCard = page.getByTestId("modbusproxy");
await expect(modbusproxyCard).toBeVisible();
await expect(modbusproxyCard).toContainText(["Configured", "no"].join(""));
});
test("modbusproxy via ui", async ({ page }) => {
await start();
await page.goto("/#/config");
await enableExperimental(page, false);
// add connection
const modbusproxyCard = page.getByTestId("modbusproxy");
await modbusproxyCard.getByRole("button", { name: "edit" }).click();
const modal = await page.getByTestId("modbusproxy-modal");
await expectModalVisible(modal);
await expect(modal).toContainText("This feature requires a sponsor token.");
await modal.getByRole("button", { name: "Add proxy connection" }).click();
await expect(modal).toContainText("Connection #1");
const evccBox = modal.getByTestId("evcc-box");
const deviceBox = modal.getByTestId("device-box");
const evccPort = evccBox.getByLabel("Port");
await expect(evccPort).toHaveValue("1502");
await evccPort.fill("501");
await expect(evccBox.getByLabel("no")).toBeChecked();
await evccBox.getByLabel("error").click();
await expect(deviceBox.getByText("Network")).toBeChecked();
await deviceBox.getByLabel("IP address or hostname").fill("127.0.0.1");
const devicePort = deviceBox.getByLabel("Port");
await expect(devicePort).toHaveValue("502");
await devicePort.fill("602");
await expect(deviceBox.getByText("TCP")).toBeChecked();
await deviceBox.getByText("RTU").click();
// validate connection
await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal);
await expect(modbusproxyCard).toContainText(["Amount", "1"].join(""));
// restart button appears
const restartButton = await page
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart();
await page.reload();
await modbusproxyCard.getByRole("button", { name: "edit" }).click();
await expect(evccBox.getByLabel("Port")).toHaveValue("501");
await expect(evccBox.getByLabel("error")).toBeChecked();
await expect(deviceBox.getByText("Network")).toBeChecked();
await deviceBox.getByLabel("IP address or hostname").fill("127.0.0.1");
await expect(deviceBox.getByLabel("Port")).toHaveValue("602");
await expect(deviceBox.getByText("RTU")).toBeChecked();
// remove connection
await modal.getByRole("button", { name: "Remove" }).click();
await expect(modal).not.toContainText("Connection #1");
});
test("modbusproxy via db (yaml to json migration)", async ({ page }) => {
await start(undefined, CONFIG_MODBUSPROXY_MIGRATE);
await page.goto("/#/config");
await enableExperimental(page, false);
const modbusproxyCard = page.getByTestId("modbusproxy");
await expect(modbusproxyCard).toContainText(["Amount", "3"].join(""));
await modbusproxyCard.getByRole("button", { name: "edit" }).click();
const modal = await page.getByTestId("modbusproxy-modal");
await expectModalVisible(modal);
const connections = modal.getByTestId("modbusproxy-connection");
await expect(connections).toHaveCount(3);
// Connection #1: 192.0.2.2:502 (TCP)
const connection1 = connections.nth(0);
await expect(connection1).toContainText("Connection #1");
await expect(connection1.getByTestId("evcc-box").getByLabel("Port")).toHaveValue("5021");
const device1 = connection1.getByTestId("device-box");
await expect(device1.getByText("Network")).toBeChecked();
await expect(device1.getByLabel("IP address or hostname")).toHaveValue("192.0.2.2");
await expect(device1.getByLabel("Port")).toHaveValue("502");
await expect(device1.getByText("TCP")).toBeChecked();
// Connection #2: /dev/ttyUSB0 9600 8N1
const connection2 = connections.nth(1);
await expect(connection2).toContainText("Connection #2");
await expect(connection2.getByTestId("evcc-box")).toBeVisible();
await expect(connection2.getByTestId("evcc-box").getByLabel("Port")).toBeVisible();
await expect(connection2.getByTestId("evcc-box").getByLabel("Port")).toHaveValue("5022");
const device2 = connection2.getByTestId("device-box");
await expect(device2.getByText("Serial")).toBeChecked();
await expect(device2.getByLabel("Device")).toHaveValue("/dev/ttyUSB0");
await expect(device2.getByLabel("Baud rate")).toHaveValue("9600");
await expect(device2.getByLabel("ComSet")).toHaveValue("8N1");
// Connection #3: 192.0.2.3:502 (RTU)
const connection3 = connections.nth(2);
await expect(connection3).toContainText("Connection #3");
await expect(connection3.getByTestId("evcc-box").getByLabel("Port")).toHaveValue("5023");
const device3 = connection3.getByTestId("device-box");
await expect(device3.getByText("Network")).toBeChecked();
await expect(device3.getByLabel("IP address or hostname")).toHaveValue("192.0.2.3");
await expect(device3.getByLabel("Port")).toHaveValue("502");
await expect(device3.getByText("RTU")).toBeChecked();
});
});

View file

@ -33,12 +33,14 @@ type TcpSettings struct {
// Settings contains the ModBus settings
type Settings struct {
ID uint8 `json:",omitempty" yaml:",omitempty"`
SubDevice int `json:",omitempty" yaml:",omitempty"`
URI, Device, Comset string `json:",omitempty" yaml:",omitempty"`
Baudrate int `json:",omitempty" yaml:",omitempty"`
UDP bool `json:",omitempty" yaml:",omitempty"`
RTU *bool `json:",omitempty" yaml:",omitempty"`
ID uint8 `json:"id,omitempty" yaml:",omitempty"`
SubDevice int `json:"subdevice,omitempty" yaml:",omitempty"`
URI string `json:"uri,omitempty" yaml:",omitempty"`
Device string `json:"device,omitempty" yaml:",omitempty"`
Comset string `json:"comset,omitempty" yaml:",omitempty"`
Baudrate int `json:"baudrate,omitempty" yaml:",omitempty"`
UDP bool `json:"udp,omitempty" yaml:",omitempty"`
RTU *bool `json:"rtu,omitempty" yaml:",omitempty"`
}
// Protocol identifies the wire format from the RTU setting