Skip to content

Commit 9bc2097

Browse files
committed
Discover scalar composite features in Home Assistant
1 parent c6e7d2d commit 9bc2097

2 files changed

Lines changed: 235 additions & 2 deletions

File tree

lib/extension/homeassistant.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,39 @@ const featurePropertyWithoutEndpoint = (feature: zhc.Feature): string => {
325325
return feature.property;
326326
};
327327

328+
const isSensitiveCompositeFeature = (feature: zhc.Feature): boolean => {
329+
return /pass(word)?|token|secret|credential|key/i.test(feature.name) || /pass(word)?|token|secret|credential|key/i.test(feature.property);
330+
};
331+
332+
const compositePathValueTemplate = (path: string[], lower = false): string => {
333+
const pathExpression = path.map((part) => `["${part}"]`).join("");
334+
const checks = path.map(
335+
(part, index) =>
336+
`${
337+
index === 0
338+
? "value_json"
339+
: `value_json${path
340+
.slice(0, index)
341+
.map((p) => `["${p}"]`)
342+
.join("")}`
343+
}["${part}"] is defined`,
344+
);
345+
const value = `value_json${pathExpression}`;
346+
const renderedValue = lower ? `${value} | string | lower` : value;
347+
348+
return `{% if ${checks.join(" and ")} %}{{ ${renderedValue} }}{% endif %}`;
349+
};
350+
351+
const compositePathCommandTemplate = (path: string[], valueTemplate: string): string => {
352+
let result = valueTemplate;
353+
354+
for (let i = path.length - 1; i >= 0; i--) {
355+
result = `{"${path[i]}": ${result}}`;
356+
}
357+
358+
return result;
359+
};
360+
328361
/**
329362
* This class handles the bridge entity configuration for Home Assistant Discovery.
330363
*/
@@ -1292,6 +1325,110 @@ export class HomeAssistant extends Extension {
12921325
break;
12931326
}
12941327

