Skip to content

Commit

Permalink
Update + tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
Nerivec committed Sep 23, 2024
1 parent 456a48f commit edb7e2f
Show file tree
Hide file tree
Showing 20 changed files with 604 additions and 898 deletions.
122 changes: 9 additions & 113 deletions src/adapter/adapter.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -60,15 +56,6 @@ abstract class Adapter extends events.EventEmitter<AdapterEventMap> {
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,
Expand All @@ -78,111 +65,20 @@ abstract class Adapter extends events.EventEmitter<AdapterEventMap> {
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);
}

// 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;
}
}
const [adapter, path, baudRate] = await discoverAdapter(serialPortOptions.adapter, serialPortOptions.path);

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 (adapterLookup[adapter]) {
serialPortOptions.adapter = adapter;
serialPortOptions.path = path;

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`);
if (baudRate !== undefined) {
serialPortOptions.baudRate = baudRate;
}

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!`));
}
});
});
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<TsType.StartResult>;
Expand Down
113 changes: 76 additions & 37 deletions src/adapter/adapterDiscovery.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import assert from 'assert';
import {platform} from 'os';

import {PortInfo} from '@serialport/bindings-cpp';
import Bonjour, {Service} from 'bonjour-service';
import {Bonjour, Service} from 'bonjour-service';

import {logger} from '../utils/logger';
import {SerialPort} from './serialPort';
import {SerialPortOptions} from './tstype';
import {Adapter, DiscoverableUSBAdapter, USBAdapterFingerprint, ValidAdapter} from './tstype';

const NS = 'zh:adapter:discovery';

type Adapter = NonNullable<SerialPortOptions['adapter']>;
type DiscoverableUSBAdapter = 'deconz' | 'ember' | 'zstack' | 'zboss' | 'zigate';
type USBFingerprint = {
vendorId: string;
productId: string;
manufacturer?: string;
pathRegex?: string;
};

/**
* @see https://serialport.io/docs/api-bindings-cpp#list
*
Expand All @@ -31,7 +21,7 @@ type USBFingerprint = {
*
* 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, USBFingerprint[]> = {
const USB_FINGERPRINTS: Record<DiscoverableUSBAdapter, USBAdapterFingerprint[]> = {
deconz: [
{
// Conbee II
Expand Down Expand Up @@ -113,7 +103,7 @@ const USB_FINGERPRINTS: Record<DiscoverableUSBAdapter, USBFingerprint[]> = {
vendorId: '0403',
productId: '6015',
manufacturer: 'Electrolama',
// pathRegex: '.*.*',
pathRegex: '.*electrolame.*', // TODO
},
{
// slae.sh cc2652rb
Expand Down Expand Up @@ -148,11 +138,18 @@ const USB_FINGERPRINTS: Record<DiscoverableUSBAdapter, USBFingerprint[]> = {
pathRegex: '.*CC2531.*',
},
{
// CC1352P_2 and CC26X2R1
// CC1352P_2
vendorId: '0451',
productId: 'bef3',
manufacturer: 'Texas Instruments',
pathRegex: '.*CC1352P_2.*', // TODO
},
{
// CC26X2R1
vendorId: '0451',
productId: 'bef3',
manufacturer: 'Texas Instruments',
// pathRegex: '.*.*',
pathRegex: '.*CC26X2R1.*', // TODO
},
{
// SMLight slzb-07p7
Expand Down Expand Up @@ -190,7 +187,7 @@ const USB_FINGERPRINTS: Record<DiscoverableUSBAdapter, USBFingerprint[]> = {
vendorId: '2fe3',
productId: '0100',
manufacturer: 'ZEPHYR',
// pathRegex: '.*.*',
pathRegex: '.*ZEPHYR.*', // TODO
},
],
zigate: [
Expand Down Expand Up @@ -219,7 +216,25 @@ const USB_FINGERPRINTS: Record<DiscoverableUSBAdapter, USBFingerprint[]> = {
],
};

function matchUSBFingerprint(portInfo: PortInfo, isWindows: boolean, entries: USBFingerprint[]): [PortInfo['path'], USBFingerprint] | undefined {
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.
/* istanbul ignore next */
portInfos.sort((a, b) => (a.path < b.path ? -1 : 1));

return portInfos;
}

