Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
b02335b
initial modbus service
iseeberg79 Dec 7, 2025
63ce90a
remove usage filter
iseeberg79 Dec 8, 2025
48466c8
fix ui
iseeberg79 Dec 8, 2025
b8b44ad
fix tests
iseeberg79 Dec 8, 2025
c32a0bc
fix integration, restore
iseeberg79 Dec 8, 2025
0c5ced8
fix, reduce
iseeberg79 Dec 8, 2025
6071b9f
simplify
iseeberg79 Dec 9, 2025
0de806d
cache
iseeberg79 Dec 9, 2025
a648282
refactor
iseeberg79 Dec 9, 2025
884ff34
Merge branch 'master' into feature/service
iseeberg79 Dec 9, 2025
a085515
fix
iseeberg79 Dec 9, 2025
d152738
cleanup
iseeberg79 Dec 9, 2025
35a1ea7
simplify UI
iseeberg79 Dec 9, 2025
0c4a96a
linter
iseeberg79 Dec 9, 2025
e9039d4
remove obsolete
iseeberg79 Dec 9, 2025
2094942
Revert UI simplification
iseeberg79 Dec 9, 2025
d47c424
fix linter
iseeberg79 Dec 9, 2025
2d601b1
mapstructure squash pattern
iseeberg79 Dec 10, 2025
700fdb1
use uri
iseeberg79 Dec 10, 2025
70038f7
remove constants
iseeberg79 Dec 10, 2025
e7bd763
fix test
iseeberg79 Dec 10, 2025
6b00d11
simplify
iseeberg79 Dec 10, 2025
3518ad8
fix
iseeberg79 Dec 10, 2025
1e185c2
dynamic getters
iseeberg79 Dec 10, 2025
6c9ede3
validate
iseeberg79 Dec 10, 2025
75df7fd
simplify
andig Dec 10, 2025
b7bd656
wip
andig Dec 10, 2025
927cda2
fix
iseeberg79 Dec 10, 2025
de1d13e
revert test
iseeberg79 Dec 10, 2025
9119f1a
Delete .project
iseeberg79 Dec 10, 2025
fdf24fc
refactor
iseeberg79 Dec 11, 2025
9871ebf
add serial
iseeberg79 Dec 11, 2025
f4df4a7
lint
iseeberg79 Dec 11, 2025
8e5bf35
applyCast tests
iseeberg79 Dec 11, 2025
3a94c54
logging
iseeberg79 Dec 11, 2025
8d6181b
use mapstructure
iseeberg79 Dec 13, 2025
fabcbc7
simplify pluginGetter
iseeberg79 Dec 13, 2025
86bf70b
wip
iseeberg79 Dec 14, 2025
6573a87
Apply suggestion from @andig
andig Dec 23, 2025
bc4462e
Merge branch 'master' into feature/service
naltatis Dec 23, 2025
6e3336b
feature service dependency groups and yaml defintion
iseeberg79 Dec 23, 2025
7e15216
simplify
iseeberg79 Dec 27, 2025
c1f2c5a
fix linter
iseeberg79 Dec 27, 2025
828332d
simplify, fix
iseeberg79 Dec 27, 2025
632be00
revert changes to types
iseeberg79 Dec 27, 2025
6985b2f
refactor
iseeberg79 Dec 29, 2025
a57b344
Merge branch 'master' into feature/service
iseeberg79 Dec 30, 2025
8dfff46
fix early expand
iseeberg79 Dec 31, 2025
9cfd23b
simplify
iseeberg79 Dec 31, 2025
59a3d98
refactor propertyfield
naltatis Jan 3, 2026
0ced775
Merge branch 'master' into feature/service
naltatis Jan 3, 2026
0dc5f54
fix modbus default handling; add e2e tests
naltatis Jan 3, 2026
27d4dc8
added comment
naltatis Jan 3, 2026
e711ece
cleanup
naltatis Jan 3, 2026
347c9ad
fix test
naltatis Jan 6, 2026
82b5a8b
remove redundant modbus defaults
iseeberg79 Jan 10, 2026
555a065
Merge branch 'master' into feature/service
naltatis Jan 11, 2026
202ee51
Merge branch 'master' into feature/service
naltatis Jan 12, 2026
1072c80
update template docs; add service, auth, missing props
naltatis Jan 12, 2026
cece1cb
rename
naltatis Jan 13, 2026
47b165d
Merge branch 'master' into feature/service
naltatis Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions assets/js/components/Config/DeviceModal/DeviceModalBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -353,7 +352,6 @@ export default defineComponent({
},
apiData(): ApiData {
let data: ApiData = {
...this.modbusDefaults,
...this.values,
};
if (this.values.type === ConfigType.Template && this.templateName) {
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 17 additions & 9 deletions assets/js/components/Config/DeviceModal/Modbus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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')">
Expand All @@ -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
Expand Down Expand Up @@ -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')">
Expand All @@ -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>
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 51 additions & 3 deletions assets/js/components/Config/DeviceModal/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});

Expand All @@ -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");
});
});
37 changes: 24 additions & 13 deletions assets/js/components/Config/DeviceModal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type TemplateParam = {

export type ParamService = {
name: string;
dependencies: string[];
service: string;
url: (values: Record<string, any>) => string;
};

Expand Down Expand Up @@ -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) => {
Expand All @@ -127,17 +143,18 @@ 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>
);

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);
Expand All @@ -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;
Expand Down
1 change: 0 additions & 1 deletion assets/js/components/Config/PropertyEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
<PropertyField
:id="id"
v-model="value"
class="me-2"
:masked="Mask"
:property="Name"
:type="Type"
Expand Down
91 changes: 49 additions & 42 deletions assets/js/components/Config/PropertyField.vue
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -294,7 +301,7 @@ export default {
};
</script>

<style>
<style scoped>
input[type="number"] {
appearance: textfield;
}
Expand Down
Loading
Loading