@impact0815/node-red-contrib-home-connect 0.0.0-stage → 0.2.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/BUILD-INFO.json +23 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/NOTICE +19 -0
- package/README.de.md +344 -0
- package/README.md +343 -2
- package/SECURITY.md +47 -0
- package/docs/API-COVERAGE.md +59 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/DASHBOARD.de.md +121 -0
- package/docs/DASHBOARD.md +121 -0
- package/docs/FIRST-PUSH.md +42 -0
- package/docs/GITHUB-UPDATE.md +76 -0
- package/docs/LOCAL-LAN.de.md +150 -0
- package/docs/LOCAL-LAN.md +150 -0
- package/docs/MESSAGE-API.md +111 -0
- package/docs/RELEASE.md +91 -0
- package/docs/TROUBLESHOOTING.de.md +230 -0
- package/docs/TROUBLESHOOTING.md +230 -0
- package/docs/images/README.md +10 -0
- package/docs/images/dashboard-details-en.png +0 -0
- package/docs/images/dashboard-overview-en.png +0 -0
- package/docs/official-api-contract.json +418 -0
- package/examples/01-first-steps-device-list.json +148 -0
- package/examples/02-events.json +165 -0
- package/examples/03-safe-read-write.json +155 -0
- package/examples/04-local-read-only.json +214 -0
- package/examples/README.de.md +62 -0
- package/examples/README.md +62 -0
- package/examples/dashboard-dishwasher-washer-oven.json +628 -0
- package/examples/dashboard-screenshot-simulated.json +405 -0
- package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
- package/lib/client.js +600 -0
- package/lib/constants.js +66 -0
- package/lib/dashboard-i18n.js +255 -0
- package/lib/device-label.js +39 -0
- package/lib/errors.js +51 -0
- package/lib/health.js +525 -0
- package/lib/local-client.js +1014 -0
- package/lib/local-crypto.js +122 -0
- package/lib/local-diagnostics.js +115 -0
- package/lib/local-profile.js +706 -0
- package/lib/local-trace.js +103 -0
- package/lib/oauth.js +23 -0
- package/lib/rate-limiter.js +186 -0
- package/lib/simulator.js +290 -0
- package/lib/sse.js +58 -0
- package/lib/validation.js +194 -0
- package/lib/websocket-transport.js +374 -0
- package/nodes/home-connect.html +795 -0
- package/nodes/home-connect.js +699 -0
- package/nodes/locales/de/home-connect.html +19 -0
- package/nodes/locales/de/home-connect.json +10 -0
- package/nodes/locales/en-US/home-connect.json +10 -0
- package/package.json +76 -4
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { HomeConnectError } = require('./errors');
|
|
7
|
+
const { formatDeviceLabel } = require('./device-label');
|
|
8
|
+
const { cleanHost, privateIp } = require('./websocket-transport');
|
|
9
|
+
|
|
10
|
+
const MAX_PROFILE_FILES = 1000;
|
|
11
|
+
const MAX_PROFILE_DEPTH = 5;
|
|
12
|
+
|
|
13
|
+
const first = (object, keys) => keys.map(key => object?.[key]).find(value => value !== undefined && value !== null && value !== '');
|
|
14
|
+
const hasProfileKey = value => Boolean(first(value, ['psk64', 'psk', 'key', 'encryptionKey', 'aesKey']));
|
|
15
|
+
|
|
16
|
+
function profileConnectionType(declaredValue, iv64) {
|
|
17
|
+
const declared = String(declaredValue || '').trim().toUpperCase();
|
|
18
|
+
// The downloader's declared connectionType is authoritative. Some exports
|
|
19
|
+
// retain an IV while declaring PSK/TLS; treating every present IV as AES
|
|
20
|
+
// sends the WebSocket upgrade to the wrong port.
|
|
21
|
+
if (declared.includes('AES')) return 'AES';
|
|
22
|
+
if (declared.includes('TLS') || declared.includes('PSK')) return 'TLS';
|
|
23
|
+
return iv64 ? 'AES' : 'TLS';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function issue(code, message, messageDe, source, details = {}) {
|
|
27
|
+
return { code, message, messageDe, ...(source ? { source } : {}), ...details };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attributes(text) {
|
|
31
|
+
const result = {};
|
|
32
|
+
for (const match of String(text).matchAll(/([\w:-]+)\s*=\s*["']([^"']*)["']/g)) result[match[1].toLowerCase()] = match[2];
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodeXml(value) {
|
|
37
|
+
return String(value || '').replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function localTag(value) {
|
|
41
|
+
return String(value || '').split(':').pop().toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function xmlUid(value) {
|
|
45
|
+
const text = String(value ?? '').trim();
|
|
46
|
+
if (!text) return '';
|
|
47
|
+
const negative = text.startsWith('-');
|
|
48
|
+
const unsigned = negative ? text.slice(1) : text;
|
|
49
|
+
const hexadecimal = /^0x[0-9a-f]+$/i.test(unsigned)
|
|
50
|
+
|| /[a-f]/i.test(unsigned)
|
|
51
|
+
|| (/^0[0-9]+$/.test(unsigned) && unsigned.length > 1);
|
|
52
|
+
const parsed = Number.parseInt(unsigned.replace(/^0x/i, ''), hexadecimal ? 16 : 10);
|
|
53
|
+
if (!Number.isSafeInteger(parsed)) return text;
|
|
54
|
+
return String(negative ? -parsed : parsed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function xmlIdentifier(value) {
|
|
58
|
+
const text = String(value ?? '').trim();
|
|
59
|
+
if (!text) return '';
|
|
60
|
+
const parsed = Number.parseInt(text.replace(/^0x/i, ''), 16);
|
|
61
|
+
return Number.isSafeInteger(parsed) ? String(parsed) : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function enumValue(value) {
|
|
65
|
+
const text = String(value ?? '').trim();
|
|
66
|
+
if (!text) return '';
|
|
67
|
+
if (/^-?0x[0-9a-f]+$/i.test(text)) {
|
|
68
|
+
const negative = text.startsWith('-');
|
|
69
|
+
const parsed = Number.parseInt(text.replace(/^-?0x/i, ''), 16);
|
|
70
|
+
return String(negative ? -parsed : parsed);
|
|
71
|
+
}
|
|
72
|
+
const parsed = Number(text);
|
|
73
|
+
return Number.isFinite(parsed) ? String(parsed) : text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function textContent(value) {
|
|
77
|
+
return decodeXml(String(value || '')
|
|
78
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
79
|
+
.replace(/<[^>]+>/g, ' ')
|
|
80
|
+
.replace(/\s+/g, ' ')
|
|
81
|
+
.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function categoryFrom(tag, name) {
|
|
85
|
+
const element = localTag(tag).replace(/s$/, '');
|
|
86
|
+
if (['status', 'setting', 'event', 'option', 'program', 'command'].includes(element)) return element;
|
|
87
|
+
const key = String(name || '');
|
|
88
|
+
if (/\.Status\./.test(key)) return 'status';
|
|
89
|
+
if (/\.Setting\./.test(key)) return 'setting';
|
|
90
|
+
if (/\.Event\./.test(key)) return 'event';
|
|
91
|
+
if (/\.Option\./.test(key)) return 'option';
|
|
92
|
+
if (/\.Program\./.test(key)) return 'program';
|
|
93
|
+
if (/\.Command\./.test(key)) return 'command';
|
|
94
|
+
return 'value';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function semanticEnumLabel(enumName, memberName) {
|
|
98
|
+
const name = decodeXml(String(memberName || '').trim());
|
|
99
|
+
const parent = decodeXml(String(enumName || '').trim());
|
|
100
|
+
if (!name) return '';
|
|
101
|
+
if (name.includes('.') || !parent) return name;
|
|
102
|
+
return `${parent}.${name}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function enumMembers(body, enumName) {
|
|
106
|
+
const values = {};
|
|
107
|
+
const memberPattern = /(?:<!--\s*([\s\S]*?)\s*-->\s*)?<(?:[\w-]+:)?(?:value|enum|enumMember|enumeration|enumerationValue)\b([^>]*)(?:\/>|>([\s\S]*?)<\/(?:[\w-]+:)?(?:value|enum|enumMember|enumeration|enumerationValue)>)/gi;
|
|
108
|
+
for (const match of String(body || '').matchAll(memberPattern)) {
|
|
109
|
+
const attrs = attributes(match[2]);
|
|
110
|
+
const key = enumValue(attrs.refvalue ?? attrs.value ?? attrs.key ?? attrs.id);
|
|
111
|
+
const rawLabel = attrs.name || attrs.label || textContent(match[3]) || textContent(match[1]);
|
|
112
|
+
const label = semanticEnumLabel(enumName, rawLabel);
|
|
113
|
+
if (key !== '' && label) values[key] = label;
|
|
114
|
+
}
|
|
115
|
+
return values;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseXmlTree(xml) {
|
|
119
|
+
const root = { tag: '#document', attrs: {}, children: [], text: '', leadingComment: '' };
|
|
120
|
+
const stack = [root];
|
|
121
|
+
let pendingComment = '';
|
|
122
|
+
const tokens = String(xml || '').match(/<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<[^>]+>|[^<]+/g) || [];
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
if (token.startsWith('<!--')) {
|
|
125
|
+
pendingComment = decodeXml(token.slice(4, -3).trim());
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (token.startsWith('<?') || token.startsWith('<!')) continue;
|
|
129
|
+
if (token.startsWith('</')) {
|
|
130
|
+
if (stack.length > 1) stack.pop();
|
|
131
|
+
pendingComment = '';
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (token.startsWith('<')) {
|
|
135
|
+
const selfClosing = /\/\s*>$/.test(token);
|
|
136
|
+
const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
|
|
137
|
+
if (!match) continue;
|
|
138
|
+
const node = {
|
|
139
|
+
tag: localTag(match[1]),
|
|
140
|
+
attrs: attributes(match[2]),
|
|
141
|
+
children: [],
|
|
142
|
+
text: '',
|
|
143
|
+
leadingComment: pendingComment
|
|
144
|
+
};
|
|
145
|
+
pendingComment = '';
|
|
146
|
+
stack.at(-1).children.push(node);
|
|
147
|
+
if (!selfClosing) stack.push(node);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (token.trim()) {
|
|
151
|
+
stack.at(-1).text += decodeXml(token).trim();
|
|
152
|
+
pendingComment = '';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return root;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function descendants(node) {
|
|
159
|
+
return (node?.children || []).flatMap(child => [child, ...descendants(child)]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function directText(node) {
|
|
163
|
+
return decodeXml(String(node?.text || '').replace(/\s+/g, ' ').trim());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function nodeName(node) {
|
|
167
|
+
const namedChild = (node?.children || []).find(child => ['name', 'key'].includes(child.tag));
|
|
168
|
+
return decodeXml(node?.attrs?.name || node?.attrs?.key || node?.attrs?.enumkey || directText(namedChild) || directText(node) || node?.leadingComment || '');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function enumMembersFromNode(node, enumName) {
|
|
172
|
+
const values = {};
|
|
173
|
+
const memberTags = new Set(['value', 'enum', 'enummember', 'enumeration', 'enumerationvalue']);
|
|
174
|
+
for (const member of descendants(node)) {
|
|
175
|
+
if (!memberTags.has(member.tag)) continue;
|
|
176
|
+
const raw = member.attrs.refvalue ?? member.attrs.value ?? member.attrs.key ?? member.attrs.id;
|
|
177
|
+
const key = enumValue(raw);
|
|
178
|
+
const rawLabel = member.attrs.name || member.attrs.label || directText(member) || member.leadingComment;
|
|
179
|
+
const label = semanticEnumLabel(enumName, rawLabel);
|
|
180
|
+
if (key !== '' && label) values[key] = label;
|
|
181
|
+
}
|
|
182
|
+
return values;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function enumReference(node) {
|
|
186
|
+
const candidates = [node, ...descendants(node).filter(child => /enum/.test(child.tag))];
|
|
187
|
+
for (const candidate of candidates) {
|
|
188
|
+
const attrs = candidate.attrs || {};
|
|
189
|
+
const value = attrs.enumerationtype ?? attrs.refenid ?? attrs.enid;
|
|
190
|
+
if (value !== undefined && value !== '') return xmlIdentifier(value);
|
|
191
|
+
}
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mergeEnumeration(enumerations, id, name, values, extra = {}) {
|
|
196
|
+
if (!id) return;
|
|
197
|
+
const current = enumerations[id] || {};
|
|
198
|
+
enumerations[id] = {
|
|
199
|
+
...current,
|
|
200
|
+
...extra,
|
|
201
|
+
name: decodeXml(name || current.name || id),
|
|
202
|
+
values: { ...(current.values || {}), ...(values || {}) }
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseFeatureXml(...documents) {
|
|
207
|
+
const features = {};
|
|
208
|
+
const enumerations = {};
|
|
209
|
+
const xmlDocuments = documents.filter(Boolean).map(String);
|
|
210
|
+
|
|
211
|
+
const trees = xmlDocuments.map(parseXmlTree);
|
|
212
|
+
for (const tree of trees) {
|
|
213
|
+
for (const node of descendants(tree)) {
|
|
214
|
+
const attrs = node.attrs || {};
|
|
215
|
+
const hasMembers = Object.keys(enumMembersFromNode(node, '')).length > 0;
|
|
216
|
+
const isEnumContainer = node.tag === 'enumerationtype'
|
|
217
|
+
|| (node.tag === 'enumeration' && attrs.refenid && hasMembers);
|
|
218
|
+
if (!isEnumContainer) continue;
|
|
219
|
+
const id = xmlIdentifier(attrs.enid || attrs.refenid);
|
|
220
|
+
if (!id) continue;
|
|
221
|
+
const name = nodeName(node) || id;
|
|
222
|
+
mergeEnumeration(enumerations, id, name, enumMembersFromNode(node, name), {
|
|
223
|
+
source: node.tag === 'enumerationtype' ? 'device-description' : 'feature-mapping',
|
|
224
|
+
...(attrs.subsetof ? { subsetOf: xmlIdentifier(attrs.subsetof) } : {})
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const inheritedValues = (id, seen = new Set()) => {
|
|
230
|
+
const enumeration = enumerations[id];
|
|
231
|
+
if (!enumeration || seen.has(id)) return {};
|
|
232
|
+
seen.add(id);
|
|
233
|
+
const parent = enumeration.subsetOf ? inheritedValues(enumeration.subsetOf, seen) : {};
|
|
234
|
+
return { ...parent, ...(enumeration.values || {}) };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
for (const tree of trees) {
|
|
238
|
+
for (const node of descendants(tree)) {
|
|
239
|
+
const attrs = node.attrs || {};
|
|
240
|
+
const uid = xmlIdentifier(attrs.uid || attrs.refuid);
|
|
241
|
+
if (!uid) continue;
|
|
242
|
+
const mappedName = nodeName(node);
|
|
243
|
+
const current = features[uid] || {};
|
|
244
|
+
const feature = { ...current };
|
|
245
|
+
if (mappedName && (mappedName.includes('.') || !feature.name)) feature.name = mappedName;
|
|
246
|
+
const access = attrs.access || attrs.accessmode;
|
|
247
|
+
if (access) feature.access = access;
|
|
248
|
+
for (const [source, target] of [
|
|
249
|
+
['type', 'type'], ['unit', 'unit'], ['min', 'min'], ['max', 'max'], ['stepSize', 'stepSize'],
|
|
250
|
+
['handling', 'handling'], ['refcid', 'refCID'], ['refdid', 'refDID']
|
|
251
|
+
]) {
|
|
252
|
+
const value = attrs[source.toLowerCase()] ?? attrs[source];
|
|
253
|
+
if (value !== undefined) feature[target] = decodeXml(value);
|
|
254
|
+
}
|
|
255
|
+
const enumerationType = enumReference(node);
|
|
256
|
+
const subsetOf = attrs.subsetof ? xmlIdentifier(attrs.subsetof) : '';
|
|
257
|
+
if (enumerationType) feature.enumerationType = enumerationType;
|
|
258
|
+
if (subsetOf) feature.subsetOf = subsetOf;
|
|
259
|
+
if (enumerationType || subsetOf) feature.enumDeclared = true;
|
|
260
|
+
const values = { ...(feature.values || {}) };
|
|
261
|
+
Object.assign(values, enumMembersFromNode(node, ''));
|
|
262
|
+
if (Object.keys(values).length) feature.values = values;
|
|
263
|
+
feature.category = categoryFrom(node.tag, feature.name);
|
|
264
|
+
features[uid] = feature;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const feature of Object.values(features)) {
|
|
269
|
+
const enumId = feature.enumerationType || feature.subsetOf;
|
|
270
|
+
const enumeration = enumerations[enumId];
|
|
271
|
+
const values = inheritedValues(enumId);
|
|
272
|
+
if (Object.keys(values).length) feature.values = { ...values, ...(feature.values || {}) };
|
|
273
|
+
if (enumeration) {
|
|
274
|
+
feature.enumName = enumeration.name;
|
|
275
|
+
feature.enumSource = enumeration.source;
|
|
276
|
+
feature.enumDeclared = true;
|
|
277
|
+
}
|
|
278
|
+
feature.category = categoryFrom(feature.category, feature.name);
|
|
279
|
+
}
|
|
280
|
+
return features;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function inferType(value, features = {}) {
|
|
284
|
+
const text = `${value || ''} ${Object.values(features).map(item => item?.name || '').join(' ')}`.toLowerCase();
|
|
285
|
+
if (/dishwasher|dishcare|geschirr/.test(text)) return 'Dishwasher';
|
|
286
|
+
if (/washer|laundry|waschmaschine/.test(text)) return 'Washer';
|
|
287
|
+
if (/oven|backofen|cooking\.oven/.test(text)) return 'Oven';
|
|
288
|
+
return 'Unknown';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeFeatures(value) {
|
|
292
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
293
|
+
const result = {};
|
|
294
|
+
for (const [uid, entry] of Object.entries(value)) {
|
|
295
|
+
const key = xmlUid(uid) || String(uid);
|
|
296
|
+
const normalized = entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
297
|
+
? { ...entry, ...(entry.values && typeof entry.values === 'object' ? { values: { ...entry.values } } : {}) }
|
|
298
|
+
: { name: String(entry || uid) };
|
|
299
|
+
normalized.category = categoryFrom(normalized.category, normalized.name);
|
|
300
|
+
result[key] = { ...(result[key] || {}), ...normalized };
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function profileCandidates(value) {
|
|
306
|
+
if (Array.isArray(value)) return value;
|
|
307
|
+
if (!value || typeof value !== 'object') return [];
|
|
308
|
+
for (const key of ['devices', 'appliances', 'homeappliances', 'profiles']) if (Array.isArray(value[key])) return value[key];
|
|
309
|
+
const values = Object.entries(value).filter(([, entry]) => entry && typeof entry === 'object');
|
|
310
|
+
if (!hasProfileKey(value) && values.some(([, entry]) => hasProfileKey(entry))) {
|
|
311
|
+
return values.map(([id, entry]) => ({ id, ...entry }));
|
|
312
|
+
}
|
|
313
|
+
return [value];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parseLocalDeviceMappings(value) {
|
|
317
|
+
if (value === undefined || value === null || value === '') return [];
|
|
318
|
+
let parsed = value;
|
|
319
|
+
if (typeof value === 'string') {
|
|
320
|
+
try { parsed = JSON.parse(value); }
|
|
321
|
+
catch (cause) { throw new HomeConnectError('Local device assignments are not valid JSON.', { code: 'LOCAL_MAPPING_JSON_INVALID', cause }); }
|
|
322
|
+
}
|
|
323
|
+
if (!Array.isArray(parsed)) throw new HomeConnectError('Local device assignments must be an array.', { code: 'LOCAL_MAPPING_INVALID' });
|
|
324
|
+
if (parsed.length > 100) throw new HomeConnectError('A maximum of 100 local device assignments is allowed.', { code: 'LOCAL_MAPPING_TOO_MANY' });
|
|
325
|
+
const result = [];
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
for (const entry of parsed) {
|
|
328
|
+
const profileId = String(entry?.profileId || entry?.haId || entry?.id || '').trim();
|
|
329
|
+
const rawHost = String(entry?.host || entry?.ip || '').trim();
|
|
330
|
+
const requested = String(entry?.connectionType || entry?.transport || 'auto').trim().toUpperCase();
|
|
331
|
+
if (!profileId) throw new HomeConnectError('A local device assignment is missing profileId.', { code: 'LOCAL_MAPPING_PROFILE_REQUIRED' });
|
|
332
|
+
if (seen.has(profileId)) throw new HomeConnectError(`Duplicate local device assignment: ${profileId}`, { code: 'LOCAL_MAPPING_DUPLICATE' });
|
|
333
|
+
if (!['AUTO', 'AES', 'TLS'].includes(requested)) throw new HomeConnectError(`Unsupported local connection type for ${profileId}.`, { code: 'LOCAL_MAPPING_CONNECTION_INVALID' });
|
|
334
|
+
let host = '';
|
|
335
|
+
if (rawHost) {
|
|
336
|
+
host = cleanHost(rawHost);
|
|
337
|
+
const literal = host.split('%')[0];
|
|
338
|
+
if (net.isIP(literal) && !privateIp(literal)) {
|
|
339
|
+
throw new HomeConnectError(`The host for ${profileId} is not a private or link-local address.`, { code: 'LOCAL_HOST_NOT_PRIVATE' });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
seen.add(profileId);
|
|
343
|
+
result.push({ profileId, host, connectionType: requested === 'AUTO' ? 'auto' : requested });
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function detectedHost(value) {
|
|
349
|
+
if (!value) return '';
|
|
350
|
+
try {
|
|
351
|
+
const host = cleanHost(value);
|
|
352
|
+
const literal = host.split('%')[0];
|
|
353
|
+
return net.isIP(literal) && !privateIp(literal) ? '' : host;
|
|
354
|
+
} catch { return ''; }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function normalizeProfile(value, extra = {}) {
|
|
358
|
+
const source = { ...value };
|
|
359
|
+
const mapping = extra.mapping || null;
|
|
360
|
+
const sourceHost = first(source, ['host', 'ipAddress', 'ip', 'hostname', 'address']);
|
|
361
|
+
const mappedHost = mapping ? mapping.host : undefined;
|
|
362
|
+
const hostText = extra.requireMapping ? mappedHost : (mappedHost || sourceHost);
|
|
363
|
+
const host = hostText ? cleanHost(hostText) : '';
|
|
364
|
+
const literal = host.split('%')[0];
|
|
365
|
+
if (host && net.isIP(literal) && !privateIp(literal)) {
|
|
366
|
+
throw new HomeConnectError('A local appliance host must be private or link-local.', { code: 'LOCAL_HOST_NOT_PRIVATE' });
|
|
367
|
+
}
|
|
368
|
+
const psk64 = first(source, ['psk64', 'psk', 'key', 'encryptionKey', 'aesKey']);
|
|
369
|
+
const iv64 = first(source, ['iv64', 'iv', 'initializationVector']);
|
|
370
|
+
const declared = first(source, ['connectionType', 'connection_type', 'protocol', 'encryption']);
|
|
371
|
+
const automaticConnectionType = profileConnectionType(declared, iv64);
|
|
372
|
+
const requestedConnectionType = String(mapping?.connectionType || 'auto').toUpperCase();
|
|
373
|
+
const connectionType = requestedConnectionType === 'AUTO' ? automaticConnectionType : requestedConnectionType;
|
|
374
|
+
if (!psk64) throw new HomeConnectError('A local appliance profile is missing its PSK.', { code: 'LOCAL_PROFILE_KEY_MISSING' });
|
|
375
|
+
if (!host && !extra.allowMissingHost) throw new HomeConnectError('A local appliance profile has no central Host/IP assignment.', { code: 'LOCAL_MAPPING_HOST_REQUIRED' });
|
|
376
|
+
if (connectionType === 'AES' && !iv64) throw new HomeConnectError('An AES appliance profile is missing its IV.', { code: 'LOCAL_PROFILE_IV_MISSING' });
|
|
377
|
+
const features = { ...normalizeFeatures(extra.features), ...normalizeFeatures(source.features || source.featureMap || source.deviceDescription) };
|
|
378
|
+
const id = String(first(source, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier']) || extra.id || sourceHost || 'local-profile');
|
|
379
|
+
const name = String(first(source, ['name', 'deviceName', 'displayName']) || `Home Connect ${id.slice(-4)}`);
|
|
380
|
+
const type = String(first(source, ['type', 'applianceType', 'deviceType']) || inferType(`${name} ${extra.filename || ''}`, features));
|
|
381
|
+
const rawModel = first(source, ['model', 'modelNumber', 'vib', 'eNumber', 'enumber']);
|
|
382
|
+
const model = rawModel ? String(rawModel).split('/')[0] : '';
|
|
383
|
+
return Object.freeze({
|
|
384
|
+
id, haId: id, host, psk64: String(psk64), ...(iv64 ? { iv64: String(iv64) } : {}), connectionType,
|
|
385
|
+
automaticConnectionType, declaredConnectionType: declared ? String(declared) : '',
|
|
386
|
+
pskIdentity: String(first(source, ['pskIdentity', 'psk_identity', 'identity']) || 'HCCOM_Local_App'),
|
|
387
|
+
name, type, brand: String(first(source, ['brand']) || 'Siemens'), ...(model ? { model } : {}), features,
|
|
388
|
+
source: extra.filename ? path.relative(extra.rootDirectory || path.dirname(extra.filename), extra.filename) : 'credential-json',
|
|
389
|
+
detectedHost: detectedHost(sourceHost)
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseJson(text, label) {
|
|
394
|
+
try { return JSON.parse(text); }
|
|
395
|
+
catch (cause) { throw new HomeConnectError(`Invalid local profile JSON (${label}).`, { code: 'LOCAL_PROFILE_JSON_INVALID', cause }); }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function profileFiles(directory, depth = 0, files = []) {
|
|
399
|
+
if (depth > MAX_PROFILE_DEPTH) return files;
|
|
400
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
401
|
+
if (files.length >= MAX_PROFILE_FILES) throw new HomeConnectError('The local profile folder contains too many files.', { code: 'LOCAL_PROFILE_FOLDER_TOO_LARGE' });
|
|
402
|
+
const filename = path.join(directory, entry.name);
|
|
403
|
+
if (entry.isDirectory()) profileFiles(filename, depth + 1, files);
|
|
404
|
+
else if (entry.isFile()) files.push(filename);
|
|
405
|
+
}
|
|
406
|
+
return files;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function companionFiles(filename, names, deviceJsonCount) {
|
|
410
|
+
const directory = path.dirname(filename);
|
|
411
|
+
const base = path.basename(filename).replace(/\.json$/i, '');
|
|
412
|
+
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
413
|
+
const sameDirectory = names.filter(candidate => path.dirname(candidate) === directory);
|
|
414
|
+
const prefixed = kind => new RegExp(`^${escapedBase}_${kind}\\.(?:xml|json)$`, 'i');
|
|
415
|
+
const generic = kind => new RegExp(`^${kind}\\.(?:xml|json)$`, 'i');
|
|
416
|
+
const pick = kind => {
|
|
417
|
+
const exact = sameDirectory.filter(candidate => prefixed(kind).test(path.basename(candidate)));
|
|
418
|
+
if (exact.length) return exact;
|
|
419
|
+
return deviceJsonCount === 1 ? sameDirectory.filter(candidate => generic(kind).test(path.basename(candidate))) : [];
|
|
420
|
+
};
|
|
421
|
+
return { deviceDescription: pick('DeviceDescription'), featureMapping: pick('FeatureMapping') };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function featuresFromCompanions(companions, rootDirectory) {
|
|
425
|
+
const all = [...companions.deviceDescription, ...companions.featureMapping];
|
|
426
|
+
const xml = all.filter(candidate => candidate.toLowerCase().endsWith('.xml')).map(candidate => fs.readFileSync(candidate, 'utf8'));
|
|
427
|
+
const json = all.filter(candidate => candidate.toLowerCase().endsWith('.json'));
|
|
428
|
+
const features = parseFeatureXml(...xml);
|
|
429
|
+
for (const filename of json) {
|
|
430
|
+
const parsed = parseJson(fs.readFileSync(filename, 'utf8'), path.relative(rootDirectory, filename));
|
|
431
|
+
Object.assign(features, normalizeFeatures(parsed.features || parsed));
|
|
432
|
+
}
|
|
433
|
+
return features;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function directoryProfiles(directory, options = {}) {
|
|
437
|
+
const names = profileFiles(directory);
|
|
438
|
+
const mappings = new Map(parseLocalDeviceMappings(options.mappings).map(entry => [entry.profileId, entry]));
|
|
439
|
+
const profiles = [];
|
|
440
|
+
const groups = [];
|
|
441
|
+
const errors = [];
|
|
442
|
+
const usedCompanions = new Set();
|
|
443
|
+
const records = [];
|
|
444
|
+
for (const filename of names.filter(value => value.toLowerCase().endsWith('.json') && !/devicedescription|featuremapping/i.test(value)).sort()) {
|
|
445
|
+
const source = path.relative(directory, filename);
|
|
446
|
+
try {
|
|
447
|
+
const parsed = parseJson(fs.readFileSync(filename, 'utf8'), source);
|
|
448
|
+
records.push({ filename, source, parsed, candidates: profileCandidates(parsed) });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
errors.push(issue(error.code, error.message, `Ungültiges Geräte-JSON: ${source}`, source));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const deviceJsonCounts = new Map();
|
|
454
|
+
const targetProfileId = String(options.targetProfileId || '').trim();
|
|
455
|
+
const selectedRecords = targetProfileId
|
|
456
|
+
? records.filter(record => record.candidates.some(candidate => String(first(candidate, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier']) || path.basename(record.filename, path.extname(record.filename))) === targetProfileId))
|
|
457
|
+
: records;
|
|
458
|
+
|
|
459
|
+
for (const record of selectedRecords) {
|
|
460
|
+
if (record.candidates.some(hasProfileKey)) deviceJsonCounts.set(path.dirname(record.filename), (deviceJsonCounts.get(path.dirname(record.filename)) || 0) + 1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
for (const record of selectedRecords) {
|
|
464
|
+
const companions = companionFiles(record.filename, names, deviceJsonCounts.get(path.dirname(record.filename)) || 0);
|
|
465
|
+
const companionList = [...companions.deviceDescription, ...companions.featureMapping];
|
|
466
|
+
const keyedCandidates = record.candidates.filter(hasProfileKey);
|
|
467
|
+
const looksLikeDevice = record.candidates.some(candidate => first(candidate, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier', 'name', 'deviceName'])) || companionList.length > 0;
|
|
468
|
+
if (!keyedCandidates.length) {
|
|
469
|
+
if (looksLikeDevice) errors.push(issue(
|
|
470
|
+
'LOCAL_PROFILE_KEY_MISSING',
|
|
471
|
+
`The appliance profile ${record.source} does not contain a PSK.`,
|
|
472
|
+
`Das Geräteprofil „${record.source}“ enthält keinen PSK-Schlüssel. Bitte das Profil vollständig neu herunterladen.`,
|
|
473
|
+
record.source
|
|
474
|
+
));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const missing = [];
|
|
478
|
+
if (!companions.deviceDescription.length) missing.push('DeviceDescription.xml');
|
|
479
|
+
if (!companions.featureMapping.length) missing.push('FeatureMapping.xml');
|
|
480
|
+
companionList.forEach(filename => usedCompanions.add(filename));
|
|
481
|
+
if (missing.length) {
|
|
482
|
+
errors.push(issue(
|
|
483
|
+
'LOCAL_PROFILE_FILES_INCOMPLETE',
|
|
484
|
+
`The appliance profile ${record.source} is incomplete; missing ${missing.join(' and ')}.`,
|
|
485
|
+
`Das Geräteprofil „${record.source}“ ist unvollständig. Es fehlt: ${missing.join(' und ')}. Bitte die drei Dateien aus demselben Download zusammenlassen.`,
|
|
486
|
+
record.source,
|
|
487
|
+
{ missing }
|
|
488
|
+
));
|
|
489
|
+
if (options.requireComplete === true) continue;
|
|
490
|
+
}
|
|
491
|
+
let features;
|
|
492
|
+
try { features = featuresFromCompanions(companions, directory); }
|
|
493
|
+
catch (error) {
|
|
494
|
+
errors.push(issue(error.code, error.message, `Begleitdatei zu „${record.source}“ ist ungültig.`, record.source));
|
|
495
|
+
if (options.requireComplete === true) continue;
|
|
496
|
+
features = {};
|
|
497
|
+
}
|
|
498
|
+
const base = path.basename(record.filename).replace(/\.json$/i, '');
|
|
499
|
+
for (const candidate of keyedCandidates) {
|
|
500
|
+
try {
|
|
501
|
+
const preliminaryId = String(first(candidate, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier']) || base);
|
|
502
|
+
const profile = normalizeProfile(candidate, {
|
|
503
|
+
id: base,
|
|
504
|
+
filename: record.filename,
|
|
505
|
+
rootDirectory: directory,
|
|
506
|
+
features,
|
|
507
|
+
mapping: mappings.get(preliminaryId),
|
|
508
|
+
requireMapping: options.requireMapping === true || options.allowMissingHost === true,
|
|
509
|
+
allowMissingHost: options.allowMissingHost === true
|
|
510
|
+
});
|
|
511
|
+
profiles.push(profile);
|
|
512
|
+
groups.push({
|
|
513
|
+
profileId: profile.haId,
|
|
514
|
+
source: profile.source,
|
|
515
|
+
deviceJson: profile.source,
|
|
516
|
+
deviceDescription: companions.deviceDescription.map(value => path.relative(directory, value)),
|
|
517
|
+
featureMapping: companions.featureMapping.map(value => path.relative(directory, value)),
|
|
518
|
+
complete: missing.length === 0
|
|
519
|
+
});
|
|
520
|
+
} catch (error) {
|
|
521
|
+
errors.push(issue(error.code, error.message, error.message, record.source));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
for (const filename of (targetProfileId ? [] : names).filter(value => /(?:^|_)(?:DeviceDescription|FeatureMapping)\.(?:xml|json)$/i.test(path.basename(value)))) {
|
|
527
|
+
if (usedCompanions.has(filename)) continue;
|
|
528
|
+
const source = path.relative(directory, filename);
|
|
529
|
+
errors.push(issue(
|
|
530
|
+
'LOCAL_PROFILE_ORPHAN_COMPANION',
|
|
531
|
+
`The companion file ${source} could not be assigned to an appliance JSON file.`,
|
|
532
|
+
`Die Begleitdatei „${source}“ konnte keinem Geräte-JSON zugeordnet werden. Dateinamen und Ordner prüfen.`,
|
|
533
|
+
source
|
|
534
|
+
));
|
|
535
|
+
}
|
|
536
|
+
return { profiles, groups, errors };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function resolveProfilePath(profilePath) {
|
|
540
|
+
const filename = path.resolve(String(profilePath || '').trim());
|
|
541
|
+
let stat;
|
|
542
|
+
try { stat = fs.statSync(filename); }
|
|
543
|
+
catch (cause) { throw new HomeConnectError('The local profile path cannot be read.', { code: 'LOCAL_PROFILE_PATH_UNREADABLE', cause }); }
|
|
544
|
+
return { filename, stat };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function scanLocalProfileDirectory(profilePath) {
|
|
548
|
+
if (!String(profilePath || '').trim()) throw new HomeConnectError('The local profile folder is required.', { code: 'LOCAL_PROFILE_PATH_REQUIRED' });
|
|
549
|
+
const { filename, stat } = resolveProfilePath(profilePath);
|
|
550
|
+
if (!stat.isDirectory()) throw new HomeConnectError('Automatic profile discovery requires a folder.', { code: 'LOCAL_PROFILE_FOLDER_REQUIRED' });
|
|
551
|
+
const loaded = directoryProfiles(filename, { allowMissingHost: true, requireComplete: true });
|
|
552
|
+
const byId = new Map();
|
|
553
|
+
for (const profile of loaded.profiles) {
|
|
554
|
+
if (!byId.has(profile.haId)) byId.set(profile.haId, []);
|
|
555
|
+
byId.get(profile.haId).push(profile);
|
|
556
|
+
}
|
|
557
|
+
const duplicateIds = [...byId.entries()].filter(([, values]) => values.length > 1).map(([id]) => id);
|
|
558
|
+
const errors = [...loaded.errors];
|
|
559
|
+
for (const profileId of duplicateIds) {
|
|
560
|
+
const sources = byId.get(profileId).map(profile => profile.source);
|
|
561
|
+
errors.push(issue(
|
|
562
|
+
'LOCAL_PROFILE_DUPLICATE',
|
|
563
|
+
`Duplicate local appliance ID ${profileId}: ${sources.join(', ')}`,
|
|
564
|
+
`Doppelte Profil-ID „${profileId}“ in ${sources.join(', ')}. Jede Profil-ID darf nur einmal vorkommen.`,
|
|
565
|
+
sources[0],
|
|
566
|
+
{ profileId, sources }
|
|
567
|
+
));
|
|
568
|
+
}
|
|
569
|
+
const profiles = [...byId.entries()].filter(([, values]) => values.length === 1).map(([, values]) => values[0]);
|
|
570
|
+
return {
|
|
571
|
+
directory: filename,
|
|
572
|
+
profiles: profiles.map(profile => {
|
|
573
|
+
const group = loaded.groups.find(entry => entry.profileId === profile.haId && entry.source === profile.source) || {};
|
|
574
|
+
return {
|
|
575
|
+
profileId: profile.haId,
|
|
576
|
+
name: profile.name,
|
|
577
|
+
type: profile.type,
|
|
578
|
+
brand: profile.brand,
|
|
579
|
+
...(profile.model ? { model: profile.model } : {}),
|
|
580
|
+
displayLabel: formatDeviceLabel(profile),
|
|
581
|
+
profileSource: profile.source,
|
|
582
|
+
detectedHost: profile.detectedHost,
|
|
583
|
+
detectedConnectionType: profile.automaticConnectionType,
|
|
584
|
+
files: {
|
|
585
|
+
deviceJson: group.deviceJson || profile.source,
|
|
586
|
+
deviceDescription: group.deviceDescription || [],
|
|
587
|
+
featureMapping: group.featureMapping || []
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}),
|
|
591
|
+
errors,
|
|
592
|
+
summary: {
|
|
593
|
+
found: profiles.length,
|
|
594
|
+
incomplete: errors.filter(error => ['LOCAL_PROFILE_FILES_INCOMPLETE', 'LOCAL_PROFILE_KEY_MISSING', 'LOCAL_PROFILE_ORPHAN_COMPANION'].includes(error.code)).length,
|
|
595
|
+
duplicates: duplicateIds.length
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function uniqueProfiles(profiles) {
|
|
601
|
+
const unique = new Map();
|
|
602
|
+
for (const profile of profiles) {
|
|
603
|
+
if (unique.has(profile.haId)) throw new HomeConnectError(`Duplicate local appliance ID: ${profile.haId}`, { code: 'LOCAL_PROFILE_DUPLICATE' });
|
|
604
|
+
unique.set(profile.haId, profile);
|
|
605
|
+
}
|
|
606
|
+
return [...unique.values()];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function loadLocalProfiles(options = {}) {
|
|
610
|
+
const profiles = [];
|
|
611
|
+
const mappings = parseLocalDeviceMappings(options.mappings);
|
|
612
|
+
const mappingById = new Map(mappings.map(entry => [entry.profileId, entry]));
|
|
613
|
+
const requireMapping = options.requireMapping === true;
|
|
614
|
+
const targetProfileId = String(options.targetProfileId || '').trim();
|
|
615
|
+
if (options.profileJson && String(options.profileJson).trim()) {
|
|
616
|
+
for (const candidate of profileCandidates(parseJson(String(options.profileJson), 'credential'))) {
|
|
617
|
+
const profileId = String(first(candidate, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier']) || 'local-profile');
|
|
618
|
+
if (targetProfileId && profileId !== targetProfileId) continue;
|
|
619
|
+
profiles.push(normalizeProfile(candidate, { mapping: mappingById.get(profileId), requireMapping }));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (options.profilePath && String(options.profilePath).trim()) {
|
|
623
|
+
const { filename, stat } = resolveProfilePath(options.profilePath);
|
|
624
|
+
if (stat.isDirectory()) {
|
|
625
|
+
const loaded = directoryProfiles(filename, { mappings, requireMapping, requireComplete: requireMapping, targetProfileId });
|
|
626
|
+
profiles.push(...loaded.profiles);
|
|
627
|
+
const fatalCodes = new Set([
|
|
628
|
+
'LOCAL_MAPPING_HOST_REQUIRED', 'LOCAL_MAPPING_CONNECTION_INVALID', 'LOCAL_PROFILE_IV_MISSING',
|
|
629
|
+
'LOCAL_PROFILE_FILES_INCOMPLETE', 'LOCAL_PROFILE_KEY_MISSING', 'LOCAL_PROFILE_ORPHAN_COMPANION'
|
|
630
|
+
]);
|
|
631
|
+
const fatal = loaded.errors.find(error => fatalCodes.has(error.code) || String(error.code || '').startsWith('LOCAL_MAPPING_'));
|
|
632
|
+
if (fatal || (!loaded.profiles.length && loaded.errors.length)) {
|
|
633
|
+
const problem = fatal || loaded.errors[0];
|
|
634
|
+
throw new HomeConnectError(problem.message, { code: problem.code });
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
const parsed = parseJson(fs.readFileSync(filename, 'utf8'), path.basename(filename));
|
|
638
|
+
for (const candidate of profileCandidates(parsed)) {
|
|
639
|
+
const profileId = String(first(candidate, ['haId', 'haID', 'id', 'serialNumber', 'serial', 'deviceId', 'identifier']) || path.basename(filename, path.extname(filename)));
|
|
640
|
+
if (targetProfileId && profileId !== targetProfileId) continue;
|
|
641
|
+
profiles.push(normalizeProfile(candidate, { filename, mapping: mappingById.get(profileId), requireMapping }));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!profiles.length && targetProfileId) {
|
|
646
|
+
throw new HomeConnectError(`The detected profile was not found: ${targetProfileId}`, { code: 'LOCAL_MAPPING_PROFILE_NOT_FOUND' });
|
|
647
|
+
}
|
|
648
|
+
if (!profiles.length) throw new HomeConnectError('No local appliance profiles were configured.', { code: 'LOCAL_PROFILE_REQUIRED' });
|
|
649
|
+
const result = uniqueProfiles(profiles);
|
|
650
|
+
if (requireMapping) {
|
|
651
|
+
const profileIds = new Set(result.map(profile => profile.haId));
|
|
652
|
+
const stale = mappings.find(mapping => !profileIds.has(mapping.profileId));
|
|
653
|
+
if (stale) throw new HomeConnectError(`The local assignment refers to an unknown profile: ${stale.profileId}`, { code: 'LOCAL_MAPPING_PROFILE_NOT_FOUND' });
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function profileCredential(profile) {
|
|
659
|
+
return {
|
|
660
|
+
id: profile.haId,
|
|
661
|
+
name: profile.name,
|
|
662
|
+
type: profile.type,
|
|
663
|
+
brand: profile.brand,
|
|
664
|
+
...(profile.model ? { model: profile.model } : {}),
|
|
665
|
+
host: profile.host,
|
|
666
|
+
connectionType: profile.connectionType,
|
|
667
|
+
psk64: profile.psk64,
|
|
668
|
+
...(profile.iv64 ? { iv64: profile.iv64 } : {}),
|
|
669
|
+
features: profile.features
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function exportLocalProfileJson(options = {}) {
|
|
674
|
+
const profiles = loadLocalProfiles({
|
|
675
|
+
profilePath: options.profilePath,
|
|
676
|
+
mappings: options.mappings,
|
|
677
|
+
requireMapping: true
|
|
678
|
+
});
|
|
679
|
+
return JSON.stringify({ devices: profiles.map(profileCredential) }, null, 2);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function publicProfile(profile) {
|
|
683
|
+
const value = {
|
|
684
|
+
haId: profile.haId,
|
|
685
|
+
name: profile.name,
|
|
686
|
+
type: profile.type,
|
|
687
|
+
brand: profile.brand,
|
|
688
|
+
...(profile.model ? { model: profile.model } : {}),
|
|
689
|
+
host: profile.host,
|
|
690
|
+
connectionType: profile.connectionType,
|
|
691
|
+
profileSource: profile.source
|
|
692
|
+
};
|
|
693
|
+
return { ...value, displayLabel: formatDeviceLabel(value) };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
module.exports = {
|
|
697
|
+
loadLocalProfiles,
|
|
698
|
+
normalizeProfile,
|
|
699
|
+
profileConnectionType,
|
|
700
|
+
parseFeatureXml,
|
|
701
|
+
publicProfile,
|
|
702
|
+
inferType,
|
|
703
|
+
parseLocalDeviceMappings,
|
|
704
|
+
scanLocalProfileDirectory,
|
|
705
|
+
exportLocalProfileJson
|
|
706
|
+
};
|