diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index 4b9579414..ed4f3c575 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -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)
diff --git a/assets/js/components/Config/DeviceModal/Modbus.vue b/assets/js/components/Config/DeviceModal/Modbus.vue
index d4c4eb6c5..9afc4d0b8 100644
--- a/assets/js/components/Config/DeviceModal/Modbus.vue
+++ b/assets/js/components/Config/DeviceModal/Modbus.vue
@@ -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')
"
>
-
-
+
-
-
+
-
+
,
default: () => [],
},
- modbus: String as PropType,
+ modbus: String as PropType,
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 { key: v, name: `${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}`;
+ },
},
});
diff --git a/assets/js/components/Config/DeviceModal/SponsorTokenRequired.vue b/assets/js/components/Config/DeviceModal/SponsorTokenRequired.vue
index 587b3ab0c..a1b8c1bcd 100644
--- a/assets/js/components/Config/DeviceModal/SponsorTokenRequired.vue
+++ b/assets/js/components/Config/DeviceModal/SponsorTokenRequired.vue
@@ -5,7 +5,7 @@
- {{ $t("config.sponsor.tokenRequired") }}
+ {{ $t(feature ? "config.sponsor.tokenRequiredFeature" : "config.sponsor.tokenRequired") }}
{{ $t("config.sponsor.tokenRequiredLearnMore") }}
@@ -19,6 +19,7 @@ export default {
name: "SponsorTokenRequired",
props: {
compact: Boolean,
+ feature: Boolean,
},
methods: {
openModal() {
diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts
index 49041e1f3..5de3b4a8f 100644
--- a/assets/js/components/Config/DeviceModal/index.ts
+++ b/assets/js/components/Config/DeviceModal/index.ts
@@ -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;
};
diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue
index 33f140080..f2981f1b1 100644
--- a/assets/js/components/Config/JsonModal.vue
+++ b/assets/js/components/Config/JsonModal.vue
@@ -1,5 +1,5 @@
-
+
{{ description + " " }}
@@ -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
- return Object.fromEntries(
- Object.entries(values).map(([key, value]) => [
- key,
- typeof value === "string" ? value.trim() : value,
- ])
- );
+ 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,
+ ])
+ );
+ }
},
},
};
diff --git a/assets/js/components/Config/ModbusProxyModal.vue b/assets/js/components/Config/ModbusProxyModal.vue
index f0c67ebed..6a56d5e09 100644
--- a/assets/js/components/Config/ModbusProxyModal.vue
+++ b/assets/js/components/Config/ModbusProxyModal.vue
@@ -1,27 +1,266 @@
-
+ >
+
+
+
+
{{ ASCII_DIAGRAM }}
+
+
+
+
+
+ {{ $t("config.modbusproxy.connection", { number: index + 1 }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("config.modbusproxy.device") }}
+
+
+
+
updateHost(host, c.settings)"
+ @update:port="(port) => updatePort(port, c.settings)"
+ @update:modbus="(modbus) => updateModbus(c.settings, modbus)"
+ />
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/assets/js/components/Config/defaultYaml/modbusproxy.yaml b/assets/js/components/Config/defaultYaml/modbusproxy.yaml
deleted file mode 100644
index adccb3162..000000000
--- a/assets/js/components/Config/defaultYaml/modbusproxy.yaml
+++ /dev/null
@@ -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"
diff --git a/assets/js/components/Config/modbus-diagram.txt b/assets/js/components/Config/modbus-diagram.txt
new file mode 100644
index 000000000..3c2bc69d1
--- /dev/null
+++ b/assets/js/components/Config/modbus-diagram.txt
@@ -0,0 +1,7 @@
+ ┌────────┐
+ ┌─ network ─> │ device │
+┌────────┐ ┌──────┐ └────────┘
+│ client │ --port--> │ evcc │
+└────────┘ └──────┘ ┌────────┐
+ └─ serial --> │ device │
+ └────────┘
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 38ceb8d56..09db0834d 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -72,7 +72,7 @@ export interface State {
shm?: ShmConfig;
sponsor?: Sponsor;
eebus?: any;
- modbusproxy?: [];
+ modbusproxy?: ModbusProxy[];
messaging?: any;
interval?: number;
circuits?: Record;
@@ -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;
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index c9e4235bf..036b39250 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -354,13 +354,12 @@
-
-
+
+
diff --git a/cmd/setup.go b/cmd/setup.go
index 4e73a3c50..59c450e2e 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -951,13 +951,33 @@ func configureDevices(conf globalconfig.All) error {
return joinErrors(errs...)
}
+// 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 {
- // migrate settings
if settings.Exists(keys.ModbusProxy) {
- *conf = []globalconfig.ModbusProxy{}
- if err := settings.Yaml(keys.ModbusProxy, new([]map[string]any), &conf); err != nil {
+ // 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 {
diff --git a/i18n/de.json b/i18n/de.json
index 2e782ff3b..30f259a28 100644
--- a/i18n/de.json
+++ b/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",
diff --git a/i18n/en.json b/i18n/en.json
index e36f3bf8b..a6f0ca002 100644
--- a/i18n/en.json
+++ b/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",
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index 2e659494c..56ce1299b 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -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{}
diff --git a/server/http.go b/server/http.go
index f8503ff93..ae9beeb1a 100644
--- a/server/http.go
+++ b/server/http.go
@@ -292,12 +292,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
// yaml handlers
for key, fun := range map[string]func() (any, any){
- keys.EEBus: func() (any, any) { return map[string]any{}, eebus.Config{} },
- 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
+ keys.EEBus: func() (any, any) { return map[string]any{}, eebus.Config{} },
+ 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.Circuits: func() (any, any) { return []map[string]any{}, []config.Named{} }, // slice
} {
other, struc := fun()
routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetStringHandler(key)}
@@ -307,10 +306,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
// json handlers
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.Shm: func() any { return new(shm.Config) },
- keys.Influx: func() any { return new(globalconfig.Influx) },
+ 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) },
} {
routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, valueChan, fun)}
routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, valueChan, fun())}
diff --git a/server/http_global_settings_handler.go b/server/http_global_settings_handler.go
index 3f9a25aa1..8708752c6 100644
--- a/server/http_global_settings_handler.go
+++ b/server/http_global_settings_handler.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"io"
"net/http"
+ "reflect"
"strconv"
"strings"
"time"
@@ -83,9 +84,12 @@ func settingsSetJsonHandler(key string, valueChan chan<- util.Param, newStruc fu
oldStruc := newStruc()
if err := settings.Json(key, &oldStruc); err == nil {
- if err := mergeMaskedAny(oldStruc, struc); err != nil {
- jsonError(w, http.StatusInternalServerError, err)
- return
+ // 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
+ }
}
}
diff --git a/tests/config-modbusproxy-migrate.sql b/tests/config-modbusproxy-migrate.sql
new file mode 100644
index 000000000..ab39450f9
--- /dev/null
+++ b/tests/config-modbusproxy-migrate.sql
@@ -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;
\ No newline at end of file
diff --git a/tests/config-modbusproxy.spec.ts b/tests/config-modbusproxy.spec.ts
new file mode 100644
index 000000000..63b27c0aa
--- /dev/null
+++ b/tests/config-modbusproxy.spec.ts
@@ -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();
+ });
+});
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index 907231ec9..2283faa1f 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -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