1328+
if (firstExposeTyped.type === "composite") {
1329+
const firstComposite = firstExposeTyped as zhc.Composite;
1330+
const addCompositeFeatureDiscovery = (feature: zhc.Feature, path: string[], labelParts: string[]): void => {
1331+
if (isSensitiveCompositeFeature(feature)) {
1332+
return;
1333+
}
1334+
1335+
if (feature.type === "composite") {
1336+
for (const child of (feature as zhc.Composite).features) {
1337+
addCompositeFeatureDiscovery(child, [...path, child.property], [...labelParts, child.label]);
1338+
}
1339+
return;
1340+
}
1341+
1342+
const allowsState = !!(feature.access & ACCESS_STATE);
1343+
const allowsSet = !!(feature.access & ACCESS_SET);
1344+
if (!allowsState && !allowsSet) {
1345+
return;
1346+
}
1347+
1348+
const objectId = path.join("_");
1349+
const name = endpointName ? `${labelParts.join(" ")} ${endpointName}` : labelParts.join(" ");
1350+
const discoveryPayload: KeyValue = {
1351+
name,
1352+
state_topic: allowsState,
1353+
...(allowsState && {
1354+
value_template: compositePathValueTemplate(path, isBinaryExpose(feature) && typeof feature.value_on === "boolean"),
1355+
}),
1356+
...(allowsSet && {
1357+
command_topic: true,
1358+
optimistic: !allowsState,
1359+
}),
1360+
};
1361+
1362+
if (isNumericExpose(feature)) {
1363+
if (allowsSet) discoveryPayload.command_template = compositePathCommandTemplate(path, "{{ value }}");
1364+
if (feature.unit) discoveryPayload.unit_of_measurement = feature.unit;
1365+
if (feature.value_step) discoveryPayload.step = feature.value_step;
1366+
if (feature.value_min != null) discoveryPayload.min = feature.value_min;
1367+
if (feature.value_max != null) discoveryPayload.max = feature.value_max;
1368+
Object.assign(discoveryPayload, NUMERIC_DISCOVERY_LOOKUP[feature.name]);
1369+
1370+
if (NUMERIC_DISCOVERY_LOOKUP[feature.name]?.device_class === "temperature") {
1371+
discoveryPayload.device_class = NUMERIC_DISCOVERY_LOOKUP[feature.name]?.device_class;
1372+
} else {
1373+
delete discoveryPayload.device_class;
1374+
}
1375+
1376+
discoveryEntries.push({
1377+
type: allowsSet ? "number" : "sensor",
1378+
object_id: objectId,
1379+
mockProperties: [{property: firstComposite.property, value: null}],
1380+
discovery_payload: discoveryPayload,
1381+
});
1382+
} else if (isEnumExpose(feature)) {
1383+
if (allowsSet) {
1384+
discoveryPayload.options = feature.values.map((v) => v.toString());
1385+
discoveryPayload.command_template = compositePathCommandTemplate(path, "{{ value | tojson }}");
1386+
}
1387+
Object.assign(discoveryPayload, ENUM_DISCOVERY_LOOKUP[feature.name]);
1388+
discoveryEntries.push({
1389+
type: allowsSet ? "select" : "sensor",
1390+
object_id: objectId,
1391+
mockProperties: [{property: firstComposite.property, value: null}],
1392+
discovery_payload: discoveryPayload,
1393+
});
1394+
} else if (isBinaryExpose(feature)) {
1395+
discoveryPayload.payload_on = feature.value_on.toString();
1396+
discoveryPayload.payload_off = feature.value_off.toString();
1397+
if (allowsSet) {
1398+
discoveryPayload.command_template = compositePathCommandTemplate(
1399+
path,
1400+
typeof feature.value_on === "boolean" ? "{{ value }}" : "{{ value | tojson }}",
1401+
);
1402+
}
1403+
Object.assign(discoveryPayload, BINARY_DISCOVERY_LOOKUP[feature.name]);
1404+
discoveryEntries.push({
1405+
type: allowsSet ? "switch" : "binary_sensor",
1406+
object_id: objectId,
1407+
mockProperties: [{property: firstComposite.property, value: null}],
1408+
discovery_payload: discoveryPayload,
1409+
});
1410+
} else if (feature.type === "text") {
1411+
const textFeature = feature as zhc.Text;
1412+
if (allowsSet) discoveryPayload.command_template = compositePathCommandTemplate(path, "{{ value | tojson }}");
1413+
Object.assign(discoveryPayload, LIST_DISCOVERY_LOOKUP[textFeature.name]);
1414+
discoveryEntries.push({
1415+
type: allowsSet ? "text" : "sensor",
1416+
object_id: objectId,
1417+
mockProperties: [{property: firstComposite.property, value: null}],
1418+
discovery_payload: discoveryPayload,
1419+
});
1420+
}
1421+
};
1422+
1423+
for (const feature of firstComposite.features) {
1424+
addCompositeFeatureDiscovery(feature, [firstComposite.property, feature.property], [firstComposite.label, feature.label]);
1425+
}
1426+
1427+
if (discoveryEntries.length > 0) {
1428+
break;
1429+
}
1430+
}
1431+
12951432
if (firstExposeTyped.type === "text" && firstExposeTyped.access & ACCESS_SET) {
12961433
discoveryEntries.push({
12971434
type: "text",

test/extensions/homeassistant.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe("Extension: HomeAssistant", () => {
126126
}
127127

128128
expect(duplicated).toStrictEqual([]);
129-
});
129+
}, 30000);
130130

