homey-lib 2.45.3 → 2.46.0
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/.eslintignore +2 -1
- package/assets/app/schema.d.ts +546 -0
- package/assets/app/schema.json +136 -0
- package/assets/capability/schema.d.ts +53 -0
- package/helpers/index.js +21 -0
- package/index.js +48 -24
- package/lib/App/index.js +184 -5
- package/lib/Capability/index.js +42 -0
- package/lib/Device/index.js +10 -0
- package/lib/Energy/index.js +6 -0
- package/lib/Media/index.js +3 -0
- package/lib/Signal/index.js +25 -0
- package/lib/Signal/validators.js +21 -0
- package/lib/Util/file.js +84 -0
- package/lib/Util/index.js +72 -0
- package/lib/Util/zigbee.js +225 -0
- package/package.json +11 -3
- package/tsconfig.types.json +15 -0
- package/types/assets/app/schema.d.ts +546 -0
- package/types/assets/capability/schema.d.ts +53 -0
- package/types/helpers/index.d.ts +12 -0
- package/types/helpers/index.d.ts.map +1 -0
- package/types/index.d.ts +24 -0
- package/types/index.d.ts.map +1 -0
- package/types/lib/App/index.d.ts +140 -0
- package/types/lib/App/index.d.ts.map +1 -0
- package/types/lib/Capability/index.d.ts +61 -0
- package/types/lib/Capability/index.d.ts.map +1 -0
- package/types/lib/Device/index.d.ts +18 -0
- package/types/lib/Device/index.d.ts.map +1 -0
- package/types/lib/Energy/index.d.ts +12 -0
- package/types/lib/Energy/index.d.ts.map +1 -0
- package/types/lib/Media/index.d.ts +8 -0
- package/types/lib/Media/index.d.ts.map +1 -0
- package/types/lib/Signal/index.d.ts +52 -0
- package/types/lib/Signal/index.d.ts.map +1 -0
- package/types/lib/Signal/validators.d.ts +37 -0
- package/types/lib/Signal/validators.d.ts.map +1 -0
- package/types/lib/Util/file.d.ts +27 -0
- package/types/lib/Util/file.d.ts.map +1 -0
- package/types/lib/Util/index.d.ts +76 -0
- package/types/lib/Util/index.d.ts.map +1 -0
- package/types/lib/Util/zigbee.d.ts +88 -0
- package/types/lib/Util/zigbee.d.ts.map +1 -0
- package/webpack/index.js +1 -1
- package/webpack.config.js +1 -0
package/lib/Media/index.js
CHANGED
package/lib/Signal/index.js
CHANGED
|
@@ -11,8 +11,16 @@ const {
|
|
|
11
11
|
prontoValidator,
|
|
12
12
|
} = require('./validators');
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {'433' | '868' | 'ir'} SignalFrequency
|
|
16
|
+
*/
|
|
17
|
+
|
|
14
18
|
class Signal {
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* @param {Record<string, unknown>} signal
|
|
22
|
+
* @param {{ frequency?: SignalFrequency }} [options]
|
|
23
|
+
*/
|
|
16
24
|
constructor(signal, { frequency = undefined } = {}) {
|
|
17
25
|
this._signal = signal;
|
|
18
26
|
this._frequency = frequency;
|
|
@@ -20,6 +28,10 @@ class Signal {
|
|
|
20
28
|
this._check = this._check.bind(this);
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
/**
|
|
32
|
+
* @param {...unknown} args
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
23
35
|
debug(...args) {
|
|
24
36
|
if (!this._debug) return;
|
|
25
37
|
|
|
@@ -27,12 +39,21 @@ class Signal {
|
|
|
27
39
|
console.log('[dbg]', ...args);
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} message
|
|
44
|
+
* @param {boolean} result
|
|
45
|
+
* @returns {void}
|
|
46
|
+
*/
|
|
30
47
|
_check(message, result) {
|
|
31
48
|
if (result !== true) {
|
|
32
49
|
throw new Error(message);
|
|
33
50
|
}
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ debug?: boolean }} [options]
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
36
57
|
async validate({
|
|
37
58
|
debug = false,
|
|
38
59
|
} = {}) {
|
|
@@ -65,6 +86,10 @@ class Signal {
|
|
|
65
86
|
this.debug('Validated successfully');
|
|
66
87
|
}
|
|
67
88
|
|
|
89
|
+
/**
|
|
90
|
+
* @param {Record<string, (value: unknown, signal: Record<string, unknown>) => { result: boolean, msg: string }>} validatorEngine
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
68
93
|
_validateWithEngine(validatorEngine) {
|
|
69
94
|
return validate(validatorEngine, this._check, this._signal);
|
|
70
95
|
}
|
package/lib/Signal/validators.js
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Record<string, unknown>} SignalDefinition
|
|
7
|
+
* @typedef {{ result: boolean, msg: string }} ValidationResult
|
|
8
|
+
* @typedef {(value: unknown, signal: SignalDefinition) => ValidationResult} Validator
|
|
9
|
+
* @typedef {Record<string, Validator>} ValidatorEngine
|
|
10
|
+
* @typedef {(message: string, result: boolean) => void} CheckFn
|
|
11
|
+
*/
|
|
12
|
+
|
|
5
13
|
/* static functions */
|
|
14
|
+
/**
|
|
15
|
+
* @param {ValidatorEngine} validator
|
|
16
|
+
* @param {CheckFn} check
|
|
17
|
+
* @param {SignalDefinition} signal
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
6
20
|
function validate(validator, check, signal) {
|
|
7
21
|
for (const propName in signal) {
|
|
8
22
|
const property = signal[propName];
|
|
@@ -70,6 +84,7 @@ function _validateGenericData(data, signal) {
|
|
|
70
84
|
return res;
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/** @type {ValidatorEngine} */
|
|
73
88
|
const genericValidator = {
|
|
74
89
|
words(words, signal) {
|
|
75
90
|
const res = { result: true, msg: 'invalid_words' };
|
|
@@ -166,6 +181,7 @@ const rfBounds = {
|
|
|
166
181
|
repetitions: { min: 1, max: 255 },
|
|
167
182
|
};
|
|
168
183
|
|
|
184
|
+
/** @type {ValidatorEngine} */
|
|
169
185
|
const rfValidator = {
|
|
170
186
|
|
|
171
187
|
words(words, signal) {
|
|
@@ -222,6 +238,7 @@ const modulationBounds = {
|
|
|
222
238
|
channelDeviation: { min: 5000, max: 50000 },
|
|
223
239
|
};
|
|
224
240
|
|
|
241
|
+
/** @type {ValidatorEngine} */
|
|
225
242
|
const modulationValidator = {
|
|
226
243
|
modulation(modulation, signal) {
|
|
227
244
|
// replace by default modulation props in homey-microcontroller?
|
|
@@ -256,6 +273,7 @@ const modulationValidator = {
|
|
|
256
273
|
},
|
|
257
274
|
};
|
|
258
275
|
|
|
276
|
+
/** @type {ValidatorEngine} */
|
|
259
277
|
const prontoValidator = {
|
|
260
278
|
cmds(cmds, signal) {
|
|
261
279
|
const res = !Object.keys(cmds).some(cmd => {
|
|
@@ -279,6 +297,7 @@ const rf433Bounds = {
|
|
|
279
297
|
carrier: { min: 433000000, max: 433990000 },
|
|
280
298
|
};
|
|
281
299
|
|
|
300
|
+
/** @type {ValidatorEngine} */
|
|
282
301
|
const rf433Validator = {
|
|
283
302
|
carrier(carrier, signal) {
|
|
284
303
|
const res = _valid_bounds(carrier, 433000000, 433990000);
|
|
@@ -290,6 +309,7 @@ const rf868Bounds = {
|
|
|
290
309
|
carrier: { min: 868000000, max: 868900000 },
|
|
291
310
|
};
|
|
292
311
|
|
|
312
|
+
/** @type {ValidatorEngine} */
|
|
293
313
|
const rf868Validator = {
|
|
294
314
|
carrier(carrier, signal) {
|
|
295
315
|
const res = _valid_bounds(carrier, rf868Bounds.carrier.min, rf868Bounds.carrier.max);
|
|
@@ -302,6 +322,7 @@ const irBounds = {
|
|
|
302
322
|
dutyCycle: { min: 30, max: 70 },
|
|
303
323
|
};
|
|
304
324
|
|
|
325
|
+
/** @type {ValidatorEngine} */
|
|
305
326
|
const irValidator = {
|
|
306
327
|
carrier(carrier, signal) {
|
|
307
328
|
const res = _valid_bounds(carrier, irBounds.carrier.min, irBounds.carrier.max);
|
package/lib/Util/file.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const ALLOWED_INTEGRITY_HASHES = new Set([
|
|
8
|
+
'blake2b512',
|
|
9
|
+
'blake2s256',
|
|
10
|
+
'sha256',
|
|
11
|
+
'sha384',
|
|
12
|
+
'sha512',
|
|
13
|
+
'sha512-256',
|
|
14
|
+
'sha3-256',
|
|
15
|
+
'sha3-384',
|
|
16
|
+
'sha3-512',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculate a hex hash for the contents of a file.
|
|
21
|
+
* @param {string} filePath - Absolute or relative path to the file.
|
|
22
|
+
* @param {string} [hashName='sha256'] - Hash algorithm name (e.g. sha256).
|
|
23
|
+
* @returns {Promise<string>} Resolves with a hex-encoded digest.
|
|
24
|
+
*/
|
|
25
|
+
async function hashFile(filePath, hashName = 'sha256') {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const hash = crypto.createHash(hashName);
|
|
28
|
+
const stream = fs.createReadStream(filePath);
|
|
29
|
+
|
|
30
|
+
stream.on('error', reject);
|
|
31
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
32
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate file integrity using a `hashName:hexDigest` string.
|
|
38
|
+
* @param {string} filePath - Absolute or relative path to the file.
|
|
39
|
+
* @param {string} integrity - Hash name and hex digest separated by `:`.
|
|
40
|
+
* @returns {Promise<boolean>} Resolves with true when hashes match.
|
|
41
|
+
*/
|
|
42
|
+
async function validateIntegrity(filePath, integrity) {
|
|
43
|
+
if (typeof integrity !== 'string') {
|
|
44
|
+
throw new TypeError('Integrity must be a string');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parts = integrity.split(':');
|
|
48
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
49
|
+
throw new Error('Integrity must be in the format "hashName:hexDigest"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [hashName, expectedHex] = parts;
|
|
53
|
+
const actualHex = await hashFile(filePath, hashName);
|
|
54
|
+
|
|
55
|
+
return actualHex === expectedHex.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create an integrity string for a file using an allowed hash algorithm.
|
|
60
|
+
* @param {string} filePath - Absolute or relative path to the file.
|
|
61
|
+
* @param {string} [hashName='sha256'] - Allowed hash algorithm name.
|
|
62
|
+
* @returns {Promise<string>} Resolves with `hashName:hexDigest`.
|
|
63
|
+
*/
|
|
64
|
+
async function getIntegrity(filePath, hashName = 'sha256') {
|
|
65
|
+
const normalizedHash = String(hashName || '').toLowerCase();
|
|
66
|
+
|
|
67
|
+
if (!ALLOWED_INTEGRITY_HASHES.has(normalizedHash)) {
|
|
68
|
+
throw new Error('Hash algorithm is not allowed for integrity checks');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const digest = await hashFile(filePath, normalizedHash);
|
|
72
|
+
return `${normalizedHash}:${digest}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getOTAFilePath({ appPath, driverId, fileName }) {
|
|
76
|
+
return path.join(appPath, 'drivers', driverId, 'assets', 'firmware', fileName);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
hashFile,
|
|
81
|
+
getIntegrity,
|
|
82
|
+
validateIntegrity,
|
|
83
|
+
getOTAFilePath,
|
|
84
|
+
};
|
package/lib/Util/index.js
CHANGED
|
@@ -1,16 +1,87 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fileUtils = require('./file');
|
|
4
|
+
const zigbeeUtils = require('./zigbee');
|
|
5
|
+
|
|
3
6
|
class Util {
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} modelId
|
|
10
|
+
* @returns {string[]}
|
|
11
|
+
*/
|
|
5
12
|
static getPlatformLocalFeatures(modelId) {
|
|
6
13
|
return Util._platformLocalFeatures[modelId] || [];
|
|
7
14
|
}
|
|
8
15
|
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} modelId
|
|
18
|
+
* @param {string[]} wantedFeatures
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
9
21
|
static getMissingPlatformLocalFeatures(modelId, wantedFeatures) {
|
|
10
22
|
const features = Util.getPlatformLocalFeatures(modelId);
|
|
11
23
|
return wantedFeatures.filter(feature => !features.includes(feature));
|
|
12
24
|
}
|
|
13
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Create an integrity string for a file using an allowed hash algorithm.
|
|
28
|
+
* NOTE: This method is only available in Node.js environments.
|
|
29
|
+
* @param {string} filePath - Absolute or relative path to the file.
|
|
30
|
+
* @param {string} hashName - Allowed hash algorithm name.
|
|
31
|
+
* @returns {Promise<string>} Resolves with `hashName:hexDigest`.
|
|
32
|
+
*/
|
|
33
|
+
static getIntegrity(filePath, hashName) {
|
|
34
|
+
return fileUtils.getIntegrity(filePath, hashName);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate file integrity using a `hashName:hexDigest` string.
|
|
39
|
+
* NOTE: This method is only available in Node.js environments.
|
|
40
|
+
* @param {string} filePath - Absolute or relative path to the file.
|
|
41
|
+
* @param {string} integrity - Hash name and hex digest separated by `:`.
|
|
42
|
+
* @returns {Promise<boolean>} Resolves with true when hashes match.
|
|
43
|
+
*/
|
|
44
|
+
static validateIntegrity(filePath, integrity) {
|
|
45
|
+
return fileUtils.validateIntegrity(filePath, integrity);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse the Zigbee OTA header from a file.
|
|
50
|
+
* NOTE: This method is only available in Node.js environments.
|
|
51
|
+
* @param {string} filePath - Absolute or relative path to the OTA file.
|
|
52
|
+
* @returns {Promise<import('./zigbee').ZigbeeOTAHeader>} Parsed header values.
|
|
53
|
+
*/
|
|
54
|
+
static parseZigbeeOTAHeader(filePath) {
|
|
55
|
+
return zigbeeUtils.parseZigbeeOTAHeader(filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a Zigbee OTA header against expected values.
|
|
60
|
+
* NOTE: This method is only available in Node.js environments.
|
|
61
|
+
* @param {object} options - Validation options.
|
|
62
|
+
* @param {string} options.filePath - Absolute or relative path to the OTA file.
|
|
63
|
+
* @param {number} [options.manufacturerCode] - Expected manufacturer code.
|
|
64
|
+
* @param {number} [options.fileVersion] - Expected file version.
|
|
65
|
+
* @param {number} [options.imageType] - Expected image type.
|
|
66
|
+
* @returns {Promise<import('./zigbee').ZigbeeOTAHeader>} Parsed header values.
|
|
67
|
+
*/
|
|
68
|
+
static validateZigbeeOTAHeader(options) {
|
|
69
|
+
return zigbeeUtils.validateZigbeeOTAHeader(options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the expected file path for an OTA file based on app path, driver ID and file name.
|
|
74
|
+
* NOTE: This method is only available in Node.js environments.
|
|
75
|
+
* @param {object} options - File path options.
|
|
76
|
+
* @param {string} options.appPath - Absolute path to the app.
|
|
77
|
+
* @param {string} options.driverId - Driver ID.
|
|
78
|
+
* @param {string} options.fileName - OTA file name.
|
|
79
|
+
* @returns {string} Resolves with the expected file path.
|
|
80
|
+
*/
|
|
81
|
+
static getOTAFilePath({ appPath, driverId, fileName }) {
|
|
82
|
+
return fileUtils.getOTAFilePath({ appPath, driverId, fileName });
|
|
83
|
+
}
|
|
84
|
+
|
|
14
85
|
}
|
|
15
86
|
|
|
16
87
|
Util.FEATURE_SPEAKER = 'speaker';
|
|
@@ -18,6 +89,7 @@ Util.FEATURE_LED_RING = 'ledring';
|
|
|
18
89
|
Util.FEATURE_NFC = 'nfc';
|
|
19
90
|
Util.FEATURE_MATTER = 'matter';
|
|
20
91
|
Util.FEATURE_CAMERA_STREAMING = 'camera-streaming';
|
|
92
|
+
/** @type {Record<string, string[]>} */
|
|
21
93
|
Util._platformLocalFeatures = {
|
|
22
94
|
homey1s: [Util.FEATURE_SPEAKER, Util.FEATURE_LED_RING, Util.FEATURE_NFC],
|
|
23
95
|
homey1d: [Util.FEATURE_SPEAKER, Util.FEATURE_LED_RING, Util.FEATURE_NFC],
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const OTA_FILE_IDENTIFIER = 0x0BEEF11E;
|
|
6
|
+
const OTA_HEADER_VERSION = 0x0100;
|
|
7
|
+
const OTA_MIN_HEADER_LENGTH = 56;
|
|
8
|
+
|
|
9
|
+
const FIELD_CONTROL_BITS = {
|
|
10
|
+
securityCredentialVersion: 1 << 0,
|
|
11
|
+
deviceSpecificFile: 1 << 1,
|
|
12
|
+
hardwareVersionsPresent: 1 << 2,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} ZigbeeOTAHeader
|
|
17
|
+
* @property {number} fileSize - File size in bytes.
|
|
18
|
+
* @property {number} fileIdentifier - OTA file identifier.
|
|
19
|
+
* @property {number} headerVersion - OTA header version.
|
|
20
|
+
* @property {number} headerLength - OTA header length in bytes.
|
|
21
|
+
* @property {number} fieldControl - Field control bitmask.
|
|
22
|
+
* @property {number} manufacturerCode - Manufacturer code.
|
|
23
|
+
* @property {number} imageType - Image type.
|
|
24
|
+
* @property {number} fileVersion - File version.
|
|
25
|
+
* @property {number} zigbeeStackVersion - Zigbee stack version.
|
|
26
|
+
* @property {string} headerString - Header string (ASCII).
|
|
27
|
+
* @property {number} totalImageSize - Total image size in bytes.
|
|
28
|
+
* @property {number} [securityCredentialVersion] - Security credential version.
|
|
29
|
+
* @property {string} [upgradeFileDestination] - Destination EUI64 hex string.
|
|
30
|
+
* @property {number} [minimumHardwareVersion] - Minimum hardware version.
|
|
31
|
+
* @property {number} [maximumHardwareVersion] - Maximum hardware version.
|
|
32
|
+
* @property {number} remainingHeaderBytes - Unparsed header bytes.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read a fixed-length slice from the start of a file.
|
|
37
|
+
* @param {string} filePath - Absolute or relative path to the OTA file.
|
|
38
|
+
* @param {number} length - Number of bytes to read.
|
|
39
|
+
* @returns {Promise<Buffer>} Buffer with the requested bytes.
|
|
40
|
+
*/
|
|
41
|
+
async function readFileSlice(filePath, length) {
|
|
42
|
+
const fileHandle = await fs.promises.open(filePath, 'r');
|
|
43
|
+
try {
|
|
44
|
+
const buffer = Buffer.alloc(length);
|
|
45
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0);
|
|
46
|
+
if (bytesRead < length) {
|
|
47
|
+
throw new Error('Unexpected end of file while reading Zigbee OTA header');
|
|
48
|
+
}
|
|
49
|
+
return buffer;
|
|
50
|
+
} finally {
|
|
51
|
+
await fileHandle.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse the Zigbee OTA header from a file.
|
|
57
|
+
* @param {string} filePath - Absolute or relative path to the OTA file.
|
|
58
|
+
* @returns {Promise<ZigbeeOTAHeader>} Parsed header values.
|
|
59
|
+
*/
|
|
60
|
+
async function parseZigbeeOTAHeader(filePath) {
|
|
61
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
62
|
+
throw new TypeError('filePath must be a non-empty string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stats = await fs.promises.stat(filePath);
|
|
66
|
+
if (stats.size < OTA_MIN_HEADER_LENGTH) {
|
|
67
|
+
throw new Error('File is too small to contain a Zigbee OTA header');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const baseHeader = await readFileSlice(filePath, OTA_MIN_HEADER_LENGTH);
|
|
71
|
+
const baseFileIdentifier = baseHeader.readUInt32LE(0);
|
|
72
|
+
|
|
73
|
+
if (baseFileIdentifier !== OTA_FILE_IDENTIFIER) {
|
|
74
|
+
throw new Error('Invalid Zigbee OTA header identifier');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const headerLength = baseHeader.readUInt16LE(6);
|
|
78
|
+
|
|
79
|
+
if (headerLength < OTA_MIN_HEADER_LENGTH) {
|
|
80
|
+
throw new Error('Zigbee OTA header length is shorter than the mandatory header size');
|
|
81
|
+
}
|
|
82
|
+
if (headerLength > stats.size) {
|
|
83
|
+
throw new Error('Zigbee OTA header length exceeds file size');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const headerBuffer = headerLength === OTA_MIN_HEADER_LENGTH
|
|
87
|
+
? baseHeader
|
|
88
|
+
: await readFileSlice(filePath, headerLength);
|
|
89
|
+
|
|
90
|
+
let offset = 0;
|
|
91
|
+
const fileIdentifier = headerBuffer.readUInt32LE(offset);
|
|
92
|
+
offset += 4;
|
|
93
|
+
if (fileIdentifier !== OTA_FILE_IDENTIFIER) {
|
|
94
|
+
throw new Error('Invalid Zigbee OTA header identifier');
|
|
95
|
+
}
|
|
96
|
+
const headerVersion = headerBuffer.readUInt16LE(offset);
|
|
97
|
+
offset += 2;
|
|
98
|
+
const parsedHeaderLength = headerBuffer.readUInt16LE(offset);
|
|
99
|
+
offset += 2;
|
|
100
|
+
const fieldControl = headerBuffer.readUInt16LE(offset);
|
|
101
|
+
offset += 2;
|
|
102
|
+
const manufacturerCode = headerBuffer.readUInt16LE(offset);
|
|
103
|
+
offset += 2;
|
|
104
|
+
const imageType = headerBuffer.readUInt16LE(offset);
|
|
105
|
+
offset += 2;
|
|
106
|
+
const fileVersion = headerBuffer.readUInt32LE(offset);
|
|
107
|
+
offset += 4;
|
|
108
|
+
const zigbeeStackVersion = headerBuffer.readUInt16LE(offset);
|
|
109
|
+
offset += 2;
|
|
110
|
+
const headerString = headerBuffer
|
|
111
|
+
.toString('ascii', offset, offset + 32)
|
|
112
|
+
.replace(/\0+$/, '');
|
|
113
|
+
offset += 32;
|
|
114
|
+
const totalImageSize = headerBuffer.readUInt32LE(offset);
|
|
115
|
+
offset += 4;
|
|
116
|
+
|
|
117
|
+
const header = {
|
|
118
|
+
fileSize: stats.size,
|
|
119
|
+
fileIdentifier,
|
|
120
|
+
headerVersion,
|
|
121
|
+
headerLength: parsedHeaderLength,
|
|
122
|
+
fieldControl,
|
|
123
|
+
manufacturerCode,
|
|
124
|
+
imageType,
|
|
125
|
+
fileVersion,
|
|
126
|
+
zigbeeStackVersion,
|
|
127
|
+
headerString,
|
|
128
|
+
totalImageSize,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (fieldControl & FIELD_CONTROL_BITS.securityCredentialVersion) {
|
|
132
|
+
header.securityCredentialVersion = headerBuffer.readUInt8(offset);
|
|
133
|
+
offset += 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (fieldControl & FIELD_CONTROL_BITS.deviceSpecificFile) {
|
|
137
|
+
header.upgradeFileDestination = headerBuffer
|
|
138
|
+
.slice(offset, offset + 8)
|
|
139
|
+
.toString('hex');
|
|
140
|
+
offset += 8;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (fieldControl & FIELD_CONTROL_BITS.hardwareVersionsPresent) {
|
|
144
|
+
header.minimumHardwareVersion = headerBuffer.readUInt16LE(offset);
|
|
145
|
+
offset += 2;
|
|
146
|
+
header.maximumHardwareVersion = headerBuffer.readUInt16LE(offset);
|
|
147
|
+
offset += 2;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (parsedHeaderLength < offset) {
|
|
151
|
+
throw new Error('Zigbee OTA header length is shorter than parsed fields');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
header.remainingHeaderBytes = parsedHeaderLength - offset;
|
|
155
|
+
|
|
156
|
+
return header;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate a Zigbee OTA header against expected values.
|
|
161
|
+
* @param {object} options - Validation options.
|
|
162
|
+
* @param {string} options.filePath - Absolute or relative path to the OTA file.
|
|
163
|
+
* @param {number} [options.manufacturerCode] - Expected manufacturer code.
|
|
164
|
+
* @param {number} [options.fileVersion] - Expected file version.
|
|
165
|
+
* @param {number} [options.imageType] - Expected image type.
|
|
166
|
+
* @returns {Promise<ZigbeeOTAHeader>} Parsed header values.
|
|
167
|
+
*/
|
|
168
|
+
async function validateZigbeeOTAHeader({
|
|
169
|
+
filePath,
|
|
170
|
+
manufacturerCode,
|
|
171
|
+
fileVersion,
|
|
172
|
+
imageType,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const header = await parseZigbeeOTAHeader(filePath);
|
|
175
|
+
|
|
176
|
+
if (header.fileIdentifier !== OTA_FILE_IDENTIFIER) {
|
|
177
|
+
throw new Error('Invalid Zigbee OTA header identifier');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (header.headerVersion !== OTA_HEADER_VERSION) {
|
|
181
|
+
throw new Error('Unsupported Zigbee OTA header version');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (header.headerLength > header.totalImageSize) {
|
|
185
|
+
throw new Error('Zigbee OTA header length exceeds total image size');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (header.totalImageSize !== header.fileSize) {
|
|
189
|
+
throw new Error('Zigbee OTA total image size does not match file size');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (manufacturerCode !== undefined) {
|
|
193
|
+
if (typeof manufacturerCode !== 'number') {
|
|
194
|
+
throw new TypeError('manufacturerCode must be a number');
|
|
195
|
+
}
|
|
196
|
+
if (header.manufacturerCode !== manufacturerCode) {
|
|
197
|
+
throw new Error('Zigbee OTA manufacturer code does not match');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (imageType !== undefined) {
|
|
202
|
+
if (typeof imageType !== 'number') {
|
|
203
|
+
throw new TypeError('imageType must be a number');
|
|
204
|
+
}
|
|
205
|
+
if (header.imageType !== imageType) {
|
|
206
|
+
throw new Error('Zigbee OTA image type does not match');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (fileVersion !== undefined) {
|
|
211
|
+
if (typeof fileVersion !== 'number') {
|
|
212
|
+
throw new TypeError('fileVersion must be a number');
|
|
213
|
+
}
|
|
214
|
+
if (header.fileVersion !== fileVersion) {
|
|
215
|
+
throw new Error('Zigbee OTA file version does not match');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return header;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
parseZigbeeOTAHeader,
|
|
224
|
+
validateZigbeeOTAHeader,
|
|
225
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homey-lib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.0",
|
|
4
4
|
"description": "Shared Library for Homey",
|
|
5
|
+
"types": "types/index.d.ts",
|
|
5
6
|
"main": "index.js",
|
|
6
7
|
"scripts": {
|
|
7
|
-
"build": "npm ci; npm run pack",
|
|
8
|
+
"build": "npm ci; npm run types:generate; npm run schema:generate; npm run schema:copy; npm run pack",
|
|
8
9
|
"pack": "webpack",
|
|
9
10
|
"lint": "eslint .",
|
|
10
11
|
"test": "mocha --parallel",
|
|
11
12
|
"locales:apply": "node scripts/apply-locale-files.js",
|
|
12
|
-
"locales:generate": "node scripts/generate-locale-files.js"
|
|
13
|
+
"locales:generate": "node scripts/generate-locale-files.js",
|
|
14
|
+
"types:generate": "tsc --project tsconfig.types.json",
|
|
15
|
+
"schema:generate": "npm run capability-schema:generate && npm run app-schema:generate",
|
|
16
|
+
"schema:copy": "mkdir -p types/assets/app types/assets/capability && cp assets/app/schema.d.ts types/assets/app/schema.d.ts && cp assets/capability/schema.d.ts types/assets/capability/schema.d.ts",
|
|
17
|
+
"capability-schema:generate": "json2ts -i assets/capability/schema.json -o assets/capability/schema.d.ts --unknownAny",
|
|
18
|
+
"app-schema:generate": "json2ts -i assets/app/schema.json -o assets/app/schema.d.ts --unknownAny"
|
|
13
19
|
},
|
|
14
20
|
"engines": {
|
|
15
21
|
"node": ">=12.13.0"
|
|
@@ -34,10 +40,12 @@
|
|
|
34
40
|
"devDependencies": {
|
|
35
41
|
"eslint": "^7.31.0",
|
|
36
42
|
"eslint-config-athom": "^2.1.1",
|
|
43
|
+
"json-schema-to-typescript": "^15.0.4",
|
|
37
44
|
"mocha": "^9.1.3",
|
|
38
45
|
"mock-fs": "^5.1.2",
|
|
39
46
|
"object-path": "^0.11.8",
|
|
40
47
|
"set-value": "^4.1.0",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
41
49
|
"webpack": "^5.98.0",
|
|
42
50
|
"webpack-cli": "^5.1.4"
|
|
43
51
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"checkJs": false,
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"emitDeclarationOnly": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./types",
|
|
9
|
+
"module": "commonjs",
|
|
10
|
+
"target": "ES2020",
|
|
11
|
+
"strict": false
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.js", "lib/**/*.js"],
|
|
14
|
+
"exclude": ["test/**", "node_modules/**"]
|
|
15
|
+
}
|