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:
parent
021260bac7
commit
b02f3d98eb
17 changed files with 751 additions and 121 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
<PropertyField
|
||||
:id="id"
|
||||
v-model="value"
|
||||
class="me-2"
|
||||
:masked="Mask"
|
||||
:property="Name"
|
||||
:type="Type"
|
||||
|
|
|
|||
|
|
@ -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,16 +63,21 @@
|
|||
:required="required"
|
||||
rows="4"
|
||||
/>
|
||||
<div v-else class="position-relative">
|
||||
<div v-else class="d-flex" :class="sizeClass">
|
||||
<div class="position-relative flex-grow-1">
|
||||
<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"
|
||||
: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
|
||||
|
|
@ -104,6 +95,14 @@
|
|||
</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
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}¶m2={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.
|
||||
|
|
|
|||
67
tests/config-param-service-modbus.spec.ts
Normal file
67
tests/config-param-service-modbus.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
tests/config-param-service-modbus.tpl.yaml
Normal file
26
tests/config-param-service-modbus.tpl.yaml
Normal 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
|
||||
|
|
@ -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
38
util/service/helper.go
Normal 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
145
util/service/modbus.go
Normal 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
146
util/service/modbus_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue