UNPKG

zigbee-herdsman

Version:

An open source ZigBee gateway solution with node.js.

654 lines (582 loc) 23.1 kB
import type {Adapter, DiscoverableUsbAdapter, UsbAdapterFingerprint} from "./tstype"; import assert from "node:assert"; import {platform} from "node:os"; import type {PortInfo} from "@serialport/bindings-cpp"; import {Bonjour, type Service} from "bonjour-service"; import {wait} from "../utils"; import {logger} from "../utils/logger"; import {SerialPort} from "./serialPort"; const NS = "zh:adapter:discovery"; const enum UsbFingerprintMatchScore { None = 0, VidPid = 1, VidPidManuf = 2, VidPidPath = 3, VidPidManufPath = 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<DiscoverableUsbAdapter, UsbAdapterFingerprint[]> = { 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 (CH variant) 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.*", }, { // Sonoff ZBDongle-E V2 (CP variant) vendorId: "10c4", productId: "ea60", manufacturer: "ITEAD", // /dev/serial/by-id/usb-Itead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_V2_a6ee897e4d1fef11aa004ad0639e525b-if00-port0 pathRegex: ".*sonoff.*plus_v2_.*", }, { // Sonoff ZBDongle-M vendorId: "10c4", productId: "ea60", manufacturer: "SONOFF", // /dev/serial/by-id/usb-SONOFF_SONOFF_Dongle_Max_MG24_08965d6b0674ef11b2f4e61e313510fd-if00-port0 pathRegex: ".*sonoff.*max.*", }, // { // // 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(?!_v2_).*", }, { // 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<string /* vendorId:productId */> = ["10c4:ea60"]; /** Time allotted for mDNS scanning */ const MDNS_SCAN_TIME = 2000; async function getSerialPortList(): Promise<PortInfo[]> { 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. /* v8 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 undefined; } 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.VidPid && isWindows) { match = entry; score = UsbFingerprintMatchScore.VidPid; } if ( score < UsbFingerprintMatchScore.VidPidManuf && entry.manufacturer && portInfo.manufacturer && matchString(portInfo.manufacturer, entry.manufacturer) ) { match = entry; score = UsbFingerprintMatchScore.VidPidManuf; 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.VidPidPath && entry.pathRegex && (matchRegex(entry.pathRegex, portInfo.path) || matchRegex(entry.pathRegex, portInfo.pnpId)) ) { if (score === UsbFingerprintMatchScore.VidPidManuf) { // best possible match, return early return [portInfo.path, UsbFingerprintMatchScore.VidPidManufPath]; } match = entry; score = UsbFingerprintMatchScore.VidPidPath; } } // poor match only returned if port info not conflict-prone if (match) { if (score > UsbFingerprintMatchScore.VidPid) { // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` if (conflictProne && score < UsbFingerprintMatchScore.VidPidPath && matchString(match.manufacturer!, "itead")) { // can't trust metadata "only" on sonoff dongles with conflicts return undefined; } return [portInfo.path, score]; } if (!conflictProne) { return [portInfo.path, score]; } } return undefined; } export async function matchUsbAdapter(adapter: Adapter, path: string): Promise<boolean> { // no point in matching this if (adapter === "zoh") { return false; } const isWindows = platform() === "win32"; const portList = await getSerialPortList(); logger.debug(() => `Connected devices: ${JSON.stringify(portList)}`, NS); for (const portInfo of portList) { 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); if (match) { logger.info(() => `Matched adapter: ${JSON.stringify(portInfo)} => ${adapter}: ${JSON.stringify(match[1])}`, NS); return true; } } return false; } export function findUsbAdapterBestMatch( adapter: Adapter | undefined, portInfo: PortInfo, isWindows: boolean, conflictProne: boolean, ): [DiscoverableUsbAdapter, NonNullable<ReturnType<typeof matchUsbFingerprint>>] | undefined { let bestMatch: [DiscoverableUsbAdapter, NonNullable<ReturnType<typeof matchUsbFingerprint>>] | undefined; for (const key in USB_FINGERPRINTS) { if (adapter && adapter !== key) { continue; } // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress` 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.VidPidManufPath) { // got best possible match, exit loop break; } } } return bestMatch; } 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}`); const bestMatch = findUsbAdapterBestMatch(adapter, portInfo, isWindows, conflictProne); 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]]; } } } function getMdnsRadioAdapter(radio: string): Adapter { switch (radio) { case "znp": return "zstack"; case "ezsp": return "ember"; default: return radio as Adapter; } } 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(); logger.info(`Starting mdns discovery for coordinator: ${mdnsDevice}`, NS); return await new Promise((resolve, reject) => { bj.findOne({type: mdnsDevice}, MDNS_SCAN_TIME, (service: Service) => { if (service) { if (service.txt?.radio_type && service.port) { const mdnsAddress = service.addresses?.[0] ?? service.host; const mdnsPort = service.port; const mdnsAdapter = getMdnsRadioAdapter(service.txt.radio_type); 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:\ntxt.radio_type, got: ${service.txt?.radio_type}\nport, got: ${service.port}`, ), ); } } else { bj.destroy(); reject(new Error(`Coordinator [${mdnsDevice}] not found after timeout of ${MDNS_SCAN_TIME}ms!`)); } }); }); } export function findTcpAdapter(path: string, adapter?: Adapter): [adapter: Adapter, path: string] { try { const url = new URL(path); assert(url.port !== ""); } catch { throw new Error("Invalid TCP path, expected format: tcp://<host>:<port>"); } 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); } if (path.startsWith("tcp://") || path.startsWith("socket://")) { return findTcpAdapter(path, adapter); } if (adapter) { try { const matched = await matchUsbAdapter(adapter, path); 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.`); } } /** * @returns List of all serial and mDNS devices found, with matching `adapter` if available */ export async function findAllDevices(): Promise<{name: string; path: string; adapter?: Adapter}[]> { const devices: {name: string; path: string; adapter?: Adapter}[] = []; const isWindows = platform() === "win32"; try { const portList = await getSerialPortList(); for (const portInfo of portList) { // override matching on Windows, too many chances of mismatch due to lacking data const bestMatch = isWindows ? undefined : findUsbAdapterBestMatch( undefined, portInfo, isWindows, USB_FINGERPRINTS_CONFLICT_IDS.includes(`${portInfo.vendorId}:${portInfo.productId}`), ); // @ts-expect-error friendlyName Windows only const friendlyName = portInfo.friendlyName ?? portInfo.pnpId; devices.push({ name: `${friendlyName} (${portInfo.manufacturer})`, path: portInfo.path, adapter: bestMatch ? bestMatch[0] : undefined, }); } /* v8 ignore start */ } catch (error) { logger.debug(`Failed to retrieve serial list ${(error as Error).message}.`, NS); } /* v8 ignore stop */ try { const bonjour = new Bonjour(); const browser = bonjour.find(null, (service) => { if (service.txt?.radio_type) { const path = `tcp://${service.addresses?.[0] ?? service.host}:${service.port}`; devices.push({ name: `${service.name ?? service.txt.name ?? "Unknown"} (${path})`, path, adapter: getMdnsRadioAdapter(service.txt.radio_type), }); } }); browser.start(); await wait(MDNS_SCAN_TIME); browser.stop(); bonjour.destroy(); /* v8 ignore start */ } catch (error) { logger.debug(`Failed to retrieve mDNS list ${(error as Error).message}.`, NS); } /* v8 ignore stop */ return devices; }