From b02335b9784a7a2d2b44d37098c8dddd641c2e4a Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 00:14:31 +0100 Subject: [PATCH 01/54] initial modbus service --- .../Config/DeviceModal/DeviceModalBase.vue | 17 +- .../js/components/Config/DeviceModal/index.ts | 17 ++ util/modbus/service.go | 261 ++++++++++++++++++ util/modbus/service_test.go | 131 +++++++++ util/templates/types.go | 39 +-- 5 files changed, 433 insertions(+), 32 deletions(-) create mode 100644 util/modbus/service.go create mode 100644 util/modbus/service_test.go diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 5a4c6b7cf0..a98cfcef19 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -323,15 +323,6 @@ export default defineComponent({ modbusCapabilities() { return (this.modbus?.Choice || []) as ModbusCapability[]; }, - modbusDefaults() { - const { ID, Comset, Baudrate, Port } = this.modbus || {}; - return { - id: ID, - comset: Comset, - baudrate: Baudrate, - port: Port, - }; - }, description() { return this.template?.Requirements?.Description; }, @@ -347,7 +338,6 @@ export default defineComponent({ }, apiData(): ApiData { let data: ApiData = { - ...this.modbusDefaults, ...this.values, }; if (this.values.type === ConfigType.Template && this.templateName) { @@ -739,11 +729,12 @@ export default defineComponent({ }, 500); }, applyServiceDefault(paramName: string) { - // Auto-apply single service value when field is empty and required + // Auto-apply single service value when field is empty + // Apply for both required AND optional fields with service values const values = this.serviceValues[paramName]; const param = this.templateParams.find((p) => p.Name === paramName); - // Only auto-apply if exactly one value is returned, field is empty, and field is required - if (values?.length === 1 && !this.values[paramName] && param?.Required) { + // Only auto-apply if exactly one value is returned and field is empty + if (values?.length === 1 && !this.values[paramName]) { this.values[paramName] = values[0]; } }, diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index b6f1fa536c..6be896dbb5 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -100,6 +100,23 @@ export function applyDefaultsFromTemplate(template: Template | null, values: Dev .forEach((p) => { values[p.Name] = p.Default; }); + + // Apply modbus defaults from template (for service dependency resolution) + const modbusParam = params.find((p) => p.Name === "modbus") as ModbusParam | undefined; + if (modbusParam) { + if (!values.id && modbusParam.ID) { + values.id = modbusParam.ID; + } + if (!values.port && modbusParam.Port) { + values.port = modbusParam.Port; + } + if (!values.comset && modbusParam.Comset) { + values.comset = modbusParam.Comset; + } + if (!values.baudrate && modbusParam.Baudrate) { + values.baudrate = modbusParam.Baudrate; + } + } } export function customChargerName(type: ConfigType, isHeating: boolean) { diff --git a/util/modbus/service.go b/util/modbus/service.go new file mode 100644 index 0000000000..182cfa9a73 --- /dev/null +++ b/util/modbus/service.go @@ -0,0 +1,261 @@ +package modbus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/evcc-io/evcc/server/service" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" + gridx "github.com/grid-x/modbus" +) + +var log = util.NewLogger("modbus") + +const ( + // DefaultModbusPort is the standard Modbus TCP port + DefaultModbusPort = "502" + // DefaultModbusID is the default Modbus device ID + DefaultModbusID = 1 +) + +func init() { + mux := http.NewServeMux() + mux.HandleFunc("GET /params", getParams) + + service.Register("modbus", mux) +} + +// ParamValue represents a single parameter value read from modbus +type ParamValue struct { + Value any `json:"value"` + Unit string `json:"unit,omitempty"` + Error string `json:"error,omitempty"` +} + +// getParams reads parameter values from a device based on template configuration +// If 'param' query parameter is provided, returns single value as array (for UI) +// If 'param' is not provided, returns all values as object (for debugging) +func getParams(w http.ResponseWriter, req *http.Request) { + // Extract query parameters + templateName := req.URL.Query().Get("template") + usage := req.URL.Query().Get("usage") + uri := req.URL.Query().Get("uri") + host := req.URL.Query().Get("host") + port := req.URL.Query().Get("port") + idStr := req.URL.Query().Get("id") + paramName := req.URL.Query().Get("param") // Optional: specific parameter name + + // Validate required parameters + if templateName == "" { + jsonError(w, http.StatusBadRequest, errors.New("missing template parameter")) + return + } + + // Build URI from host and port if uri not directly provided + if uri == "" { + if host == "" { + jsonError(w, http.StatusBadRequest, errors.New("missing uri or host parameter")) + return + } + // Default port if not specified + if port == "" { + port = DefaultModbusPort + } + uri = fmt.Sprintf("%s:%s", host, port) + } + + // Parse device ID + var modbusSettings Settings + modbusSettings.URI = uri + if idStr != "" { + if _, err := fmt.Sscanf(idStr, "%d", &modbusSettings.ID); err != nil { + jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid id parameter: %w", err)) + return + } + } + + // Load template + tmpl, err := templates.ByName(templates.Meter, templateName) + if err != nil { + jsonError(w, http.StatusBadRequest, fmt.Errorf("template '%s' not found: %w", templateName, err)) + return + } + + // Read parameters with context timeout + ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second) + defer cancel() + + result := make(map[string]ParamValue) + + // Iterate over template parameters and read those with register configuration + for _, param := range tmpl.Params { + // Skip parameters that don't match the requested usage (if specified) + // This allows filtering battery vs. pv parameters for all-in-one templates + if usage != "" && len(param.Usages) > 0 { + found := false + for _, u := range param.Usages { + if u == usage { + found = true + break + } + } + if !found { + continue + } + } + + // Check if parameter has register configuration in properties + if param.Properties == nil { + continue + } + + registerCfg, hasRegister := param.Properties["register"] + if !hasRegister { + continue + } + + // Decode register configuration + var reg Register + if err := util.DecodeOther(registerCfg, ®); err != nil { + result[param.Name] = ParamValue{ + Error: fmt.Sprintf("failed to decode register config: %v", err), + } + continue + } + + // Read from modbus + value, err := readRegister(ctx, modbusSettings, reg) + if err != nil { + result[param.Name] = ParamValue{ + Error: err.Error(), + } + log.DEBUG.Printf("Failed to read %s: %v", param.Name, err) + continue + } + + // Get scale factor if present + scale := 1.0 + if scaleCfg, ok := param.Properties["scale"]; ok { + if s, ok := scaleCfg.(float64); ok { + scale = s + } + } + + // Apply scale + scaledValue := value * scale + + // Apply cast if present + var finalValue any = scaledValue + if castCfg, ok := param.Properties["cast"]; ok { + if castType, ok := castCfg.(string); ok { + switch castType { + case "int": + finalValue = int64(scaledValue + 0.5) // Round to nearest + case "float": + finalValue = scaledValue + case "string": + finalValue = fmt.Sprintf("%v", scaledValue) + } + } + } + + // Store result with unit from param description + result[param.Name] = ParamValue{ + Value: finalValue, + Unit: param.Unit, + } + + log.DEBUG.Printf("Read %s: %v %s", param.Name, finalValue, param.Unit) + } + + // If specific parameter requested, return as string array (for UI compatibility) + if paramName != "" { + if paramValue, ok := result[paramName]; ok { + // If there was an error reading the parameter, return empty array + if paramValue.Error != "" { + jsonWrite(w, []string{}) + return + } + // Return value as string array + valueStr := fmt.Sprintf("%v", paramValue.Value) + jsonWrite(w, []string{valueStr}) + return + } + // Parameter not found or error + jsonWrite(w, []string{}) + return + } + + // Return all results as object (for debugging/batch requests) + jsonWrite(w, result) +} + +// readRegister reads a single modbus register +func readRegister(ctx context.Context, settings Settings, reg Register) (float64, error) { + if err := reg.Error(); err != nil { + return 0, fmt.Errorf("invalid register config: %w", err) + } + + // Create temporary modbus connection + Lock() + defer Unlock() + + conn, err := NewConnection(ctx, settings.URI, settings.Device, + settings.Comset, settings.Baudrate, settings.Protocol(), settings.ID) + if err != nil { + return 0, fmt.Errorf("failed to create modbus connection: %w", err) + } + + // Get register operation + funcCode, err := reg.FuncCode() + if err != nil { + return 0, fmt.Errorf("failed to get function code: %w", err) + } + + length, err := reg.Length() + if err != nil { + return 0, fmt.Errorf("failed to get register length: %w", err) + } + + // Get decoder + decode, err := reg.DecodeFunc() + if err != nil { + return 0, fmt.Errorf("failed to get decode function: %w", err) + } + + // Read from register + var bytes []byte + switch funcCode { + case gridx.FuncCodeReadHoldingRegisters: + bytes, err = conn.ReadHoldingRegisters(reg.Address, length) + case gridx.FuncCodeReadInputRegisters: + bytes, err = conn.ReadInputRegisters(reg.Address, length) + default: + return 0, fmt.Errorf("unsupported function code: %d (only holding and input registers supported)", funcCode) + } + + if err != nil { + return 0, fmt.Errorf("failed to read register %d: %w", reg.Address, err) + } + + // Decode value + value := decode(bytes) + return value, nil +} + +// jsonWrite writes a JSON response +func jsonWrite(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// jsonError writes an error response +func jsonError(w http.ResponseWriter, status int, err error) { + w.WriteHeader(status) + jsonWrite(w, util.ErrorAsJson(err)) +} diff --git a/util/modbus/service_test.go b/util/modbus/service_test.go new file mode 100644 index 0000000000..0e138de0c8 --- /dev/null +++ b/util/modbus/service_test.go @@ -0,0 +1,131 @@ +package modbus + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/util/templates" + "github.com/stretchr/testify/assert" +) + +func init() { + // Register test template from the same directory as this test file + // Go tests run with the working directory set to the package directory + _ = templates.Register(templates.Meter, "testdata/modbus-service-test.tpl.yaml") +} + +func TestGetParams_MissingTemplate(t *testing.T) { + req := httptest.NewRequest("GET", "/params", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "missing template parameter") +} + +func TestGetParams_MissingUriAndHost(t *testing.T) { + req := httptest.NewRequest("GET", "/params?template=test", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "missing uri or host parameter") +} + +func TestGetParams_URIConstruction_WithDefaultPort(t *testing.T) { + // This test verifies that the service constructs URI from host+port + // We can't test the full flow without a real Modbus device, + // but we can verify the URI construction logic by checking error messages + + req := httptest.NewRequest("GET", "/params?template=nonexistent&host=192.168.1.1", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + // Should fail at template loading (template "nonexistent" doesn't exist) + // but this proves URI was constructed from host + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, body, "template not found", "Error message should mention template not found") + assert.Contains(t, body, "nonexistent", "Error message should include the template name") +} + +func TestGetParams_URIConstruction_WithCustomPort(t *testing.T) { + req := httptest.NewRequest("GET", "/params?template=nonexistent&host=192.168.1.1&port=1502", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + // Should fail at template loading but proves URI construction worked + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, body, "template not found", "Error message should mention template not found") + assert.Contains(t, body, "nonexistent", "Error message should include the template name") +} + +func TestGetParams_DirectURI(t *testing.T) { + // Verify that direct URI parameter works (backwards compatibility) + req := httptest.NewRequest("GET", "/params?template=nonexistent&uri=192.168.1.1:502", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + // Should fail at template loading but proves direct URI works + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, body, "template not found", "Error message should mention template not found") + assert.Contains(t, body, "nonexistent", "Error message should include the template name") +} + +func TestGetParams_InvalidID(t *testing.T) { + req := httptest.NewRequest("GET", "/params?template=modbus-service-test&uri=192.168.1.1:502&id=invalid", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, body, "invalid id parameter", "Error message should mention invalid id") +} + +func TestGetParams_SuccessfulRequest(t *testing.T) { + // This test verifies the complete request flow up to the Modbus connection + // It uses a real test template to ensure template loading works correctly + + // Use host+port format (new feature) + req := httptest.NewRequest("GET", "/params?template=modbus-service-test&host=192.168.1.1&port=502&id=1¶m=testparam", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + // Template should be found, but Modbus connection will fail (no real device) + // The service returns HTTP 200 with an empty array when param is requested but read fails + assert.Equal(t, http.StatusOK, w.Code, "Expected OK status, got: %s", body) + assert.Equal(t, "[]\n", body, "Expected empty array for failed param read, got: %s", body) +} + +func TestGetParams_AllParameters(t *testing.T) { + // Test without param query parameter - should return all parameters + req := httptest.NewRequest("GET", "/params?template=modbus-service-test&uri=192.168.1.1:502&id=1", nil) + w := httptest.NewRecorder() + + getParams(w, req) + + body := w.Body.String() + + // Template should be found, connection will fail, but should return object with error details + assert.Equal(t, http.StatusOK, w.Code, "Expected OK status, got: %s", body) + // Should return object with all parameters (not array) + assert.Contains(t, body, "{", "Expected JSON object for all parameters") + assert.Contains(t, body, "testparam", "Expected testparam in response") + assert.Contains(t, body, "error", "Expected error field for failed reads") +} diff --git a/util/templates/types.go b/util/templates/types.go index 50e26cbc72..0307ffc230 100644 --- a/util/templates/types.go +++ b/util/templates/types.go @@ -183,25 +183,26 @@ type LinkedTemplate struct { // 3. defaults.yaml modbus section // 4. template type Param struct { - Name string // Param name which is used for assigning defaults properties and referencing in render - Description TextLanguage // language specific titles (presented in UI instead of Name) - Help TextLanguage // cli configuration help - Preset string `json:"-"` // Reference a predefined set of params - Required bool `json:",omitempty"` // cli if the user has to provide a non empty value - Mask bool `json:",omitempty"` // cli if the value should be masked, e.g. for passwords - Private bool `json:",omitempty"` // value should be redacted in bug reports, e.g. email, locations, ... - Advanced bool `json:",omitempty"` // cli if the user does not need to be asked. Requires a "Default" to be defined. - Deprecated bool `json:",omitempty"` // if the parameter is deprecated and thus should not be presented in the cli or docs - Default string `json:",omitempty"` // default value if no user value is provided in the configuration - Example string `json:",omitempty"` // cli example value - Value string `json:"-"` // user provided value via cli configuration - Values []string `json:",omitempty"` // user provided list of values e.g. for Type "list" - Unit string `json:",omitempty"` // unit of the value, e.g. "kW", "kWh", "A", "V" - Usages []string `json:",omitempty"` // restrict param to these usage types, e.g. "battery" for home battery capacity - Type ParamType // string representation of the value type, "string" is default - Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage" - Service string `json:",omitempty"` // defines a service to provide choices - AllInOne bool `json:"-"` // defines if the defined usages can all be present in a single device + Name string // Param name which is used for assigning defaults properties and referencing in render + Description TextLanguage // language specific titles (presented in UI instead of Name) + Help TextLanguage // cli configuration help + Preset string `json:"-"` // Reference a predefined set of params + Required bool `json:",omitempty"` // cli if the user has to provide a non empty value + Mask bool `json:",omitempty"` // cli if the value should be masked, e.g. for passwords + Private bool `json:",omitempty"` // value should be redacted in bug reports, e.g. email, locations, ... + Advanced bool `json:",omitempty"` // cli if the user does not need to be asked. Requires a "Default" to be defined. + Deprecated bool `json:",omitempty"` // if the parameter is deprecated and thus should not be presented in the cli or docs + Default string `json:",omitempty"` // default value if no user value is provided in the configuration + Example string `json:",omitempty"` // cli example value + Value string `json:"-"` // user provided value via cli configuration + Values []string `json:",omitempty"` // user provided list of values e.g. for Type "list" + Unit string `json:",omitempty"` // unit of the value, e.g. "kW", "kWh", "A", "V" + Usages []string `json:",omitempty"` // restrict param to these usage types, e.g. "battery" for home battery capacity + Type ParamType // string representation of the value type, "string" is default + Choice []string `json:",omitempty"` // defines a set of choices, e.g. "grid", "pv", "battery", "charge" for "usage" + Service string `json:",omitempty"` // defines a service to provide choices + AllInOne bool `json:"-"` // defines if the defined usages can all be present in a single device + Properties map[string]any `json:",omitempty"` // additional properties for service configuration (e.g., register, scale, cast) // TODO move somewhere else should not be part of the param definition Baudrate int `json:",omitempty"` // device specific default for modbus RS485 baudrate From 63ce90aa4eb7afe9e0fd8de3eccd396ae1fcc416 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:18:55 +0100 Subject: [PATCH 02/54] remove usage filter --- util/modbus/service.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/util/modbus/service.go b/util/modbus/service.go index 182cfa9a73..6bd7ae2960 100644 --- a/util/modbus/service.go +++ b/util/modbus/service.go @@ -43,7 +43,6 @@ type ParamValue struct { func getParams(w http.ResponseWriter, req *http.Request) { // Extract query parameters templateName := req.URL.Query().Get("template") - usage := req.URL.Query().Get("usage") uri := req.URL.Query().Get("uri") host := req.URL.Query().Get("host") port := req.URL.Query().Get("port") @@ -94,21 +93,6 @@ func getParams(w http.ResponseWriter, req *http.Request) { // Iterate over template parameters and read those with register configuration for _, param := range tmpl.Params { - // Skip parameters that don't match the requested usage (if specified) - // This allows filtering battery vs. pv parameters for all-in-one templates - if usage != "" && len(param.Usages) > 0 { - found := false - for _, u := range param.Usages { - if u == usage { - found = true - break - } - } - if !found { - continue - } - } - // Check if parameter has register configuration in properties if param.Properties == nil { continue From 48466c86fb7e6ee0a5d8a1ae798c57efe55483f2 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:42:58 +0100 Subject: [PATCH 03/54] fix ui --- .../Config/DeviceModal/DeviceModalBase.vue | 1 - assets/js/components/Config/DeviceModal/index.ts | 16 ++++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index a98cfcef19..03e605739e 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -732,7 +732,6 @@ export default defineComponent({ // Auto-apply single service value when field is empty // Apply for both required AND optional fields with service values const values = this.serviceValues[paramName]; - const param = this.templateParams.find((p) => p.Name === paramName); // Only auto-apply if exactly one value is returned and field is empty if (values?.length === 1 && !this.values[paramName]) { this.values[paramName] = values[0]; diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 6be896dbb5..37f3739426 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -104,17 +104,17 @@ export function applyDefaultsFromTemplate(template: Template | null, values: Dev // Apply modbus defaults from template (for service dependency resolution) const modbusParam = params.find((p) => p.Name === "modbus") as ModbusParam | undefined; if (modbusParam) { - if (!values.id && modbusParam.ID) { - values.id = modbusParam.ID; + if (!values["id"] && modbusParam.ID) { + values["id"] = modbusParam.ID; } - if (!values.port && modbusParam.Port) { - values.port = modbusParam.Port; + if (!values["port"] && modbusParam.Port) { + values["port"] = modbusParam.Port; } - if (!values.comset && modbusParam.Comset) { - values.comset = modbusParam.Comset; + if (!values["comset"] && modbusParam.Comset) { + values["comset"] = modbusParam.Comset; } - if (!values.baudrate && modbusParam.Baudrate) { - values.baudrate = modbusParam.Baudrate; + if (!values["baudrate"] && modbusParam.Baudrate) { + values["baudrate"] = modbusParam.Baudrate; } } } From b8b44ad68397a5044757f0cdb63831442620c087 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 21:47:47 +0100 Subject: [PATCH 04/54] fix tests --- .../modbus-service-test.tpl.yaml | 24 +++++++++++++++++++ util/modbus/service_test.go | 6 ++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 tests/modbus-service/modbus-service-test.tpl.yaml diff --git a/tests/modbus-service/modbus-service-test.tpl.yaml b/tests/modbus-service/modbus-service-test.tpl.yaml new file mode 100644 index 0000000000..544150bc78 --- /dev/null +++ b/tests/modbus-service/modbus-service-test.tpl.yaml @@ -0,0 +1,24 @@ +template: modbus-service-test +group: generic +products: + - description: + generic: Modbus Service Test Template +params: + - name: usage + choice: ["grid"] + - name: modbus + choice: ["tcpip"] + id: 1 + port: 502 + - name: testparam + description: + generic: Test Parameter + required: false + properties: + register: + address: 100 + type: holding + encoding: uint16 + scale: 1.0 +render: | + type: custom diff --git a/util/modbus/service_test.go b/util/modbus/service_test.go index 0e138de0c8..3cf6403d27 100644 --- a/util/modbus/service_test.go +++ b/util/modbus/service_test.go @@ -10,9 +10,9 @@ import ( ) func init() { - // Register test template from the same directory as this test file - // Go tests run with the working directory set to the package directory - _ = templates.Register(templates.Meter, "testdata/modbus-service-test.tpl.yaml") + // Register test template from the tests directory + // Path is relative to the package directory (util/modbus) + _ = templates.Register(templates.Meter, "../../tests/modbus-service/modbus-service-test.tpl.yaml") } func TestGetParams_MissingTemplate(t *testing.T) { From c32a0bcf6e4bf5d3c48c933e86bfcff412991bb5 Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:09:12 +0100 Subject: [PATCH 05/54] fix integration, restore --- .../js/components/Config/DeviceModal/DeviceModalBase.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 03e605739e..69bbf149ba 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -729,11 +729,11 @@ export default defineComponent({ }, 500); }, applyServiceDefault(paramName: string) { - // Auto-apply single service value when field is empty - // Apply for both required AND optional fields with service values + // Auto-apply single service value when field is empty and required const values = this.serviceValues[paramName]; - // Only auto-apply if exactly one value is returned and field is empty - if (values?.length === 1 && !this.values[paramName]) { + const param = this.templateParams.find((p) => p.Name === paramName); + // Only auto-apply if exactly one value is returned, field is empty, and field is required + if (values?.length === 1 && !this.values[paramName] && param?.Required) { this.values[paramName] = values[0]; } }, From 0c5ced8c08eac761098819e44a744bbe3f8ef87a Mon Sep 17 00:00:00 2001 From: Ingo <71161062+iseeberg79@users.noreply.github.com> Date: Mon, 8 Dec 2025 23:51:59 +0100 Subject: [PATCH 06/54] fix, reduce --- assets/js/components/Config/PropertyField.vue | 86 ++++++++++++++++--- util/templates/types.go | 41 ++++----- 2 files changed, 93 insertions(+), 34 deletions(-) diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 14d6da7b42..83b8a79945 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -1,17 +1,43 @@