131131
it("Should discover devices and groups", async () => {
132132
settings.set(["homeassistant", "experimental_event_entities"], true);
@@ -237,7 +237,7 @@ describe("Extension: HomeAssistant", () => {
237237
object_id: "ha_discovery_group",
238238
default_entity_id: "switch.ha_discovery_group",
239239
unique_id: "9_switch_zigbee2mqtt",
240-
group: ["0x0017880104e45542_switch_right_zigbee2mqtt"],
240+
group: ["0x000b57fffec6a5b7_color_options_execute_if_off_zigbee2mqtt", "0x0017880104e45542_switch_right_zigbee2mqtt"],
241241
origin: origin,
242242
value_template: '{{ value_json["state"] }}',
243243
};
@@ -996,6 +996,102 @@ describe("Extension: HomeAssistant", () => {
996996
});
997997
});
998998

999+
it("Should discover safe scalar child entities for composite exposes", () => {
1000+
const siren = getZ2MEntity(devices.HS2WD) as Device;
1001+
assert(siren.definition);
1002+
const originalExposes = siren.definition.exposes;
1003+
1004+
siren.definition.exposes = [
1005+
{
1006+
type: "composite",
1007+
name: "network",
1008+
property: "network",
1009+
label: "Network",
1010+
access: 3,
1011+
features: [
1012+
{
1013+
type: "numeric",
1014+
name: "timeout",
1015+
property: "timeout",
1016+
label: "Timeout",
1017+
access: 3,
1018+
unit: "min",
1019+
value_min: 1,
1020+
value_max: 60,
1021+
value_step: 1,
1022+
},
1023+
{
1024+
type: "enum",
1025+
name: "mode",
1026+
property: "mode",
1027+
label: "Mode",
1028+
access: 3,
1029+
values: ["auto", "manual"],
1030+
},
1031+
{
1032+
type: "text",
1033+
name: "password",
1034+
property: "password",
1035+
label: "Password",
1036+
access: 3,
1037+
},
1038+
{
1039+
type: "composite",
1040+
name: "advanced",
1041+
property: "advanced",
1042+
label: "Advanced",
1043+
access: 3,
1044+
features: [
1045+
{
1046+
type: "binary",
1047+
name: "enabled",
1048+
property: "enabled",
1049+
label: "Enabled",
1050+
access: 3,
1051+
value_on: true,
1052+
value_off: false,
1053+
},
1054+
],
1055+
},
1056+
],
1057+
},
1058+
] as zhc.Expose[];
1059+
1060+
try {
1061+
// @ts-expect-error private
1062+
const configs = extension.getConfigs(siren);
1063+
1064+
expect(
1065+
configs
1066+
.map((config) => `${config.type}:${config.object_id}`)
1067+
.filter((id) => id.startsWith("number:network_") || id.startsWith("select:network_") || id.startsWith("switch:network_")),
1068+
).toStrictEqual(["number:network_timeout", "select:network_mode", "switch:network_advanced_enabled"]);
1069+
expect(configs[0].discovery_payload).toMatchObject({
1070+
value_template:
1071+
'{% if value_json["network"] is defined and value_json["network"]["timeout"] is defined %}{{ value_json["network"]["timeout"] }}{% endif %}',
1072+
command_template: '{"network": {"timeout": {{ value }}}}',
1073+
unit_of_measurement: "min",
1074+
min: 1,
1075+
max: 60,
1076+
step: 1,
1077+
});
1078+
expect(configs[1].discovery_payload).toMatchObject({
1079+
command_template: '{"network": {"mode": {{ value | tojson }}}}',
1080+
options: ["auto", "manual"],
1081+
});
1082+
expect(configs[2].discovery_payload).toMatchObject({
1083+
value_template:
1084+
'{% if value_json["network"] is defined and value_json["network"]["advanced"] is defined and value_json["network"]["advanced"]["enabled"] is defined %}{{ value_json["network"]["advanced"]["enabled"] | string | lower }}{% endif %}',
1085+
command_template: '{"network": {"advanced": {"enabled": {{ value }}}}}',
1086+
payload_on: "true",
1087+
payload_off: "false",
1088+
});
1089+
expect(configs.find((config) => config.object_id.includes("password"))).toBeUndefined();
1090+
} finally {
1091+
siren.definition.exposes = originalExposes;
1092+
}
1093+
});
1094+
9991095
it("Should discover devices with speed-controlled fan", () => {
10001096
const payload = {
10011097
state_topic: "zigbee2mqtt/fanbee",

0 commit comments

Comments
 (0)