smart-home-engine 1.53.4 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/assets/{index-Cp--IWao.css → index-Bu5o4KXc.css} +1 -1
- package/dist/web/assets/index-CqYTIaIG.js +306 -0
- package/dist/web/assets/{tsMode-Ci4l56iF.js → tsMode-20i8zomj.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/src/config.js +21 -1
- package/src/index.js +107 -9
- package/src/lib/crash-record.js +103 -0
- package/src/lib/secrets.js +28 -1
- package/src/matter/bindings.js +291 -0
- package/src/matter/bridge-templates.js +261 -0
- package/src/matter/controller.js +1299 -12
- package/src/matter/dcl.js +252 -0
- package/src/matter/device-health.js +337 -0
- package/src/matter/groups.js +192 -0
- package/src/matter/mqtt-bridge.js +513 -0
- package/src/matter/thread-datasets.js +285 -0
- package/src/matter/thread-health.js +278 -0
- package/src/matter/thread-mesh.js +535 -0
- package/src/matter/thread.js +267 -0
- package/src/web/ai-tools.js +369 -0
- package/src/web/matter-api.js +560 -0
- package/src/web/prompts/tools.md +2 -1
- package/src/web/server.js +4 -1
- package/dist/web/assets/index-DPRtP55L.js +0 -298
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matter bindings — the pure half (roadmap M13): what to write into a target's Access Control
|
|
5
|
+
* list and a source's Binding list so that the source may drive the target directly, and the
|
|
6
|
+
* guard that keeps she's own Administer entry in every ACL it writes. No I/O here; the controller
|
|
7
|
+
* reads and writes the attributes and calls these functions in between.
|
|
8
|
+
*
|
|
9
|
+
* Two independent clusters make a binding (Matter core spec 9.10 and 9.6):
|
|
10
|
+
* - the target's Access Control list (endpoint 0, cluster 0x001F) must grant the source node
|
|
11
|
+
* the Operate privilege on the bound cluster/endpoint — else the target refuses the command;
|
|
12
|
+
* - the source's Binding list (its endpoint, cluster 0x001E) names the targets it sends to.
|
|
13
|
+
* Both are fabric-scoped lists: a write replaces this fabric's entries wholesale, so every write
|
|
14
|
+
* goes through the guard below.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PRIVILEGE = { view: 1, proxyView: 2, operate: 3, manage: 4, administer: 5 };
|
|
18
|
+
const AUTH_MODE = { pase: 1, case: 2, group: 3 };
|
|
19
|
+
|
|
20
|
+
const BINDING_CLUSTER_ID = 0x001e;
|
|
21
|
+
const ACCESS_CONTROL_CLUSTER_ID = 0x001f;
|
|
22
|
+
const SWITCH_CLUSTER_ID = 0x003b;
|
|
23
|
+
|
|
24
|
+
/** The clusters a binding may carry, with the plain words the UI uses. */
|
|
25
|
+
const BINDABLE_CLUSTERS = {
|
|
26
|
+
onOff: { id: 0x0006, label: 'on/off' },
|
|
27
|
+
levelControl: { id: 0x0008, label: 'dimming' },
|
|
28
|
+
colorControl: { id: 0x0300, label: 'colour' },
|
|
29
|
+
windowCovering: { id: 0x0102, label: 'blinds' },
|
|
30
|
+
thermostat: { id: 0x0201, label: 'thermostat' },
|
|
31
|
+
fanControl: { id: 0x0202, label: 'fan' },
|
|
32
|
+
doorLock: { id: 0x0101, label: 'lock' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Device types whose endpoints are built to be binding sources (Matter device library). */
|
|
36
|
+
const SOURCE_DEVICE_TYPES = {
|
|
37
|
+
0x0103: 'On/Off Light Switch',
|
|
38
|
+
0x0104: 'Dimmer Switch',
|
|
39
|
+
0x0105: 'Color Dimmer Switch',
|
|
40
|
+
0x0203: 'Window Covering Controller',
|
|
41
|
+
0x000b: 'Door Lock Controller',
|
|
42
|
+
0x0018: 'Pump Controller',
|
|
43
|
+
0x0017: 'Control Bridge',
|
|
44
|
+
};
|
|
45
|
+
const GENERIC_SWITCH_DEVICE_TYPE = 0x000f;
|
|
46
|
+
|
|
47
|
+
/** Defaults the specification guarantees when a device does not report its limits. */
|
|
48
|
+
const DEFAULT_LIMITS = { subjectsPerAccessControlEntry: 4, targetsPerAccessControlEntry: 3, accessControlEntriesPerFabric: 4 };
|
|
49
|
+
|
|
50
|
+
function clusterIdOf(cluster) {
|
|
51
|
+
if (typeof cluster === 'number') return cluster;
|
|
52
|
+
const known = BINDABLE_CLUSTERS[cluster];
|
|
53
|
+
if (known) return known.id;
|
|
54
|
+
const n = Number(cluster);
|
|
55
|
+
if (Number.isFinite(n)) return n;
|
|
56
|
+
throw new Error(`Unknown cluster "${cluster}"`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clusterNameOf(id) {
|
|
60
|
+
for (const [name, def] of Object.entries(BINDABLE_CLUSTERS)) if (def.id === id) return name;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameId(a, b) {
|
|
65
|
+
if (a === undefined || a === null || b === undefined || b === null) return false;
|
|
66
|
+
return String(a) === String(b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** she's own Administer entry on a device: CASE, privilege 5, subjects containing she's node ID. */
|
|
70
|
+
function findAdminEntry(acl, sheNodeId) {
|
|
71
|
+
return (acl ?? []).find((e) => e && e.privilege === PRIVILEGE.administer && e.authMode === AUTH_MODE.case && (e.subjects ?? []).some((s) => sameId(s, sheNodeId))) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The guard: an ACL list is written only when it still carries she's own Administer entry —
|
|
76
|
+
* without it the device would refuse every further interaction from she, and only a factory
|
|
77
|
+
* reset brings it back.
|
|
78
|
+
*/
|
|
79
|
+
function assertSafeAcl(acl, sheNodeId) {
|
|
80
|
+
if (!Array.isArray(acl)) throw new Error('ACL is not a list');
|
|
81
|
+
if (!findAdminEntry(acl, sheNodeId)) {
|
|
82
|
+
throw new Error(`refusing to write an ACL without she's own Administer entry (node ${sheNodeId}) — she would lock itself out of the device`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Entries she manages for bindings and groups: Operate, the auth mode (CASE for a source node,
|
|
88
|
+
* Group for a group ID), exactly one subject, explicit targets.
|
|
89
|
+
*/
|
|
90
|
+
function isOperateEntryFor(entry, subject, authMode = AUTH_MODE.case) {
|
|
91
|
+
return (
|
|
92
|
+
!!entry &&
|
|
93
|
+
entry.privilege === PRIVILEGE.operate &&
|
|
94
|
+
entry.authMode === authMode &&
|
|
95
|
+
Array.isArray(entry.subjects) &&
|
|
96
|
+
entry.subjects.length === 1 &&
|
|
97
|
+
sameId(entry.subjects[0], subject) &&
|
|
98
|
+
Array.isArray(entry.targets)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function targetMatches(t, endpoint, clusterId) {
|
|
103
|
+
return !!t && sameId(t.cluster, clusterId) && sameId(t.endpoint, endpoint);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function effectiveLimits(limits) {
|
|
107
|
+
const out = { ...DEFAULT_LIMITS };
|
|
108
|
+
for (const k of Object.keys(DEFAULT_LIMITS)) {
|
|
109
|
+
const v = Number(limits?.[k]);
|
|
110
|
+
if (Number.isFinite(v) && v > 0) out[k] = v;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Grant the source node Operate on the target endpoint's clusters. Returns the new ACL list (the
|
|
117
|
+
* input is not modified). Extends she's own Operate entry for that source when one exists and has
|
|
118
|
+
* room, else adds an entry; refuses when the device's limits would be exceeded.
|
|
119
|
+
*
|
|
120
|
+
* @param {object[]} acl the target's ACL entries of she's fabric, as read
|
|
121
|
+
* @param {{ subject: bigint|number|string, endpoint: number, clusters: (number|string)[], sheNodeId: bigint|number|string, limits?: object, fabricIndex?: number }} opts
|
|
122
|
+
* @returns {object[]}
|
|
123
|
+
*/
|
|
124
|
+
function grantOperate(acl, { subject, endpoint, clusters, sheNodeId, limits, fabricIndex, authMode = AUTH_MODE.case }) {
|
|
125
|
+
assertSafeAcl(acl, sheNodeId);
|
|
126
|
+
const lim = effectiveLimits(limits);
|
|
127
|
+
const out = acl.map((e) => ({ ...e, subjects: e.subjects ? [...e.subjects] : e.subjects, targets: e.targets ? e.targets.map((t) => ({ ...t })) : e.targets }));
|
|
128
|
+
const wanted = clusters.map(clusterIdOf).filter((id, i, arr) => arr.indexOf(id) === i);
|
|
129
|
+
const missing = wanted.filter((id) => !out.some((e) => isOperateEntryFor(e, subject, authMode) && e.targets.some((t) => targetMatches(t, endpoint, id))));
|
|
130
|
+
if (missing.length === 0) return out;
|
|
131
|
+
const fi = fabricIndex ?? findAdminEntry(acl, sheNodeId).fabricIndex;
|
|
132
|
+
for (const id of missing) {
|
|
133
|
+
const target = { cluster: id, endpoint: Number(endpoint), deviceType: null };
|
|
134
|
+
const room = out.find((e) => isOperateEntryFor(e, subject, authMode) && e.targets.length < lim.targetsPerAccessControlEntry);
|
|
135
|
+
if (room) {
|
|
136
|
+
room.targets.push(target);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (out.length >= lim.accessControlEntriesPerFabric) {
|
|
140
|
+
throw new Error(`the device allows only ${lim.accessControlEntriesPerFabric} access control entries per fabric and all are in use`);
|
|
141
|
+
}
|
|
142
|
+
const entry = { privilege: PRIVILEGE.operate, authMode, subjects: [typeof subject === 'bigint' ? subject : BigInt(subject)], targets: [target] };
|
|
143
|
+
if (fi !== undefined) entry.fabricIndex = fi;
|
|
144
|
+
out.push(entry);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Take the Operate grant for the given endpoint/clusters away from the source node — only from
|
|
151
|
+
* entries she manages (Operate, CASE, that one subject). An entry left without targets is dropped.
|
|
152
|
+
* Returns the new ACL list.
|
|
153
|
+
*/
|
|
154
|
+
function revokeOperate(acl, { subject, endpoint, clusters, sheNodeId, authMode = AUTH_MODE.case }) {
|
|
155
|
+
assertSafeAcl(acl, sheNodeId);
|
|
156
|
+
const ids = clusters.map(clusterIdOf);
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const e of acl) {
|
|
159
|
+
if (!isOperateEntryFor(e, subject, authMode)) {
|
|
160
|
+
out.push(e);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const targets = e.targets.filter((t) => !ids.some((id) => targetMatches(t, endpoint, id)));
|
|
164
|
+
if (targets.length > 0) out.push({ ...e, targets });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Which clusters of target endpoint the source node is granted Operate on (by she's entries). */
|
|
170
|
+
function grantedClusters(acl, { subject, endpoint, authMode = AUTH_MODE.case }) {
|
|
171
|
+
const ids = new Set();
|
|
172
|
+
for (const e of acl ?? []) {
|
|
173
|
+
if (!isOperateEntryFor(e, subject, authMode)) continue;
|
|
174
|
+
for (const t of e.targets) if (sameId(t.endpoint, endpoint) && t.cluster !== null && t.cluster !== undefined) ids.add(Number(t.cluster));
|
|
175
|
+
}
|
|
176
|
+
return [...ids];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function bindingEntryMatches(b, { node, group, endpoint, cluster }) {
|
|
180
|
+
if (!b || !sameId(b.cluster, cluster)) return false;
|
|
181
|
+
if (group !== undefined && group !== null) return sameId(b.group, group) && (b.node === undefined || b.node === null);
|
|
182
|
+
return sameId(b.node, node) && sameId(b.endpoint, endpoint);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Append unicast binding entries `{ node, endpoint, cluster }` for the clusters not bound yet.
|
|
187
|
+
* Returns the new list (and which clusters were added).
|
|
188
|
+
*/
|
|
189
|
+
function addBindingEntries(bindings, { node, group, endpoint, clusters, fabricIndex }) {
|
|
190
|
+
const out = (bindings ?? []).map((b) => ({ ...b }));
|
|
191
|
+
const added = [];
|
|
192
|
+
const isGroup = group !== undefined && group !== null;
|
|
193
|
+
for (const c of clusters) {
|
|
194
|
+
const cluster = clusterIdOf(c);
|
|
195
|
+
if (out.some((b) => bindingEntryMatches(b, { node, group, endpoint, cluster }))) continue;
|
|
196
|
+
const entry = isGroup ? { group: Number(group), cluster } : { node: typeof node === 'bigint' ? node : BigInt(node), endpoint: Number(endpoint), cluster };
|
|
197
|
+
if (fabricIndex !== undefined) entry.fabricIndex = fabricIndex;
|
|
198
|
+
out.push(entry);
|
|
199
|
+
added.push(cluster);
|
|
200
|
+
}
|
|
201
|
+
return { bindings: out, added };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Remove the unicast binding entries for the clusters; returns the new list and which clusters were removed. */
|
|
205
|
+
function removeBindingEntries(bindings, { node, group, endpoint, clusters }) {
|
|
206
|
+
const ids = clusters.map(clusterIdOf);
|
|
207
|
+
const removed = [];
|
|
208
|
+
const out = (bindings ?? []).filter((b) => {
|
|
209
|
+
const hit = ids.find((cluster) => bindingEntryMatches(b, { node, group, endpoint, cluster }));
|
|
210
|
+
if (hit === undefined) return true;
|
|
211
|
+
removed.push(hit);
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
return { bindings: out, removed };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The clusters of `{ node, endpoint }` that other entries of the list still bind — their ACL grant must stay. */
|
|
218
|
+
function clustersStillBound(bindings, { node, group, endpoint }) {
|
|
219
|
+
const ids = new Set();
|
|
220
|
+
const isGroup = group !== undefined && group !== null;
|
|
221
|
+
for (const b of bindings ?? []) {
|
|
222
|
+
if (b.cluster === undefined || b.cluster === null) continue;
|
|
223
|
+
const hit = isGroup ? sameId(b.group, group) : sameId(b.node, node) && sameId(b.endpoint, endpoint);
|
|
224
|
+
if (hit) ids.add(Number(b.cluster));
|
|
225
|
+
}
|
|
226
|
+
return [...ids];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Does the source still bind anything to the group (its key set must stay)? */
|
|
230
|
+
function groupStillBound(bindings, group) {
|
|
231
|
+
return (bindings ?? []).some((b) => sameId(b.group, group));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Classify the endpoints of the commissioned nodes into binding sources and targets.
|
|
236
|
+
*
|
|
237
|
+
* @param {{ nodeId: string, name: string|null, online: boolean, endpoints: { endpointId: number, name: string|null, deviceTypes: number[], serverList: number[], clientList: number[] }[] }[]} nodes
|
|
238
|
+
* @returns {{ sources: object[], targets: object[], notBindable: object[] }}
|
|
239
|
+
*/
|
|
240
|
+
function classifyEndpoints(nodes) {
|
|
241
|
+
const sources = [];
|
|
242
|
+
const targets = [];
|
|
243
|
+
const notBindable = [];
|
|
244
|
+
for (const node of nodes) {
|
|
245
|
+
for (const ep of node.endpoints) {
|
|
246
|
+
if (ep.endpointId === 0) continue;
|
|
247
|
+
const servers = (ep.serverList ?? []).map(Number);
|
|
248
|
+
const clients = (ep.clientList ?? []).map(Number);
|
|
249
|
+
const base = { nodeId: node.nodeId, nodeName: node.name, online: node.online, endpointId: ep.endpointId, name: ep.name, deviceTypes: ep.deviceTypes ?? [] };
|
|
250
|
+
const serverClusters = Object.entries(BINDABLE_CLUSTERS)
|
|
251
|
+
.filter(([, def]) => servers.includes(def.id))
|
|
252
|
+
.map(([name, def]) => ({ name, id: def.id, label: def.label }));
|
|
253
|
+
if (serverClusters.length) targets.push({ ...base, clusters: serverClusters });
|
|
254
|
+
const clientClusters = Object.entries(BINDABLE_CLUSTERS)
|
|
255
|
+
.filter(([, def]) => clients.includes(def.id))
|
|
256
|
+
.map(([name, def]) => ({ name, id: def.id, label: def.label }));
|
|
257
|
+
if (servers.includes(BINDING_CLUSTER_ID) && clientClusters.length) {
|
|
258
|
+
sources.push({ ...base, clusters: clientClusters, deviceTypeName: (ep.deviceTypes ?? []).map((t) => SOURCE_DEVICE_TYPES[t]).find(Boolean) ?? null });
|
|
259
|
+
} else if (servers.includes(SWITCH_CLUSTER_ID) || (ep.deviceTypes ?? []).includes(GENERIC_SWITCH_DEVICE_TYPE)) {
|
|
260
|
+
notBindable.push({ ...base, reason: 'sends button events only (Generic Switch) — bind it with a script, see she.matter.sub' });
|
|
261
|
+
} else if (servers.includes(BINDING_CLUSTER_ID) && !clientClusters.length && !serverClusters.length) {
|
|
262
|
+
notBindable.push({ ...base, reason: 'has a Binding cluster but no client cluster she can bind (On/Off, Level Control, …)' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { sources, targets, notBindable };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
PRIVILEGE,
|
|
271
|
+
AUTH_MODE,
|
|
272
|
+
BINDING_CLUSTER_ID,
|
|
273
|
+
ACCESS_CONTROL_CLUSTER_ID,
|
|
274
|
+
SWITCH_CLUSTER_ID,
|
|
275
|
+
BINDABLE_CLUSTERS,
|
|
276
|
+
SOURCE_DEVICE_TYPES,
|
|
277
|
+
DEFAULT_LIMITS,
|
|
278
|
+
clusterIdOf,
|
|
279
|
+
clusterNameOf,
|
|
280
|
+
sameId,
|
|
281
|
+
findAdminEntry,
|
|
282
|
+
assertSafeAcl,
|
|
283
|
+
grantOperate,
|
|
284
|
+
revokeOperate,
|
|
285
|
+
grantedClusters,
|
|
286
|
+
addBindingEntries,
|
|
287
|
+
removeBindingEntries,
|
|
288
|
+
clustersStillBound,
|
|
289
|
+
groupStillBound,
|
|
290
|
+
classifyEndpoints,
|
|
291
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Matter ↔ MQTT bridge's pure parts (roadmap M19): topic templates with `${placeholders}`
|
|
5
|
+
* (filled outbound, turned into subscription patterns and matched inbound — node-red-contrib-ccu's
|
|
6
|
+
* mqtt node is the model), the payload formats, the MQTT device names, the cluster deny-list and
|
|
7
|
+
* the resolution of a `set` into a Matter command or an attribute write.
|
|
8
|
+
*
|
|
9
|
+
* The defaults are mqtt-smarthome (SPEC.md 2.0): `{ val, ts, lc }`, `matter/status/#`, `matter/set/#`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const mqttWildcard = require('../lib/mqtt-wildcards');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TEMPLATES = Object.freeze({
|
|
15
|
+
connected: 'matter/connected',
|
|
16
|
+
attribute: 'matter/status/${device}/${endpoint}/${cluster}/${attribute}',
|
|
17
|
+
event: 'matter/status/${device}/${endpoint}/${cluster}/${event}',
|
|
18
|
+
online: 'matter/status/${device}/online',
|
|
19
|
+
set: 'matter/set/${device}/${endpoint}/${cluster}/${attributeOrCommand}',
|
|
20
|
+
get: 'matter/get/${device}/${endpoint}/${cluster}/${attribute}',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const OUTBOUND = ['connected', 'attribute', 'event', 'online'];
|
|
24
|
+
const INBOUND = ['set', 'get'];
|
|
25
|
+
const PLACEHOLDERS = ['device', 'nodeId', 'endpoint', 'cluster', 'attribute', 'event', 'attributeOrCommand'];
|
|
26
|
+
const FORMATS = ['plain', 'basic', 'extended'];
|
|
27
|
+
|
|
28
|
+
/** Clusters never bridged (D-24): diagnostics, commissioning, the data model's plumbing. */
|
|
29
|
+
const DENY_CLUSTERS = new Set([
|
|
30
|
+
'descriptor',
|
|
31
|
+
'binding',
|
|
32
|
+
'accessControl',
|
|
33
|
+
'groups',
|
|
34
|
+
'groupKeyManagement',
|
|
35
|
+
'generalDiagnostics',
|
|
36
|
+
'softwareDiagnostics',
|
|
37
|
+
'threadNetworkDiagnostics',
|
|
38
|
+
'wiFiNetworkDiagnostics',
|
|
39
|
+
'ethernetNetworkDiagnostics',
|
|
40
|
+
'otaSoftwareUpdateRequestor',
|
|
41
|
+
'otaSoftwareUpdateProvider',
|
|
42
|
+
'identify',
|
|
43
|
+
'fixedLabel',
|
|
44
|
+
'userLabel',
|
|
45
|
+
'basicInformation',
|
|
46
|
+
'generalCommissioning',
|
|
47
|
+
'networkCommissioning',
|
|
48
|
+
'administratorCommissioning',
|
|
49
|
+
'operationalCredentials',
|
|
50
|
+
'timeSynchronization',
|
|
51
|
+
'icdManagement',
|
|
52
|
+
'powerSourceConfiguration',
|
|
53
|
+
'localizationConfiguration',
|
|
54
|
+
'timeFormatLocalization',
|
|
55
|
+
'unitLocalization',
|
|
56
|
+
'diagnosticLogs',
|
|
57
|
+
'scenesManagement',
|
|
58
|
+
'proxyConfiguration',
|
|
59
|
+
'proxyDiscovery',
|
|
60
|
+
'proxyValid',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Global attributes every cluster has — plumbing, not state. */
|
|
64
|
+
const DENY_ATTRIBUTES = new Set(['generatedCommandList', 'acceptedCommandList', 'eventList', 'attributeList', 'featureMap', 'clusterRevision']);
|
|
65
|
+
|
|
66
|
+
/** camelCase → snake_case (`levelControl` → `level_control`, `currentPositionLiftPercent100ths` → `current_position_lift_percent100ths`). */
|
|
67
|
+
function snake(name) {
|
|
68
|
+
return String(name)
|
|
69
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
70
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** snake_case → camelCase (`on_off` → `onOff`). */
|
|
75
|
+
function camel(name) {
|
|
76
|
+
return String(name).replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An MQTT device name from a device name: lower snake_case, nothing MQTT minds (`/`, `+`, `#`, spaces). */
|
|
80
|
+
function mqttName(name) {
|
|
81
|
+
const s = String(name ?? '')
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c])
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
86
|
+
.replace(/^_+|_+$/g, '');
|
|
87
|
+
return s || 'device';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Make a name unique among the taken ones: `name`, `name_2`, `name_3`, … */
|
|
91
|
+
function uniqueName(name, taken) {
|
|
92
|
+
const set = new Set(taken);
|
|
93
|
+
if (!set.has(name)) return name;
|
|
94
|
+
for (let i = 2; ; i++) if (!set.has(`${name}_${i}`)) return `${name}_${i}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function placeholdersOf(template) {
|
|
98
|
+
return [...String(template).matchAll(/\$\{([a-zA-Z]+)\}/g)].map((m) => m[1]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fill a template with values; a missing value becomes an empty segment. */
|
|
102
|
+
function fill(template, vars) {
|
|
103
|
+
return String(template).replace(/\$\{([a-zA-Z]+)\}/g, (_m, k) => (vars[k] === undefined || vars[k] === null ? '' : String(vars[k])));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The subscription pattern of an inbound template: every placeholder `+`, a placeholder at the
|
|
108
|
+
* very end `#` (it may span several segments — node-red-contrib-ccu does the same).
|
|
109
|
+
*/
|
|
110
|
+
function toPattern(template) {
|
|
111
|
+
const t = String(template);
|
|
112
|
+
const parts = t.split('/');
|
|
113
|
+
return parts
|
|
114
|
+
.map((seg, i) => {
|
|
115
|
+
if (!/\$\{[a-zA-Z]+\}/.test(seg)) return seg;
|
|
116
|
+
return i === parts.length - 1 && /^\$\{[a-zA-Z]+\}$/.test(seg) ? '#' : '+';
|
|
117
|
+
})
|
|
118
|
+
.join('/');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Match a topic against a template: the placeholder values, or null. A segment with text around
|
|
123
|
+
* a placeholder (`${device}_x`) matches the pattern but cannot be split — it is returned whole.
|
|
124
|
+
*/
|
|
125
|
+
function match(template, topic) {
|
|
126
|
+
const parts = String(template).split('/');
|
|
127
|
+
const pattern = toPattern(template);
|
|
128
|
+
const captured = mqttWildcard(topic, pattern);
|
|
129
|
+
if (captured === null) return null;
|
|
130
|
+
const vars = {};
|
|
131
|
+
let c = 0;
|
|
132
|
+
parts.forEach((seg) => {
|
|
133
|
+
const ph = placeholdersOf(seg);
|
|
134
|
+
if (!ph.length) return;
|
|
135
|
+
vars[ph[0]] = captured[c++];
|
|
136
|
+
});
|
|
137
|
+
return vars;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate a template set: every template a non-empty string of known placeholders (an empty
|
|
142
|
+
* one disables that topic), no MQTT wildcards, and an inbound template must not match what an
|
|
143
|
+
* outbound one publishes (she would answer its own status). Returns a list of problems.
|
|
144
|
+
*/
|
|
145
|
+
function validate(templates) {
|
|
146
|
+
const problems = [];
|
|
147
|
+
const t = { ...DEFAULT_TEMPLATES, ...(templates ?? {}) };
|
|
148
|
+
for (const [key, value] of Object.entries(t)) {
|
|
149
|
+
if (!(key in DEFAULT_TEMPLATES)) {
|
|
150
|
+
problems.push(`unknown template "${key}"`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (typeof value !== 'string') {
|
|
154
|
+
problems.push(`${key}: must be a string`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (value === '') continue;
|
|
158
|
+
if (/[+#]/.test(value)) problems.push(`${key}: no MQTT wildcards in a template`);
|
|
159
|
+
if (/^\/|\/\/|\/$/.test(value)) problems.push(`${key}: empty topic segment`);
|
|
160
|
+
for (const ph of placeholdersOf(value)) if (!PLACEHOLDERS.includes(ph)) problems.push(`${key}: unknown placeholder \${${ph}}`);
|
|
161
|
+
if (INBOUND.includes(key) && !placeholdersOf(value).includes('device') && !placeholdersOf(value).includes('nodeId'))
|
|
162
|
+
problems.push(`${key}: needs \${device} or \${nodeId}`);
|
|
163
|
+
}
|
|
164
|
+
const flagged = new Set(problems.map((p) => p.split(':')[0]));
|
|
165
|
+
const sample = { device: 'sample_device', nodeId: '7', endpoint: '1', cluster: 'on_off', attribute: 'on_off', event: 'initial_press', attributeOrCommand: 'on_off' };
|
|
166
|
+
for (const inKey of INBOUND) {
|
|
167
|
+
if (!t[inKey] || flagged.has(inKey)) continue;
|
|
168
|
+
for (const outKey of OUTBOUND) {
|
|
169
|
+
if (!t[outKey] || flagged.has(outKey)) continue;
|
|
170
|
+
if (mqttWildcard(fill(t[outKey], sample), toPattern(t[inKey])) !== null) problems.push(`${inKey} overlaps ${outKey}: she would answer its own ${outKey} topics`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (t.set && t.get && t.set === t.get) problems.push('set and get are the same template');
|
|
174
|
+
return problems;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The outbound payload for a value in the chosen format. */
|
|
178
|
+
function formatPayload(format, val, { ts, lc, matter } = {}) {
|
|
179
|
+
const v = val;
|
|
180
|
+
switch (format) {
|
|
181
|
+
case 'plain':
|
|
182
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
183
|
+
case 'extended':
|
|
184
|
+
return JSON.stringify({ val: v, ts, lc, matter });
|
|
185
|
+
case 'basic':
|
|
186
|
+
default:
|
|
187
|
+
return JSON.stringify({ val: v, ts, lc });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** An inbound payload: `{"val": …}` or a plain value (JSON when it parses, else the string). */
|
|
192
|
+
function parseInbound(payload) {
|
|
193
|
+
const s = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload ?? '');
|
|
194
|
+
if (s === '') return '';
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(s);
|
|
197
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'val' in parsed) return parsed.val;
|
|
198
|
+
return parsed;
|
|
199
|
+
} catch {
|
|
200
|
+
return s;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Commands behind an attribute `set` (D: on/off, level, blinds, colour temperature). */
|
|
205
|
+
const MAPPED = {
|
|
206
|
+
'onOff/onOff': (val) => {
|
|
207
|
+
if (val === 'toggle') return { command: 'toggle' };
|
|
208
|
+
return { command: truthy(val) ? 'on' : 'off' };
|
|
209
|
+
},
|
|
210
|
+
'levelControl/currentLevel': (val) => ({ command: 'moveToLevel', args: { level: Number(val), transitionTime: 0 } }),
|
|
211
|
+
'windowCovering/currentPositionLiftPercent100ths': (val) => ({ command: 'goToLiftPercentage', args: { liftPercent100thsValue: Number(val) } }),
|
|
212
|
+
'windowCovering/currentPositionTiltPercent100ths': (val) => ({ command: 'goToTiltPercentage', args: { tiltPercent100thsValue: Number(val) } }),
|
|
213
|
+
'colorControl/colorTemperatureMireds': (val) => ({ command: 'moveToColorTemperature', args: { colorTemperatureMireds: Number(val), transitionTime: 0 } }),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function truthy(val) {
|
|
217
|
+
if (typeof val === 'string') return !['', '0', 'false', 'off', 'no'].includes(val.trim().toLowerCase());
|
|
218
|
+
return !!val;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What a `set` on `<cluster>/<attributeOrCommand>` does: a mapped command, the command of that
|
|
223
|
+
* name with the payload object as its fields, or an attribute write.
|
|
224
|
+
* @param {string} cluster camelCase
|
|
225
|
+
* @param {string} name camelCase attribute or command name
|
|
226
|
+
* @param {*} val the parsed payload
|
|
227
|
+
* @param {{ commands?: object, attributes?: object }} clusterDef the matter.js cluster definition (Complete)
|
|
228
|
+
* @returns {{ kind: 'command', command: string, args?: object } | { kind: 'write', attribute: string, value: * }}
|
|
229
|
+
*/
|
|
230
|
+
function resolveSet(cluster, name, val, clusterDef) {
|
|
231
|
+
const mapped = MAPPED[`${cluster}/${name}`];
|
|
232
|
+
if (mapped) return { kind: 'command', ...mapped(val) };
|
|
233
|
+
const commands = clusterDef?.commands ?? {};
|
|
234
|
+
const attributes = clusterDef?.attributes ?? {};
|
|
235
|
+
if (commands[name]) return { kind: 'command', command: name, args: val && typeof val === 'object' && !Array.isArray(val) ? val : {} };
|
|
236
|
+
if (attributes[name]) return { kind: 'write', attribute: name, value: val };
|
|
237
|
+
throw new Error(`"${name}" is neither a command nor an attribute of cluster "${cluster}"`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
DEFAULT_TEMPLATES,
|
|
242
|
+
OUTBOUND,
|
|
243
|
+
INBOUND,
|
|
244
|
+
PLACEHOLDERS,
|
|
245
|
+
FORMATS,
|
|
246
|
+
DENY_CLUSTERS,
|
|
247
|
+
DENY_ATTRIBUTES,
|
|
248
|
+
snake,
|
|
249
|
+
camel,
|
|
250
|
+
mqttName,
|
|
251
|
+
uniqueName,
|
|
252
|
+
placeholdersOf,
|
|
253
|
+
fill,
|
|
254
|
+
toPattern,
|
|
255
|
+
match,
|
|
256
|
+
validate,
|
|
257
|
+
formatPayload,
|
|
258
|
+
parseInbound,
|
|
259
|
+
resolveSet,
|
|
260
|
+
truthy,
|
|
261
|
+
};
|