homey-lib 2.45.4 → 2.46.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.
@@ -80,6 +80,7 @@ export type DriverSettings = (
80
80
  [k: string]: unknown;
81
81
  }
82
82
  )[];
83
+ export type Integrity = string;
83
84
  export type AppSettings = (
84
85
  | {
85
86
  type: "text" | "password" | "textarea" | "label";
@@ -247,6 +248,7 @@ export interface App {
247
248
  [k: string]: unknown;
248
249
  }[];
249
250
  settings?: DriverSettings;
251
+ firmwareUpdates?: ZigbeeFirmwareUpdates;
250
252
  gtin?: string | string[];
251
253
  matter?: MatterDevice;
252
254
  zwave?: ZwaveDevice;
@@ -373,6 +375,43 @@ export interface ZwaveSetting {
373
375
  signed?: boolean;
374
376
  [k: string]: unknown;
375
377
  }
378
+ export interface ZigbeeFirmwareUpdates {
379
+ queryNextImageTimeout?: number;
380
+ minImageBlockPeriod?: number;
381
+ maxImageBlockSize?: number;
382
+ imageBlockRequestTimeout?: number;
383
+ upgradeEndRequestTimeout?: number;
384
+ upgradeEndDelay?: number;
385
+ postUpgradeAnnounceTimeout?: number;
386
+ /**
387
+ * @minItems 1
388
+ */
389
+ updates: [ZigbeeFirmwareUpdate, ...ZigbeeFirmwareUpdate[]];
390
+ }
391
+ export interface ZigbeeFirmwareUpdate {
392
+ changelog: I18NObject;
393
+ device: ZigbeeFirmwareUpdateDevice;
394
+ /**
395
+ * @minItems 1
396
+ */
397
+ files: [ZigbeeFirmwareUpdateFile, ...ZigbeeFirmwareUpdateFile[]];
398
+ }
399
+ export interface ZigbeeFirmwareUpdateDevice {
400
+ manufacturerName: string | string[];
401
+ productId: string | string[];
402
+ }
403
+ export interface ZigbeeFirmwareUpdateFile {
404
+ fileVersion: number;
405
+ imageType: number;
406
+ manufacturerCode: number;
407
+ minFileVersion?: number;
408
+ maxFileVersion?: number;
409
+ minHardwareVersion?: number;
410
+ maxHardwareVersion?: number;
411
+ size: number;
412
+ name: string;
413
+ integrity: Integrity;
414
+ }
376
415
  export interface MatterDevice {
377
416
  vendorId: number | number[];
378
417
  productId: number | number[];
@@ -32,6 +32,135 @@
32
32
  },
33
33
  "additionalProperties": false
34
34
  },
35
+ "integrity": {
36
+ "type": "string",
37
+ "pattern": "^(blake2b512|blake2s256|sha256|sha384|sha512|sha512-256|sha3-256|sha3-384|sha3-512):[0-9a-fA-F]+$"
38
+ },
39
+ "zigbee-firmware-update-device": {
40
+ "type": "object",
41
+ "required": ["manufacturerName", "productId"],
42
+ "additionalProperties": false,
43
+ "properties": {
44
+ "manufacturerName": {
45
+ "oneOf": [
46
+ {
47
+ "type": "string"
48
+ },
49
+ {
50
+ "type": "array",
51
+ "items": {
52
+ "type": "string"
53
+ }
54
+ }
55
+ ]
56
+ },
57
+ "productId": {
58
+ "oneOf": [
59
+ {
60
+ "type": "string"
61
+ },
62
+ {
63
+ "type": "array",
64
+ "items": {
65
+ "type": "string"
66
+ }
67
+ }
68
+ ]
69
+ }
70
+ }
71
+ },
72
+ "zigbee-firmware-update-file": {
73
+ "type": "object",
74
+ "required": ["fileVersion", "size", "imageType", "manufacturerCode", "name", "integrity"],
75
+ "additionalProperties": false,
76
+ "properties": {
77
+ "fileVersion": {
78
+ "type": "number"
79
+ },
80
+ "imageType": {
81
+ "type": "number"
82
+ },
83
+ "manufacturerCode": {
84
+ "type": "number"
85
+ },
86
+ "minFileVersion": {
87
+ "type": "number"
88
+ },
89
+ "maxFileVersion": {
90
+ "type": "number"
91
+ },
92
+ "minHardwareVersion": {
93
+ "type": "number"
94
+ },
95
+ "maxHardwareVersion": {
96
+ "type": "number"
97
+ },
98
+ "size": {
99
+ "type": "number"
100
+ },
101
+ "name": {
102
+ "type": "string"
103
+ },
104
+ "integrity": {
105
+ "$ref": "#/definitions/integrity"
106
+ }
107
+ }
108
+ },
109
+ "zigbee-firmware-update": {
110
+ "type": "object",
111
+ "required": ["changelog", "device", "files"],
112
+ "additionalProperties": false,
113
+ "properties": {
114
+ "changelog": {
115
+ "$ref": "#/definitions/i18nObject"
116
+ },
117
+ "device": {
118
+ "$ref": "#/definitions/zigbee-firmware-update-device"
119
+ },
120
+ "files": {
121
+ "type": "array",
122
+ "minItems": 1,
123
+ "items": {
124
+ "$ref": "#/definitions/zigbee-firmware-update-file"
125
+ }
126
+ }
127
+ }
128
+ },
129
+ "zigbee-firmware-updates": {
130
+ "type": "object",
131
+ "additionalProperties": false,
132
+ "required": ["updates"],
133
+ "properties": {
134
+ "queryNextImageTimeout": {
135
+ "type": "number"
136
+ },
137
+ "minImageBlockPeriod": {
138
+ "type": "number"
139
+ },
140
+ "maxImageBlockSize": {
141
+ "type": "number"
142
+ },
143
+ "imageBlockRequestTimeout": {
144
+ "type": "number"
145
+ },
146
+ "upgradeEndRequestTimeout": {
147
+ "type": "number"
148
+ },
149
+ "upgradeEndDelay": {
150
+ "type": "number"
151
+ },
152
+ "postUpgradeAnnounceTimeout": {
153
+ "type": "number"
154
+ },
155
+ "updates": {
156
+ "type": "array",
157
+ "minItems": 1,
158
+ "items": {
159
+ "$ref": "#/definitions/zigbee-firmware-update"
160
+ }
161
+ }
162
+ }
163
+ },
35
164
  "author": {
36
165
  "required": ["name"],
37
166
  "properties": {
@@ -1316,6 +1445,13 @@
1316
1445
  "settings": {
1317
1446
  "$ref": "#/definitions/driverSettings"
1318
1447
  },
1448
+ "firmwareUpdates": {
1449
+ "oneOf": [
1450
+ {
1451
+ "$ref": "#/definitions/zigbee-firmware-updates"
1452
+ }
1453
+ ]
1454
+ },
1319
1455
  "gtin": {
1320
1456
  "oneOf": [
1321
1457
  {
package/helpers/index.js CHANGED
@@ -38,3 +38,13 @@ try {
38
38
  }
39
39
  } catch (err) {
40
40
  }
41
+
42
+ function toArray(value) {
43
+ if (value) {
44
+ return Array.isArray(value) ? value : [value];
45
+ }
46
+
47
+ return [];
48
+ }
49
+
50
+ module.exports.toArray = toArray;
package/index.js CHANGED
@@ -47,3 +47,4 @@ module.exports.getBatteries = Energy.getBatteries.bind(Energy);
47
47
 
48
48
  /** @typedef {import('./assets/app/schema').App} AppManifest */
49
49
  /** @typedef {import('./assets/capability/schema').Capability} CapabilityDefinition */
50
+ /** @typedef {import('./assets/app/schema').ZigbeeFirmwareUpdates} ZigbeeFirmwareUpdates */
package/lib/App/index.js CHANGED
@@ -14,6 +14,7 @@ const Device = require('../Device');
14
14
  const Capability = require('../Capability');
15
15
  const Signal = require('../Signal');
16
16
  const Energy = require('../Energy');
17
+ const Util = require('../Util');
17
18
 
18
19
  const {
19
20
  openAsync,
@@ -28,6 +29,7 @@ const {
28
29
  extname,
29
30
  basename,
30
31
  dirname,
32
+ toArray,
31
33
  } = require('../../helpers');
32
34
 
33
35
  /** @typedef {import('../../assets/app/schema').App} AppManifest */
@@ -384,32 +386,29 @@ class App {
384
386
  // validate `appJson.drivers[].capabilitiesOptions`
385
387
  if (driver.capabilitiesOptions) {
386
388
  for (const [capabilityId, capabilityOptions] of Object.entries(driver.capabilitiesOptions)) {
387
- // Merge canonical + extra values for target_power_mode - canonical always present, extras appended
388
- if (Capability.isInstanceOfId(capabilityId, 'target_power_mode')) {
389
+ // Validate target_power_mode values - check canonical IDs present, no reserved prefixes
390
+ if (Capability.isInstanceOfId(capabilityId, 'target_power_mode') && Array.isArray(capabilityOptions.values)) {
389
391
  const canonicalValues = Capability.getCapability('target_power_mode').values;
390
- const providedValues = Array.isArray(capabilityOptions.values) ? capabilityOptions.values : [];
391
392
 
392
- const seenIds = new Set(canonicalValues.map(canonical => canonical.id));
393
- const extraValues = [];
394
-
395
- for (const value of providedValues) {
396
- if (!value || typeof value !== 'object' || typeof value.id !== 'string') continue;
397
- if (seenIds.has(value.id)) continue;
398
- seenIds.add(value.id);
399
- extraValues.push(value);
393
+ for (const canonical of canonicalValues) {
394
+ const provided = capabilityOptions.values.find(v => v && typeof v === 'object' && v.id === canonical.id);
395
+ if (!provided) {
396
+ throw new Error(`drivers.${driver.id}.capabilitiesOptions.${capabilityId}.values must include canonical value "${canonical.id}"`);
397
+ }
400
398
  }
401
399
 
402
- const RESERVED_PREFIXES = ['homey_', 'device_'];
400
+ const canonicalIds = new Set(canonicalValues.map(v => v.id));
401
+ const extraValues = capabilityOptions.values.filter(v => v && typeof v === 'object' && typeof v.id === 'string' && !canonicalIds.has(v.id));
402
+
403
+ const RESERVED_PREFIXES = ['homey_'];
403
404
  const reservedValues = extraValues.filter(value =>
404
405
  RESERVED_PREFIXES.some(prefix => value.id.startsWith(prefix))
405
406
  );
406
407
 
407
408
  if (reservedValues.length) {
408
409
  const ids = reservedValues.map(value => value.id).join(', ');
409
- throw new Error(`drivers.${driver.id}.capabilitiesOptions.${capabilityId} custom values cannot use reserved prefixes "homey_" or "device_": ${ids}`);
410
+ throw new Error(`drivers.${driver.id}.capabilitiesOptions.${capabilityId}.values custom values cannot use reserved prefix "homey_": ${ids}`);
410
411
  }
411
-
412
- capabilityOptions.values = [...canonicalValues, ...extraValues];
413
412
  }
414
413
 
415
414
  // validate target_power exclude range must include 0
@@ -644,6 +643,88 @@ class App {
644
643
  if (driver.matter && !(driver.connectivity && driver.connectivity.includes('matter'))) {
645
644
  throw new Error(`drivers.${driver.id} Matter drivers require 'connectivity' to include 'matter'.`);
646
645
  }
646
+
647
+ // Validate driver.firmwareUpdates
648
+ if (driver.firmwareUpdates && Array.isArray(driver.firmwareUpdates.updates)) {
649
+ // TODO: Remove when we want to roll out firmware updates.
650
+ if (level !== 'debug') {
651
+ throw new Error(`drivers.${driver.id} firmwareUpdates can only be included in debug mode validation.`);
652
+ }
653
+
654
+ const isZigbeeDriver = typeof driver.zigbee === 'object' && driver.zigbee !== null;
655
+
656
+ if (!isZigbeeDriver) {
657
+ throw new Error(`drivers.${driver.id} firmwareUpdates are only supported for Zigbee drivers (missing driver.zigbee)`);
658
+ }
659
+
660
+ for (const [updateIndex, update] of Object.entries(driver.firmwareUpdates.updates)) {
661
+ const hasChangelogString = typeof update.changelog === 'string';
662
+ const hasChangelogEnString = update.changelog && typeof update.changelog.en === 'string';
663
+
664
+ if (!hasChangelogString && !hasChangelogEnString) {
665
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}] is missing a changelog string`);
666
+ }
667
+
668
+ if (!Array.isArray(update.files) || update.files.length === 0) {
669
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}].files must include at least one file`);
670
+ }
671
+
672
+ if (isZigbeeDriver) {
673
+ const driverManufacturerNames = toArray(driver.zigbee.manufacturerName);
674
+ const driverProductIds = toArray(driver.zigbee.productId);
675
+
676
+ const updateManufacturerNames = toArray(update.device.manufacturerName);
677
+ const updateProductIds = toArray(update.device.productId);
678
+
679
+ if (updateManufacturerNames.length === 0 || updateProductIds.length === 0) {
680
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}] must include at least one manufacturerName and productId`);
681
+ }
682
+
683
+ const hasMismatchedManufacturerName = updateManufacturerNames.some(name => !driverManufacturerNames.includes(name));
684
+ const hasMismatchedProductId = updateProductIds.some(id => !driverProductIds.includes(id));
685
+
686
+ if (hasMismatchedManufacturerName) {
687
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}] has a manufacturerName that does not match the driver zigbee.manufacturerName`);
688
+ }
689
+
690
+ if (hasMismatchedProductId) {
691
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}] has a productId that does not match the driver zigbee.productId`);
692
+ }
693
+ }
694
+
695
+ const seenFiles = new Set();
696
+
697
+ for (const [fileIndex, file] of Object.entries(update.files)) {
698
+ const relativeFilePath = Util.getOTAFilePath({ appPath: '', driverId: driver.id, fileName: file.name });
699
+ await this._ensureFileExistsCaseSensitive(relativeFilePath);
700
+ const filePath = join(this._path, relativeFilePath);
701
+ const isIntegrityValid = await Util.validateIntegrity(filePath, file.integrity);
702
+
703
+ if (!isIntegrityValid) {
704
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}].files[${fileIndex}] integrity mismatch`);
705
+ }
706
+
707
+ if (isZigbeeDriver) {
708
+ try {
709
+ await Util.validateZigbeeOTAHeader({
710
+ filePath,
711
+ manufacturerCode: file.manufacturerCode,
712
+ fileVersion: file.fileVersion,
713
+ imageType: file.imageType,
714
+ });
715
+ } catch (err) {
716
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}].files[${fileIndex}] invalid Zigbee OTA header: ${err.message}`);
717
+ }
718
+ }
719
+
720
+ const fileId = `${file.manufacturerCode}-${file.imageType}-${file.fileVersion}`;
721
+ if (seenFiles.has(fileId)) {
722
+ throw new Error(`drivers.${driver.id}.firmwareUpdates.updates[${updateIndex}] has multiple files with the same manufacturerCode, imageType and fileVersion`);
723
+ }
724
+ seenFiles.add(fileId);
725
+ }
726
+ }
727
+ }
647
728
  }
648
729
 
649
730
  const allDriversMatter = appJson.drivers.every(driver => driver.connectivity && driver.connectivity.includes('matter'));
@@ -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,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fileUtils = require('./file');
4
+ const zigbeeUtils = require('./zigbee');
5
+
3
6
  class Util {
4
7
 
5
8
  /**
@@ -20,6 +23,65 @@ class Util {
20
23
  return wantedFeatures.filter(feature => !features.includes(feature));
21
24
  }
22
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
+
23
85
  }
24
86
 
25
87
  Util.FEATURE_SPEAKER = 'speaker';