diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index 2f4b36498c..255344499d 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -1,18 +1,14 @@ import events from 'events'; -import Bonjour, {Service} from 'bonjour-service'; - import * as Models from '../models'; -import {logger} from '../utils/logger'; import {BroadcastAddress} from '../zspec/enums'; import * as Zcl from '../zspec/zcl'; import * as Zdo from '../zspec/zdo'; import * as ZdoTypes from '../zspec/zdo/definition/tstypes'; +import {discoverAdapter} from './adapterDiscovery'; import * as AdapterEvents from './events'; import * as TsType from './tstype'; -const NS = 'zh:adapter'; - interface AdapterEventMap { deviceJoined: [payload: AdapterEvents.DeviceJoinedPayload]; zclPayload: [payload: AdapterEvents.ZclPayload]; @@ -60,15 +56,6 @@ abstract class Adapter extends events.EventEmitter { const {EZSPAdapter} = await import('./ezsp/adapter'); const {EmberAdapter} = await import('./ember/adapter'); const {ZBOSSAdapter} = await import('./zboss/adapter'); - type AdapterImplementation = - | typeof ZStackAdapter - | typeof DeconzAdapter - | typeof ZiGateAdapter - | typeof EZSPAdapter - | typeof EmberAdapter - | typeof ZBOSSAdapter; - - let adapters: AdapterImplementation[]; const adapterLookup = { zstack: ZStackAdapter, deconz: DeconzAdapter, @@ -78,111 +65,16 @@ abstract class Adapter extends events.EventEmitter { zboss: ZBOSSAdapter, }; - if (serialPortOptions.adapter && serialPortOptions.adapter !== 'auto') { - if (adapterLookup[serialPortOptions.adapter]) { - adapters = [adapterLookup[serialPortOptions.adapter]]; - } else { - throw new Error(`Adapter '${serialPortOptions.adapter}' does not exists, possible options: ${Object.keys(adapterLookup).join(', ')}`); - } - } else { - adapters = Object.values(adapterLookup); - } + const [adapter, path] = await discoverAdapter(serialPortOptions.adapter, serialPortOptions.path); - // Use ZStackAdapter by default - let adapter: AdapterImplementation = adapters[0]; - - if (!serialPortOptions.path) { - logger.debug('No path provided, auto detecting path', NS); - for (const candidate of adapters) { - const path = await candidate.autoDetectPath(); - if (path) { - logger.debug(`Auto detected path '${path}' from adapter '${candidate.name}'`, NS); - serialPortOptions.path = path; - adapter = candidate; - break; - } - } - - if (!serialPortOptions.path) { - throw new Error('No path provided and failed to auto detect path'); - } - } else if (serialPortOptions.path.startsWith('mdns://')) { - const mdnsDevice = serialPortOptions.path.substring(7); - - if (mdnsDevice.length == 0) { - throw new Error(`No mdns device specified. You must specify the coordinator mdns service type after mdns://, e.g. mdns://my-adapter`); - } - - const bj = new Bonjour(); - const mdnsTimeout = 2000; // timeout for mdns scan - - logger.info(`Starting mdns discovery for coordinator: ${mdnsDevice}`, NS); - - await new Promise((resolve, reject) => { - bj.findOne({type: mdnsDevice}, mdnsTimeout, function (service: Service) { - if (service) { - if (service.txt?.radio_type && service.txt?.baud_rate && service.addresses && service.port) { - const mdnsIp = service.addresses[0]; - const mdnsPort = service.port; - const mdnsAdapter = ( - service.txt.radio_type == 'znp' ? 'zstack' : service.txt.radio_type - ) as TsType.SerialPortOptions['adapter']; - const mdnsBaud = parseInt(service.txt.baud_rate); - - logger.info(`Coordinator Ip: ${mdnsIp}`, NS); - logger.info(`Coordinator Port: ${mdnsPort}`, NS); - logger.info(`Coordinator Radio: ${mdnsAdapter}`, NS); - logger.info(`Coordinator Baud: ${mdnsBaud}\n`, NS); - bj.destroy(); - - serialPortOptions.path = `tcp://${mdnsIp}:${mdnsPort}`; - serialPortOptions.adapter = mdnsAdapter; - serialPortOptions.baudRate = mdnsBaud; - - if ( - serialPortOptions.adapter && - serialPortOptions.adapter !== 'auto' && - adapterLookup[serialPortOptions.adapter] !== undefined - ) { - adapter = adapterLookup[serialPortOptions.adapter]; - resolve(new adapter(networkOptions, serialPortOptions, backupPath, adapterOptions)); - } else { - reject(new Error(`Adapter ${serialPortOptions.adapter} is not supported.`)); - } - } else { - bj.destroy(); - reject( - new Error( - `Coordinator returned wrong Zeroconf format! The following values are expected:\n` + - `txt.radio_type, got: ${service.txt?.radio_type}\n` + - `txt.baud_rate, got: ${service.txt?.baud_rate}\n` + - `address, got: ${service.addresses?.[0]}\n` + - `port, got: ${service.port}`, - ), - ); - } - } else { - bj.destroy(); - reject(new Error(`Coordinator [${mdnsDevice}] not found after timeout of ${mdnsTimeout}ms!`)); - } - }); - }); + if (adapterLookup[adapter]) { + serialPortOptions.adapter = adapter; + serialPortOptions.path = path; + + return new adapterLookup[adapter](networkOptions, serialPortOptions, backupPath, adapterOptions); } else { - try { - // Determine adapter to use - for (const candidate of adapters) { - if (await candidate.isValidPath(serialPortOptions.path)) { - logger.debug(`Path '${serialPortOptions.path}' is valid for '${candidate.name}'`, NS); - adapter = candidate; - break; - } - } - } catch (error) { - logger.debug(`Failed to validate path: '${error}'`, NS); - } + throw new Error(`Adapter '${adapter}' does not exists, possible options: ${Object.keys(adapterLookup).join(', ')}`); } - - return new adapter(networkOptions, serialPortOptions, backupPath, adapterOptions); } public abstract start(): Promise; @@ -201,8 +93,6 @@ abstract class Adapter extends events.EventEmitter { public abstract getNetworkParameters(): Promise; - public abstract setTransmitPower(value: number): Promise; - public abstract addInstallCode(ieeeAddress: string, key: Buffer): Promise; public abstract waitFor( diff --git a/src/adapter/adapterDiscovery.ts b/src/adapter/adapterDiscovery.ts new file mode 100644 index 0000000000..0eec896070 --- /dev/null +++ b/src/adapter/adapterDiscovery.ts @@ -0,0 +1,526 @@ +import {platform} from 'os'; + +import {PortInfo} from '@serialport/bindings-cpp'; +import {Bonjour, Service} from 'bonjour-service'; + +import {logger} from '../utils/logger'; +import {SerialPort} from './serialPort'; +import {Adapter, DiscoverableUSBAdapter, USBAdapterFingerprint} from './tstype'; + +const NS = 'zh:adapter:discovery'; + +const enum USBFingerprintMatchScore { + NONE = 0, + VID_PID = 1, + VID_PID_MANUF = 2, + VID_PID_PATH = 3, + VID_PID_MANUF_PATH = 4, +} + +/** + * @see https://serialport.io/docs/api-bindings-cpp#list + * + * On Windows, there are occurrences where `manufacturer` is replaced by the OS driver. Example: `ITEAD` => `wch.cn`. + * + * In virtualized environments, the passthrough mechanism can affect the `path`. + * Example: + * Linux: /dev/serial/by-id/usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_20240122184111-if00 + * Windows host => Linux guest: /dev/serial/by-id/usb-1a86_USB_Single_Serial_54DD002111-if00 + * + * XXX: vendorId `10c4` + productId `ea60` is a problem on Windows since can't match `path` and possibly can't match `manufacturer` to refine properly + */ +const USB_FINGERPRINTS: Record = { + deconz: [ + { + // Conbee II + vendorId: '1cf1', + productId: '0030', + manufacturer: 'dresden elektronik ingenieurtechnik GmbH', + // /dev/serial/by-id/usb-dresden_elektronik_ingenieurtechnik_GmbH_ConBee_II_DE2132111-if00 + pathRegex: '.*conbee.*', + }, + { + // Conbee III + vendorId: '0403', + productId: '6015', + manufacturer: 'dresden elektronik ingenieurtechnik GmbH', + // /dev/serial/by-id/usb-dresden_elektronik_ConBee_III_DE03188111-if00-port0 + pathRegex: '.*conbee.*', + }, + ], + ember: [ + // { + // // TODO: Easyiot ZB-GW04 (v1.1) + // vendorId: '', + // productId: '', + // manufacturer: '', + // pathRegex: '.*.*', + // }, + // { + // // TODO: Easyiot ZB-GW04 (v1.2) + // vendorId: '1a86', + // productId: '', + // manufacturer: '', + // // /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 + // pathRegex: '.*.*', + // }, + { + // Home Assistant SkyConnect + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'Nabu Casa', + // /dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3abe54797c91ed118fc3cad13b20a111-if00-port0 + pathRegex: '.*Nabu_Casa_SkyConnect.*', + }, + // { + // // TODO: Home Assistant Yellow + // vendorId: '', + // productId: '', + // manufacturer: '', + // // /dev/ttyAMA1 + // pathRegex: '.*.*', + // }, + { + // SMLight slzb-07 + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', + // /dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-07_be9faa0786e1ea11bd68dc2d9a583111-if00-port0 + // /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_a215650c853bec119a079e957a0af111-if00-port0 + pathRegex: '.*slzb-07_.*', // `_` to not match 07p7 + }, + { + // SMLight slzb-07mg24 + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', + pathRegex: '.*slzb-07mg24.*', + }, + { + // Sonoff ZBDongle-E V2 + vendorId: '1a86', + productId: '55d4', + manufacturer: 'ITEAD', + // /dev/serial/by-id/usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_20240122184111-if00 + // /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_186ff44314e2ed11b891eb5162c61111-if00-port0 + pathRegex: '.*sonoff.*plus.*', + }, + // { + // // TODO: Z-station by z-wave.me (EFR32MG21A020F1024IM32) + // vendorId: '', + // productId: '', + // // manufacturer: '', + // // /dev/serial/by-id/usb-Silicon_Labs_CP2105_Dual_USB_to_UART_Bridge_Controller_012BA111-if01-port0 + // pathRegex: '.*CP2105.*', + // }, + ], + zstack: [ + { + // ZZH + vendorId: '0403', + productId: '6015', + manufacturer: 'Electrolama', + pathRegex: '.*electrolama.*', + }, + { + // slae.sh cc2652rb + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'Silicon Labs', + // /dev/serial/by-id/usb-Silicon_Labs_slae.sh_cc2652rb_stick_-_slaesh_s_iot_stuff_00_12_4B_00_21_A8_EC_79-if00-port0 + pathRegex: '.*slae\\.sh_cc2652rb.*', + }, + { + // Sonoff ZBDongle-P (CC2652P) + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'ITEAD', + // /dev/serial/by-id/usb-Silicon_Labs_Sonoff_Zigbee_3.0_USB_Dongle_Plus_0111-if00-port0 + // /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_b8b49abd27a6ed11a280eba32981d111-if00-port0 + pathRegex: '.*sonoff.*plus.*', + }, + { + // CC2538 + vendorId: '0451', + productId: '16c8', + manufacturer: 'Texas Instruments', + // zStack30x: /dev/serial/by-id/usb-Texas_Instruments_CC2538_USB_CDC-if00 + pathRegex: '.*CC2538.*', + }, + { + // CC2531 + vendorId: '0451', + productId: '16a8', + manufacturer: 'Texas Instruments', + // /dev/serial/by-id/usb-Texas_Instruments_TI_CC2531_USB_CDC___0X00124B0018ED1111-if00 + pathRegex: '.*CC2531.*', + }, + { + // Texas instruments launchpads + vendorId: '0451', + productId: 'bef3', + manufacturer: 'Texas Instruments', + pathRegex: '.*Texas_Instruments.*', + }, + { + // SMLight slzb-07p7 + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', + // /dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-07p7_be9faa0786e1ea11bd68dc2d9a583111-if00-port0 + pathRegex: '.*SLZB-07p7.*', + }, + { + // SMLight slzb-06p7 + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', + // /dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-06p7_82e43faf9872ed118bb924f3fdf7b791-if00-port0 + pathRegex: '.*SMLIGHT_SLZB-06p7_.*', + }, + { + // SMLight slzb-06p10 + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', + // /dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-06p10_40df2f3e3977ed11b142f6fafdf7b791-if00-port0 + pathRegex: '.*SMLIGHT_SLZB-06p10_.*', + }, + { + // TubesZB ? + vendorId: '10c4', + productId: 'ea60', + // manufacturer: '', + pathRegex: '.*tubeszb.*', + }, + { + // TubesZB ? + vendorId: '1a86', + productId: '7523', + // manufacturer: '', + pathRegex: '.*tubeszb.*', + }, + { + // ZigStar + vendorId: '1a86', + productId: '7523', + // manufacturer: '', + pathRegex: '.*zigstar.*', + }, + ], + zboss: [ + { + // Nordic Zigbee NCP + vendorId: '2fe3', + productId: '0100', + manufacturer: 'ZEPHYR', + // /dev/serial/by-id/usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DAD111-if00 + pathRegex: '.*ZEPHYR.*', + }, + ], + zigate: [ + { + // ZiGate PL2303HX (blue) + vendorId: '067b', + productId: '2303', + manufacturer: 'zigate_PL2303', + pathRegex: '.*zigate.*', + }, + { + // ZiGate CP2102 (red) + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'zigate_cp2102', + pathRegex: '.*zigate.*', + }, + { + // ZiGate+ V2 CDM_21228 + vendorId: '0403', + productId: '6015', + // manufacturer: '', + // /dev/serial/by-id/usb-FTDI_ZiGate_ZIGATE+-if00-port0 + pathRegex: '.*zigate.*', + }, + ], +}; + +/** + * Vendor and Product IDs that are prone to conflict if only matching on vendorId+productId. + */ +const USB_FINGERPRINTS_CONFLICT_IDS: ReadonlyArray = ['10c4:ea60']; + +async function getSerialPortList(): Promise { + const portInfos = await SerialPort.list(); + + // TODO: can sorting be removed in favor of `path` regex matching? + + // CC1352P_2 and CC26X2R1 lists as 2 USB devices with same manufacturer, productId and vendorId + // one is the actual chip interface, other is the XDS110. + // The chip is always exposed on the first one after alphabetical sorting. + /* istanbul ignore next */ + portInfos.sort((a, b) => (a.path < b.path ? -1 : 1)); + + return portInfos; +} + +/** + * Case insensitive string matching. + * @param str1 + * @param str2 + * @returns + */ +function matchString(str1: string, str2: string): boolean { + return str1.localeCompare(str2, undefined, {sensitivity: 'base'}) === 0; +} + +/** + * Case insensitive regex matching. + * @param regexStr Passed to RegExp constructor. + * @param str Always returns false if undefined. + * @returns + */ +function matchRegex(regexStr: string, str?: string): boolean { + return str !== undefined && new RegExp(regexStr, 'i').test(str); +} + +function matchUSBFingerprint( + portInfo: PortInfo, + entries: USBAdapterFingerprint[], + isWindows: boolean, + conflictProne: boolean, +): [path: PortInfo['path'], score: number] | undefined { + if (!portInfo.vendorId || !portInfo.productId) { + // port info is missing essential information for proper matching, ignore it + return; + } + + let match: USBAdapterFingerprint | undefined; + let score: number = USBFingerprintMatchScore.NONE; + + for (const entry of entries) { + if (!matchString(portInfo.vendorId, entry.vendorId) || !matchString(portInfo.productId, entry.productId)) { + continue; + } + + // allow matching on vendorId+productId only on Windows + if (score < USBFingerprintMatchScore.VID_PID && isWindows) { + match = entry; + score = USBFingerprintMatchScore.VID_PID; + } + + if ( + score < USBFingerprintMatchScore.VID_PID_MANUF && + entry.manufacturer && + portInfo.manufacturer && + matchString(portInfo.manufacturer, entry.manufacturer) + ) { + match = entry; + score = USBFingerprintMatchScore.VID_PID_MANUF; + + if (isWindows && !conflictProne) { + // path will never match on Windows (COMx), assume vendor+product+manufacturer is "exact match" + // except for conflict-prone, since it could easily return a mismatch (better to return no match and force manual config) + return [portInfo.path, score]; + } + } + + if ( + score < USBFingerprintMatchScore.VID_PID_PATH && + entry.pathRegex && + (matchRegex(entry.pathRegex, portInfo.path) || matchRegex(entry.pathRegex, portInfo.pnpId)) + ) { + if (score === USBFingerprintMatchScore.VID_PID_MANUF) { + // best possible match, return early + return [portInfo.path, USBFingerprintMatchScore.VID_PID_MANUF_PATH]; + } else { + match = entry; + score = USBFingerprintMatchScore.VID_PID_PATH; + } + } + } + + // poor match only returned if port info not conflict-prone + return match && (score > USBFingerprintMatchScore.VID_PID || !conflictProne) ? [portInfo.path, score] : undefined; +} + +export async function matchUSBAdapter(adapter: Adapter, path: string): Promise { + const isWindows = platform() === 'win32'; + const portList = await getSerialPortList(); + + logger.debug(() => `Connected devices: ${JSON.stringify(portList)}`, NS); + + for (const portInfo of portList) { + /* istanbul ignore else */ + if (portInfo.path !== path) { + continue; + } + + const conflictProne = USB_FINGERPRINTS_CONFLICT_IDS.includes(`${portInfo.vendorId}:${portInfo.productId}`); + const match = matchUSBFingerprint(portInfo, USB_FINGERPRINTS[adapter === 'ezsp' ? 'ember' : adapter], isWindows, conflictProne); + + /* istanbul ignore else */ + if (match) { + logger.info(() => `Matched adapter: ${JSON.stringify(portInfo)} => ${adapter}: ${JSON.stringify(match[1])}`, NS); + return true; + } + } + + return false; +} + +export async function findUSBAdapter( + adapter?: Adapter, + path?: string, +): Promise<[adapter: DiscoverableUSBAdapter, path: PortInfo['path']] | undefined> { + const isWindows = platform() === 'win32'; + // refine to DiscoverableUSBAdapter + adapter = adapter && adapter === 'ezsp' ? 'ember' : adapter; + const portList = await getSerialPortList(); + + logger.debug(() => `Connected devices: ${JSON.stringify(portList)}`, NS); + + for (const portInfo of portList) { + if (path && portInfo.path !== path) { + continue; + } + + const conflictProne = USB_FINGERPRINTS_CONFLICT_IDS.includes(`${portInfo.vendorId}:${portInfo.productId}`); + let bestMatch: [DiscoverableUSBAdapter, NonNullable>] | undefined; + + for (const key in USB_FINGERPRINTS) { + if (adapter && adapter !== key) { + continue; + } + + const match = matchUSBFingerprint(portInfo, USB_FINGERPRINTS[key as DiscoverableUSBAdapter]!, isWindows, conflictProne); + + // register the match if no previous or better score + if (match && (!bestMatch || bestMatch[1][1] < match[1])) { + bestMatch = [key as DiscoverableUSBAdapter, match]; + + if (match[1] === USBFingerprintMatchScore.VID_PID_MANUF_PATH) { + // got best possible match, exit loop + break; + } + } + } + + if (bestMatch) { + logger.info( + () => `Matched adapter: ${JSON.stringify(portInfo)} => ${bestMatch[0]}: path=${bestMatch[1][0]}, score=${bestMatch[1][1]}`, + NS, + ); + return [bestMatch[0], bestMatch[1][0]]; + } + } +} + +export async function findmDNSAdapter(path: string): Promise<[adapter: Adapter, path: string]> { + const mdnsDevice = path.substring(7); + + if (mdnsDevice.length == 0) { + throw new Error(`No mdns device specified. You must specify the coordinator mdns service type after mdns://, e.g. mdns://my-adapter`); + } + + const bj = new Bonjour(); + const mdnsTimeout = 2000; // timeout for mdns scan + + logger.info(`Starting mdns discovery for coordinator: ${mdnsDevice}`, NS); + + return await new Promise((resolve, reject) => { + bj.findOne({type: mdnsDevice}, mdnsTimeout, function (service: Service) { + if (service) { + if (service.txt?.radio_type && service.port) { + const mdnsAddress = service.addresses?.[0] ?? service.host; + const mdnsPort = service.port; + const mdnsAdapter = (service.txt.radio_type == 'znp' ? 'zstack' : service.txt.radio_type) as Adapter; + + logger.info(`Coordinator Address: ${mdnsAddress}`, NS); + logger.info(`Coordinator Port: ${mdnsPort}`, NS); + logger.info(`Coordinator Radio: ${mdnsAdapter}`, NS); + bj.destroy(); + + resolve([mdnsAdapter, `tcp://${mdnsAddress}:${mdnsPort}`]); + } else { + bj.destroy(); + reject( + new Error( + `Coordinator returned wrong Zeroconf format! The following values are expected:\n` + + `txt.radio_type, got: ${service.txt?.radio_type}\n` + + `port, got: ${service.port}`, + ), + ); + } + } else { + bj.destroy(); + reject(new Error(`Coordinator [${mdnsDevice}] not found after timeout of ${mdnsTimeout}ms!`)); + } + }); + }); +} + +export async function findTCPAdapter(path: string, adapter?: Adapter): Promise<[adapter: Adapter, path: string]> { + const regex = /^(tcp|socket):\/\/(?:[0-9]{1,3}\.){3}[0-9]{1,3}:\d{1,5}$/gm; + + if (!regex.test(path)) { + throw new Error(`Invalid TCP path, expected format: tcp://:`); + } + + if (!adapter) { + throw new Error(`Cannot discover TCP adapters at this time. Specify valid 'adapter' and 'port' in your configuration.`); + } + + // always use `tcp://` format + return [adapter, path.replace(/^socket/, 'tcp')]; +} + +/** + * Discover adapter using mDNS, TCP or USB. + * + * @param adapter The adapter type. + * - mDNS: Unused. + * - TCP: Required, cannot discover at this time. + * - USB: Optional, limits the discovery to the specified adapter type. + * @param path The path to the adapter. + * - mDNS: Required, serves to initiate the discovery. + * - TCP: Required, cannot discover at this time. + * - USB: Optional, limits the discovery to the specified path. + * @returns adapter An adapter type supported by Z2M. While result is TS-typed, this should be validated against actual values before use. + * @returns path Path to adapter. + */ +export async function discoverAdapter(adapter?: Adapter, path?: string): Promise<[adapter: Adapter, path: string]> { + if (path) { + if (path.startsWith('mdns://')) { + return await findmDNSAdapter(path); + } else if (path.startsWith('tcp://') || path.startsWith('socket://')) { + return await findTCPAdapter(path, adapter); + } else if (adapter) { + try { + const matched = await matchUSBAdapter(adapter, path); + + /* istanbul ignore else */ + if (!matched) { + logger.debug(`Unable to match USB adapter: ${adapter} | ${path}`, NS); + } + } catch (error) { + logger.debug(`Error while trying to match USB adapter (${(error as Error).message}).`, NS); + } + + return [adapter, path]; + } + } + + try { + // default to matching USB + const match = await findUSBAdapter(adapter, path); + + if (!match) { + throw new Error(`No valid USB adapter found`); + } + + // keep adapter if `ezsp` since findUSBAdapter returns DiscoverableUSBAdapter + return adapter && adapter === 'ezsp' ? [adapter, match[1]] : match; + } catch (error) { + throw new Error(`USB adapter discovery error (${(error as Error).message}). Specify valid 'adapter' and 'port' in your configuration.`); + } +} diff --git a/src/adapter/deconz/adapter/deconzAdapter.ts b/src/adapter/deconz/adapter/deconzAdapter.ts index 559b8d3133..86931eed68 100644 --- a/src/adapter/deconz/adapter/deconzAdapter.ts +++ b/src/adapter/deconz/adapter/deconzAdapter.ts @@ -76,14 +76,6 @@ class DeconzAdapter extends Adapter { }, 1000); } - public static async isValidPath(path: string): Promise { - return await Driver.isValidPath(path); - } - - public static async autoDetectPath(): Promise { - return await Driver.autoDetectPath(); - } - /** * Adapter methods */ @@ -307,9 +299,8 @@ class DeconzAdapter extends Adapter { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public async addInstallCode(ieeeAddress: string, key: Buffer): Promise { - return await Promise.reject(new Error('Add install code is not supported')); + await this.driver.writeLinkKey(ieeeAddress, ZSpec.Utils.aes128MmoHash(key)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -582,11 +573,6 @@ class DeconzAdapter extends Adapter { throw new Error('not supported'); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async setTransmitPower(value: number): Promise { - throw new Error('not supported'); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public async sendZclFrameInterPANIeeeAddr(zclFrame: Zcl.Frame, ieeeAddr: string): Promise { throw new Error('not supported'); diff --git a/src/adapter/deconz/driver/constants.ts b/src/adapter/deconz/driver/constants.ts index 0d7d2c2b60..3c5aa46ed7 100644 --- a/src/adapter/deconz/driver/constants.ts +++ b/src/adapter/deconz/driver/constants.ts @@ -15,6 +15,7 @@ const PARAM = { CHANNEL_MASK: 0x0a, APS_EXT_PAN_ID: 0x0b, NETWORK_KEY: 0x18, + LINK_KEY: 0x19, CHANNEL: 0x1c, PERMIT_JOIN: 0x21, WATCHDOG_TTL: 0x26, diff --git a/src/adapter/deconz/driver/driver.ts b/src/adapter/deconz/driver/driver.ts index 24f00a6b53..365678b6b6 100644 --- a/src/adapter/deconz/driver/driver.ts +++ b/src/adapter/deconz/driver/driver.ts @@ -1,5 +1,4 @@ /* istanbul ignore file */ -/* eslint-disable */ import events from 'events'; import net from 'net'; @@ -8,7 +7,6 @@ import slip from 'slip'; import {logger} from '../../../utils/logger'; import {SerialPort} from '../../serialPort'; -import SerialPortUtils from '../../serialPortUtils'; import SocketPortUtils from '../../socketPortUtils'; import PARAM, {ApsDataRequest, parameterT, ReceivedDataResponse, Request} from './constants'; import {frameParserEvents} from './frameParser'; @@ -17,10 +15,6 @@ import Writer from './writer'; const NS = 'zh:deconz:driver'; -const autoDetectDefinitions = [ - {manufacturer: 'dresden elektronik ingenieurtechnik GmbH', vendorId: '1cf1', productId: '0030'}, // Conbee II -]; - const queue: Array = []; export const busyQueue: Array = []; const apsQueue: Array = []; @@ -48,7 +42,7 @@ class Driver extends events.EventEmitter { private parser: Parser; private frameParserEvent = frameParserEvents; private seqNumber: number; - private timeoutResetTimeout: any; + private timeoutResetTimeout?: NodeJS.Timeout; private apsRequestFreeSlots: number; private apsDataConfirm: number; private apsDataIndication: number; @@ -66,7 +60,7 @@ class Driver extends events.EventEmitter { this.path = path; this.initialized = false; this.seqNumber = 0; - this.timeoutResetTimeout = null; + this.timeoutResetTimeout = undefined; this.apsRequestFreeSlots = 1; this.apsDataConfirm = 0; @@ -80,23 +74,22 @@ class Driver extends events.EventEmitter { this.writer = new Writer(); this.parser = new Parser(); - const that = this; setInterval(() => { - that.deviceStateRequest() - .then((result) => {}) - .catch((error) => {}); + this.deviceStateRequest() + .then(() => {}) + .catch(() => {}); }, 10000); setInterval( () => { - that.writeParameterRequest(0x26, 600) // reset watchdog // 10 minutes - .then((result) => {}) - .catch((error) => { + this.writeParameterRequest(0x26, 600) // reset watchdog // 10 minutes + .then(() => {}) + .catch(() => { //try again logger.debug('try again to reset watchdog', NS); - that.writeParameterRequest(0x26, 600) - .then((result) => {}) - .catch((error) => { + this.writeParameterRequest(0x26, 600) + .then(() => {}) + .catch(() => { logger.debug('warning watchdog was not reset', NS); }); }); @@ -105,8 +98,8 @@ class Driver extends events.EventEmitter { ); // 8 minutes this.onParsed = this.onParsed.bind(this); - this.frameParserEvent.on('receivedDataNotification', (data: number) => { - this.catchPromise(this.checkDeviceStatus(data)); + this.frameParserEvent.on('receivedDataNotification', async (data: number) => { + await this.catchPromise(this.checkDeviceStatus(data)); }); this.on('close', () => { @@ -122,12 +115,12 @@ class Driver extends events.EventEmitter { protected intervals: NodeJS.Timeout[] = []; - protected registerInterval(interval: NodeJS.Timeout) { + protected registerInterval(interval: NodeJS.Timeout): void { this.intervals.push(interval); } - protected catchPromise(val: any) { - return Promise.resolve(val).catch((err) => logger.debug(`Promise was caught with reason: ${err}`, NS)); + protected async catchPromise(val: Promise): Promise> { + return await Promise.resolve(val).catch((err) => logger.debug(`Promise was caught with reason: ${err}`, NS)); } public setDelay(delay: number): void { @@ -157,48 +150,38 @@ class Driver extends events.EventEmitter { this.HANDLE_DEVICE_STATUS_DELAY = 60; } - const that = this; this.registerInterval( setInterval(() => { - that.processQueue(); + this.processQueue(); }, this.PROCESS_QUEUES), ); // fire non aps requests this.registerInterval( - setInterval(() => { - this.catchPromise(that.processBusyQueue()); + setInterval(async () => { + await this.catchPromise(this.processBusyQueue()); }, this.PROCESS_QUEUES), ); // check timeouts for non aps requests this.registerInterval( - setInterval(() => { - this.catchPromise(that.processApsQueue()); + setInterval(async () => { + await this.catchPromise(this.processApsQueue()); }, this.PROCESS_QUEUES), ); // fire aps request this.registerInterval( setInterval(() => { - that.processApsBusyQueue(); + this.processApsBusyQueue(); }, this.PROCESS_QUEUES), ); // check timeouts for all open aps requests this.registerInterval( - setInterval(() => { - this.catchPromise(that.processApsConfirmIndQueue()); + setInterval(async () => { + this.processApsConfirmIndQueue(); }, this.PROCESS_QUEUES), ); // fire aps indications and confirms this.registerInterval( - setInterval(() => { - this.catchPromise(that.handleDeviceStatus()); + setInterval(async () => { + await this.catchPromise(this.handleDeviceStatus()); }, this.HANDLE_DEVICE_STATUS_DELAY), ); // query confirm and indication requests } - public static async isValidPath(path: string): Promise { - return SerialPortUtils.is(path, autoDetectDefinitions); - } - - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - return paths.length > 0 ? paths[0] : undefined; - } - private onPortClose(): void { logger.debug('Port closed', NS); this.initialized = false; @@ -207,7 +190,7 @@ class Driver extends events.EventEmitter { public async open(baudrate: number): Promise { this.currentBaudRate = baudrate; - return SocketPortUtils.isTcpPath(this.path) ? this.openSocketPort() : this.openSerialPort(baudrate); + return await (SocketPortUtils.isTcpPath(this.path) ? this.openSocketPort() : this.openSerialPort(baudrate)); } public openSerialPort(baudrate: number): Promise { @@ -250,24 +233,23 @@ class Driver extends events.EventEmitter { this.socketPort.pipe(this.parser); this.parser.on('parsed', this.onParsed); - return new Promise((resolve, reject): void => { - this.socketPort!.on('connect', function () { + return await new Promise((resolve, reject): void => { + this.socketPort!.on('connect', () => { logger.debug('Socket connected', NS); }); - const self = this; - this.socketPort!.on('ready', async function () { + this.socketPort!.on('ready', async () => { logger.debug('Socket ready', NS); - self.initialized = true; + this.initialized = true; resolve(); }); this.socketPort!.once('close', this.onPortClose); - this.socketPort!.on('error', function (error) { + this.socketPort!.on('error', (error) => { logger.error(`Socket error ${error}`, NS); reject(new Error(`Error while opening socket`)); - self.initialized = false; + this.initialized = false; }); this.socketPort!.connect(info.port, info.host); @@ -281,7 +263,13 @@ class Driver extends events.EventEmitter { this.serialPort.flush((): void => { this.serialPort!.close((error): void => { this.initialized = false; - error == null ? resolve() : reject(new Error(`Error while closing serialport '${error}'`)); + + if (error == null) { + resolve(); + } else { + reject(new Error(`Error while closing serialport '${error}'`)); + } + this.emit('close'); }); }); @@ -318,6 +306,10 @@ class Driver extends events.EventEmitter { }); } + public async writeLinkKey(ieeeAddress: string, hashedKey: Buffer): Promise { + await this.writeParameterRequest(PARAM.PARAM.Network.LINK_KEY, [...this.macAddrStringToArray(ieeeAddress), ...hashedKey]); + } + public readFirmwareVersionRequest(): Promise { const seqNumber = this.nextSeqNumber(); return new Promise((resolve, reject): void => { @@ -329,7 +321,7 @@ class Driver extends events.EventEmitter { }); } - private sendReadParameterRequest(parameterId: number, seqNumber: number) { + private sendReadParameterRequest(parameterId: number, seqNumber: number): void { /* command id, sequence number, 0, framelength(U16), payloadlength(U16), parameter id */ if (parameterId === PARAM.PARAM.Network.NETWORK_KEY) { this.sendRequest(Buffer.from([PARAM.PARAM.FrameType.ReadParameter, seqNumber, 0x00, 0x09, 0x00, 0x02, 0x00, parameterId, 0x00])); @@ -338,11 +330,11 @@ class Driver extends events.EventEmitter { } } - private sendWriteParameterRequest(parameterId: number, value: parameterT, seqNumber: number) { + private sendWriteParameterRequest(parameterId: number, value: parameterT, seqNumber: number): void { /* command id, sequence number, 0, framelength(U16), payloadlength(U16), parameter id, pameter */ let parameterLength = 0; if (parameterId === PARAM.PARAM.STK.Endpoint) { - let arrayParameterValue = value as number[]; + const arrayParameterValue = value as number[]; parameterLength = arrayParameterValue.length; } else { parameterLength = this.getLengthOfParameter(parameterId); @@ -422,20 +414,21 @@ class Driver extends events.EventEmitter { } } - private sendReadFirmwareVersionRequest(seqNumber: number) { + private sendReadFirmwareVersionRequest(seqNumber: number): void { /* command id, sequence number, 0, framelength(U16) */ this.sendRequest(Buffer.from([PARAM.PARAM.FrameType.ReadFirmwareVersion, seqNumber, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00])); } - private sendReadDeviceStateRequest(seqNumber: number) { + private sendReadDeviceStateRequest(seqNumber: number): void { /* command id, sequence number, 0, framelength(U16) */ this.sendRequest(Buffer.from([PARAM.PARAM.FrameType.ReadDeviceState, seqNumber, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00])); } - private sendRequest(buffer: Buffer) { + private sendRequest(buffer: Buffer): void { const frame = Buffer.concat([buffer, this.calcCrc(buffer)]); const slipframe = slip.encode(frame); + // TODO: write not awaited? if (this.serialPort) { this.serialPort.write(slipframe, function (err) { if (err) { @@ -451,7 +444,7 @@ class Driver extends events.EventEmitter { } } - private processQueue() { + private processQueue(): void { if (queue.length === 0) { return; } @@ -496,7 +489,7 @@ class Driver extends events.EventEmitter { } } - private async processBusyQueue() { + private async processBusyQueue(): Promise { let i = busyQueue.length; while (i--) { const req: Request = busyQueue[i]; @@ -510,7 +503,7 @@ class Driver extends events.EventEmitter { // after a timeout the timeoutcounter will be reset after 1 min. If another timeout happen then the timeoutcounter // will not be reset clearTimeout(this.timeoutResetTimeout); - this.timeoutResetTimeout = null; + this.timeoutResetTimeout = undefined; this.resetTimeoutCounterAfter1min(); req.reject(new Error('TIMEOUT')); if (this.timeoutCounter >= 2) { @@ -539,13 +532,13 @@ class Driver extends events.EventEmitter { }); } - private sendChangeNetworkStateRequest(seqNumber: number, networkState: number) { + private sendChangeNetworkStateRequest(seqNumber: number, networkState: number): void { this.sendRequest(Buffer.from([PARAM.PARAM.NetworkState.CHANGE_NETWORK_STATE, seqNumber, 0x00, 0x06, 0x00, networkState])); } - private deviceStateRequest() { + private async deviceStateRequest(): Promise { const seqNumber = this.nextSeqNumber(); - return new Promise((resolve, reject): void => { + return await new Promise((resolve, reject): void => { //logger.debug(`DEVICE_STATE Request - seqNr: ${seqNumber}`, NS); const ts = 0; const commandId = PARAM.PARAM.FrameType.ReadDeviceState; @@ -554,7 +547,7 @@ class Driver extends events.EventEmitter { }); } - private async checkDeviceStatus(currentDeviceStatus: number) { + private async checkDeviceStatus(currentDeviceStatus: number): Promise { const networkState = currentDeviceStatus & 0x03; this.apsDataConfirm = (currentDeviceStatus >> 2) & 0x01; this.apsDataIndication = (currentDeviceStatus >> 3) & 0x01; @@ -576,12 +569,12 @@ class Driver extends events.EventEmitter { ); } - private async handleDeviceStatus() { + private async handleDeviceStatus(): Promise { if (this.apsDataConfirm === 1) { try { logger.debug('query aps data confirm', NS); this.apsDataConfirm = 0; - const x = await this.querySendDataStateRequest(); + await this.querySendDataStateRequest(); } catch (error) { // @ts-expect-error TODO: this doesn't look right? if (error.status === 5) { @@ -593,7 +586,7 @@ class Driver extends events.EventEmitter { try { logger.debug('query aps data indication', NS); this.apsDataIndication = 0; - const x = await this.readReceivedDataRequest(); + await this.readReceivedDataRequest(); } catch (error) { // @ts-expect-error TODO: this doesn't look right? if (error.status === 5) { @@ -624,7 +617,6 @@ class Driver extends events.EventEmitter { return new Promise((resolve, reject): void => { //logger.debug(`push enqueue send data request to apsQueue. seqNr: ${seqNumber}`, NS); const ts = 0; - const requestId = request.requestId; const commandId = PARAM.PARAM.APS.DATA_REQUEST; const req: Request = {commandId, seqNumber, request, resolve, reject, ts}; apsQueue.push(req); @@ -643,7 +635,7 @@ class Driver extends events.EventEmitter { }); } - private async processApsQueue() { + private async processApsQueue(): Promise { if (apsQueue.length === 0) { return; } @@ -681,7 +673,7 @@ class Driver extends events.EventEmitter { } } - private async processApsConfirmIndQueue() { + private processApsConfirmIndQueue(): void { if (apsConfirmIndQueue.length === 0) { return; } @@ -699,7 +691,7 @@ class Driver extends events.EventEmitter { if (this.DELAY === 0) { this.sendReadReceivedDataRequest(req.seqNumber); } else { - await this.sendReadReceivedDataRequest(req.seqNumber); + this.sendReadReceivedDataRequest(req.seqNumber); } break; case PARAM.PARAM.APS.DATA_CONFIRM: @@ -707,7 +699,7 @@ class Driver extends events.EventEmitter { if (this.DELAY === 0) { this.sendQueryDataStateRequest(req.seqNumber); } else { - await this.sendQueryDataStateRequest(req.seqNumber); + this.sendQueryDataStateRequest(req.seqNumber); } break; default: @@ -716,18 +708,18 @@ class Driver extends events.EventEmitter { } } - private sendQueryDataStateRequest(seqNumber: number) { + private sendQueryDataStateRequest(seqNumber: number): void { logger.debug(`DATA_CONFIRM - sending data state request - SeqNr. ${seqNumber}`, NS); this.sendRequest(Buffer.from([PARAM.PARAM.APS.DATA_CONFIRM, seqNumber, 0x00, 0x07, 0x00, 0x00, 0x00])); } - private sendReadReceivedDataRequest(seqNumber: number) { + private sendReadReceivedDataRequest(seqNumber: number): void { logger.debug(`DATA_INDICATION - sending read data request - SeqNr. ${seqNumber}`, NS); // payloadlength = 0, flag = none this.sendRequest(Buffer.from([PARAM.PARAM.APS.DATA_INDICATION, seqNumber, 0x00, 0x08, 0x00, 0x01, 0x00, 0x01])); } - private sendEnqueueSendDataRequest(request: ApsDataRequest, seqNumber: number) { + private sendEnqueueSendDataRequest(request: ApsDataRequest, seqNumber: number): void { const payloadLength = 12 + (request.destAddrMode === PARAM.PARAM.addressMode.GROUP_ADDR ? 2 : request.destAddrMode === PARAM.PARAM.addressMode.NWK_ADDR ? 3 : 9) + @@ -784,7 +776,7 @@ class Driver extends events.EventEmitter { ); } - private processApsBusyQueue() { + private processApsBusyQueue(): void { let i = apsBusyQueue.length; while (i--) { const req = apsBusyQueue[i]; @@ -821,7 +813,7 @@ class Driver extends events.EventEmitter { addr = '0' + addr; } } - let result: number[] = new Array(); + const result = new Array(); let y = 0; for (let i = 0; i < 8; i++) { result[i] = parseInt(addr.substr(y, 2), 16); @@ -885,11 +877,11 @@ class Driver extends events.EventEmitter { return new Promise((resolve) => setTimeout(resolve, ms)); } - private resetTimeoutCounterAfter1min() { - if (this.timeoutResetTimeout === null) { + private resetTimeoutCounterAfter1min(): void { + if (this.timeoutResetTimeout === undefined) { this.timeoutResetTimeout = setTimeout(() => { this.timeoutCounter = 0; - this.timeoutResetTimeout = null; + this.timeoutResetTimeout = undefined; }, 60000); } } diff --git a/src/adapter/deconz/driver/writer.ts b/src/adapter/deconz/driver/writer.ts index 1de397ec9c..a7ba21f1c9 100644 --- a/src/adapter/deconz/driver/writer.ts +++ b/src/adapter/deconz/driver/writer.ts @@ -1,9 +1,7 @@ /* istanbul ignore file */ -/* eslint-disable */ import * as stream from 'stream'; -// @ts-ignore import slip from 'slip'; import {logger} from '../../../utils/logger'; diff --git a/src/adapter/ember/adapter/emberAdapter.ts b/src/adapter/ember/adapter/emberAdapter.ts index 42ef5f85bf..4d097cf2cb 100644 --- a/src/adapter/ember/adapter/emberAdapter.ts +++ b/src/adapter/ember/adapter/emberAdapter.ts @@ -6,7 +6,7 @@ import equals from 'fast-deep-equal/es6'; import {Adapter, TsType} from '../..'; import {Backup, UnifiedBackupStorage} from '../../../models'; -import {BackupUtils, Queue, RealpathSync, Wait} from '../../../utils'; +import {BackupUtils, Queue, Wait} from '../../../utils'; import {logger} from '../../../utils/logger'; import * as ZSpec from '../../../zspec'; import {EUI64, ExtendedPanId, NodeId, PanId} from '../../../zspec/tstypes'; @@ -14,12 +14,8 @@ import * as Zcl from '../../../zspec/zcl'; import * as Zdo from '../../../zspec/zdo'; import * as ZdoTypes from '../../../zspec/zdo/definition/tstypes'; import {DeviceJoinedPayload, DeviceLeavePayload, ZclPayload} from '../../events'; -import SerialPortUtils from '../../serialPortUtils'; -import SocketPortUtils from '../../socketPortUtils'; import { EMBER_HIGH_RAM_CONCENTRATOR, - EMBER_INSTALL_CODE_CRC_SIZE, - EMBER_INSTALL_CODE_SIZES, EMBER_LOW_RAM_CONCENTRATOR, EMBER_MIN_BROADCAST_ADDRESS, INTERPAN_APS_FRAME_TYPE, @@ -72,8 +68,8 @@ import { SecManContext, SecManKey, } from '../types'; -import {aesMmoHashInit, initNetworkCache, initSecurityManagerContext} from '../utils/initters'; -import {halCommonCrc16, highByte, highLowToInt, lowByte, lowHighBytes} from '../utils/math'; +import {initNetworkCache, initSecurityManagerContext} from '../utils/initters'; +import {lowHighBytes} from '../utils/math'; import {FIXED_ENDPOINTS} from './endpoints'; import {EmberOneWaitress, OneWaitressEvents} from './oneWaitress'; @@ -111,14 +107,6 @@ enum NetworkInitAction { FORM_BACKUP, } -/** NOTE: Drivers can override `manufacturer`. Verify logic doesn't work in most cases anyway. */ -const autoDetectDefinitions = [ - /** NOTE: Manuf code "0x1321" for "Shenzhen Sonoff Technologies Co., Ltd." */ - {manufacturer: 'ITEAD', vendorId: '1a86', productId: '55d4'}, // Sonoff ZBDongle-E - /** NOTE: Manuf code "0x134B" for "Nabu Casa, Inc." */ - {manufacturer: 'Nabu Casa', vendorId: '10c4', productId: 'ea60'}, // Home Assistant SkyConnect -]; - /** * Application generated ZDO messages use sequence numbers 0-127, and the stack * uses sequence numbers 128-255. This simplifies life by eliminating the need @@ -735,8 +723,13 @@ export class EmberAdapter extends Adapter { throw new Error(`Failed to get network parameters with status=${SLStatus[status]}.`); } - if (this.adapterOptions.transmitPower != null && parameters.radioTxPower !== this.adapterOptions.transmitPower) { - await this.setTransmitPower(this.adapterOptions.transmitPower); + if (this.adapterOptions.transmitPower != undefined && parameters.radioTxPower !== this.adapterOptions.transmitPower) { + const status = await this.ezsp.ezspSetRadioPower(this.adapterOptions.transmitPower); + + if (status !== SLStatus.OK) { + // soft-fail, don't prevent start + logger.error(`Failed to set transmit power to ${this.adapterOptions.transmitPower} status=${SLStatus[status]}.`, NS); + } } this.networkCache.parameters = parameters; @@ -968,16 +961,12 @@ export class EmberAdapter extends Adapter { logger.info(`[INIT TC] Forming from backup.`, NS); // `backup` valid in this `action` path (not detected by TS) /* istanbul ignore next */ - const keyList: LinkKeyBackupData[] = backup!.devices.map((device) => { - const octets = Array.from(device.ieeeAddress.reverse()); - - return { - deviceEui64: `0x${octets.map((octet) => octet.toString(16).padStart(2, '0')).join('')}`, - key: {contents: device.linkKey!.key}, - outgoingFrameCounter: device.linkKey!.txCounter, - incomingFrameCounter: device.linkKey!.rxCounter, - }; - }); + const keyList: LinkKeyBackupData[] = backup!.devices.map((device) => ({ + deviceEui64: ZSpec.Utils.eui64BEBufferToHex(device.ieeeAddress), + key: {contents: device.linkKey!.key}, + outgoingFrameCounter: device.linkKey!.txCounter, + incomingFrameCounter: device.linkKey!.rxCounter, + })); // before forming await this.importLinkKeys(keyList); @@ -1201,19 +1190,14 @@ export class EmberAdapter extends Adapter { // Rather than give the real link key, the backup contains a hashed version of the key. // This is done to prevent a compromise of the backup data from compromising the current link keys. // This is per the Smart Energy spec. - const [hashStatus, hashedKey] = await this.emberAesHashSimple(plaintextKey.contents); - - if (hashStatus === SLStatus.OK) { - keyList.push({ - deviceEui64: context.eui64, - key: {contents: hashedKey}, - outgoingFrameCounter: apsKeyMeta.outgoingFrameCounter, - incomingFrameCounter: apsKeyMeta.incomingFrameCounter, - }); - } else { - // this should never happen? - logger.error(`[BACKUP] Failed to hash link key at index ${i} with status=${SLStatus[hashStatus]}. Omitting from backup.`, NS); - } + const hashedKey = ZSpec.Utils.aes128MmoHash(plaintextKey.contents); + + keyList.push({ + deviceEui64: context.eui64, + key: {contents: hashedKey}, + outgoingFrameCounter: apsKeyMeta.outgoingFrameCounter, + incomingFrameCounter: apsKeyMeta.incomingFrameCounter, + }); } } @@ -1503,26 +1487,6 @@ export class EmberAdapter extends Adapter { return status; } - /** - * This is a convenience method when the hash data is less than 255 - * bytes. It inits, updates, and finalizes the hash in one function call. - * - * @param data const uint8_t* The data to hash. Expected of valid length (as in, not larger alloc) - * - * @returns An ::SLStatus value indicating EMBER_SUCCESS if the hash was - * calculated successfully. EMBER_INVALID_CALL if the block size is not a - * multiple of 16 bytes, and EMBER_INDEX_OUT_OF_RANGE is returned when the - * data exceeds the maximum limits of the hash function. - * @returns result uint8_t* The location where the result of the hash will be written. - */ - private async emberAesHashSimple(data: Buffer): Promise<[SLStatus, result: Buffer]> { - const context = aesMmoHashInit(); - - const [status, reContext] = await this.ezsp.ezspAesMmoHash(context, true, data); - - return [status, reContext?.result]; - } - /** * Set the trust center policy bitmask using decision. * @param decision @@ -1571,28 +1535,6 @@ export class EmberAdapter extends Adapter { //-- START Adapter implementation - /* istanbul ignore next */ - public static async isValidPath(path: string): Promise { - // For TCP paths we cannot get device information, therefore we cannot validate it. - if (SocketPortUtils.isTcpPath(path)) { - return false; - } - - try { - return await SerialPortUtils.is(RealpathSync(path), autoDetectDefinitions); - } catch (error) { - logger.debug(`Failed to determine if path is valid: '${error}'`, NS); - return false; - } - } - - /* istanbul ignore next */ - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - paths.sort((a, b) => (a < b ? -1 : 1)); - return paths.length > 0 ? paths[0] : undefined; - } - public async start(): Promise { logger.info(`======== Ember Adapter Starting ========`, NS); const result = await this.initEzsp(); @@ -1745,56 +1687,12 @@ export class EmberAdapter extends Adapter { }); } - // queued - public async setTransmitPower(value: number): Promise { - return await this.queue.execute(async () => { - const status = await this.ezsp.ezspSetRadioPower(value); - - if (status !== SLStatus.OK) { - throw new Error(`Failed to set transmit power to ${value} status=${SLStatus[status]}.`); - } - }); - } - // queued public async addInstallCode(ieeeAddress: string, key: Buffer): Promise { - // codes with CRC, check CRC before sending to NCP, otherwise let NCP handle - if (EMBER_INSTALL_CODE_SIZES.indexOf(key.length) !== -1) { - // Reverse the bits in a byte (uint8_t) - const reverse = (b: number): number => { - return (((((b * 0x0802) & 0x22110) | ((b * 0x8020) & 0x88440)) * 0x10101) >> 16) & 0xff; - }; - let crc = 0xffff; // uint16_t - - // Compute the CRC and verify that it matches. - // The bit reversals, byte swap, and ones' complement are due to differences between halCommonCrc16 and the Smart Energy version. - for (let index = 0; index < key.length - EMBER_INSTALL_CODE_CRC_SIZE; index++) { - crc = halCommonCrc16(reverse(key[index]), crc); - } - - crc = ~highLowToInt(reverse(lowByte(crc)), reverse(highByte(crc))) & 0xffff; - - if ( - key[key.length - EMBER_INSTALL_CODE_CRC_SIZE] !== lowByte(crc) || - key[key.length - EMBER_INSTALL_CODE_CRC_SIZE + 1] !== highByte(crc) - ) { - throw new Error(`[ADD INSTALL CODE] Failed for '${ieeeAddress}'; invalid code CRC.`); - } else { - logger.debug(`[ADD INSTALL CODE] CRC validated for '${ieeeAddress}'.`, NS); - } - } - return await this.queue.execute(async () => { - // Compute the key from the install code and CRC. - const [aesStatus, keyContents] = await this.emberAesHashSimple(key); - - if (aesStatus !== SLStatus.OK) { - throw new Error(`[ADD INSTALL CODE] Failed AES hash for '${ieeeAddress}' with status=${SLStatus[aesStatus]}.`); - } - // Add the key to the transient key table. // This will be used while the DUT joins. - const impStatus = await this.ezsp.ezspImportTransientKey(ieeeAddress as EUI64, {contents: keyContents}); + const impStatus = await this.ezsp.ezspImportTransientKey(ieeeAddress as EUI64, {contents: ZSpec.Utils.aes128MmoHash(key)}); if (impStatus == SLStatus.OK) { logger.debug(`[ADD INSTALL CODE] Success for '${ieeeAddress}'.`, NS); diff --git a/src/adapter/ember/consts.ts b/src/adapter/ember/consts.ts index 38db6e595b..37cb3d0c68 100644 --- a/src/adapter/ember/consts.ts +++ b/src/adapter/ember/consts.ts @@ -77,23 +77,6 @@ export const EMBER_HIGH_RAM_CONCENTRATOR = 0xfff9; /** The short address of the trust center. This address never changes dynamically. */ export const EMBER_TRUST_CENTER_NODE_ID = 0x0000; -/** The size of the CRC that is appended to an installation code. */ -export const EMBER_INSTALL_CODE_CRC_SIZE = 2; - -/** The number of sizes of acceptable installation codes used in Certificate Based Key Establishment (CBKE). */ -export const EMBER_NUM_INSTALL_CODE_SIZES = 4; - -/** - * Various sizes of valid installation codes that are stored in the manufacturing tokens. - * Note that each size includes 2 bytes of CRC appended to the end of the installation code. - */ -export const EMBER_INSTALL_CODE_SIZES = [ - 6 + EMBER_INSTALL_CODE_CRC_SIZE, - 8 + EMBER_INSTALL_CODE_CRC_SIZE, - 12 + EMBER_INSTALL_CODE_CRC_SIZE, - 16 + EMBER_INSTALL_CODE_CRC_SIZE, -]; - /** * Default value for context's PSA algorithm permission (CCM* with 4 byte tag). * Only used by NCPs with secure key storage; define is mirrored here to allow diff --git a/src/adapter/ezsp/adapter/ezspAdapter.ts b/src/adapter/ezsp/adapter/ezspAdapter.ts index 4ec74905b1..c938387d4c 100644 --- a/src/adapter/ezsp/adapter/ezspAdapter.ts +++ b/src/adapter/ezsp/adapter/ezspAdapter.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import * as Models from '../../../models'; -import {Queue, RealpathSync, Wait, Waitress} from '../../../utils'; +import {Queue, Wait, Waitress} from '../../../utils'; import {logger} from '../../../utils/logger'; import * as ZSpec from '../../../zspec'; import * as Zcl from '../../../zspec/zcl'; @@ -11,19 +11,12 @@ import * as Zdo from '../../../zspec/zdo'; import * as ZdoTypes from '../../../zspec/zdo/definition/tstypes'; import Adapter from '../../adapter'; import {ZclPayload} from '../../events'; -import SerialPortUtils from '../../serialPortUtils'; -import SocketPortUtils from '../../socketPortUtils'; import {AdapterOptions, CoordinatorVersion, NetworkOptions, NetworkParameters, SerialPortOptions, StartResult} from '../../tstype'; import {Driver, EmberIncomingMessage} from '../driver'; import {EmberEUI64, EmberStatus} from '../driver/types'; const NS = 'zh:ezsp'; -const autoDetectDefinitions = [ - {manufacturer: 'ITEAD', vendorId: '1a86', productId: '55d4'}, // Sonoff ZBDongle-E - {manufacturer: 'Nabu Casa', vendorId: '10c4', productId: 'ea60'}, // Home Assistant SkyConnect -]; - interface WaitressMatcher { address?: number | string; endpoint: number; @@ -149,7 +142,7 @@ class EZSPAdapter extends Adapter { `'ezsp' driver is deprecated and will only remain to provide support for older firmware (pre 7.4.x). Migration to 'ember' is recommended. If using Zigbee2MQTT see https://github.com/Koenkk/zigbee2mqtt/discussions/21462`, NS, ); - return await this.driver.startup(); + return await this.driver.startup(this.adapterOptions.transmitPower); } public async stop(): Promise { @@ -165,26 +158,6 @@ class EZSPAdapter extends Adapter { } } - public static async isValidPath(path: string): Promise { - // For TCP paths we cannot get device information, therefore we cannot validate it. - if (SocketPortUtils.isTcpPath(path)) { - return false; - } - - try { - return await SerialPortUtils.is(RealpathSync(path), autoDetectDefinitions); - } catch (error) { - logger.debug(`Failed to determine if path is valid: '${error}'`, NS); - return false; - } - } - - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - paths.sort((a, b) => (a < b ? -1 : 1)); - return paths.length > 0 ? paths[0] : undefined; - } - public async getCoordinatorIEEE(): Promise { return `0x${this.driver.ieee.toString()}`; } @@ -569,13 +542,6 @@ class EZSPAdapter extends Adapter { }); } - public async setTransmitPower(value: number): Promise { - logger.debug(`setTransmitPower to ${value}`, NS); - return await this.queue.execute(async () => { - await this.driver.setRadioPower(value); - }); - } - public async setChannelInterPAN(channel: number): Promise { return await this.queue.execute(async () => { this.interpanLock = true; diff --git a/src/adapter/ezsp/driver/driver.ts b/src/adapter/ezsp/driver/driver.ts index e6fb969dfe..f1bd4899a4 100644 --- a/src/adapter/ezsp/driver/driver.ts +++ b/src/adapter/ezsp/driver/driver.ts @@ -175,7 +175,7 @@ export class Driver extends EventEmitter { } } - public async startup(): Promise { + public async startup(transmitPower?: number): Promise { let result: TsType.StartResult = 'resumed'; this.transactionID = 1; // this.ezsp = undefined; @@ -260,12 +260,12 @@ export class Driver extends EventEmitter { if (restore) { // restore logger.info('Restore network from backup', NS); - await this.formNetwork(true); + await this.formNetwork(true, transmitPower); result = 'restored'; } else { // reset logger.info('Form network', NS); - await this.formNetwork(false); + await this.formNetwork(false, transmitPower); result = 'reset'; } } @@ -301,6 +301,10 @@ export class Driver extends EventEmitter { await this.multicast.subscribe(ZSpec.GP_GROUP_ID, ZSpec.GP_ENDPOINT); // await this.multicast.subscribe(1, 901); + if (transmitPower != undefined && this.networkParams.radioTxPower !== transmitPower) { + await this.ezsp.execCommand('setRadioPower', {power: transmitPower}); + } + return result; } @@ -318,7 +322,7 @@ export class Driver extends EventEmitter { return !valid; } - private async formNetwork(restore: boolean): Promise { + private async formNetwork(restore: boolean, transmitPower?: number): Promise { let backup; await this.ezsp.execCommand('clearTransientLinkKeys'); @@ -341,7 +345,7 @@ export class Driver extends EventEmitter { await this.ezsp.setInitialSecurityState(initial_security_state); const parameters: EmberNetworkParameters = new EmberNetworkParameters(); - parameters.radioTxPower = 5; + parameters.radioTxPower = transmitPower ?? 5; parameters.joinMethod = EmberJoinMethod.USE_MAC_ASSOCIATION; parameters.nwkManagerId = 0; parameters.nwkUpdateId = 0; @@ -864,10 +868,6 @@ export class Driver extends EventEmitter { ); } - public setRadioPower(value: number): Promise { - return this.ezsp.execCommand('setRadioPower', {power: value}); - } - public setChannel(channel: number): Promise { return this.ezsp.execCommand('setLogicalAndRadioChannel', {radioChannel: channel}); } diff --git a/src/adapter/serialPortUtils.ts b/src/adapter/serialPortUtils.ts deleted file mode 100644 index 1377cfa132..0000000000 --- a/src/adapter/serialPortUtils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {EqualsPartial} from '../utils'; -import {SerialPort} from './serialPort'; - -interface PortInfoMatch { - manufacturer: string; - vendorId: string; - productId: string; -} - -async function find(matchers: PortInfoMatch[]): Promise { - let devices = await SerialPort.list(); - devices = devices.filter((device) => matchers.find((matcher) => EqualsPartial(device, matcher)) != null); - return devices.map((device) => device.path); -} - -async function is(path: string, matchers: PortInfoMatch[]): Promise { - const devices = await SerialPort.list(); - const device = devices.find((device) => device.path === path); - if (!device) { - return false; - } - - return matchers.find((matcher) => EqualsPartial(device, matcher)) != null; -} - -export default {is, find}; diff --git a/src/adapter/tstype.ts b/src/adapter/tstype.ts index cbb35f9389..e7402a2460 100644 --- a/src/adapter/tstype.ts +++ b/src/adapter/tstype.ts @@ -1,4 +1,14 @@ -interface NetworkOptions { +export type Adapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate' | 'ezsp'; +export type DiscoverableUSBAdapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate'; + +export type USBAdapterFingerprint = { + vendorId: string; + productId: string; + manufacturer?: string; + pathRegex: string; +}; + +export interface NetworkOptions { panID: number; extendedPanID?: number[]; channelList: number[]; @@ -6,14 +16,14 @@ interface NetworkOptions { networkKeyDistribute?: boolean; } -interface SerialPortOptions { +export interface SerialPortOptions { baudRate?: number; rtscts?: boolean; path?: string; - adapter?: 'zstack' | 'deconz' | 'zigate' | 'ezsp' | 'ember' | 'zboss' | 'auto'; + adapter?: Adapter; } -interface AdapterOptions { +export interface AdapterOptions { concurrent?: number; delay?: number; disableLED: boolean; @@ -21,16 +31,16 @@ interface AdapterOptions { forceStartWithInconsistentAdapterConfiguration?: boolean; } -interface CoordinatorVersion { +export interface CoordinatorVersion { type: string; meta: {[s: string]: number | string}; } -type DeviceType = 'Coordinator' | 'EndDevice' | 'Router' | 'Unknown'; +export type DeviceType = 'Coordinator' | 'EndDevice' | 'Router' | 'Unknown'; -type StartResult = 'resumed' | 'reset' | 'restored'; +export type StartResult = 'resumed' | 'reset' | 'restored'; -interface LQINeighbor { +export interface LQINeighbor { ieeeAddr: string; networkAddress: number; linkquality: number; @@ -38,21 +48,21 @@ interface LQINeighbor { depth: number; } -interface LQI { +export interface LQI { neighbors: LQINeighbor[]; } -interface RoutingTableEntry { +export interface RoutingTableEntry { destinationAddress: number; status: string; nextHop: number; } -interface RoutingTable { +export interface RoutingTable { table: RoutingTableEntry[]; } -interface Backup { +export interface Backup { adapterType: 'zStack'; time: string; meta: {[s: string]: number}; @@ -60,23 +70,8 @@ interface Backup { data: any; } -interface NetworkParameters { +export interface NetworkParameters { panID: number; extendedPanID: number; channel: number; } - -export { - SerialPortOptions, - NetworkOptions, - CoordinatorVersion, - DeviceType, - LQI, - LQINeighbor, - RoutingTable, - Backup, - NetworkParameters, - StartResult, - RoutingTableEntry, - AdapterOptions, -}; diff --git a/src/adapter/z-stack/adapter/adapter-backup.ts b/src/adapter/z-stack/adapter/adapter-backup.ts index f91d7a22d9..1d3f431ddb 100644 --- a/src/adapter/z-stack/adapter/adapter-backup.ts +++ b/src/adapter/z-stack/adapter/adapter-backup.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as Models from '../../../models'; import {BackupUtils} from '../../../utils'; import {logger} from '../../../utils/logger'; -import {NULL_NODE_ID} from '../../../zspec'; +import {NULL_NODE_ID, Utils as ZSpecUtils} from '../../../zspec'; import {NvItemsIds, NvSystemIds} from '../constants/common'; import * as Structs from '../structs'; import {AddressManagerUser, SecurityManagerAuthenticationOption} from '../structs'; @@ -232,10 +232,10 @@ export class AdapterBackup { const missing = oldBackup.devices.filter( (d) => d.linkKey && - ieeeAddressesInDatabase.includes(`0x${d.ieeeAddress.toString('hex')}`) && + ieeeAddressesInDatabase.includes(ZSpecUtils.eui64BEBufferToHex(d.ieeeAddress)) && !backup.devices.find((dd) => d.ieeeAddress.equals(dd.ieeeAddress)), ); - const missingStr = missing.map((d) => `0x${d.ieeeAddress.toString('hex')}`).join(', '); + const missingStr = missing.map((d) => ZSpecUtils.eui64BEBufferToHex(d.ieeeAddress)).join(', '); logger.debug( `Following devices with link key are missing from new backup but present in old backup and database, ` + `adding them back: ${missingStr}`, diff --git a/src/adapter/z-stack/adapter/zStackAdapter.ts b/src/adapter/z-stack/adapter/zStackAdapter.ts index bf93c6b4ff..ed9d9ba0ac 100644 --- a/src/adapter/z-stack/adapter/zStackAdapter.ts +++ b/src/adapter/z-stack/adapter/zStackAdapter.ts @@ -152,7 +152,7 @@ class ZStackAdapter extends Adapter { } if (this.adapterOptions.transmitPower != null) { - await this.setTransmitPower(this.adapterOptions.transmitPower); + await this.znp.request(Subsystem.SYS, 'stackTune', {operation: 0, value: this.adapterOptions.transmitPower}); } return await startResult; @@ -163,14 +163,6 @@ class ZStackAdapter extends Adapter { await this.znp.close(); } - public static async isValidPath(path: string): Promise { - return await Znp.isValidPath(path); - } - - public static async autoDetectPath(): Promise { - return await Znp.autoDetectPath(); - } - public async getCoordinatorIEEE(): Promise { return await this.queue.execute(async () => { this.checkInterpanLock(); @@ -961,12 +953,6 @@ class ZStackAdapter extends Adapter { }); } - public async setTransmitPower(value: number): Promise { - return await this.queue.execute(async () => { - await this.znp.request(Subsystem.SYS, 'stackTune', {operation: 0, value}); - }); - } - private waitForInternal( networkAddress: number | undefined, endpoint: number, @@ -1143,17 +1129,7 @@ class ZStackAdapter extends Adapter { } private toAddressString(address: number | string): string { - if (typeof address === 'number') { - let addressString = address.toString(16); - - for (let i = addressString.length; i < 16; i++) { - addressString = '0' + addressString; - } - - return `0x${addressString}`; - } else { - return address.toString(); - } + return typeof address === 'number' ? `0x${address.toString(16).padStart(16, '0')}` : address.toString(); } private waitressTimeoutFormatter(matcher: WaitressMatcher, timeout: number): string { diff --git a/src/adapter/z-stack/znp/znp.ts b/src/adapter/z-stack/znp/znp.ts index 3eaf78d6a7..0327a1c3bc 100755 --- a/src/adapter/z-stack/znp/znp.ts +++ b/src/adapter/z-stack/znp/znp.ts @@ -2,11 +2,10 @@ import assert from 'assert'; import events from 'events'; import net from 'net'; -import {Queue, RealpathSync, Wait, Waitress} from '../../../utils'; +import {Queue, Wait, Waitress} from '../../../utils'; import {logger} from '../../../utils/logger'; import {ClusterId as ZdoClusterId} from '../../../zspec/zdo'; import {SerialPort} from '../../serialPort'; -import SerialPortUtils from '../../serialPortUtils'; import SocketPortUtils from '../../socketPortUtils'; import * as Constants from '../constants'; import {Frame as UnpiFrame, Parser as UnpiParser, Writer as UnpiWriter} from '../unpi'; @@ -38,13 +37,6 @@ interface WaitressMatcher { state?: number; } -const autoDetectDefinitions = [ - {manufacturer: 'Texas Instruments', vendorId: '0451', productId: '16c8'}, // CC2538 - {manufacturer: 'Texas Instruments', vendorId: '0451', productId: '16a8'}, // CC2531 - {manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, // CC1352P_2 and CC26X2R1 - {manufacturer: 'Electrolama', vendorId: '0403', productId: '6015'}, // ZZH -]; - class Znp extends events.EventEmitter { private path: string; private baudRate: number; @@ -198,31 +190,6 @@ class Znp extends events.EventEmitter { } } - public static async isValidPath(path: string): Promise { - // For TCP paths we cannot get device information, therefore we cannot validate it. - if (SocketPortUtils.isTcpPath(path)) { - return false; - } - - try { - return await SerialPortUtils.is(RealpathSync(path), autoDetectDefinitions); - } catch (error) { - logger.error(`Failed to determine if path is valid: '${error}'`, NS); - return false; - } - } - - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - - // CC1352P_2 and CC26X2R1 lists as 2 USB devices with same manufacturer, productId and vendorId - // one is the actual chip interface, other is the XDS110. - // The chip is always exposed on the first one after alphabetical sorting. - paths.sort((a, b) => (a < b ? -1 : 1)); - - return paths.length > 0 ? paths[0] : undefined; - } - public async close(): Promise { logger.info('closing', NS); this.queue.clear(); diff --git a/src/adapter/zboss/adapter/zbossAdapter.ts b/src/adapter/zboss/adapter/zbossAdapter.ts index 30c546181a..ae09ceaea1 100644 --- a/src/adapter/zboss/adapter/zbossAdapter.ts +++ b/src/adapter/zboss/adapter/zbossAdapter.ts @@ -4,26 +4,19 @@ import assert from 'assert'; import {Adapter, TsType} from '../..'; import {Backup} from '../../../models'; -import {Queue, RealpathSync, Waitress} from '../../../utils'; +import {Queue, Waitress} from '../../../utils'; import {logger} from '../../../utils/logger'; import * as ZSpec from '../../../zspec'; import * as Zcl from '../../../zspec/zcl'; import * as Zdo from '../../../zspec/zdo'; import * as ZdoTypes from '../../../zspec/zdo/definition/tstypes'; import {ZclPayload} from '../../events'; -import SerialPortUtils from '../../serialPortUtils'; -import SocketPortUtils from '../../socketPortUtils'; import {ZBOSSDriver} from '../driver'; import {CommandId, DeviceUpdateStatus} from '../enums'; import {FrameType, ZBOSSFrame} from '../frame'; const NS = 'zh:zboss'; -const autoDetectDefinitions = [ - // Nordic Zigbee NCP - {manufacturer: 'ZEPHYR', vendorId: '2fe3', productId: '0100'}, -]; - interface WaitressMatcher { address: number | string; endpoint: number; @@ -109,32 +102,12 @@ export class ZBOSSAdapter extends Adapter { } } - public static async isValidPath(path: string): Promise { - // For TCP paths we cannot get device information, therefore we cannot validate it. - if (SocketPortUtils.isTcpPath(path)) { - return false; - } - - try { - return await SerialPortUtils.is(RealpathSync(path), autoDetectDefinitions); - } catch (error) { - logger.debug(`Failed to determine if path is valid: '${error}'`, NS); - return false; - } - } - - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - paths.sort((a, b) => (a < b ? -1 : 1)); - return paths.length > 0 ? paths[0] : null; - } - public async start(): Promise { logger.info(`ZBOSS Adapter starting`, NS); await this.driver.connect(); - return await this.driver.startup(); + return await this.driver.startup(this.adapterOptions.transmitPower); } public async stop(): Promise { @@ -198,14 +171,6 @@ export class ZBOSSAdapter extends Adapter { }); } - public async setTransmitPower(value: number): Promise { - if (this.driver.isInitialized()) { - return await this.queue.execute(async () => { - await this.driver.execCommand(CommandId.SET_TX_POWER, {txPower: value}); - }); - } - } - public async addInstallCode(ieeeAddress: string, key: Buffer): Promise { logger.error(() => `NOT SUPPORTED: sendZclFrameToGroup(${ieeeAddress},${key.toString('hex')}`, NS); throw new Error(`Install code is not supported for 'zboss' yet`); diff --git a/src/adapter/zboss/driver.ts b/src/adapter/zboss/driver.ts index 8044815c5e..727a030ed8 100644 --- a/src/adapter/zboss/driver.ts +++ b/src/adapter/zboss/driver.ts @@ -84,7 +84,7 @@ export class ZBOSSDriver extends EventEmitter { await this.execCommand(CommandId.NCP_RESET, {options}, 10000); } - public async startup(): Promise { + public async startup(transmitPower?: number): Promise { logger.info(`Driver startup`, NS); let result: TsType.StartResult = 'resumed'; @@ -135,6 +135,10 @@ export class ZBOSSDriver extends EventEmitter { //await this.execCommand(CommandId.SET_ED_TIMEOUT, {timeout: 8}); //await this.execCommand(CommandId.SET_MAX_CHILDREN, {children: 100}); + if (transmitPower != undefined) { + await this.execCommand(CommandId.SET_TX_POWER, {txPower: transmitPower}); + } + return result; } diff --git a/src/adapter/zigate/adapter/zigateAdapter.ts b/src/adapter/zigate/adapter/zigateAdapter.ts index 5c2670076c..dc4a43de32 100644 --- a/src/adapter/zigate/adapter/zigateAdapter.ts +++ b/src/adapter/zigate/adapter/zigateAdapter.ts @@ -91,6 +91,10 @@ class ZiGateAdapter extends Adapter { destinationEndpoint: ZSpec.HA_ENDPOINT, groupAddress: default_bind_group, }); + + if (this.adapterOptions.transmitPower != undefined) { + await this.driver.sendCommand(ZiGateCommandCode.SetTXpower, {value: this.adapterOptions.transmitPower}); + } } catch (error) { throw new Error('failed to connect to zigate adapter ' + (error as Error).message); } @@ -190,14 +194,6 @@ class ZiGateAdapter extends Adapter { throw new Error('This adapter does not support backup'); } - public async setTransmitPower(value: number): Promise { - try { - await this.driver.sendCommand(ZiGateCommandCode.SetTXpower, {value: value}); - } catch (error) { - throw new Error(`Set transmitpower failed ${error}`); - } - } - public async sendZdo( ieeeAddress: string, networkAddress: number, @@ -530,14 +526,6 @@ class ZiGateAdapter extends Adapter { return {promise: waiter.start().promise, cancel}; } - public static async isValidPath(path: string): Promise { - return await Driver.isValidPath(path); - } - - public static async autoDetectPath(): Promise { - return await Driver.autoDetectPath(); - } - /** * InterPAN !!! not implemented */ diff --git a/src/adapter/zigate/driver/buffaloZiGate.ts b/src/adapter/zigate/driver/buffaloZiGate.ts index a977921441..9b9ae9280d 100644 --- a/src/adapter/zigate/driver/buffaloZiGate.ts +++ b/src/adapter/zigate/driver/buffaloZiGate.ts @@ -1,6 +1,7 @@ /* istanbul ignore file */ import {Buffalo} from '../../../buffalo'; +import {Utils as ZSpecUtils} from '../../../zspec'; import {EUI64} from '../../../zspec/tstypes'; import {BuffaloZclOptions} from '../../../zspec/zcl/definition/tstype'; import {getMacCapFlags} from '../../../zspec/zdo/utils'; @@ -182,8 +183,9 @@ class BuffaloZiGate extends Buffalo { } public readIeeeAddrBE(): EUI64 { - return `0x${this.readBuffer(8).toString('hex')}`; + return ZSpecUtils.eui64BEBufferToHex(this.readBuffer(8)); } + public writeIeeeAddrBE(value: string /*TODO: EUI64*/): void { this.writeUInt32BE(parseInt(value.slice(2, 10), 16)); this.writeUInt32BE(parseInt(value.slice(10), 16)); diff --git a/src/adapter/zigate/driver/zigate.ts b/src/adapter/zigate/driver/zigate.ts index 3e36a92e47..d2d4179f59 100644 --- a/src/adapter/zigate/driver/zigate.ts +++ b/src/adapter/zigate/driver/zigate.ts @@ -13,7 +13,6 @@ import * as ZSpec from '../../../zspec'; import * as Zdo from '../../../zspec/zdo'; import {EndDeviceAnnounce, GenericZdoResponse, ResponseMap as ZdoResponseMap} from '../../../zspec/zdo/definition/tstypes'; import {SerialPort} from '../../serialPort'; -import SerialPortUtils from '../../serialPortUtils'; import SocketPortUtils from '../../socketPortUtils'; import {SerialPortOptions} from '../../tstype'; import {equal, ZiGateResponseMatcher, ZiGateResponseMatcherRule} from './commandType'; @@ -23,11 +22,6 @@ import ZiGateObject from './ziGateObject'; const NS = 'zh:zigate:driver'; -const autoDetectDefinitions = [ - {manufacturer: 'zigate_PL2303', vendorId: '067b', productId: '2303'}, - {manufacturer: 'zigate_cp2102', vendorId: '10c4', productId: 'ea60'}, -]; - const timeouts = { reset: 30000, default: 10000, @@ -204,15 +198,6 @@ export default class ZiGate extends EventEmitter { }); } - public static async isValidPath(path: string): Promise { - return await SerialPortUtils.is(path, autoDetectDefinitions); - } - - public static async autoDetectPath(): Promise { - const paths = await SerialPortUtils.find(autoDetectDefinitions); - return paths.length > 0 ? paths[0] : undefined; - } - public open(): Promise { return SocketPortUtils.isTcpPath(this.path) ? this.openSocketPort() : this.openSerialPort(); } diff --git a/src/buffalo/buffalo.ts b/src/buffalo/buffalo.ts index a40f891a66..cc59c98603 100644 --- a/src/buffalo/buffalo.ts +++ b/src/buffalo/buffalo.ts @@ -1,4 +1,6 @@ -import {EUI64} from '../zspec/tstypes'; +import type {EUI64} from '../zspec/tstypes'; + +import {Utils as ZSpecUtils} from '../zspec'; class Buffalo { protected position: number; @@ -238,7 +240,7 @@ class Buffalo { } public readIeeeAddr(): EUI64 { - return `0x${Buffer.from(this.readBuffer(8)).reverse().toString('hex')}`; + return ZSpecUtils.eui64LEBufferToHex(this.readBuffer(8)); } public writeBuffer(values: Buffer | number[], length: number): void { diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 1a43225736..1db602faed 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -78,9 +78,8 @@ class Controller extends events.EventEmitter { // @ts-expect-error assigned and validated in start() private touchlink: Touchlink; - private permitJoinNetworkClosedTimer: NodeJS.Timeout | undefined; private permitJoinTimeoutTimer: NodeJS.Timeout | undefined; - private permitJoinTimeout: number | undefined; + private permitJoinTimeout: number; private backupTimer: NodeJS.Timeout | undefined; private databaseSaveTimer: NodeJS.Timeout | undefined; private stopping: boolean; @@ -100,6 +99,7 @@ class Controller extends events.EventEmitter { this.adapterDisconnected = true; // set false after adapter.start() is successfully called this.options = mixinDeep(JSON.parse(JSON.stringify(DefaultOptions)), options); this.unknownDevices = new Set(); + this.permitJoinTimeout = 0; // Validate options for (const channel of this.options.network.channelList) { @@ -271,65 +271,64 @@ class Controller extends events.EventEmitter { // match valid else asserted above key = Buffer.from(key.match(/.{1,2}/g)!.map((d) => parseInt(d, 16))); - await this.adapter.addInstallCode(ieeeAddr, key); - } + // will throw if code cannot be fixed and is invalid + const [adjustedKey, adjusted] = ZSpec.Utils.checkInstallCode(key, true); + + if (adjusted) { + logger.info(`Install code was adjusted for reason '${adjusted}'.`, NS); + } - public async permitJoin(permit: boolean, device?: Device, time?: number): Promise { - await this.permitJoinInternal(permit, 'manual', device, time); + logger.info(`Adding install code for ${ieeeAddr}.`, NS); + + await this.adapter.addInstallCode(ieeeAddr, adjustedKey); } - public async permitJoinInternal(permit: boolean, reason: 'manual' | 'timer_expired', device?: Device, time?: number): Promise { - clearInterval(this.permitJoinNetworkClosedTimer); + public async permitJoin(time: number, device?: Device): Promise { clearInterval(this.permitJoinTimeoutTimer); - this.permitJoinNetworkClosedTimer = undefined; this.permitJoinTimeoutTimer = undefined; - this.permitJoinTimeout = undefined; - - if (permit) { - await this.adapter.permitJoin(254, device?.networkAddress); - await this.greenPower.permitJoin(254, device?.networkAddress); - - // TODO: remove https://github.com/Koenkk/zigbee-herdsman/issues/940 - // Zigbee 3 networks automatically close after max 255 seconds, keep network open. - this.permitJoinNetworkClosedTimer = setInterval(async (): Promise => { - try { - await this.adapter.permitJoin(254, device?.networkAddress); - await this.greenPower.permitJoin(254, device?.networkAddress); - } catch (error) { - logger.error(`Failed to keep permit join alive: ${error}`, NS); + this.permitJoinTimeout = 0; + + if (time > 0) { + // never permit more than uint8, and never permit 255 that is often equal to "forever" + assert(time <= 254, `Cannot permit join for more than 254 seconds.`); + + await this.adapter.permitJoin(time, device?.networkAddress); + await this.greenPower.permitJoin(time, device?.networkAddress); + + // TODO: should use setTimeout and timer only for open/close emit + // let the other end (frontend) do the sec-by-sec updating (without mqtt publish) + // Also likely creates a gap of a few secs between what Z2M says and what the stack actually has => unreliable timer end + this.permitJoinTimeout = time; + this.permitJoinTimeoutTimer = setInterval(async (): Promise => { + // assumed valid number while in interval + this.permitJoinTimeout--; + + if (this.permitJoinTimeout <= 0) { + clearInterval(this.permitJoinTimeoutTimer); + this.permitJoinTimeoutTimer = undefined; + this.permitJoinTimeout = 0; + + this.emit('permitJoinChanged', {permitted: false, timeout: this.permitJoinTimeout}); + } else { + this.emit('permitJoinChanged', {permitted: true, timeout: this.permitJoinTimeout}); } - }, 200 * 1000); - - // TODO: prevent many mqtt messages by doing this on the other end? - if (typeof time === 'number') { - this.permitJoinTimeout = time; - this.permitJoinTimeoutTimer = setInterval(async (): Promise => { - // assumed valid number while in interval - this.permitJoinTimeout!--; - - if (this.permitJoinTimeout! <= 0) { - await this.permitJoinInternal(false, 'timer_expired'); - } else { - this.emit('permitJoinChanged', {permitted: true, timeout: this.permitJoinTimeout, reason}); - } - }, 1000); - } + }, 1000); - this.emit('permitJoinChanged', {permitted: true, reason, timeout: this.permitJoinTimeout}); + this.emit('permitJoinChanged', {permitted: true, timeout: this.permitJoinTimeout}); } else { logger.debug('Disable joining', NS); + await this.greenPower.permitJoin(0); await this.adapter.permitJoin(0); - this.emit('permitJoinChanged', {permitted: false, reason, timeout: this.permitJoinTimeout}); + this.emit('permitJoinChanged', {permitted: false, timeout: this.permitJoinTimeout}); } } - public getPermitJoin(): boolean { - return this.permitJoinNetworkClosedTimer != undefined; - } - - public getPermitJoinTimeout(): number | undefined { + /** + * @returns Timeout until permit joining expires. [0-254], with 0 being "not permitting joining". + */ + public getPermitJoinTimeout(): number { return this.permitJoinTimeout; } @@ -354,7 +353,7 @@ class Controller extends events.EventEmitter { this.databaseSave(); } else { try { - await this.permitJoinInternal(false, 'manual'); + await this.permitJoin(0); } catch (error) { logger.error(`Failed to disable join on stop: ${error}`, NS); } @@ -397,10 +396,10 @@ class Controller extends events.EventEmitter { public async coordinatorCheck(): Promise<{missingRouters: Device[]}> { if (await this.adapter.supportsBackup()) { const backup = await this.adapter.backup(this.getDeviceIeeeAddresses()); - const devicesInBackup = backup.devices.map((d) => `0x${d.ieeeAddress.toString('hex')}`); + const devicesInBackup = backup.devices.map((d) => ZSpec.Utils.eui64BEBufferToHex(d.ieeeAddress)); const missingRouters = []; - for (const device of this.getDevicesIterator((d) => d.type === 'Router' && !devicesInBackup.includes(d.ieeeAddr))) { + for (const device of this.getDevicesIterator((d) => d.type === 'Router' && !devicesInBackup.includes(d.ieeeAddr as EUI64))) { missingRouters.push(device); } @@ -523,13 +522,6 @@ class Controller extends events.EventEmitter { await Wait(12000); } - /** - * Set transmit power of the adapter - */ - public async setTransmitPower(value: number): Promise { - return await this.adapter.setTransmitPower(value); - } - public async identifyUnknownDevice(nwkAddress: number): Promise { if (this.unknownDevices.has(nwkAddress)) { // prevent duplicate triggering @@ -657,7 +649,7 @@ class Controller extends events.EventEmitter { // Green power devices don't have an ieeeAddr, the sourceID is unique and static so use this. let ieeeAddr = payload.sourceID.toString(16); - ieeeAddr = `0x${'0'.repeat(16 - ieeeAddr.length)}${ieeeAddr}`; + ieeeAddr = `0x${ieeeAddr.padStart(16, '0')}`; // Green power devices dont' have a modelID, create a modelID based on the deviceID (=type) const modelID = `GreenPower_${payload.deviceID}`; diff --git a/src/controller/events.ts b/src/controller/events.ts index f4db44705d..282950f6b6 100644 --- a/src/controller/events.ts +++ b/src/controller/events.ts @@ -25,7 +25,6 @@ interface DeviceLeavePayload { interface PermitJoinChangedPayload { permitted: boolean; - reason: 'timer_expired' | 'manual'; timeout?: number; } diff --git a/src/controller/model/endpoint.ts b/src/controller/model/endpoint.ts index a7aa00c918..3c8ff776e2 100644 --- a/src/controller/model/endpoint.ts +++ b/src/controller/model/endpoint.ts @@ -788,7 +788,7 @@ class Endpoint extends Entity { public waitForCommand( clusterKey: number | string, commandKey: number | string, - transactionSequenceNumber: number, + transactionSequenceNumber: number | undefined, timeout: number, ): {promise: Promise<{header: Zcl.Header; payload: KeyValue}>; cancel: () => void} { const device = this.getDevice(); diff --git a/src/utils/equalsPartial.ts b/src/utils/equalsPartial.ts deleted file mode 100644 index fd87d02942..0000000000 --- a/src/utils/equalsPartial.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Equals from 'fast-deep-equal/es6'; - -function equalsPartial(object: T, expected: Partial): boolean { - const entries = Object.entries(expected) as [keyof T, unknown][]; - return entries.every(([key, value]) => Equals(object[key], value)); -} - -export default equalsPartial; diff --git a/src/utils/index.ts b/src/utils/index.ts index d53ffe46bf..04973310bf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,9 +1,8 @@ import * as BackupUtils from './backup'; -import EqualsPartial from './equalsPartial'; import Queue from './queue'; import RealpathSync from './realpathSync'; import * as Utils from './utils'; import Wait from './wait'; import Waitress from './waitress'; -export {Wait, Queue, Waitress, EqualsPartial, RealpathSync, BackupUtils, Utils}; +export {Wait, Queue, Waitress, RealpathSync, BackupUtils, Utils}; diff --git a/src/zspec/consts.ts b/src/zspec/consts.ts index 9c47c0c29b..12422817a8 100644 --- a/src/zspec/consts.ts +++ b/src/zspec/consts.ts @@ -72,3 +72,13 @@ export const PAN_ID_SIZE = 2; export const EXTENDED_PAN_ID_SIZE = 8; /** Size of an encryption key in bytes. */ export const DEFAULT_ENCRYPTION_KEY_SIZE = 16; +/** Size of a AES-128-MMO (Matyas-Meyer-Oseas) block in bytes. */ +export const AES_MMO_128_BLOCK_SIZE = 16; +/** + * Valid install code sizes, including `INSTALL_CODE_CRC_SIZE`. + * + * NOTE: 18 is now standard, first for iterations, order after is important (8 before 10)! + */ +export const INSTALL_CODE_SIZES: ReadonlyArray = [18, 8, 10, 14]; +/** Size of the CRC appended to install codes. */ +export const INSTALL_CODE_CRC_SIZE = 2; diff --git a/src/zspec/utils.ts b/src/zspec/utils.ts index eb2962b60a..8b17f453b8 100644 --- a/src/zspec/utils.ts +++ b/src/zspec/utils.ts @@ -1,4 +1,8 @@ -import {ALL_802_15_4_CHANNELS} from './consts'; +import type {EUI64} from './tstypes'; + +import {createCipheriv} from 'crypto'; + +import {AES_MMO_128_BLOCK_SIZE, ALL_802_15_4_CHANNELS, INSTALL_CODE_CRC_SIZE, INSTALL_CODE_SIZES} from './consts'; import {BroadcastAddress} from './enums'; /** @@ -35,3 +39,267 @@ export const isBroadcastAddress = (address: number): boolean => { address === BroadcastAddress.LOW_POWER_ROUTERS ); }; + +/** + * Represent a little endian buffer in `0x...` form + * + * NOTE: the buffer is always copied to avoid reversal in reference + */ +export const eui64LEBufferToHex = (eui64LEBuf: Buffer): EUI64 => `0x${Buffer.from(eui64LEBuf).reverse().toString('hex')}`; + +/** + * Represent a big endian buffer in `0x...` form + */ +export const eui64BEBufferToHex = (eui64BEBuf: Buffer): EUI64 => `0x${eui64BEBuf.toString('hex')}`; + +/** + * Calculate the CRC 8, 16 or 32 for the given data. + * + * @see https://www.crccalc.com/ + * + * @param data + * @param length CRC Length + * @param poly Polynomial + * @param crc Initialization value + * @param xorOut Final XOR value + * @param refIn Reflected In + * @param refOut Reflected Out + * @returns The calculated CRC + * + * NOTE: This is not exported for test coverage reasons (large number of combinations possible, many unused). + * Specific, needed, algorithms should be defined as exported wrappers below, and coverage added for them. + */ +/* istanbul ignore next */ +function calcCRC( + data: number[] | Uint8Array | Buffer, + length: 8 | 16 | 32, + poly: number, + crc: number = 0, + xorOut: number = 0, + refIn: boolean = false, + refOut: boolean = false, +): number { + // https://web.archive.org/web/20150226083354/http://leetcode.com/2011/08/reverse-bits.html + const reflect = (x: number, size: 8 | 16 | 32): number => { + if (size === 8) { + x = ((x & 0x55) << 1) | ((x & 0xaa) >> 1); + x = ((x & 0x33) << 2) | ((x & 0xcc) >> 2); + x = ((x & 0x0f) << 4) | ((x & 0xf0) >> 4); + } else if (size === 16) { + x = ((x & 0x5555) << 1) | ((x & 0xaaaa) >> 1); + x = ((x & 0x3333) << 2) | ((x & 0xcccc) >> 2); + x = ((x & 0x0f0f) << 4) | ((x & 0xf0f0) >> 4); + x = ((x & 0x00ff) << 8) | ((x & 0xff00) >> 8); + } /* if (size === 32) */ else { + x = ((x & 0x55555555) << 1) | ((x & 0xaaaaaaaa) >> 1); + x = ((x & 0x33333333) << 2) | ((x & 0xcccccccc) >> 2); + x = ((x & 0x0f0f0f0f) << 4) | ((x & 0xf0f0f0f0) >> 4); + x = ((x & 0x00ff00ff) << 8) | ((x & 0xff00ff00) >> 8); + x = ((x & 0x0000ffff) << 16) | ((x & 0xffff0000) >> 16); + } + + return x; + }; + + poly = (1 << length) | poly; + + for (let byte of data) { + if (refIn) { + byte = reflect(byte, 8); + } + + crc ^= byte << (length - 8); + + for (let i = 0; i < 8; i++) { + crc <<= 1; + + if (crc & (1 << length)) { + crc ^= poly; + } + } + } + + if (refOut) { + crc = reflect(crc, length); + } + + return crc ^ xorOut; +} + +/** + * CRC-16/X-25 + * aka CRC-16/IBM-SDLC + * aka CRC-16/ISO-HDLC + * aka CRC-16/ISO-IEC-14443-3-B + * aka CRC-B + * aka X-25 + * + * Shortcut for `calcCRC(data, 16, 0x1021, 0xFFFF, 0xFFFF, true, true)` + * + * Used for Install Codes - see Document 13-0402-13 - 10.1 + */ +export function crc16X25(data: number[] | Uint8Array | Buffer): number { + return calcCRC(data, 16, 0x1021, 0xffff, 0xffff, true, true); +} + +/** + * CRC-16/XMODEM + * aka CRC-16/ACORN + * aka CRC-16/LTE + * aka CRC-16/V-41-MSB + * aka XMODEM + * aka ZMODEM + * + * Shortcut for `calcCRC(data, 16, 0x1021)` + * + * Used for XMODEM transfers, often involved in ZigBee environments + */ +export function crc16XMODEM(data: number[] | Uint8Array | Buffer): number { + return calcCRC(data, 16, 0x1021); +} + +/** + * CRC-16/CCITT + * aka CRC-16/KERMIT + * aka CRC-16/BLUETOOTH + * aka CRC-16/CCITT-TRUE + * aka CRC-16/V-41-LSB + * aka CRC-CCITT + * aka KERMIT + * + * Shortcut for `calcCRC(data, 16, 0x1021, 0x0000, 0x0000, true, true)` + */ +export function crc16CCITT(data: number[] | Uint8Array | Buffer): number { + return calcCRC(data, 16, 0x1021, 0x0000, 0x0000, true, true); +} + +/** + * CRC-16/CCITT-FALSE + * aka CRC-16/IBM-3740 + * aka CRC-16/AUTOSAR + * + * Shortcut for `calcCRC(data, 16, 0x1021, 0xffff)` + */ +export function crc16CCITTFALSE(data: number[] | Uint8Array | Buffer): number { + return calcCRC(data, 16, 0x1021, 0xffff); +} + +/** + * AES-128-MMO (Matyas-Meyer-Oseas) hashing (using node 'crypto' built-in with 'aes-128-ecb') + * + * Used for Install Codes - see Document 13-0402-13 - 10.1 + */ +export function aes128MmoHash(data: Buffer): Buffer { + const update = (result: Buffer, data: Buffer, dataSize: number): boolean => { + while (dataSize >= AES_MMO_128_BLOCK_SIZE) { + const cipher = createCipheriv('aes-128-ecb', result, null); + const block = data.subarray(0, AES_MMO_128_BLOCK_SIZE); + const encryptedBlock = Buffer.concat([cipher.update(block), cipher.final()]); + + // XOR encrypted and plaintext + for (let i = 0; i < AES_MMO_128_BLOCK_SIZE; i++) { + result[i] = encryptedBlock[i] ^ block[i]; + } + + data = data.subarray(AES_MMO_128_BLOCK_SIZE); + dataSize -= AES_MMO_128_BLOCK_SIZE; + } + + return true; + }; + + const hashResult = Buffer.alloc(AES_MMO_128_BLOCK_SIZE); + const temp = Buffer.alloc(AES_MMO_128_BLOCK_SIZE); + let remainingLength = data.length; + let position = 0; + + for (position; remainingLength >= AES_MMO_128_BLOCK_SIZE; ) { + update(hashResult, data.subarray(position, position + AES_MMO_128_BLOCK_SIZE), data.length); + + position += AES_MMO_128_BLOCK_SIZE; + remainingLength -= AES_MMO_128_BLOCK_SIZE; + } + + for (let i = 0; i < remainingLength; i++) { + temp[i] = data[position + i]; + } + + // per the spec, concatenate a 1 bit followed by all zero bits + temp[remainingLength] = 0x80; + + // if appending the bit string will push us beyond the 16-byte boundary, hash that block and append another 16-byte block + if (AES_MMO_128_BLOCK_SIZE - remainingLength < 3) { + update(hashResult, temp, AES_MMO_128_BLOCK_SIZE); + temp.fill(0); + } + + temp[AES_MMO_128_BLOCK_SIZE - 2] = (data.length >> 5) & 0xff; + temp[AES_MMO_128_BLOCK_SIZE - 1] = (data.length << 3) & 0xff; + + update(hashResult, temp, AES_MMO_128_BLOCK_SIZE); + + const result = Buffer.alloc(AES_MMO_128_BLOCK_SIZE); + + for (let i = 0; i < AES_MMO_128_BLOCK_SIZE; i++) { + result[i] = hashResult[i]; + } + + return result; +} + +/** + * Check if install code (little-endian) is valid, and if not, and requested, fix it. + * + * WARNING: Due to conflicting sizes between 8-length code with invalid CRC, and 10-length code missing CRC, given 8-length codes are always assumed to be 8-length code with invalid CRC (most probable scenario). + * + * @param code The code to check. Reference is not modified by this procedure but is returned when code was valid, as `outCode`. + * @param adjust If false, throws if the install code is invalid, otherwise try to fix it (CRC) + * @returns + * - The adjusted code, or `code` if not adjusted. + * - If adjust is false, undefined, otherwise, the reason why the code needed adjusting or undefined if not. + * - Throws when adjust=false and invalid, or cannot fix. + */ +export function checkInstallCode(code: Buffer, adjust: boolean = true): [outCode: Buffer, adjusted: string | undefined] { + const crcLowByteIndex = code.length - INSTALL_CODE_CRC_SIZE; + const crcHighByteIndex = code.length - INSTALL_CODE_CRC_SIZE + 1; + + for (const codeSize of INSTALL_CODE_SIZES) { + if (code.length === codeSize) { + // install code has CRC, check if valid, if not, replace it + const crc = crc16X25(code.subarray(0, -2)); + const crcHighByte = (crc >> 8) & 0xff; + const crcLowByte = crc & 0xff; + + if (code[crcLowByteIndex] !== crcLowByte || code[crcHighByteIndex] !== crcHighByte) { + // see WARNING above, 8 is smallest valid length, so always ends up here + if (adjust) { + const outCode = Buffer.from(code); + outCode[crcLowByteIndex] = crcLowByte; + outCode[crcHighByteIndex] = crcHighByte; + + return [outCode, 'invalid CRC']; + } else { + throw new Error(`Install code ${code.toString('hex')} failed CRC validation`); + } + } + + return [code, undefined]; + } else if (code.length === codeSize - INSTALL_CODE_CRC_SIZE) { + if (adjust) { + // install code is missing CRC + const crc = crc16X25(code); + const outCode = Buffer.alloc(code.length + INSTALL_CODE_CRC_SIZE); + + code.copy(outCode, 0); + outCode.writeUInt16LE(crc, code.length); + + return [outCode, 'missing CRC']; + } else { + throw new Error(`Install code ${code.toString('hex')} failed CRC validation`); + } + } + } + + // never returned from within the above loop + throw new Error(`Install code ${code.toString('hex')} has invalid size`); +} diff --git a/test/adapter/adapter.test.ts b/test/adapter/adapter.test.ts new file mode 100644 index 0000000000..a27d4422f5 --- /dev/null +++ b/test/adapter/adapter.test.ts @@ -0,0 +1,863 @@ +import os from 'os'; + +import {Bonjour, BrowserConfig} from 'bonjour-service'; + +import {Adapter} from '../../src/adapter'; +import {DeconzAdapter} from '../../src/adapter/deconz/adapter'; +import {EmberAdapter} from '../../src/adapter/ember/adapter'; +import {EZSPAdapter} from '../../src/adapter/ezsp/adapter'; +import {SerialPort} from '../../src/adapter/serialPort'; +import {ZStackAdapter} from '../../src/adapter/z-stack/adapter'; +import {ZBOSSAdapter} from '../../src/adapter/zboss/adapter'; +import {ZiGateAdapter} from '../../src/adapter/zigate/adapter'; +import { + DECONZ_CONBEE_II, + EMBER_SKYCONNECT, + EMBER_ZBDONGLE_E, + ZBOSS_NORDIC, + ZIGATE_PLUSV2, + ZSTACK_CC2538, + ZSTACK_SMLIGHT_SLZB_06P10, + ZSTACK_SMLIGHT_SLZB_07, + ZSTACK_ZBDONGLE_P, +} from '../mockAdapters'; + +const mockBonjourResult = jest.fn().mockImplementation((type) => ({ + name: 'Mock Adapter', + type: `${type}_mdns`, + port: '1122', + host: 'mock_adapter.local', + addresses: ['192.168.1.123'], + txt: { + radio_type: `${type}`, + }, +})); +const mockBonjourFindOne = jest.fn((opts: BrowserConfig | null, timeout: number, callback?: CallableFunction) => { + if (callback) { + callback(mockBonjourResult(opts?.type)); + } +}); +const mockBonjourDestroy = jest.fn(); + +jest.mock('bonjour-service', () => ({ + Bonjour: jest.fn(() => ({ + findOne: mockBonjourFindOne, + destroy: mockBonjourDestroy, + })), +})); + +describe('Adapter', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + describe('mDNS discovery', () => { + beforeEach(() => { + mockBonjourResult.mockClear(); + mockBonjourFindOne.mockClear(); + mockBonjourDestroy.mockClear(); + }); + + it.each([ + ['deconz', DeconzAdapter], + ['ember', EmberAdapter], + ['ezsp', EZSPAdapter], + ['zstack', ZStackAdapter], + ['zboss', ZBOSSAdapter], + ['zigate', ZiGateAdapter], + ])('for %s', async (name, adapterCls) => { + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: `mdns://${name}`}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(adapterCls); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'tcp://192.168.1.123:1122', + adapter: name, + }); + }); + + it('for zstack as znp', async () => { + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: `mdns://znp`}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'tcp://192.168.1.123:1122', + adapter: 'zstack', + }); + }); + + it('falls back to host if no addresses', async () => { + mockBonjourResult.mockReturnValueOnce({ + name: 'Mock Adapter', + type: `my_adapter_mdns`, + port: '1122', + host: 'mock_adapter.local', + txt: { + radio_type: `zstack`, + }, + }); + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: `mdns://zstack`}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'tcp://mock_adapter.local:1122', + adapter: `zstack`, + }); + }); + + it('times out', async () => { + mockBonjourResult.mockReturnValueOnce(null); + const fakeAdapterName = 'mdns_test_device'; + + expect(async () => { + await Adapter.create({panID: 0, channelList: []}, {path: `mdns://${fakeAdapterName}`}, 'test.db', {disableLED: false}); + }).rejects.toThrow(`Coordinator [${fakeAdapterName}] not found after timeout of 2000ms!`); + }); + + it('given invalid path', async () => { + expect(async () => { + await Adapter.create({panID: 0, channelList: []}, {path: `mdns://`}, 'test.db', {disableLED: false}); + }).rejects.toThrow(`No mdns device specified. You must specify the coordinator mdns service type after mdns://, e.g. mdns://my-adapter`); + }); + + it('returns invalid format', async () => { + mockBonjourResult.mockReturnValueOnce({ + name: 'Mock Adapter', + type: `my_adapter_mdns`, + port: '1122', + host: 'my_adapter.local', + addresses: ['192.168.1.123'], + txt: { + radio_type: undefined, + }, + }); + + expect(async () => { + await Adapter.create({panID: 0, channelList: []}, {path: `mdns://my_adapter`}, 'test.db', {disableLED: false}); + }).rejects.toThrow( + `Coordinator returned wrong Zeroconf format! The following values are expected:\n` + + `txt.radio_type, got: undefined\n` + + `port, got: 1122`, + ); + }); + }); + + describe('TCP discovery', () => { + it('returns config with tcp path', async () => { + const adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {path: `tcp://192.168.1.321:3456`, adapter: `zstack`}, + 'test.db.backup', + {disableLED: false}, + ); + + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: `tcp://192.168.1.321:3456`, + adapter: `zstack`, + }); + }); + + it('returns config with socket path', async () => { + const adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {path: `socket://192.168.1.321:3456`, adapter: `zstack`}, + 'test.db.backup', + {disableLED: false}, + ); + + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: `tcp://192.168.1.321:3456`, + adapter: `zstack`, + }); + }); + + it('invalid path', async () => { + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: `tcp://192168.1.321:3456`, adapter: `zstack`}, 'test.db.backup', { + disableLED: false, + }); + }).rejects.toThrow(`Invalid TCP path, expected format: tcp://:`); + }); + + it('invalid adapter', async () => { + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: `tcp://192.168.1.321:3456`}, 'test.db.backup', { + disableLED: false, + }); + }).rejects.toThrow(`Cannot discover TCP adapters at this time. Specify valid 'adapter' and 'port' in your configuration.`); + }); + }); + + describe('USB discovery', () => { + let listSpy: jest.SpyInstance; + let platformSpy: jest.SpyInstance; + + beforeAll(() => { + listSpy = jest.spyOn(SerialPort, 'list'); + listSpy.mockReturnValue([DECONZ_CONBEE_II, EMBER_ZBDONGLE_E, ZSTACK_CC2538, ZBOSS_NORDIC, ZIGATE_PLUSV2]); + + platformSpy = jest.spyOn(os, 'platform'); + platformSpy.mockReturnValue('linux'); + }); + + describe('without config', () => { + it('detects each adapter', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + let adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {baudRate: 57600}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(DeconzAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: DECONZ_CONBEE_II.path, + adapter: 'deconz', + baudRate: 57600, + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {baudRate: 115200}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ember', + baudRate: 115200, + }); + + listSpy.mockReturnValueOnce([ZSTACK_CC2538]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZBOSS_NORDIC]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZBOSS_NORDIC.path, + adapter: 'zboss', + }); + + listSpy.mockReturnValueOnce([ZIGATE_PLUSV2]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZiGateAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZIGATE_PLUSV2.path, + adapter: 'zigate', + }); + }); + + it('detects on Windows with manufacturer present', async () => { + platformSpy.mockReturnValueOnce('win32'); + listSpy.mockReturnValueOnce([ + { + // Windows sample - Sonoff Dongle-E + path: 'COM3', + manufacturer: 'ITEAD', + serialNumber: '54DD002111', + pnpId: 'USB\\VID_1A86&PID_55D4\\54DD002111', + locationId: 'Port_#0005.Hub_#0001', + friendlyName: 'USB-Enhanced-SERIAL CH9102 (COM3)', + vendorId: '1A86', + productId: '55D4', + }, + ]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'COM3', + adapter: 'ember', + }); + }); + + it('detects on Windows without manufacturer present', async () => { + // Note: this is the least-accurate possible match + platformSpy.mockReturnValueOnce('win32'); + listSpy.mockReturnValueOnce([ + { + // Windows sample - Sonoff Dongle-E + path: 'COM3', + manufacturer: 'wch.cn', + serialNumber: '54DD002111', + pnpId: 'USB\\VID_1A86&PID_55D4\\54DD002111', + locationId: 'Port_#0005.Hub_#0001', + friendlyName: 'USB-Enhanced-SERIAL CH9102 (COM3)', + vendorId: '1A86', + productId: '55D4', + }, + ]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'COM3', + adapter: 'ember', + }); + }); + + it('detects with pnpId instead of path', async () => { + listSpy.mockReturnValueOnce([{...ZBOSS_NORDIC, path: '/dev/ttyUSB0', pnpId: 'usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00'}]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyUSB0', + adapter: 'zboss', + }); + }); + + it('detects with conflict vendor+product IDs', async () => { + listSpy.mockReturnValueOnce([{...EMBER_SKYCONNECT, manufacturer: undefined}]); + + let adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_SKYCONNECT.path, + adapter: 'ember', + }); + + listSpy.mockReturnValueOnce([{...ZSTACK_ZBDONGLE_P, path: '/dev/ttyACM0'}]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyACM0', + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZSTACK_SMLIGHT_SLZB_06P10]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_SMLIGHT_SLZB_06P10.path, + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZSTACK_SMLIGHT_SLZB_07]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_SMLIGHT_SLZB_07.path, + adapter: 'ember', + }); + }); + + it('returns first from list with multiple adapters - nothing to match against', async () => { + // NOTE: list is currently sorted + // const sortedPaths = [DECONZ_CONBEE_II.path, ZSTACK_CC2538.path, EMBER_ZBDONGLE_E.path].sort(); + // console.log(sortedPaths[0]); + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II, ZSTACK_CC2538, EMBER_ZBDONGLE_E]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ember', + }); + }); + + it('throws on failure to get SerialPort.list', async () => { + listSpy.mockRejectedValueOnce(new Error('spawn udevadm ENOENT')); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow(`USB adapter discovery error (spawn udevadm ENOENT). Specify valid 'adapter' and 'port' in your configuration.`); + }); + + it('throws on failure to detect with conflict vendor+product IDs', async () => { + listSpy.mockReturnValueOnce([{...EMBER_SKYCONNECT, path: '/dev/ttyACM0', manufacturer: undefined}]); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow( + `USB adapter discovery error (No valid USB adapter found). Specify valid 'adapter' and 'port' in your configuration.`, + ); + }); + }); + + describe('with adapter+path config', () => { + it('detects each adapter', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + let adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'deconz', path: DECONZ_CONBEE_II.path}, + 'test.db.backup', + {disableLED: false}, + ); + + expect(adapter).toBeInstanceOf(DeconzAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: DECONZ_CONBEE_II.path, + adapter: 'deconz', + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'ember', path: EMBER_ZBDONGLE_E.path}, + 'test.db.backup', + {disableLED: false}, + ); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ember', + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'ezsp', path: EMBER_ZBDONGLE_E.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(EZSPAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ezsp', + }); + + listSpy.mockReturnValueOnce([ZSTACK_CC2538]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack', path: ZSTACK_CC2538.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZBOSS_NORDIC]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zboss', path: ZBOSS_NORDIC.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZBOSS_NORDIC.path, + adapter: 'zboss', + }); + + listSpy.mockReturnValueOnce([ZIGATE_PLUSV2]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zigate', path: ZIGATE_PLUSV2.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZiGateAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZIGATE_PLUSV2.path, + adapter: 'zigate', + }); + }); + + it('detects with multiple adapters connected', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II, ZSTACK_CC2538, EMBER_ZBDONGLE_E]); + + const adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'zstack', path: ZSTACK_CC2538.path}, + 'test.db.backup', + {disableLED: false}, + ); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + }); + + it('detects with pnpId instead of path', async () => { + listSpy.mockReturnValueOnce([{...ZBOSS_NORDIC, path: '/dev/ttyUSB0', pnpId: 'usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00'}]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zboss', path: '/dev/ttyUSB0'}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyUSB0', + adapter: 'zboss', + }); + }); + + it('detects with conflict vendor+product IDs', async () => { + listSpy.mockReturnValueOnce([{...EMBER_SKYCONNECT, manufacturer: undefined}]); + + let adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'ember', path: EMBER_SKYCONNECT.path}, + 'test.db.backup', + {disableLED: false}, + ); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_SKYCONNECT.path, + adapter: 'ember', + }); + + listSpy.mockReturnValueOnce([{...ZSTACK_ZBDONGLE_P, path: '/dev/ttyACM0'}]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack', path: '/dev/ttyACM0'}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyACM0', + adapter: 'zstack', + }); + }); + + it('returns instance anyway on failure to match', async () => { + listSpy.mockReturnValueOnce([]); + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack', path: 'dev/ttyUSB0'}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: 'dev/ttyUSB0', + adapter: 'zstack', + }); + }); + + it('returns instance anyway on failure to match with different path', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + const adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'deconz', path: '/dev/ttyUSB0'}, + 'test.db.backup', + { + disableLED: false, + }, + ); + + expect(adapter).toBeInstanceOf(DeconzAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyUSB0', + adapter: 'deconz', + }); + }); + + it('returns instance anyway on failure to get SerialPort.list', async () => { + listSpy.mockRejectedValueOnce(new Error('spawn udevadm ENOENT')); + + const adapter = await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + {adapter: 'zstack', path: ZSTACK_CC2538.path}, + 'test.db.backup', + {disableLED: false}, + ); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + }); + + it('throws on failure to match invalid adapter', async () => { + listSpy.mockReturnValueOnce([]); + + expect(async () => { + await Adapter.create( + {panID: 0x1a62, channelList: [11]}, + // @ts-expect-error invalid on purpose + {adapter: 'invalid', path: 'dev/ttyUSB0'}, + 'test.db.backup', + {disableLED: false}, + ); + }).rejects.toThrow(`Adapter 'invalid' does not exists, possible options: zstack, deconz, zigate, ezsp, ember, zboss`); + }); + }); + + describe('with adapter only config', () => { + it('detects each adapter', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + let adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'deconz'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(DeconzAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: DECONZ_CONBEE_II.path, + adapter: 'deconz', + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'ember'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ember', + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'ezsp'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(EZSPAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ezsp', + }); + + listSpy.mockReturnValueOnce([ZSTACK_CC2538]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZBOSS_NORDIC]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zboss'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZBOSS_NORDIC.path, + adapter: 'zboss', + }); + + listSpy.mockReturnValueOnce([ZIGATE_PLUSV2]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zigate'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZiGateAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZIGATE_PLUSV2.path, + adapter: 'zigate', + }); + }); + + it('detects with multiple adapters connected', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II, ZSTACK_CC2538, EMBER_ZBDONGLE_E]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack'}, 'test.db.backup', {disableLED: false}); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + }); + + it('detects with pnpId instead of path', async () => { + listSpy.mockReturnValueOnce([{...ZBOSS_NORDIC, path: '/dev/ttyUSB0', pnpId: 'usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00'}]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zboss'}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyUSB0', + adapter: 'zboss', + }); + }); + + it('throws on failure to detect with conflict vendor+product IDs', async () => { + listSpy.mockReturnValueOnce([{...EMBER_SKYCONNECT, path: '/dev/ttyACM0', manufacturer: undefined}]); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: 'zstack'}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow( + `USB adapter discovery error (No valid USB adapter found). Specify valid 'adapter' and 'port' in your configuration.`, + ); + }); + }); + + describe('with path only config', () => { + it('detects each adapter', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + let adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: DECONZ_CONBEE_II.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(DeconzAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: DECONZ_CONBEE_II.path, + adapter: 'deconz', + }); + + listSpy.mockReturnValueOnce([EMBER_ZBDONGLE_E]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: EMBER_ZBDONGLE_E.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(EmberAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: EMBER_ZBDONGLE_E.path, + adapter: 'ember', + }); + + listSpy.mockReturnValueOnce([ZSTACK_CC2538]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: ZSTACK_CC2538.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + + listSpy.mockReturnValueOnce([ZBOSS_NORDIC]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: ZBOSS_NORDIC.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZBOSS_NORDIC.path, + adapter: 'zboss', + }); + + listSpy.mockReturnValueOnce([ZIGATE_PLUSV2]); + + adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: ZIGATE_PLUSV2.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZiGateAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZIGATE_PLUSV2.path, + adapter: 'zigate', + }); + }); + + it('detects with multiple adapters connected', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II, ZSTACK_CC2538, EMBER_ZBDONGLE_E]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: ZSTACK_CC2538.path}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZStackAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: ZSTACK_CC2538.path, + adapter: 'zstack', + }); + }); + + it('detects with pnpId instead of path', async () => { + listSpy.mockReturnValueOnce([{...ZBOSS_NORDIC, path: '/dev/ttyUSB0', pnpId: 'usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00'}]); + + const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: '/dev/ttyUSB0'}, 'test.db.backup', { + disableLED: false, + }); + + expect(adapter).toBeInstanceOf(ZBOSSAdapter); + // @ts-expect-error protected + expect(adapter.serialPortOptions).toStrictEqual({ + path: '/dev/ttyUSB0', + adapter: 'zboss', + }); + }); + + it('throws on failure to match with different path', async () => { + listSpy.mockReturnValueOnce([DECONZ_CONBEE_II]); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {path: '/dev/ttyUSB0'}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow( + `USB adapter discovery error (No valid USB adapter found). Specify valid 'adapter' and 'port' in your configuration.`, + ); + }); + }); + + it('throws on failure to match when port info too limited', async () => { + listSpy.mockReturnValueOnce([{...DECONZ_CONBEE_II, vendorId: undefined}]); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow(`USB adapter discovery error (No valid USB adapter found). Specify valid 'adapter' and 'port' in your configuration.`); + + listSpy.mockReturnValueOnce([{...DECONZ_CONBEE_II, productId: undefined}]); + + expect(async () => { + await Adapter.create({panID: 0x1a62, channelList: [11]}, {}, 'test.db.backup', {disableLED: false}); + }).rejects.toThrow(`USB adapter discovery error (No valid USB adapter found). Specify valid 'adapter' and 'port' in your configuration.`); + }); + }); +}); diff --git a/test/adapter/ember/emberAdapter.test.ts b/test/adapter/ember/emberAdapter.test.ts index 61b91d327a..bca411cf2c 100644 --- a/test/adapter/ember/emberAdapter.test.ts +++ b/test/adapter/ember/emberAdapter.test.ts @@ -31,7 +31,6 @@ import {EzspConfigId, EzspDecisionBitmask, EzspEndpointFlag, EzspPolicyId, EzspV import {EmberEzspEventMap, Ezsp} from '../../../src/adapter/ember/ezsp/ezsp'; import {EzspError} from '../../../src/adapter/ember/ezspError'; import { - EmberAesMmoHashContext, EmberApsFrame, EmberMulticastTableEntry, EmberNetworkInitStruct, @@ -121,7 +120,7 @@ const DEFAULT_BACKUP: Readonly = { }, devices: [], }; -const DEFAULT_COORDINATOR_IEEE: EUI64 = `0x${Buffer.from(DEFAULT_BACKUP.coordinator_ieee, 'hex').reverse().toString('hex')}`; +const DEFAULT_COORDINATOR_IEEE: EUI64 = ZSpec.Utils.eui64LEBufferToHex(Buffer.from(DEFAULT_BACKUP.coordinator_ieee, 'hex')); const DEFAULT_ADAPTER_NETWORK_PARAMETERS: EmberNetworkParameters = { extendedPanId: DEFAULT_NETWORK_OPTIONS.extendedPanID!, panId: DEFAULT_NETWORK_OPTIONS.panID, @@ -231,10 +230,6 @@ const mockEzspGetVersionStruct = jest.fn().mockResolvedValue([ const mockEzspSetConfigurationValue = jest.fn().mockResolvedValue(SLStatus.OK); const mockEzspSetValue = jest.fn().mockResolvedValue(SLStatus.OK); const mockEzspSetPolicy = jest.fn().mockResolvedValue(SLStatus.OK); -const mockEzspAesMmoHash = jest.fn().mockImplementation((context: EmberAesMmoHashContext, finalize: boolean, data: Buffer) => [ - SLStatus.OK, - {result: data, length: data.length} as EmberAesMmoHashContext, // echo data -]); const mockEzspPermitJoining = jest.fn().mockImplementation((duration: number) => { setTimeout(async () => { mockEzspEmitter.emit('stackStatus', duration > 0 ? SLStatus.ZIGBEE_NETWORK_OPENED : SLStatus.ZIGBEE_NETWORK_CLOSED); @@ -319,7 +314,6 @@ jest.mock('../../../src/adapter/ember/ezsp/ezsp', () => ({ ezspSetConfigurationValue: mockEzspSetConfigurationValue, ezspSetValue: mockEzspSetValue, ezspSetPolicy: mockEzspSetPolicy, - ezspAesMmoHash: mockEzspAesMmoHash, ezspPermitJoining: mockEzspPermitJoining, ezspSendBroadcast: mockEzspSendBroadcast, ezspSendUnicast: mockEzspSendUnicast, @@ -374,7 +368,6 @@ const ezspMocks = [ mockEzspSetConfigurationValue, mockEzspSetValue, mockEzspSetPolicy, - mockEzspAesMmoHash, mockEzspPermitJoining, mockEzspSendBroadcast, mockEzspSendUnicast, @@ -871,6 +864,24 @@ describe('Ember Adapter Layer', () => { expect(mockEzspSetRadioPower).toHaveBeenCalledTimes(0); }); + it('Starts with mismatching transmit power, failure does not present start', async () => { + adapter = new EmberAdapter( + DEFAULT_NETWORK_OPTIONS, + DEFAULT_SERIAL_PORT_OPTIONS, + backupPath, + Object.assign({}, DEFAULT_ADAPTER_OPTIONS, {transmitPower: 12}), + ); + mockEzspSetRadioPower.mockResolvedValueOnce(SLStatus.FAIL); + + const result = adapter.start(); + + await jest.advanceTimersByTimeAsync(5000); + await expect(result).resolves.toStrictEqual('resumed'); + expect(mockEzspSetRadioPower).toHaveBeenCalledTimes(1); + expect(mockEzspSetRadioPower).toHaveBeenCalledWith(12); + expect(loggerSpies.error).toHaveBeenCalledWith(`Failed to set transmit power to 12 status=FAIL.`, 'zh:ember'); + }); + it('Fails to start when EZSP layer fails to start', async () => { adapter = new EmberAdapter(DEFAULT_NETWORK_OPTIONS, DEFAULT_SERIAL_PORT_OPTIONS, backupPath, DEFAULT_ADAPTER_OPTIONS); @@ -1725,6 +1736,7 @@ describe('Ember Adapter Layer', () => { psaKeyAlgPermission: 0, }; const k1 = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + const k1Hashed = ZSpec.Utils.aes128MmoHash(k1); const k1Metadata: SecManAPSKeyMetadata = { bitmask: EmberKeyStructBitmask.HAS_INCOMING_FRAME_COUNTER | EmberKeyStructBitmask.HAS_OUTGOING_FRAME_COUNTER, outgoingFrameCounter: 1, @@ -1741,6 +1753,7 @@ describe('Ember Adapter Layer', () => { psaKeyAlgPermission: 0, }; const k2 = Buffer.from([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]); + const k2Hashed = ZSpec.Utils.aes128MmoHash(k2); const k2Metadata: SecManAPSKeyMetadata = { bitmask: EmberKeyStructBitmask.HAS_INCOMING_FRAME_COUNTER | EmberKeyStructBitmask.HAS_OUTGOING_FRAME_COUNTER, outgoingFrameCounter: 10, @@ -1757,6 +1770,7 @@ describe('Ember Adapter Layer', () => { psaKeyAlgPermission: 0, }; const k3 = Buffer.from([3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]); + const k3Hashed = ZSpec.Utils.aes128MmoHash(k3); const k3Metadata: SecManAPSKeyMetadata = { bitmask: EmberKeyStructBitmask.HAS_INCOMING_FRAME_COUNTER | EmberKeyStructBitmask.HAS_OUTGOING_FRAME_COUNTER, outgoingFrameCounter: 100, @@ -1776,19 +1790,19 @@ describe('Ember Adapter Layer', () => { expect(keys).toStrictEqual([ { deviceEui64: k1Context.eui64, - key: {contents: k1}, + key: {contents: k1Hashed}, outgoingFrameCounter: k1Metadata.outgoingFrameCounter, incomingFrameCounter: k1Metadata.incomingFrameCounter, } as LinkKeyBackupData, { deviceEui64: k2Context.eui64, - key: {contents: k2}, + key: {contents: k2Hashed}, outgoingFrameCounter: k2Metadata.outgoingFrameCounter, incomingFrameCounter: k2Metadata.incomingFrameCounter, } as LinkKeyBackupData, { deviceEui64: k3Context.eui64, - key: {contents: k3}, + key: {contents: k3Hashed}, outgoingFrameCounter: k3Metadata.outgoingFrameCounter, incomingFrameCounter: k3Metadata.incomingFrameCounter, } as LinkKeyBackupData, @@ -1808,36 +1822,6 @@ describe('Ember Adapter Layer', () => { await expect(adapter.exportLinkKeys()).rejects.toThrow(`[BACKUP] Failed to retrieve key table size from NCP with status=FAIL.`); }); - it('Fails to export link keys due to failed AES hashing', async () => { - const k1Context: SecManContext = { - coreKeyType: SecManKeyType.APP_LINK, - keyIndex: 0, - derivedType: SecManDerivedKeyType.NONE, - eui64: '0x1122334455667788', - multiNetworkIndex: 0, - flags: SecManFlag.EUI_IS_VALID | SecManFlag.KEY_INDEX_IS_VALID, - psaKeyAlgPermission: 0, - }; - const k1 = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); - const k1Metadata: SecManAPSKeyMetadata = { - bitmask: EmberKeyStructBitmask.HAS_INCOMING_FRAME_COUNTER | EmberKeyStructBitmask.HAS_OUTGOING_FRAME_COUNTER, - outgoingFrameCounter: 1, - incomingFrameCounter: 2, - ttlInSeconds: 0, - }; - - mockEzspGetConfigurationValue.mockResolvedValueOnce([SLStatus.OK, 1]); - mockEzspExportLinkKeyByIndex.mockResolvedValueOnce([SLStatus.OK, k1Context, {contents: k1} as SecManKey, k1Metadata]); - mockEzspAesMmoHash.mockResolvedValueOnce([SLStatus.FAIL, {result: k1, length: k1.length} as EmberAesMmoHashContext]); - - await adapter.exportLinkKeys(); - - expect(loggerSpies.error).toHaveBeenCalledWith( - `[BACKUP] Failed to hash link key at index 0 with status=FAIL. Omitting from backup.`, - 'zh:ember', - ); - }); - it('Imports link keys', async () => { const k1Context: SecManContext = { coreKeyType: SecManKeyType.APP_LINK, @@ -2271,63 +2255,23 @@ describe('Ember Adapter Layer', () => { expect(mockEzspGetNetworkParameters).toHaveBeenCalledTimes(1); }); - it('Adapter impl: setTransmitPower', async () => { - await expect(adapter.setTransmitPower(10)).resolves.toStrictEqual(undefined); - expect(mockEzspSetRadioPower).toHaveBeenCalledTimes(1); - }); - - it('Adapter impl: throws when setTransmitPower fails', async () => { - mockEzspSetRadioPower.mockResolvedValueOnce(SLStatus.FAIL); - - await expect(adapter.setTransmitPower(10)).rejects.toThrow(`Failed to set transmit power to 10 status=FAIL.`); - expect(mockEzspSetRadioPower).toHaveBeenCalledTimes(1); - }); - - it('Adapter impl: addInstallCode without local CRC validation', async () => { - await expect(adapter.addInstallCode('0x1122334455667788', Buffer.alloc(16))).resolves.toStrictEqual(undefined); - expect(mockEzspAesMmoHash).toHaveBeenCalledTimes(1); - expect(mockEzspImportTransientKey).toHaveBeenCalledTimes(1); - expect(loggerSpies.debug).toHaveBeenCalledWith(`[ADD INSTALL CODE] Success for '0x1122334455667788'.`, 'zh:ember'); - }); - - it('Adapter impl: addInstallCode with local CRC validation', async () => { + it('Adapter impl: addInstallCode', async () => { await expect( adapter.addInstallCode('0x1122334455667788', Buffer.from('DD7ED5CDAA8E2C708B67D2B1573DB6843A5F', 'hex')), ).resolves.toStrictEqual(undefined); - expect(mockEzspAesMmoHash).toHaveBeenCalledTimes(1); expect(mockEzspImportTransientKey).toHaveBeenCalledTimes(1); - expect(loggerSpies.debug).toHaveBeenCalledWith(`[ADD INSTALL CODE] CRC validated for '0x1122334455667788'.`, 'zh:ember'); expect(loggerSpies.debug).toHaveBeenCalledWith(`[ADD INSTALL CODE] Success for '0x1122334455667788'.`, 'zh:ember'); }); - it('Adapter impl: throw when addInstallCode fails AES hashing', async () => { - mockEzspAesMmoHash.mockResolvedValueOnce([SLStatus.FAIL, Buffer.alloc(16)]); - - await expect(adapter.addInstallCode('0x1122334455667788', Buffer.alloc(16))).rejects.toThrow( - `[ADD INSTALL CODE] Failed AES hash for '0x1122334455667788' with status=FAIL.`, - ); - expect(mockEzspAesMmoHash).toHaveBeenCalledTimes(1); - expect(mockEzspImportTransientKey).toHaveBeenCalledTimes(0); - }); - it('Adapter impl: throw when addInstallCode fails import transient key', async () => { mockEzspImportTransientKey.mockResolvedValueOnce(SLStatus.FAIL); await expect(adapter.addInstallCode('0x1122334455667788', Buffer.alloc(16))).rejects.toThrow( `[ADD INSTALL CODE] Failed for '0x1122334455667788' with status=FAIL.`, ); - expect(mockEzspAesMmoHash).toHaveBeenCalledTimes(1); expect(mockEzspImportTransientKey).toHaveBeenCalledTimes(1); }); - it('Adapter impl: throw when addInstallCode fails local CRC validation', async () => { - await expect(adapter.addInstallCode('0x1122334455667788', Buffer.alloc(18))).rejects.toThrow( - `[ADD INSTALL CODE] Failed for '0x1122334455667788'; invalid code CRC.`, - ); - expect(mockEzspAesMmoHash).toHaveBeenCalledTimes(0); - expect(mockEzspImportTransientKey).toHaveBeenCalledTimes(0); - }); - it('Adapter impl: waitFor', async () => { const waiter = adapter.waitFor(1234, 1, Zcl.FrameType.GLOBAL, Zcl.Direction.CLIENT_TO_SERVER, 10, 0, 1, 15000); const spyCancel = jest.spyOn(waiter, 'cancel'); diff --git a/test/adapter/z-stack/adapter.test.ts b/test/adapter/z-stack/adapter.test.ts index 1048334dc6..6e98de4211 100644 --- a/test/adapter/z-stack/adapter.test.ts +++ b/test/adapter/z-stack/adapter.test.ts @@ -1384,9 +1384,6 @@ jest.mock('../../../src/utils/queue', () => { }); }); -Znp.isValidPath = jest.fn().mockReturnValue(true); -Znp.autoDetectPath = jest.fn().mockReturnValue('/dev/autodetected'); - const mocksClear = [mockLogger.debug, mockLogger.info, mockLogger.warning, mockLogger.error]; describe('zstack-adapter', () => { @@ -1647,7 +1644,7 @@ describe('zstack-adapter', () => { mockZnpRequestWith(empty3AlignedRequestMock); await adapter.start(); fs.writeFileSync(backupFile, JSON.stringify(backupWithMissingDevice), 'utf8'); - const devicesInDatabase = backupWithMissingDevice.devices.map((d) => `0x${d.ieee_address}`); + const devicesInDatabase = backupWithMissingDevice.devices.map((d) => ZSpec.Utils.eui64BEBufferToHex(d.ieee_address)); const backup = await adapter.backup(devicesInDatabase); const missingDevice = backup.devices.find((d) => d.ieeeAddress.toString('hex') == '00128d11124fa80b'); expect(missingDevice).not.toBeNull(); @@ -2078,17 +2075,6 @@ describe('zstack-adapter', () => { }); /* Original Tests */ - it('Is valid path', async () => { - const result = await ZStackAdapter.isValidPath('/dev/autodetected'); - expect(result).toBeTruthy(); - expect(Znp.isValidPath).toHaveBeenCalledWith('/dev/autodetected'); - }); - - it('Auto detect path', async () => { - const result = await ZStackAdapter.autoDetectPath(); - expect(result).toBe('/dev/autodetected'); - expect(Znp.autoDetectPath).toHaveBeenCalledTimes(1); - }); it('Call znp constructor', async () => { expect(Znp).toHaveBeenCalledWith('dummy', 800, false); @@ -2196,16 +2182,6 @@ describe('zstack-adapter', () => { expect(mockZnpRequest).toHaveBeenCalledWith(Subsystem.SYS, 'stackTune', {operation: 0, value: 2}); }); - it('Set transmit power', async () => { - basicMocks(); - await adapter.start(); - mockZnpRequest.mockClear(); - mockQueueExecute.mockClear(); - await adapter.setTransmitPower(15); - expect(mockZnpRequest).toHaveBeenCalledTimes(1); - expect(mockZnpRequest).toHaveBeenCalledWith(Subsystem.SYS, 'stackTune', {operation: 0, value: 15}); - }); - it('Support LED should go to false when LED request fails', async () => { basicMocks(); await adapter.start(); diff --git a/test/adapter/z-stack/znp.test.ts b/test/adapter/z-stack/znp.test.ts index d88804220f..b8045912ef 100644 --- a/test/adapter/z-stack/znp.test.ts +++ b/test/adapter/z-stack/znp.test.ts @@ -202,25 +202,6 @@ describe('ZNP', () => { expect(mockSerialPortOnce).toHaveBeenCalledTimes(2); }); - it('Open autodetect port', async () => { - mockSerialPortList.mockReturnValue([ - {manufacturer: 'Not texas instruments', vendorId: '0451', productId: '16a8', path: '/dev/autodetected2'}, - {path: '/dev/tty.usbmodemL43001T22', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T24', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T21', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - ]); - - expect(await Znp.autoDetectPath()).toBe('/dev/tty.usbmodemL43001T21'); - }); - - it('Autodetect port error when there are not available devices', async () => { - mockSerialPortList.mockReturnValue([ - {manufacturer: 'Not texas instruments', vendorId: '0451', productId: '16a8', path: '/dev/autodetected2'}, - ]); - - expect(await Znp.autoDetectPath()).toBeUndefined(); - }); - it('Open and close tcp port', async () => { znp = new Znp('tcp://localhost:8080', 100, false); await znp.open(); @@ -251,43 +232,6 @@ describe('ZNP', () => { expect(znp.isInitialized()).toBeFalsy(); }); - it('Check if tcp path is valid', async () => { - expect(await Znp.isValidPath('tcp://192.168.2.1:8080')).toBeFalsy(); - expect(await Znp.isValidPath('tcp://localhost:8080')).toBeFalsy(); - expect(await Znp.isValidPath('tcp://192.168.2.1')).toBeFalsy(); - expect(await Znp.isValidPath('tcp://localhost')).toBeFalsy(); - expect(await Znp.isValidPath('tcp')).toBeFalsy(); - }); - - it('Check if path is valid', async () => { - mockSerialPortList.mockReturnValue([ - {manufacturer: 'Not texas instruments', vendorId: '0451', productId: '16a8', path: '/dev/autodetected2'}, - {path: '/dev/tty.usbmodemL43001T22', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T24', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T21', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - ]); - - expect(await Znp.isValidPath('/dev/tty.usbmodemL43001T21')).toBeTruthy(); - expect(await Znp.isValidPath('/dev/autodetected2')).toBeFalsy(); - }); - - it('Check if path is valid; return false when path does not exist in device list', async () => { - mockSerialPortList.mockReturnValue([ - {manufacturer: 'Not texas instruments', vendorId: '0451', productId: '16a8', path: '/dev/autodetected2'}, - {path: '/dev/tty.usbmodemL43001T22', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T24', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - {path: '/dev/tty.usbmodemL43001T21', manufacturer: 'Texas Instruments', vendorId: '0451', productId: 'bef3'}, - ]); - - expect(await Znp.isValidPath('/dev/notexisting')).toBeFalsy(); - }); - - it('Check if path is valid path resolve fails', async () => { - mockRealPathSyncError = true; - expect(await Znp.isValidPath('/dev/tty.usbmodemL43001T21')).toBeFalsy(); - mockRealPathSyncError = false; - }); - it('Open with error', async () => { mockSerialPortAsyncOpen.mockImplementationOnce(() => { return new Promise((resolve, reject) => { diff --git a/test/controller.test.ts b/test/controller.test.ts index 6448afe0fe..311a928a50 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -64,7 +64,6 @@ const mockAdapterSupportsBackup = jest.fn().mockReturnValue(true); const mockAdapterReset = jest.fn(); const mockAdapterStop = jest.fn(); const mockAdapterStart = jest.fn().mockReturnValue('resumed'); -const mockAdapterSetTransmitPower = jest.fn(); const mockAdapterGetCoordinatorIEEE = jest.fn().mockReturnValue('0x0000012300000000'); const mockAdapterGetNetworkParameters = jest.fn().mockReturnValue({panID: 1, extendedPanID: 3, channel: 15}); const mocksendZclFrameToGroup = jest.fn(); @@ -399,7 +398,6 @@ jest.mock('../src/adapter/z-stack/adapter/zStackAdapter', () => { }, getNetworkParameters: mockAdapterGetNetworkParameters, waitFor: mockAdapterWaitFor, - setTransmitPower: mockAdapterSetTransmitPower, sendZclFrameToEndpoint: mocksendZclFrameToEndpoint, sendZclFrameToGroup: mocksendZclFrameToGroup, sendZclFrameToAll: mocksendZclFrameToAll, @@ -439,33 +437,7 @@ const getTempFile = (filename: string): string => { return path.join(TEMP_PATH, filename); }; -// Mock static methods -const mockZStackAdapterIsValidPath = jest.fn().mockReturnValue(true); -const mockZStackAdapterAutoDetectPath = jest.fn().mockReturnValue('/dev/autodetected'); -ZStackAdapter.isValidPath = mockZStackAdapterIsValidPath; -ZStackAdapter.autoDetectPath = mockZStackAdapterAutoDetectPath; - -const mockDeconzAdapterIsValidPath = jest.fn().mockReturnValue(true); -const mockDeconzAdapterAutoDetectPath = jest.fn().mockReturnValue('/dev/autodetected'); -DeconzAdapter.isValidPath = mockDeconzAdapterIsValidPath; -DeconzAdapter.autoDetectPath = mockDeconzAdapterAutoDetectPath; - -const mockZiGateAdapterIsValidPath = jest.fn().mockReturnValue(true); -const mockZiGateAdapterAutoDetectPath = jest.fn().mockReturnValue('/dev/autodetected'); -ZiGateAdapter.isValidPath = mockZiGateAdapterIsValidPath; -ZiGateAdapter.autoDetectPath = mockZiGateAdapterAutoDetectPath; - -const mocksRestore = [ - mockAdapterPermitJoin, - mockAdapterStop, - mocksendZclFrameToAll, - mockZStackAdapterIsValidPath, - mockZStackAdapterAutoDetectPath, - mockDeconzAdapterIsValidPath, - mockDeconzAdapterAutoDetectPath, - mockZiGateAdapterIsValidPath, - mockZiGateAdapterAutoDetectPath, -]; +const mocksRestore = [mockAdapterPermitJoin, mockAdapterStop, mocksendZclFrameToAll]; const events: { deviceJoined: Events.DeviceJoinedPayload[]; @@ -491,6 +463,7 @@ const events: { const backupPath = getTempFile('backup'); +const mockAcceptJoiningDeviceHandler = jest.fn((ieeeAddr: string): Promise => Promise.resolve(true)); const options = { network: { panID: 0x1a63, @@ -499,8 +472,8 @@ const options = { serialPort: { baudRate: 115200, rtscts: true, - path: '/dummy/conbee', - adapter: undefined, + path: '/dev/ttyUSB0', + adapter: 'zstack' as const, }, adapter: { disableLED: false, @@ -508,7 +481,7 @@ const options = { databasePath: getTempFile('database.db'), databaseBackupPath: getTempFile('database.db.backup'), backupPath, - acceptJoiningDeviceHandler: jest.fn().mockResolvedValue(true), + acceptJoiningDeviceHandler: mockAcceptJoiningDeviceHandler, }; const databaseContents = () => fs.readFileSync(options.databasePath).toString(); @@ -540,7 +513,11 @@ describe('Controller', () => { configureReportDefaultRsp = false; enroll170 = true; options.network.channelList = [15]; - Object.keys(events).forEach((key) => (events[key] = [])); + + for (const event in events) { + events[event] = []; + } + Device.resetCache(); Group.resetCache(); @@ -570,7 +547,7 @@ describe('Controller', () => { extendedPanID: [221, 221, 221, 221, 221, 221, 221, 221], channelList: [15], }, - {baudRate: 115200, path: '/dummy/conbee', rtscts: true, adapter: undefined}, + {baudRate: 115200, path: '/dev/ttyUSB0', rtscts: true, adapter: 'zstack'}, backupPath, {disableLED: false}, ); @@ -1637,12 +1614,6 @@ describe('Controller', () => { expect(changeChannelSpy).toHaveBeenCalledTimes(0); }); - it('Set transmit power', async () => { - await controller.start(); - await controller.setTransmitPower(15); - expect(mockAdapterSetTransmitPower).toHaveBeenCalledWith(15); - }); - it('Get coordinator version', async () => { await controller.start(); expect(await controller.getCoordinatorVersion()).toEqual({type: 'zStack', meta: {version: 1}}); @@ -1792,8 +1763,7 @@ describe('Controller', () => { }); it('Join a device and explictly accept it', async () => { - const mockAcceptJoiningDeviceHandler = jest.fn().mockReturnValue(true); - controller = new Controller({...options, acceptJoiningDeviceHandler: mockAcceptJoiningDeviceHandler}); + controller = new Controller(options); controller.on('deviceJoined', (device) => events.deviceJoined.push(device)); controller.on('deviceInterview', (device) => events.deviceInterview.push(deepClone(device))); await controller.start(); @@ -1870,8 +1840,8 @@ describe('Controller', () => { }); it('Join a device and explictly refuses it', async () => { - const mockAcceptJoiningDeviceHandler = jest.fn().mockReturnValue(false); - controller = new Controller({...options, acceptJoiningDeviceHandler: mockAcceptJoiningDeviceHandler}); + mockAcceptJoiningDeviceHandler.mockResolvedValueOnce(false); + controller = new Controller(options); controller.on('deviceJoined', (device) => events.deviceJoined.push(device)); controller.on('deviceInterview', (device) => events.deviceInterview.push(deepClone(device))); await controller.start(); @@ -1887,8 +1857,8 @@ describe('Controller', () => { }); it('Join a device and explictly refuses it but LEAVE request fails', async () => { - const mockAcceptJoiningDeviceHandler = jest.fn().mockReturnValue(false); - controller = new Controller({...options, acceptJoiningDeviceHandler: mockAcceptJoiningDeviceHandler}); + mockAcceptJoiningDeviceHandler.mockResolvedValueOnce(false); + controller = new Controller(options); controller.on('deviceJoined', (device) => events.deviceJoined.push(device)); controller.on('deviceInterview', (device) => events.deviceInterview.push(deepClone(device))); await controller.start(); @@ -2325,15 +2295,16 @@ describe('Controller', () => { ); }); - it('Add install code 16 byte', async () => { + it('Add install code 16 byte - missing CRC is appended', async () => { await controller.start(); const code = 'RB01SG0D836591B3CC0010000000000000000000000D6F00179F2BC9DLKD0F471C9BBA2C0208608E91EED17E2B1'; await controller.addInstallCode(code); expect(mockAddInstallCode).toHaveBeenCalledTimes(1); expect(mockAddInstallCode).toHaveBeenCalledWith( '0x000D6F00179F2BC9', - Buffer.from([0xd0, 0xf4, 0x71, 0xc9, 0xbb, 0xa2, 0xc0, 0x20, 0x86, 0x08, 0xe9, 0x1e, 0xed, 0x17, 0xe2, 0xb1]), + Buffer.from([0xd0, 0xf4, 0x71, 0xc9, 0xbb, 0xa2, 0xc0, 0x20, 0x86, 0x08, 0xe9, 0x1e, 0xed, 0x17, 0xe2, 0xb1, 0x9a, 0xec]), ); + expect(mockLogger.info).toHaveBeenCalledWith(`Install code was adjusted for reason 'missing CRC'.`, 'zh:controller'); }); it('Add install code Aqara', async () => { @@ -2358,88 +2329,146 @@ describe('Controller', () => { ); }); - it('Controller permit joining', async () => { + it('Add install code invalid', async () => { + await controller.start(); + + const code = '54EF44100006E7DF|3313A005E177A647FC7925620AB207'; + + expect(async () => { + await controller.addInstallCode(code); + }).rejects.toThrow(`Install code 3313a005e177a647fc7925620ab207 has invalid size`); + + expect(mockAddInstallCode).toHaveBeenCalledTimes(0); + }); + + it('Controller permit joining all, disabled automatically', async () => { await controller.start(); - await controller.permitJoin(true); + await controller.permitJoin(254); + + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(mockAdapterPermitJoin.mock.calls[0][0]).toStrictEqual(254); + expect(events.permitJoinChanged.length).toStrictEqual(1); + expect(events.permitJoinChanged[0]).toStrictEqual({permitted: true, timeout: 254}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(254); + + // Green power + const commisionFrameEnable = Zcl.Frame.create(1, 1, true, undefined, 2, 'commisioningMode', 33, {options: 0x0b, commisioningWindow: 254}, {}); + + expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(1); + expect(mocksendZclFrameToAll.mock.calls[0][0]).toStrictEqual(ZSpec.GP_ENDPOINT); + expect(deepClone(mocksendZclFrameToAll.mock.calls[0][1])).toStrictEqual(deepClone(commisionFrameEnable)); + expect(mocksendZclFrameToAll.mock.calls[0][2]).toStrictEqual(ZSpec.GP_ENDPOINT); + + await jest.advanceTimersByTimeAsync(250 * 1000); + + expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(1); expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); - expect(mockAdapterPermitJoin.mock.calls[0][0]).toBe(254); - expect(events.permitJoinChanged.length).toBe(1); - expect(events.permitJoinChanged[0]).toStrictEqual({permitted: true, reason: 'manual', timeout: undefined}); - expect(controller.getPermitJoin()).toBe(true); + expect(controller.getPermitJoinTimeout()).toStrictEqual(4); + + // Timer expired + await jest.advanceTimersByTimeAsync(10 * 1000); + + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(events.permitJoinChanged.length).toStrictEqual(255); + expect(events.permitJoinChanged[254]).toStrictEqual({permitted: false, timeout: 0}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(0); // Green power expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(1); + }); + + it('Controller permit joining all, disabled manually', async () => { + await controller.start(); + await controller.permitJoin(254); + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(mockAdapterPermitJoin.mock.calls[0][0]).toStrictEqual(254); + expect(events.permitJoinChanged.length).toStrictEqual(1); + expect(events.permitJoinChanged[0]).toStrictEqual({permitted: true, timeout: 254}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(254); + + // Green power const commisionFrameEnable = Zcl.Frame.create(1, 1, true, undefined, 2, 'commisioningMode', 33, {options: 0x0b, commisioningWindow: 254}, {}); - expect(mocksendZclFrameToAll.mock.calls[0][0]).toBe(ZSpec.GP_ENDPOINT); + + expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(1); + expect(mocksendZclFrameToAll.mock.calls[0][0]).toStrictEqual(ZSpec.GP_ENDPOINT); expect(deepClone(mocksendZclFrameToAll.mock.calls[0][1])).toStrictEqual(deepClone(commisionFrameEnable)); - expect(mocksendZclFrameToAll.mock.calls[0][2]).toBe(ZSpec.GP_ENDPOINT); + expect(mocksendZclFrameToAll.mock.calls[0][2]).toStrictEqual(ZSpec.GP_ENDPOINT); - // should call it again ever +- 200 seconds - jest.advanceTimersByTime(210 * 1000); - await flushPromises(); - expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(2); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(2); - expect(mockAdapterPermitJoin.mock.calls[1][0]).toBe(254); - jest.advanceTimersByTime(210 * 1000); - await flushPromises(); - expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(3); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(3); - expect(mockAdapterPermitJoin.mock.calls[2][0]).toBe(254); - expect(events.permitJoinChanged.length).toBe(1); - expect(controller.getPermitJoin()).toBe(true); + await jest.advanceTimersByTimeAsync(250 * 1000); + + expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(1); + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(events.permitJoinChanged.length).toStrictEqual(251); + expect(controller.getPermitJoinTimeout()).toStrictEqual(4); // Disable - await controller.permitJoin(false); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(4); - expect(mockAdapterPermitJoin.mock.calls[3][0]).toBe(0); - jest.advanceTimersByTime(210 * 1000); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(4); - expect(events.permitJoinChanged.length).toBe(2); - expect(events.permitJoinChanged[1]).toStrictEqual({permitted: false, reason: 'manual', timeout: undefined}); - expect(controller.getPermitJoin()).toBe(false); + await controller.permitJoin(0); + + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(2); + expect(mockAdapterPermitJoin.mock.calls[1][0]).toStrictEqual(0); + expect(events.permitJoinChanged.length).toStrictEqual(252); + expect(events.permitJoinChanged[251]).toStrictEqual({permitted: false, timeout: 0}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(0); // Green power - expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(4); - const commissionFrameDisable = Zcl.Frame.create(1, 1, true, undefined, 5, 'commisioningMode', 33, {options: 0x0a, commisioningWindow: 0}, {}); - expect(mocksendZclFrameToAll.mock.calls[3][0]).toBe(ZSpec.GP_ENDPOINT); - expect(deepClone(mocksendZclFrameToAll.mock.calls[3][1])).toStrictEqual(deepClone(commissionFrameDisable)); - expect(mocksendZclFrameToAll.mock.calls[3][2]).toBe(ZSpec.GP_ENDPOINT); - expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(4); + const commissionFrameDisable = Zcl.Frame.create(1, 1, true, undefined, 3, 'commisioningMode', 33, {options: 0x0a, commisioningWindow: 0}, {}); + + expect(mocksendZclFrameToAll).toHaveBeenCalledTimes(2); + expect(mocksendZclFrameToAll.mock.calls[1][0]).toStrictEqual(ZSpec.GP_ENDPOINT); + expect(deepClone(mocksendZclFrameToAll.mock.calls[1][1])).toStrictEqual(deepClone(commissionFrameDisable)); + expect(mocksendZclFrameToAll.mock.calls[1][2]).toStrictEqual(ZSpec.GP_ENDPOINT); }); it('Controller permit joining through specific device', async () => { await controller.start(); await mockAdapterEvents['deviceJoined']({networkAddress: 129, ieeeAddr: '0x129'}); - await controller.permitJoin(true, controller.getDeviceByIeeeAddr('0x129')); + await controller.permitJoin(254, controller.getDeviceByIeeeAddr('0x129')); + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); - expect(mockAdapterPermitJoin.mock.calls[0][0]).toBe(254); - expect(mockAdapterPermitJoin.mock.calls[0][1]).toBe(129); + expect(mockAdapterPermitJoin.mock.calls[0][0]).toStrictEqual(254); + expect(mockAdapterPermitJoin.mock.calls[0][1]).toStrictEqual(129); + expect(controller.getPermitJoinTimeout()).toStrictEqual(254); - jest.advanceTimersByTime(210 * 1000); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(2); - expect(mockAdapterPermitJoin.mock.calls[1][0]).toBe(254); - expect(mockAdapterPermitJoin.mock.calls[1][1]).toBe(129); + // Timer expired + await jest.advanceTimersByTimeAsync(300 * 1000); + + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(controller.getPermitJoinTimeout()).toStrictEqual(0); }); it('Controller permit joining for specific time', async () => { await controller.start(); - await controller.permitJoin(true, undefined, 10); + await controller.permitJoin(10); + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); - expect(mockAdapterPermitJoin.mock.calls[0][0]).toBe(254); - expect(events.permitJoinChanged.length).toBe(1); - expect(events.permitJoinChanged[0]).toStrictEqual({permitted: true, reason: 'manual', timeout: 10}); + expect(mockAdapterPermitJoin.mock.calls[0][0]).toStrictEqual(10); + expect(events.permitJoinChanged.length).toStrictEqual(1); + expect(events.permitJoinChanged[0]).toStrictEqual({permitted: true, timeout: 10}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(10); - // Timer ends - jest.advanceTimersByTime(5 * 1000); - await flushPromises(); - expect(controller.getPermitJoinTimeout()).toBe(5); - jest.advanceTimersByTime(7 * 1000); - await flushPromises(); - expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(2); - expect(mockAdapterPermitJoin.mock.calls[1][0]).toBe(0); - expect(events.permitJoinChanged.length).toBe(11); - expect(events.permitJoinChanged[5]).toStrictEqual({permitted: true, reason: 'manual', timeout: 5}); - expect(events.permitJoinChanged[10]).toStrictEqual({permitted: false, reason: 'timer_expired', timeout: undefined}); + await jest.advanceTimersByTimeAsync(5 * 1000); + + expect(events.permitJoinChanged.length).toStrictEqual(6); + expect(events.permitJoinChanged[5]).toStrictEqual({permitted: true, timeout: 5}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(5); + + // Timer expired + await jest.advanceTimersByTimeAsync(7 * 1000); + + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(1); + expect(events.permitJoinChanged.length).toStrictEqual(11); + expect(events.permitJoinChanged[10]).toStrictEqual({permitted: false, timeout: 0}); + expect(controller.getPermitJoinTimeout()).toStrictEqual(0); + }); + + it('Controller permit joining for too long time throws', async () => { + await controller.start(); + + expect(async () => { + await controller.permitJoin(255); + }).rejects.toThrow(`Cannot permit join for more than 254 seconds.`); + expect(mockAdapterPermitJoin).toHaveBeenCalledTimes(0); + expect(events.permitJoinChanged.length).toStrictEqual(0); }); it('Shouldnt create backup when adapter doesnt support it', async () => { @@ -7277,414 +7306,6 @@ describe('Controller', () => { expect(endpoint.getClusterAttributeValue('msOccupancySensing', 'occupancy')).toBe(0); }); - it('Adapter create', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(true); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: '/dev/bla', baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - expect(mockZStackAdapterIsValidPath).toHaveBeenCalledWith('/dev/bla'); - expect(ZStackAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/bla', rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - }); - - it('Adapter create continue when is valid path fails', async () => { - mockZStackAdapterIsValidPath.mockImplementationOnce(() => { - throw new Error('failed'); - }); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: '/dev/bla', baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - expect(mockZStackAdapterIsValidPath).toHaveBeenCalledWith('/dev/bla'); - expect(ZStackAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/bla', rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - }); - - it('Adapter create auto detect', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(true); - mockZStackAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: undefined, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - expect(ZStackAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/test', rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - }); - - it('Adapter mdns timeout test', async () => { - const fakeAdapterName = 'mdns_test_device'; - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual(new Error(`Coordinator [${fakeAdapterName}] not found after timeout of 2000ms!`)); - } - }); - - it('Adapter mdns without type test', async () => { - const fakeAdapterName = ''; - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual( - new Error(`No mdns device specified. You must specify the coordinator mdns service type after mdns://, e.g. mdns://my-adapter`), - ); - } - }); - - it('Adapter mdns wrong Zeroconf test', async () => { - const fakeAdapterName = 'mdns_test_device'; - const fakeIp = '111.111.111.111'; - const fakePort = 6638; - const fakeBaud = '115200'; - - // @ts-expect-error mock - Bonjour.prototype.findOne = function (opts?: BrowserConfig | undefined, timeout?: number, callback?: CallableFunction): Browser { - setTimeout(() => { - callback?.({name: 'fakeAdapter', type: fakeAdapterName, port: fakePort, addresses: [fakeIp], txt: {baud_rate: fakeBaud}}); - }, 200); - }; - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual( - new Error( - `Coordinator returned wrong Zeroconf format! The following values are expected:\n` + - `txt.radio_type, got: undefined\n` + - `txt.baud_rate, got: 115200\n` + - `address, got: 111.111.111.111\n` + - `port, got: 6638`, - ), - ); - } - }); - - it('Adapter mdns detection ezsp test', async () => { - const fakeAdapterName = 'mdns_test_device'; - const fakeIp = '111.111.111.111'; - const fakePort = 6638; - const fakeRadio = 'ezsp'; - const fakeBaud = '115200'; - - // @ts-expect-error mock - Bonjour.prototype.findOne = function (opts?: BrowserConfig | undefined, timeout?: number, callback?: CallableFunction): Browser { - setTimeout(() => { - callback?.({ - name: 'fakeAdapter', - type: fakeAdapterName, - port: fakePort, - addresses: [fakeIp], - txt: {radio_type: fakeRadio, baud_rate: fakeBaud}, - }); - }, 200); - }; - - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - - expect(mockLogger.info.mock.calls[0][0]).toBe(`Starting mdns discovery for coordinator: ${fakeAdapterName}`); - expect(mockLogger.info.mock.calls[1][0]).toBe(`Coordinator Ip: ${fakeIp}`); - expect(mockLogger.info.mock.calls[2][0]).toBe(`Coordinator Port: ${fakePort}`); - expect(mockLogger.info.mock.calls[3][0]).toBe(`Coordinator Radio: ${fakeRadio}`); - expect(mockLogger.info.mock.calls[4][0]).toBe(`Coordinator Baud: ${fakeBaud}\n`); - }); - - it('Adapter mdns detection unsupported adapter test', async () => { - const fakeAdapterName = 'mdns_test_device'; - const fakeIp = '111.111.111.111'; - const fakePort = 6638; - const fakeRadio = 'auto'; - const fakeBaud = '115200'; - - // @ts-expect-error mock - Bonjour.prototype.findOne = function (opts?: BrowserConfig | undefined, timeout?: number, callback?: CallableFunction): Browser { - setTimeout(() => { - callback?.({ - name: 'fakeAdapter', - type: fakeAdapterName, - port: fakePort, - addresses: [fakeIp], - txt: {radio_type: fakeRadio, baud_rate: fakeBaud}, - }); - }, 200); - }; - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual(new Error(`Adapter ${fakeRadio} is not supported.`)); - } - }); - - it('Adapter mdns detection zstack test', async () => { - const fakeAdapterName = 'mdns_test_device'; - const fakeIp = '111.111.111.111'; - const fakePort = 6638; - const fakeRadio = 'znp'; - const fakeBaud = '115200'; - - // @ts-expect-error mock - Bonjour.prototype.findOne = function (opts?: BrowserConfig | undefined, timeout?: number, callback?: CallableFunction): Browser { - setTimeout(() => { - callback?.({ - name: 'fakeAdapter', - type: fakeAdapterName, - port: fakePort, - addresses: [fakeIp], - txt: {radio_type: fakeRadio, baud_rate: fakeBaud}, - }); - }, 200); - }; - - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: `mdns://${fakeAdapterName}`, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - - expect(mockLogger.info.mock.calls[0][0]).toBe(`Starting mdns discovery for coordinator: ${fakeAdapterName}`); - expect(mockLogger.info.mock.calls[1][0]).toBe(`Coordinator Ip: ${fakeIp}`); - expect(mockLogger.info.mock.calls[2][0]).toBe(`Coordinator Port: ${fakePort}`); - expect(mockLogger.info.mock.calls[3][0]).toBe(`Coordinator Radio: zstack`); - expect(mockLogger.info.mock.calls[4][0]).toBe(`Coordinator Baud: ${fakeBaud}\n`); - }); - - it('Adapter create auto detect nothing found', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(false); - mockZStackAdapterAutoDetectPath.mockReturnValueOnce(null); - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: undefined, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual(new Error('No path provided and failed to auto detect path')); - } - }); - - it('Adapter create with unknown path should take ZStackAdapter by default', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(false); - mockZStackAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: undefined, baudRate: 100, rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - expect(ZStackAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/test', rtscts: false, adapter: undefined}, - 'test.db', - { - disableLED: false, - }, - ); - }); - - it('Adapter create should be able to specify adapter', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(false); - mockZStackAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - mockDeconzAdapterIsValidPath.mockReturnValueOnce(false); - mockDeconzAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - mockZiGateAdapterIsValidPath.mockReturnValueOnce(false); - mockZiGateAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: undefined, baudRate: 100, rtscts: false, adapter: 'deconz'}, - 'test.db', - { - disableLED: false, - }, - ); - expect(DeconzAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/test', rtscts: false, adapter: 'deconz'}, - 'test.db', - { - disableLED: false, - }, - ); - await Adapter.create( - { - panID: 0, - channelList: [], - }, - {path: undefined, baudRate: 100, rtscts: false, adapter: 'zigate'}, - 'test.db', - { - disableLED: false, - }, - ); - expect(ZiGateAdapter).toHaveBeenCalledWith( - { - panID: 0, - channelList: [], - }, - {baudRate: 100, path: '/dev/test', rtscts: false, adapter: 'zigate'}, - 'test.db', - { - disableLED: false, - }, - ); - }); - - it('Adapter create should throw on uknown adapter', async () => { - mockZStackAdapterIsValidPath.mockReturnValueOnce(false); - mockZStackAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - mockDeconzAdapterIsValidPath.mockReturnValueOnce(false); - mockDeconzAdapterAutoDetectPath.mockReturnValueOnce('/dev/test'); - - try { - await Adapter.create( - { - panID: 0, - channelList: [], - }, - { - path: undefined, - baudRate: 100, - rtscts: false, - // @ts-expect-error bad on purpose - adapter: 'efr', - }, - 'test.db', - { - disableLED: false, - }, - ); - } catch (e) { - expect(e).toStrictEqual(new Error(`Adapter 'efr' does not exists, possible options: zstack, deconz, zigate, ezsp, ember, zboss`)); - } - }); - it('Emit read from device', async () => { await controller.start(); await mockAdapterEvents['deviceJoined']({networkAddress: 129, ieeeAddr: '0x129'}); @@ -8458,6 +8079,7 @@ describe('Controller', () => { it('Should handle comissioning frame gracefully', async () => { await controller.start(); + mockLogger.error.mockClear(); const buffer = Buffer.from([25, 10, 2, 11, 254, 0]); const frame = Zcl.Frame.fromBuffer(Zcl.Clusters.greenPower.ID, Zcl.Header.fromBuffer(buffer)!, buffer, {}); await mockAdapterEvents['zclPayload']({ @@ -10246,15 +9868,6 @@ describe('Controller', () => { }); }); - it('Adapter permitJoin fails to keep alive', async () => { - await controller.start(); - mockAdapterPermitJoin.mockResolvedValueOnce(undefined).mockRejectedValueOnce('timeout'); - await controller.permitJoin(true); - await jest.advanceTimersByTimeAsync(240 * 1000); - - expect(mockLogger.error).toHaveBeenCalledWith(`Failed to keep permit join alive: timeout`, 'zh:controller'); - }); - it('Adapter permitJoin fails during stop', async () => { await controller.start(); mockAdapterPermitJoin.mockRejectedValueOnce('timeout'); diff --git a/test/mockAdapters.ts b/test/mockAdapters.ts new file mode 100644 index 0000000000..aa5ff84b16 --- /dev/null +++ b/test/mockAdapters.ts @@ -0,0 +1,57 @@ +export const DECONZ_CONBEE_II = { + path: '/dev/serial/by-id/usb-dresden_elektronik_ingenieurtechnik_GmbH_ConBee_II_DE2132111-if00', + vendorId: '1cf1', + productId: '0030', + manufacturer: 'dresden elektronik ingenieurtechnik GmbH', +}; +export const EMBER_ZBDONGLE_E = { + path: '/dev/serial/by-id/usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_20240122184111-if00', + vendorId: '1A86', // uppercased for extra coverage + productId: '55d4', + manufacturer: 'ITEAD', +}; +// vendorId+productId conflict with all 10c4:ea60 +export const EMBER_SKYCONNECT = { + path: '/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3abe54797c91ed118fc3cad13b20a111-if00-port0', + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'Nabu Casa', +}; +export const ZSTACK_CC2538 = { + path: '/dev/serial/by-id/usb-Texas_Instruments_CC2538_USB_CDC-if00', + vendorId: '0451', + productId: '16C8', // uppercased for extra coverage + manufacturer: 'Texas Instruments', +}; +// vendorId+productId conflict with all 10c4:ea60 +export const ZSTACK_ZBDONGLE_P = { + path: '/dev/serial/by-id/usb-Silicon_Labs_Sonoff_Zigbee_3.0_USB_Dongle_Plus_0111-if00-port0', + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'ITEAD', +}; +// vendorId+productId conflict with all 10c4:ea60 +export const ZSTACK_SMLIGHT_SLZB_06P10 = { + path: '/dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-06p10_40df2f3e3977ed11b142f6fafdf7b791-if00-port0', + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', +}; +// vendorId+productId conflict with all 10c4:ea60 +export const ZSTACK_SMLIGHT_SLZB_07 = { + path: '/dev/serial/by-id/usb-SMLIGHT_SMLIGHT_SLZB-07_be9faa0786e1ea11bd68dc2d9a583111-if00-port0', + vendorId: '10c4', + productId: 'ea60', + manufacturer: 'SMLIGHT', +}; +export const ZBOSS_NORDIC = { + path: '/dev/serial/by-id/usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00', + vendorId: '2fe3', + productId: '0100', + manufacturer: 'ZEPHYR', +}; +export const ZIGATE_PLUSV2 = { + path: '/dev/serial/by-id/usb-FTDI_ZiGate_ZIGATE+-if00-port0', + vendorId: '0403', + productId: '6015', +}; diff --git a/test/zspec/utils.test.ts b/test/zspec/utils.test.ts index 7c5d0e147e..b58c754559 100644 --- a/test/zspec/utils.test.ts +++ b/test/zspec/utils.test.ts @@ -5,10 +5,12 @@ describe('ZSpec Utils', () => { expect(ZSpec.Utils.channelsToUInt32Mask(ZSpec.ALL_802_15_4_CHANNELS)).toStrictEqual(ZSpec.ALL_802_15_4_CHANNELS_MASK); expect(ZSpec.Utils.channelsToUInt32Mask(ZSpec.PREFERRED_802_15_4_CHANNELS)).toStrictEqual(ZSpec.PREFERRED_802_15_4_CHANNELS_MASK); }); + it('Converts channels uint32 mask to number array', () => { expect(ZSpec.Utils.uint32MaskToChannels(ZSpec.ALL_802_15_4_CHANNELS_MASK)).toStrictEqual(ZSpec.ALL_802_15_4_CHANNELS); expect(ZSpec.Utils.uint32MaskToChannels(ZSpec.PREFERRED_802_15_4_CHANNELS_MASK)).toStrictEqual(ZSpec.PREFERRED_802_15_4_CHANNELS); }); + it('Checks if address is broadcast', () => { expect(ZSpec.Utils.isBroadcastAddress(ZSpec.BroadcastAddress.DEFAULT)).toBeTruthy(); expect(ZSpec.Utils.isBroadcastAddress(ZSpec.BroadcastAddress.RX_ON_WHEN_IDLE)).toBeTruthy(); @@ -16,4 +18,108 @@ describe('ZSpec Utils', () => { expect(ZSpec.Utils.isBroadcastAddress(ZSpec.BroadcastAddress.LOW_POWER_ROUTERS)).toBeTruthy(); expect(ZSpec.Utils.isBroadcastAddress(0x0f30)).toBeFalsy(); }); + + it('Converts EUI64 LE buffer to 0x...', () => { + const buffer = Buffer.from([0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]); + + expect(ZSpec.Utils.eui64LEBufferToHex(buffer)).toStrictEqual(`0x1122334455667788`); + // reference not reversed + expect(buffer.toString('hex')).toStrictEqual(`8877665544332211`); + }); + + it('Converts EUI64 BE buffer to 0x...', () => { + const buffer = Buffer.from([0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]); + + expect(ZSpec.Utils.eui64BEBufferToHex(buffer)).toStrictEqual(`0x8877665544332211`); + }); + + it('Calculates CRC variants', () => { + // see https://www.crccalc.com/ + const val1 = Buffer.from('83FED3407A939723A5C639FF4C12', 'hex').subarray(0, -2); // crc-appended + + expect(ZSpec.Utils.crc16X25(val1)).toStrictEqual(0x124c); + expect(ZSpec.Utils.crc16XMODEM(val1)).toStrictEqual(0xc4b8); + expect(ZSpec.Utils.crc16CCITT(val1)).toStrictEqual(0x7292); + expect(ZSpec.Utils.crc16CCITTFALSE(val1)).toStrictEqual(0x4041); + + const val2 = Buffer.from('83FED3407A939723A5C639B26916D505C3B5', 'hex').subarray(0, -2); // crc-appended + + expect(ZSpec.Utils.crc16X25(val2)).toStrictEqual(0xb5c3); + expect(ZSpec.Utils.crc16XMODEM(val2)).toStrictEqual(0xff08); + expect(ZSpec.Utils.crc16CCITT(val2)).toStrictEqual(0x1a6a); + expect(ZSpec.Utils.crc16CCITTFALSE(val2)).toStrictEqual(0x9502); + }); + + it('Hashes using AES-128-MMO', () => { + const val1 = Buffer.from('83FED3407A939723A5C639FF4C12', 'hex'); + // Example from Zigbee spec + const val2 = Buffer.from('83FED3407A939723A5C639B26916D505C3B5', 'hex'); + + expect(ZSpec.Utils.aes128MmoHash(val1)).toStrictEqual(Buffer.from('58C1828CF7F1C3FE29E7B1024AD84BFA', 'hex')); + expect(ZSpec.Utils.aes128MmoHash(val2)).toStrictEqual(Buffer.from('66B6900981E1EE3CA4206B6B861C02BB', 'hex')); + }); + + it('Checks install codes of all lengths', () => { + expect(() => ZSpec.Utils.checkInstallCode(Buffer.from('001122', 'hex'))).toThrow(`Install code 001122 has invalid size`); + + const code8Valid = Buffer.from('83FED3407A932B70', 'hex'); + const code8Invalid = Buffer.from('FFFED3407A939723', 'hex'); + const code8InvalidFixed = Buffer.from('FFFED3407A93DE84', 'hex'); + const code8MissingCRC = Buffer.from('83FED3407A93', 'hex'); + + expect(ZSpec.Utils.checkInstallCode(code8Valid)).toStrictEqual([code8Valid, undefined]); + expect(ZSpec.Utils.checkInstallCode(code8Invalid)).toStrictEqual([code8InvalidFixed, 'invalid CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code8Invalid, false)).toThrow(`Install code ${code8Invalid.toString('hex')} failed CRC validation`); + expect(ZSpec.Utils.checkInstallCode(code8MissingCRC)).toStrictEqual([code8Valid, 'missing CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code8MissingCRC, false)).toThrow( + `Install code ${code8MissingCRC.toString('hex')} failed CRC validation`, + ); + + const code10Valid = Buffer.from('83FED3407A93972397FC', 'hex'); + const code10Invalid = Buffer.from('FFFED3407A939723A5C6', 'hex'); + const code10InvalidFixed = Buffer.from('FFFED3407A9397238C4F', 'hex'); + // consired as 8-length with invalid CRC + const code10MissingCRC = Buffer.from('83FED3407A939723', 'hex'); + const code10MissingCRCFixed = Buffer.from('83FED3407A932B70', 'hex'); + + expect(ZSpec.Utils.checkInstallCode(code10Valid)).toStrictEqual([code10Valid, undefined]); + expect(ZSpec.Utils.checkInstallCode(code10Invalid)).toStrictEqual([code10InvalidFixed, 'invalid CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code10Invalid, false)).toThrow( + `Install code ${code10Invalid.toString('hex')} failed CRC validation`, + ); + expect(ZSpec.Utils.checkInstallCode(code10MissingCRC)).toStrictEqual([code10MissingCRCFixed, 'invalid CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code10MissingCRC, false)).toThrow( + `Install code ${code10MissingCRC.toString('hex')} failed CRC validation`, + ); + + const code14Valid = Buffer.from('83FED3407A939723A5C639FF4C12', 'hex'); + const code14Invalid = Buffer.from('FFFED3407A939723A5C639FF4C12', 'hex'); + const code14InvalidFixed = Buffer.from('FFFED3407A939723A5C639FFDE74', 'hex'); + const code14MissingCRC = Buffer.from('83FED3407A939723A5C639FF', 'hex'); + + expect(ZSpec.Utils.checkInstallCode(code14Valid)).toStrictEqual([code14Valid, undefined]); + expect(ZSpec.Utils.checkInstallCode(code14Invalid)).toStrictEqual([code14InvalidFixed, 'invalid CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code14Invalid, false)).toThrow( + `Install code ${code14Invalid.toString('hex')} failed CRC validation`, + ); + expect(ZSpec.Utils.checkInstallCode(code14MissingCRC)).toStrictEqual([code14Valid, 'missing CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code14MissingCRC, false)).toThrow( + `Install code ${code14MissingCRC.toString('hex')} failed CRC validation`, + ); + + const code18Valid = Buffer.from('83FED3407A939723A5C639B26916D505C3B5', 'hex'); + const code18Invalid = Buffer.from('FFFED3407A939723A5C639B26916D505C3B5', 'hex'); + const code18InvalidFixed = Buffer.from('FFFED3407A939723A5C639B26916D505EEB1', 'hex'); + const code18MissingCRC = Buffer.from('83FED3407A939723A5C639B26916D505', 'hex'); + + expect(ZSpec.Utils.checkInstallCode(code18Valid)).toStrictEqual([code18Valid, undefined]); + expect(ZSpec.Utils.checkInstallCode(code18Invalid)).toStrictEqual([code18InvalidFixed, 'invalid CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code18Invalid, false)).toThrow( + `Install code ${code18Invalid.toString('hex')} failed CRC validation`, + ); + expect(ZSpec.Utils.checkInstallCode(code18MissingCRC)).toStrictEqual([code18Valid, 'missing CRC']); + expect(() => ZSpec.Utils.checkInstallCode(code18MissingCRC, false)).toThrow( + `Install code ${code18MissingCRC.toString('hex')} failed CRC validation`, + ); + }); });