function matchUSBFingerprint(
portInfo: PortInfo,
isWindows: boolean,
entries: USBAdapterFingerprint[],
): [PortInfo['path'], USBAdapterFingerprint] | undefined {
if (!portInfo.vendorId || !portInfo.productId) {
// port info is missing essential information for proper matching, ignore it
return;
Expand All @@ -240,24 +255,32 @@ function matchUSBFingerprint(portInfo: PortInfo, isWindows: boolean, entries: US
}
}

export async function findUSBAdapter(adapter?: Adapter, path?: string): Promise<[adapter: Adapter, path: PortInfo['path']] | undefined> {
assert(adapter !== 'auto' && adapter !== 'ezsp', `Cannot discover USB adapter for '${adapter}'.`);

export async function matchUSBAdapter(adapter: ValidAdapter, path: string): Promise<boolean> {
const isWindows = platform() === 'win32';

for (const portInfo of await SerialPort.list()) {
if (path && portInfo.path !== path) {
for (const portInfo of await getSerialPortList()) {
/* istanbul ignore else */
if (portInfo.path !== path) {
continue;
}

if (adapter) {
const match = matchUSBFingerprint(portInfo, isWindows, USB_FINGERPRINTS[adapter]);
const match = matchUSBFingerprint(portInfo, isWindows, USB_FINGERPRINTS[adapter === 'ezsp' ? 'ember' : adapter]);

if (match) {
logger.info(`Matched adapter: ${JSON.stringify(portInfo)} => ${adapter}: ${JSON.stringify(match[1])}`, NS);
return [adapter, match[0]];
}
/* 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(path?: string): Promise<[adapter: DiscoverableUSBAdapter, path: PortInfo['path']] | undefined> {
const isWindows = platform() === 'win32';

for (const portInfo of await getSerialPortList()) {
if (path && portInfo.path !== path) {
continue;
}

Expand All @@ -266,13 +289,13 @@ export async function findUSBAdapter(adapter?: Adapter, path?: string): Promise<

if (match) {
logger.info(`Matched adapter: ${JSON.stringify(portInfo)} => ${key}: ${JSON.stringify(match[1])}`, NS);
return [key as Adapter, match[0]];
return [key as DiscoverableUSBAdapter, match[0]];
}
}
}
}

export async function findmDNSAdapter(path: string): Promise<[adapter: string, path: string, baudRate: number]> {
export async function findmDNSAdapter(path: string): Promise<[adapter: ValidAdapter, path: string, baudRate: number]> {
const mdnsDevice = path.substring(7);

if (mdnsDevice.length == 0) {
Expand Down Expand Up @@ -328,10 +351,14 @@ export async function findmDNSAdapter(path: string): Promise<[adapter: string, p
});
}

export async function findTCPAdapter(path: string, adapter?: Adapter): Promise<[adapter: string, path: string]> {
const regex = /^(?:tcp:\/\/)[\w.-]+[:][\d]+$/gm;
export async function findTCPAdapter(path: string, adapter?: Adapter): Promise<[adapter: ValidAdapter, path: string]> {
const regex = /^tcp:\/\/(?:[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://<host>:<port>`);
}

if (!regex.test(path) || !adapter || adapter === 'auto') {
if (!adapter || adapter === 'auto') {
throw new Error(`Cannot discover TCP adapters at this time. Please specify valid 'adapter' and 'path' manually.`);
}

Expand All @@ -353,20 +380,32 @@ export async function findTCPAdapter(path: string, adapter?: Adapter): Promise<[
* @returns path Path to adapter.
* @returns baudRate [optional] Discovered baud rate of the adapter. Valid only for mDNS discovery at the moment.
*/
export async function discoverAdapter(adapter?: Adapter, path?: string): Promise<[adapter: string, path: string, baudRate?: number | undefined]> {
export async function discoverAdapter(
adapter?: Adapter,
path?: string,
): Promise<[adapter: ValidAdapter, path: string, baudRate?: number | undefined]> {
if (path) {
if (path.startsWith('mdns://')) {
return await findmDNSAdapter(path);
} else if (path.startsWith('tcp://')) {
return await findTCPAdapter(path, adapter);
} else if (adapter && adapter !== 'auto') {
const matched = await matchUSBAdapter(adapter, path);

/* istanbul ignore else */
if (!matched) {
logger.error(`Unable to match USB adapter: ${adapter} | ${path}`, NS);
}

return [adapter, path];
}
}

// default to matching USB
const match = await findUSBAdapter(adapter === 'auto' ? undefined : adapter, path);
const match = await findUSBAdapter(path);

if (!match) {
throw new Error(`Unable to find matching USB adapter.`);
throw new Error(`Unable to find a valid USB adapter.`);
}

return match;
Expand Down
8 changes: 0 additions & 8 deletions src/adapter/deconz/adapter/deconzAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,6 @@ class DeconzAdapter extends Adapter {
}, 1000);
}

public static async isValidPath(path: string): Promise<boolean> {
return await Driver.isValidPath(path);
}

public static async autoDetectPath(): Promise<string | undefined> {
return await Driver.autoDetectPath();
}

/**
* Adapter methods
*/
Expand Down
Loading

0 comments on commit edb7e2f

Please sign in to comment.