Config UI: visualize Modbusproxy (#24015)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
7924922d4b
commit
07bcca2925
19 changed files with 666 additions and 119 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
7
assets/js/components/Config/modbus-diagram.txt
Normal file
7
assets/js/components/Config/modbus-diagram.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
┌────────┐
|
||||
┌─ network ─> │ device │
|
||||
┌────────┐ ┌──────┐ └────────┘
|
||||
│ client │ --port--> │ evcc │
|
||||
└────────┘ └──────┘ ┌────────┐
|
||||
└─ serial --> │ device │
|
||||
└────────┘
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
cmd/setup.go
30
cmd/setup.go
|
|
@ -951,13 +951,33 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
for _, cfg := range *conf {
|
||||
|
|
|
|||
20
i18n/de.json
20
i18n/de.json
|
|
@ -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",
|
||||
|
|
|
|||
20
i18n/en.json
20
i18n/en.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
19
tests/config-modbusproxy-migrate.sql
Normal file
19
tests/config-modbusproxy-migrate.sql
Normal 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;
|
||||
139
tests/config-modbusproxy.spec.ts
Normal file
139
tests/config-modbusproxy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue