Add Modbus service for dynamic parameter reading (#25908)

Co-authored-by: andig <cpuidle@gmail.com>
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Ingo 2026-01-13 13:15:44 +01:00 • committed by GitHub
parent 021260bac7
commit b02f3d98eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 751 additions and 121 deletions

View file

@ -330,12 +330,11 @@ export default defineComponent({
return (this.modbus?.Choice || []) as ModbusCapability[];
},
modbusDefaults() {
const { ID, Comset, Baudrate, Port } = this.modbus || {};
return {
id: ID,
comset: Comset,
baudrate: Baudrate,
port: Port,
id: this.modbus?.ID,
comset: this.modbus?.Comset,
baudrate: this.modbus?.Baudrate,
port: this.modbus?.Port,
};
},
description() {
@ -353,7 +352,6 @@ export default defineComponent({
},
apiData(): ApiData {
let data: ApiData = {
...this.modbusDefaults,
...this.values,
};
if (this.values.type === ConfigType.Template && this.templateName) {
@ -743,7 +741,10 @@ export default defineComponent({
clearTimeout(this.serviceValuesTimer);
}
this.serviceValuesTimer = setTimeout(async () => {
this.serviceValues = await fetchServiceValues(this.templateParams, this.values);
this.serviceValues = await fetchServiceValues(this.templateParams, {
...this.modbusDefaults,
...this.values,
});
}, 500);
},
applyServiceDefault(paramName: string) {

View file

@ -45,8 +45,8 @@
type="Int"
class="me-2"
required
:model-value="id || defaultId || 1"
@change="$emit('update:id', $event.target.value)"
:model-value="id || defaultId"
@input="$emit('update:id', $event.target.value)"
/>
</FormRow>
<div v-if="connection === MODBUS_CONNECTION.TCPIP">
@ -62,7 +62,7 @@
class="me-2"
required
:model-value="host"
@change="$emit('update:host', $event.target.value)"
@input="$emit('update:host', $event.target.value)"
/>
</FormRow>
<FormRow :id="formId('modbusPort')" :label="$t('config.modbus.port')">
@ -72,8 +72,8 @@
type="Int"
class="me-2 w-50"
required
:model-value="port || defaultPort || 502"
@change="$emit('update:port', $event.target.value)"
:model-value="port || defaultPort"
@input="$emit('update:port', $event.target.value)"
/>
</FormRow>
<FormRow
@ -142,7 +142,7 @@
:choice="baudrateOptions"
required
:model-value="baudrate || defaultBaudrate"
@change="$emit('update:baudrate', parseInt($event.target.value))"
@input="$emit('update:baudrate', parseInt($event.target.value))"
/>
</FormRow>
<FormRow :id="formId('modbusComset')" :label="$t('config.modbus.comset')">
@ -153,8 +153,8 @@
class="me-2 w-50"
:choice="comsetOptions"
required
:model-value="comset || defaultComset || '8N1'"
@change="$emit('update:comset', $event.target.value)"
:model-value="comset || defaultComset"
@input="$emit('update:comset', $event.target.value)"
/>
</FormRow>
</div>
@ -268,7 +268,15 @@ export default defineComponent({
this.setConnectionAndProtocolByModbus(newValue);
}
},
connection() {
connection(newValue: MODBUS_CONNECTION, oldValue: MODBUS_CONNECTION) {
if (newValue !== oldValue) {
// Clear connection-specific parameters to ensure correct dependency group is used
if (newValue === MODBUS_CONNECTION.TCPIP) {
this.$emit("update:device", undefined);
} else if (newValue === MODBUS_CONNECTION.SERIAL) {
this.$emit("update:host", undefined);
}
}
this.applyServiceDefault();
},
device(newValue: string | undefined) {

View file

@ -21,9 +21,7 @@ describe("createServiceEndpoints", () => {
const endpoints = createServiceEndpoints(params);
const homeEndpoint = endpoints.find(({ name }) => name === "home")!;
const powerEndpoint = endpoints.find(({ name }) => name === "power")!;
expect(homeEndpoint.dependencies).toEqual([]);
expect(homeEndpoint.url({})).toBe("homes");
expect(powerEndpoint.dependencies).toEqual(["home"]);
expect(powerEndpoint.url({ home: "main" })).toBe("homes/main/sensors");
expect(powerEndpoint.url({ home: "with space" })).toBe("homes/with%20space/sensors");
expect(powerEndpoint.url({} as Record<string, string>)).toBe("homes/{home}/sensors");
@ -36,7 +34,6 @@ describe("createServiceEndpoints", () => {
];
const endpoints = createServiceEndpoints(params);
const sensorEndpoint = endpoints.find(({ name }) => name === "sensor")!;
expect(sensorEndpoint.dependencies).toEqual(["home", "sensor"]);
expect(sensorEndpoint.url({ home: "hq", sensor: "battery" })).toBe("homes/hq/sensors/battery");
});
@ -51,4 +48,55 @@ describe("createServiceEndpoints", () => {
"homes/{home}/sensors/{sensor}?token={token}"
);
});
it("expands {modbus} for TCP/IP", () => {
const params = [buildParam("param", "service?address=100&{modbus}")];
const endpoints = createServiceEndpoints(params);
expect(endpoints[0]!.url({ host: "192.168.1.1", port: "502", id: "1" })).toBe(
"service?address=100&uri=192.168.1.1:502&id=1"
);
});
it("expands {modbus} for serial", () => {
const params = [buildParam("param", "service?address=100&{modbus}")];
const endpoints = createServiceEndpoints(params);
expect(
endpoints[0]!.url({ device: "/dev/ttyUSB0", baudrate: "9600", comset: "8N1", id: "1" })
).toBe("service?address=100&device=%2Fdev%2FttyUSB0&baudrate=9600&comset=8N1&id=1");
});
it("leaves {modbus} unexpanded when connection missing", () => {
const params = [buildParam("param", "service?address=100&{modbus}")];
const endpoints = createServiceEndpoints(params);
expect(endpoints[0]!.url({})).toBe("service?address=100&{modbus}");
});
it("prefers device over host when both present", () => {
const params = [buildParam("param", "service?{modbus}")];
const endpoints = createServiceEndpoints(params);
expect(
endpoints[0]!.url({
device: "/dev/ttyUSB0",
baudrate: "9600",
comset: "8N1",
host: "192.168.1.1",
port: "502",
id: "1",
})
).toBe("service?device=%2Fdev%2FttyUSB0&baudrate=9600&comset=8N1&id=1");
});
it("treats empty strings as missing values", () => {
const params = [buildParam("sensor", "homes/{home}/sensors")];
const endpoints = createServiceEndpoints(params);
// Empty string should be treated as missing, leaving placeholder
expect(endpoints[0]!.url({ home: "" })).toBe("homes/{home}/sensors");
// Non-empty value should replace placeholder
expect(endpoints[0]!.url({ home: "main" })).toBe("homes/main/sensors");
});
});

View file

@ -35,7 +35,7 @@ export type TemplateParam = {
export type ParamService = {
name: string;
dependencies: string[];
service: string;
url: (values: Record<string, any>) => string;
};
@ -118,6 +118,22 @@ export async function loadServiceValues(path: string) {
}
}
// Expand {modbus} to actual connection params based on values
const expandModbus = (service: string, values: Record<string, any>): string => {
if (!service.includes("{modbus}")) return service;
if (values["device"]) {
return service.replace(
"{modbus}",
"device={device}&baudrate={baudrate}&comset={comset}&id={id}"
);
}
if (values["host"]) {
return service.replace("{modbus}", "uri={host}:{port}&id={id}");
}
return service;
};
export const createServiceEndpoints = (params: TemplateParam[]): ParamService[] => {
return params
.map((param) => {
@ -127,7 +143,8 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[]
const stringValues = (values: Record<string, any>): Record<string, string> =>
Object.entries(values).reduce(
(acc, [key, val]) => {
if (val !== undefined && val !== null) acc[key] = String(val);
if (val !== undefined && val !== null && val !== "" && key !== "modbus")
acc[key] = String(val);
return acc;
},
{} as Record<string, string>
@ -135,9 +152,9 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[]
return {
name: param.Name,
dependencies: extractPlaceholders(param.Service),
service: param.Service,
url: (values: Record<string, any>) =>
replacePlaceholders(param.Service!, stringValues(values)),
replacePlaceholders(expandModbus(param.Service!, values), stringValues(values)),
} as ParamService;
})
.filter((endpoint): endpoint is ParamService => endpoint !== null);
@ -152,17 +169,11 @@ export const fetchServiceValues = async (
await Promise.all(
endpoints.map(async (endpoint) => {
const params: Record<string, any> = {};
endpoint.dependencies.forEach((dependency) => {
if (values[dependency]) {
params[dependency] = values[dependency];
}
});
if (Object.keys(params).length !== endpoint.dependencies.length) {
// missing dependency values, skip
const url = endpoint.url(values);
if (extractPlaceholders(url).length > 0) {
// missing values, not all placeholders are filled
return;
}
const url = endpoint.url(params);
const data = await loadServiceValues(url);
if (data) {
result[endpoint.name] = data;

View file

@ -10,7 +10,6 @@
<PropertyField
:id="id"
v-model="value"
class="me-2"
:masked="Mask"
:property="Name"
:type="Type"

View file

@ -1,19 +1,5 @@
<template>
<div v-if="unitValue" class="input-group" :class="inputClasses">
<input
:id="id"
v-model="value"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:aria-describedby="id + '_unit'"
class="form-control"
:class="{ 'text-end': endAlign }"
/>
<span :id="id + '_unit'" class="input-group-text">{{ unitValue }}</span>
</div>
<div v-else-if="icons" class="d-flex flex-wrap">
<div v-if="icons" class="d-flex flex-wrap">
<div
v-for="{ key } in selectOptions"
v-show="key === value || selectMode"
@ -77,32 +63,45 @@
:required="required"
rows="4"
/>
<div v-else class="position-relative">
<input
:id="id"
v-model="value"
:list="datalistId"
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${inputClasses}`"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:autocomplete="masked || datalistId ? 'off' : null"
/>
<button
v-if="showClearButton"
type="button"
class="form-control-clear"
:aria-label="$t('config.general.clear')"
@click="value = ''"
<div v-else class="d-flex" :class="sizeClass">
<div class="position-relative flex-grow-1">
<input
:id="id"
v-model="value"
:list="datalistId"
:type="inputType"
:step="step"
:placeholder="placeholder"
:required="required"
:aria-describedby="unitValue ? id + '_unit' : null"
:class="`${datalistId && serviceValues.length > 0 ? 'form-select' : 'form-control'} ${showClearButton ? 'has-clear-button' : ''} ${invalid ? 'is-invalid' : ''} ${endAlign ? 'text-end' : ''}`"
:style="
unitValue ? 'border-top-right-radius: 0; border-bottom-right-radius: 0' : null
"
:autocomplete="masked || datalistId ? 'off' : null"
/>
<button
v-if="showClearButton"
type="button"
class="form-control-clear"
:aria-label="$t('config.general.clear')"
@click="value = ''"
>
&times;
</button>
<datalist v-if="showDatalist" :id="datalistId">
<option v-for="v in serviceValues" :key="v" :value="v">
{{ v }}
</option>
</datalist>
</div>
<span
v-if="unitValue"
:id="id + '_unit'"
class="input-group-text"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
>{{ unitValue }}</span
>
&times;
</button>
<datalist v-if="showDatalist" :id="datalistId">
<option v-for="v in serviceValues" :key="v" :value="v">
{{ v }}
</option>
</datalist>
</div>
</template>
@ -148,7 +147,15 @@ export default {
// no values
if (length === 0) return false;
// value selected, dont offer single same option again
if (this.value && this.serviceValues.includes(this.value)) return false;
// Convert both to strings for comparison to handle number/string type mismatches
const valueStr = String(this.value ?? "");
if (
this.value != null &&
valueStr !== "" &&
this.serviceValues.some((v) => String(v) === valueStr)
) {
return false;
}
return true;
},
showClearButton() {
@ -294,7 +301,7 @@ export default {
};
</script>
<style>
<style scoped>
input[type="number"] {
appearance: textfield;
}

View file

@ -31,38 +31,6 @@ Either `brand`, or `description` need to be set.
`group` is used to group switchable sockets and generic device support (e.g. SunSpec) templates.
## `guidedsetup` (Obsolete)
`guidedsetup` is enabled when the device has linked templates or >1 usage. It is used with devices that provide multiple meter usages, or meter devices that are typically installed with specific other devices. Mostly used for meter devices that provided multiple usage data with the same user input. These devices are then sorted at the bottom of the product list.
## `linked`
Allows to define a list of meter devices that are typically installed with this device. Enables `guidedsetup` mode.
#### `template`
`template` expects the linked device `template` value
#### `usage`
`usage` expects the meter usage type this device will be used for
**Possible values**:
- `grid`: for grid meters
- `pv`: for pv inverter/meter
- `battery`: for battery inverter/meter
#### `multiple`
`multiple:true` to define that multiple devices of this template can be added
#### `excludetemplate`
`excludetemplate` defines a linked device `template` value. If defined and a device of the linked template is added, then this linked template won't be considered in the flow
Example Use Case: With SMA Home Manager, there can be a SMA Energy Meter used for getting the PV generation or multiple SMA PV inverters. But never both together. So if the used added an SMA Energy Meter, then the flow shoudn't ask for SMA PV inverters.
## `capabilities`
`capabilities` provides an option to define special capabilities of the device as a list of strings
@ -109,6 +77,28 @@ en: |
**Attention**: Token is only valid for 2 minutes.
```
## `auth`
`auth` defines OAuth authentication configuration for devices that require user authorization. When specified, the UI OAuth flow and token management are handled automatically. The auth endpoint is called when all required parameters are filled and is re-called on every parameter change.
### `type`
`type` specifies the OAuth provider type. This must reference a dedicated OAuth implementation.
**Available types**: `homeassistant`, `ford-connect`, `viessmann`, `cardata`, `volvo-connected`
### `params`
`params` is a list of parameter names (from the `params` section) that are required for the OAuth configuration. These parameters will be passed to the authentication provider when initiating the OAuth flow. Once all listed parameters have values, the authorization is prepared and the UI displays a redirect link to the external service and device code (if applicable). The preparation is re-triggered whenever any parameter value changes.
**Example**:
```yaml
auth:
type: viessmann
params: [clientid, redirecturi, gateway_serial]
```
## `params`
`params` describes the set of parameters the user needs to provide a value for.
@ -128,10 +118,6 @@ en: |
- `usage`: specifies a list of meter classes, the device can be used for. Possible values are `grid`, `pv`, `battery`, and `charger`
- `modbus`: specifies that this device is accessed via modbus. It requires the `choice` property to have a list of possible interface values the device provides. These values can be `rs485` and `tcpip`. The command will use either to ask the appropriate questions and settings. The `render` section needs to include the string `{{include "modbus" .}}` in all places where the configuration needs modbus settings.
#### Usage Options
- `allineone`: Defines if the different usages are all available in a single device. Enables `guidedsetup` mode.
#### Modbus Options
- `id`: Device specific default for modbus ID
@ -171,11 +157,21 @@ en: |
### `mask`
`mask: true` defines if the user input should be masked, e.g. for passwords. Defaut is `false`
`mask: true` defines if the user input should be masked in the UI (password field). Used for sensitive credentials like passwords, tokens, and API keys that should be hidden from view. Default is `false`.
**Note**: Cannot be used together with `private`.
### `private`
`private: true` marks a parameter as containing personal data (e.g., email addresses, VIN numbers, MAC addresses, locations). This data will be redacted from bug reports and diagnostic information but is visible in the UI. Default is `false`.
**Examples of private data**: usernames, email addresses, VIN, URI, MAC addresses, latitude/longitude, serial numbers
**Note**: Cannot be used together with `mask`.
### `default`
`default` defines a default value to be used. For these cases the user can then simply press enter in the CLI.
`default` defines a default value to be used, which will be pre-filled in the configuration UI.
### `example`
@ -183,25 +179,94 @@ en: |
### `type`
`type` allows to define the value type to let the CLI verify the user provided content
`type` allows to define the value type to let the UI verify the user provided content
**Possible values**:
- `string`: for string values (default)
- `bool`: for `true` and `false` values. If `help` is provided, than that help text is presented as the question
- `int`: for int values
- `float`: for float values
- `list`: for a list of strings, e.g.used for defining a list of `identifiers` for `vehicles`
- `chargemodes`: for a selection of charge modes (including `None` which results in the param not being set)
- `bool`: for `true` and `false` values
- `choice`: for a selection from predefined options (defined in `choice` property)
- `chargemodes`: for a selection of charge modes (`Off`, `Now`, `MinPV`, `PV`), including `None` which results in the param not being set
- `duration`: for duration values (e.g., `5m`, `1h30m`, `10s`)
- `float`: for floating point numbers
- `int`: for integer values
- `list`: for a list of strings (newline-separated in textarea), e.g., used for defining a list of `identifiers` for vehicles
### `choice`
`choice` defines the list of possible values when `type: choice` is used. The user can select one value from this list via a dropdown.
**Format**: Array of strings
**Example**:
```yaml
- name: schema
type: choice
choice: ["https", "http"]
default: https
- name: channel
type: choice
choice: ["general", "feedIn", "controlledLoad"]
required: true
```
### `advanced`
`advanced` allows to specify if the param should only be asked if the cli is run with `--advanced`. Mostly used for non required params that are meant for users with advanced needs and knowledge.
`advanced: true` marks a parameter as advanced. Advanced parameters are hidden by default in the UI and can be expanded by the user. Mostly used for non-required params that are meant for users with advanced needs and knowledge.
### `help`
`help` expects language specific help texts via `generic` (language independent), `de`, `en`
### `service`
`service` specifies an API endpoint that provides dynamic data or suggestions for this parameter during configuration. When set, the UI will call this service to provide auto-completion or pre-populated options to the user.
**Format**: `service-name/endpoint` or `service-name/endpoint?param1={param1}&param2={param2}`
Parameters from other params can be referenced using `{param-name}` syntax, which will be replaced with the user's input for that parameter. The endpoint will only be called once the user has entered values for all referenced parameters. The endpoint is called every time a referenced parameter value changes.
**UI behaviour**:
Service endpoints must return an array of strings (e.g., `["value1", "value2"]`). These values are shown as suggestions, not strict selections - users can always enter custom text values. The UI handles service responses differently based on the parameter configuration and response content:
- **Auto-fill (prepopulation)**: If the service returns exactly **one** value, the parameter is **required**, and the field is currently **empty**, the value will be automatically filled into the field.
- **Dropdown suggestions**: In all other cases (multiple values, non-required parameter, or field already has a value), the returned values are shown as a dropdown/datalist for the user to select from or ignore.
- **Empty response**: If the service returns an empty array or no data, the field remains a regular text input.
**Available services**:
- **Hardware**
`hardware/serial`: Lists available serial ports on the system
- **Modbus**
`modbus/read?...`: Reads a value from a modbus register (for validation/testing)
The `modbus` service supports a special `{modbus}` parameter that will be automatically expanded to the appropriate connection parameters based on the user's modbus configuration:
```yaml
# Template definition
- name: voltage
service: modbus/read?address=100&type=holding&{modbus}
# Expanded for TCP connection:
# modbus/read?address=100&type=holding&uri=192.168.1.10:502&id=1
# Expanded for RTU connection:
# modbus/read?address=100&type=holding&device=/dev/ttyUSB0&baudrate=9600&id=1
```
- **Home Assistant**
`homeassistant/instances`: Auto-discovers Home Assistant instances on the network
`homeassistant/entities?uri={uri}&domain=sensor`: Lists entities from a Home Assistant instance filtered by domain(s). Multiple domains can be comma-separated (e.g., `domain=sensor,binary_sensor` or `domain=number,input_number`)
## `render`
`render` contains the internal device configuration. All `param` `name` values can be used as a template variable, e.g. `{{ .host }}` for a param named `host`. The content is a go template, so all of go template feature can be used, e.g. `{{- if ... }}` statements, etc.

View file

@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
const templateFlags = [
"--disable-auth",
"--template-type",
"meter",
"--template",
"tests/config-param-service-modbus.tpl.yaml",
];
test.beforeAll(async () => {
await start(undefined, undefined, templateFlags);
});
test.afterAll(async () => {
await stop();
});
async function openMeterModal(page: Page) {
await page.goto("/#/config");
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Service Modbus Test Meter");
return meterModal;
}
test.describe("modbus service expansion", async () => {
test("tcp/ip and serial switching", async ({ page }) => {
const meterModal = await openMeterModal(page);
await meterModal.getByLabel("Register address").fill("100");
await meterModal.getByLabel("IP address or hostname").fill("192.168.1.1");
await meterModal.getByLabel("Port").fill("502");
await expect(meterModal.getByLabel("Test value")).toHaveValue("100,id:2,tcp");
await meterModal.getByLabel("Test value").clear();
await meterModal.getByText("RS485").first().click();
await meterModal.getByLabel("Modbus ID").fill("44");
await meterModal.getByLabel("Device").fill("/dev/ttyUSB0");
await meterModal.getByLabel("Baud rate").selectOption("9600");
await meterModal.getByLabel("ComSet").selectOption("8N1");
await expect(meterModal.getByLabel("Test value")).toHaveValue("100,id:44,serial");
});
test("no service call without connection params", async ({ page }) => {
const meterModal = await openMeterModal(page);
await meterModal.getByLabel("Register address").fill("100");
await expect(meterModal.getByLabel("Test value")).toHaveValue("");
});
test("template default id and port are used", async ({ page }) => {
const meterModal = await openMeterModal(page);
await meterModal.getByLabel("Register address").fill("100");
await meterModal.getByLabel("IP address or hostname").fill("192.168.1.1");
await expect(meterModal.getByLabel("Test value")).toHaveValue("100,id:2,tcp");
});
});

View file

@ -0,0 +1,26 @@
template: service-modbus
group: generic
products:
- description:
generic: Service Modbus Test Meter
params:
- name: usage
choice: ["grid"]
- name: modbus
choice: ["rs485", "tcpip"]
id: 2
- name: address
description:
generic: Register address
type: int
required: true
- name: value
description:
generic: Test value
type: string
unit: "TXT"
service: demo/modbus?address={address}&{modbus}
required: true
render: |
type: custom

View file

@ -12,6 +12,7 @@ func init() {
mux.HandleFunc("GET /single", getSingle)
mux.HandleFunc("GET /country", getCountry)
mux.HandleFunc("GET /{country}/city", getCity)
mux.HandleFunc("GET /modbus", getModbus)
service.Register("demo", mux)
}
@ -37,3 +38,30 @@ func getCity(w http.ResponseWriter, req *http.Request) {
}
json.NewEncoder(w).Encode(cities)
}
func getModbus(w http.ResponseWriter, req *http.Request) {
// Verify that either uri or device is provided (mimics modbus connection params)
uri := req.URL.Query().Get("uri")
device := req.URL.Query().Get("device")
address := req.URL.Query().Get("address")
id := req.URL.Query().Get("id")
if uri == "" && device == "" {
http.Error(w, "either uri or device parameter required", http.StatusBadRequest)
return
}
if address == "" {
http.Error(w, "address parameter required", http.StatusBadRequest)
return
}
// Return different values based on connection type and id
// Format: address,id:id,type (e.g., "100,id:2,tcp")
connType := "tcp"
if device != "" {
connType = "serial"
}
result := address + ",id:" + id + "," + connType
json.NewEncoder(w).Encode([]string{result})
}

38
util/service/helper.go Normal file
View file

@ -0,0 +1,38 @@
package service
import (
"encoding/json"
"net/http"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/spf13/cast"
)
// applyCast applies optional type casting
func applyCast(value any, castType string) any {
switch strings.ToLower(castType) {
case "int":
return cast.ToInt64(value)
case "float":
return cast.ToFloat64(value)
case "bool":
return cast.ToBool(value)
case "string":
return cast.ToString(value)
default:
return value
}
}
// 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))
}

145
util/service/modbus.go Normal file
View file

@ -0,0 +1,145 @@
package service
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
"github.com/fatih/structs"
"github.com/spf13/cast"
)
// Simple cache for service responses
type cacheEntry struct {
value any
timestamp time.Time
}
var (
cache = make(map[string]cacheEntry)
mu sync.RWMutex
cacheTTL = 1 * time.Minute // Cache for 1 minute
)
// Query combines modbus settings, register config, and additional parameters
type Query struct {
modbus.Settings `mapstructure:",squash"`
modbus.Register `mapstructure:",squash"`
Scale float64 // scaling factor
ResultType string // type cast (int, float, string)
}
func init() {
mux := http.NewServeMux()
mux.HandleFunc("GET /read", modbusRead)
service.Register("modbus", mux)
}
// modbusRead reads a parameter value from a device based on URL parameters
// Returns single value as array (for UI compatibility)
func modbusRead(w http.ResponseWriter, req *http.Request) {
// Convert URL query parameters to map for decoding
cc := make(map[string]any)
for k := range req.URL.Query() {
cc[k] = req.URL.Query().Get(k)
}
// Decode query parameters into Query struct using mapstructure
query := Query{
Scale: 1.0,
}
if err := util.DecodeOther(cc, &query); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
// Validate required parameters
if (query.URI == "" && query.Device == "") || cc["address"] == nil {
jsonError(w, http.StatusBadRequest, fmt.Errorf("uri or device and address parameters are required"))
return
}
// Create cache key from connection string and register address
cacheKey := fmt.Sprintf("%s:%s:%d", query.URI, query.Device, query.Address)
// Check cache first
mu.RLock()
if entry, ok := cache[cacheKey]; ok && time.Since(entry.timestamp) < cacheTTL {
mu.RUnlock()
jsonWrite(w, []string{cast.ToString(entry.value)})
return
}
mu.RUnlock()
// Read value from modbus using plugin
// Use background context so connection isn't tied to HTTP request lifecycle
value, err := readRegisterValue(context.TODO(), query)
if err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
// Apply optional cast
if query.ResultType != "" {
value = applyCast(value, query.ResultType)
}
// Store in cache
mu.Lock()
cache[cacheKey] = cacheEntry{
value: value,
timestamp: time.Now(),
}
mu.Unlock()
jsonWrite(w, []string{cast.ToString(value)})
}
// readRegisterValue reads a modbus register value by reusing the modbus plugin
func readRegisterValue(ctx context.Context, query Query) (res any, err error) {
// Convert Settings to map (plugin expects Settings fields at top level)
cfg := structs.Map(query.Settings)
// Plugin expects Register as nested object, not flattened
cfg["register"] = query.Register
cfg["scale"] = query.Scale
p, err := plugin.NewModbusFromConfig(ctx, cfg)
if err != nil {
return 0, fmt.Errorf("failed to create modbus plugin: %w", err)
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("read failed: %v", r)
}
}()
// Choose getter based on encoding type
encoding := strings.ToLower(query.Encoding)
// String encodings need special handling
if encoding == "string" || encoding == "bytes" {
g, err := p.(plugin.StringGetter).StringGetter()
if err != nil {
return nil, err
}
return g()
}
// For all numeric encodings (int*, float*, bool*), use FloatGetter
g, err := p.(plugin.FloatGetter).FloatGetter()
if err != nil {
return nil, err
}
return g()
}

146
util/service/modbus_test.go Normal file
View file

@ -0,0 +1,146 @@
package service
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetParams_DirectURI(t *testing.T) {
// Verify that direct URI parameter works
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&address=100&type=holding&encoding=uint16", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_WithScale(t *testing.T) {
// Test with scale parameter
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&id=1&address=1068&type=holding&encoding=float32s&scale=0.001", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_WithResultType(t *testing.T) {
// Test with resulttype parameter
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&id=1&address=1068&type=holding&encoding=float32s&resulttype=int", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_CompleteRequest(t *testing.T) {
// Test complete request with all parameters
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&id=1&address=1068&type=holding&encoding=float32s&scale=0.001&resulttype=int", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_RS485Serial(t *testing.T) {
// Test RS485 serial connection with device parameter
req := httptest.NewRequest("GET", "/read?device=/dev/ttyUSB0&baudrate=9600&comset=8N1&id=1&address=1068&type=holding&encoding=float32s&scale=0.001", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_RS485Serial_WithResultType(t *testing.T) {
// Test RS485 serial with resulttype parameter
req := httptest.NewRequest("GET", "/read?device=/dev/ttyUSB0&baudrate=19200&comset=8N1&id=1&address=100&type=holding&encoding=uint16&resulttype=int", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotEmpty(t, w.Body.String())
}
func TestGetParams_MissingConnection(t *testing.T) {
// Test that either uri or device is required
req := httptest.NewRequest("GET", "/read?id=1&address=100&type=holding&encoding=uint16", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
// Should return 400 error
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "uri or device")
}
func TestGetParams_MissingAddress(t *testing.T) {
// Test that address parameter is required
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&type=holding&encoding=uint16", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "address")
}
func TestGetParams_AddressZero(t *testing.T) {
// Test that address=0 is valid (not treated as missing)
req := httptest.NewRequest("GET", "/read?uri=192.168.1.1:502&address=0&type=holding&encoding=uint16", nil)
w := httptest.NewRecorder()
modbusRead(w, req)
// Should NOT return 400 - address 0 is valid, will fail at connection
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestApplyCast(t *testing.T) {
tests := []struct {
name string
value any
castType string
expected any
}{
// Int conversions
{"float to int", 42.7, "int", int64(42)},
{"string to int", "42", "int", int64(42)},
{"int to int", 42, "int", int64(42)},
{"negative float to int", -42.9, "int", int64(-42)},
// Float conversions
{"int to float", 42, "float", float64(42.0)},
{"string to float", "42.7", "float", float64(42.7)},
{"float to float", 42.7, "float", float64(42.7)},
// String conversions
{"int to string", 42, "string", "42"},
{"float to string", 42.7, "string", "42.7"},
{"string to string", "hello", "string", "hello"},
// Unknown/empty type (should return original)
{"unknown type", 42, "unknown", 42},
{"empty type", 42, "", 42},
{"nil value", nil, "int", int64(0)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := applyCast(tt.value, tt.castType)
assert.Equal(t, tt.expected, result)
})
}
}

View file

@ -62,3 +62,13 @@ func (c *configDefaults) ParamByName(name string) (int, Param) {
}
return -1, Param{}
}
// ModbusDefault returns the default value for a modbus parameter
func (c *configDefaults) ModbusDefault(name string) any {
for _, p := range c.Modbus.Definitions {
if p.Name == name {
return p.Default
}
}
return nil
}

View file

@ -745,6 +745,12 @@ modbus:
de: Kommunikationsparameter des Adapters
en: Communication parameter for the adapter
default: 8N1
- name: port
description:
de: Port
en: Port
default: 502
type: int
types:
rs485serial:
description:

View file

@ -77,7 +77,7 @@ func fromBytes(b []byte) (Template, error) {
TemplateDefinition: definition,
}
for _, f := range []func() error{tmpl.ResolvePresets, tmpl.ResolveGroup, tmpl.UpdateParamsWithDefaults, tmpl.Validate} {
for _, f := range []func() error{tmpl.ResolvePresets, tmpl.ResolveGroup, tmpl.UpdateParamsWithDefaults, tmpl.UpdateModbusParamsWithDefaults, tmpl.Validate} {
if err := f(); err != nil {
return tmpl, fmt.Errorf("template '%s': %w", tmpl.Template, err)
}

View file

@ -33,6 +33,31 @@ func (t *Template) UpdateParamsWithDefaults() error {
return nil
}
// UpdateModbusParamsWithDefaults populates modbus param fields with global defaults
// when device-specific values are not set (zero/empty).
func (t *Template) UpdateModbusParamsWithDefaults() error {
idx, modbusParam := t.ParamByName(ParamModbus)
if idx == -1 || len(modbusParam.Choice) == 0 {
return nil
}
if modbusParam.ID == 0 {
modbusParam.ID = cast.ToInt(ConfigDefaults.ModbusDefault(ModbusParamId))
}
if modbusParam.Baudrate == 0 {
modbusParam.Baudrate = cast.ToInt(ConfigDefaults.ModbusDefault(ModbusParamBaudrate))
}
if modbusParam.Comset == "" {
modbusParam.Comset = cast.ToString(ConfigDefaults.ModbusDefault(ModbusParamComset))
}
if modbusParam.Port == 0 {
modbusParam.Port = cast.ToInt(ConfigDefaults.ModbusDefault(ModbusParamPort))
}
t.Params[idx] = modbusParam
return nil
}
// validate the template (only rudimentary for now)
func (t *Template) Validate() error {
for _, c := range t.Capabilities {