Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/adapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,17 @@ export abstract class Adapter extends events.EventEmitter<AdapterEventMap> {
backupPath: string,
adapterOptions: TsType.AdapterOptions,
): Promise<Adapter> {
const [adapter, path] = await discoverAdapter(serialPortOptions.adapter, serialPortOptions.path);
serialPortOptions.adapter = adapter;
serialPortOptions.path = path;
const discovered = await discoverAdapter(serialPortOptions.adapter, serialPortOptions.path);
serialPortOptions.adapter = discovered.adapter;
serialPortOptions.path = discovered.path;
if (serialPortOptions.baudRate === undefined && discovered.baudRate !== undefined) {
serialPortOptions.baudRate = discovered.baudRate;
}
if (serialPortOptions.rtscts === undefined && discovered.rtscts !== undefined) {
serialPortOptions.rtscts = discovered.rtscts;
}

switch (adapter) {
switch (discovered.adapter) {
case "zstack": {
const {ZStackAdapter} = await import("./z-stack/adapter/zStackAdapter.js");

Expand Down Expand Up @@ -92,7 +98,7 @@ export abstract class Adapter extends events.EventEmitter<AdapterEventMap> {
return new EZSPAdapter(networkOptions, serialPortOptions, backupPath, adapterOptions);
}
default: {
throw new Error(`Adapter '${adapter}' does not exists, possible options: zstack, ember, deconz, zigate, zboss, zoh, ezsp`);
throw new Error(`Adapter '${discovered.adapter}' does not exists, possible options: zstack, ember, deconz, zigate, zboss, zoh, ezsp`);
}
}
}
Expand Down
56 changes: 36 additions & 20 deletions src/adapter/adapterDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {PortInfo} from "@serialport/bindings-cpp";
import {Bonjour, type Service} from "bonjour-service";
import {wait} from "../utils";
import {logger} from "../utils/logger";
import type {TsType} from ".";
import {SerialPort} from "./serialPort";
import type {Adapter, DiscoverableUsbAdapter, UsbAdapterFingerprint} from "./tstype";

Expand Down Expand Up @@ -65,13 +66,22 @@ const USB_FINGERPRINTS: Record<DiscoverableUsbAdapter, UsbAdapterFingerprint[]>
// pathRegex: '.*.*',
// },
{
// Home Assistant SkyConnect
// Home Assistant Connect ZBT-1
vendorId: "10c4",
productId: "ea60",
manufacturer: "Nabu Casa",
// /dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3abe54797c91ed118fc3cad13b20a111-if00-port0
pathRegex: ".*Nabu_Casa_SkyConnect.*",
},
{
// Home Assistant Connect ZBT-2
vendorId: "303a",
productId: "4001",
manufacturer: "Nabu Casa",
// /dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE58D6C-if00
pathRegex: ".*Nabu_Casa_ZBT-2.*",
serialPortOptions: {baudRate: 460800, rtscts: true},
},
// {
// // TODO: Home Assistant Yellow
// vendorId: '',
Expand Down Expand Up @@ -323,7 +333,7 @@ function matchUsbFingerprint(
entries: UsbAdapterFingerprint[],
isWindows: boolean,
conflictProne: boolean,
): [path: PortInfo["path"], score: number] | undefined {
): [path: PortInfo["path"], score: number, fingerprint: UsbAdapterFingerprint] | undefined {
if (!portInfo.vendorId || !portInfo.productId) {
// port info is missing essential information for proper matching, ignore it
return undefined;
Expand Down Expand Up @@ -355,7 +365,7 @@ function matchUsbFingerprint(
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];
return [portInfo.path, score, match];
}
}

Expand All @@ -366,7 +376,7 @@ function matchUsbFingerprint(
) {
if (score === UsbFingerprintMatchScore.VidPidManuf) {
// best possible match, return early
return [portInfo.path, UsbFingerprintMatchScore.VidPidManufPath];
return [portInfo.path, UsbFingerprintMatchScore.VidPidManufPath, entry];
}

match = entry;
Expand All @@ -383,18 +393,18 @@ function matchUsbFingerprint(
return undefined;
}

return [portInfo.path, score];
return [portInfo.path, score, match];
}

if (!conflictProne) {
return [portInfo.path, score];
return [portInfo.path, score, match];
}
}

return undefined;
}

export async function matchUsbAdapter(adapter: Adapter, path: string): Promise<boolean> {
export async function matchUsbAdapter(adapter: Adapter, path: string): Promise<UsbAdapterFingerprint | false> {
// no point in matching this
if (adapter === "zoh") {
return false;
Expand All @@ -415,7 +425,7 @@ export async function matchUsbAdapter(adapter: Adapter, path: string): Promise<b

if (match) {
logger.info(() => `Matched adapter: ${JSON.stringify(portInfo)} => ${adapter}: ${JSON.stringify(match[1])}`, NS);
return true;
return match[2];
}
}

Expand Down Expand Up @@ -452,10 +462,7 @@ export function findUsbAdapterBestMatch(
return bestMatch;
}

export async function findUsbAdapter(
adapter?: Adapter,
path?: string,
): Promise<[adapter: DiscoverableUsbAdapter, path: PortInfo["path"]] | undefined> {
export async function findUsbAdapter(adapter?: Adapter, path?: string): Promise<TsType.SerialPortOptions | undefined> {
const isWindows = platform() === "win32";
// refine to DiscoverableUSBAdapter
adapter = adapter && adapter === "ezsp" ? "ember" : adapter;
Expand All @@ -476,7 +483,12 @@ export async function findUsbAdapter(
() => `Matched adapter: ${JSON.stringify(portInfo)} => ${bestMatch[0]}: path=${bestMatch[1][0]}, score=${bestMatch[1][1]}`,
NS,
);
return [bestMatch[0], bestMatch[1][0]];
return {
adapter: bestMatch[0],
path: bestMatch[1][0],
rtscts: bestMatch[1][2].serialPortOptions?.rtscts,
baudRate: bestMatch[1][2].serialPortOptions?.baudRate,
};
}
}
}
Expand All @@ -492,7 +504,7 @@ function getMdnsRadioAdapter(radio: string): Adapter {
}
}

export async function findMdnsAdapter(path: string): Promise<[adapter: Adapter, path: string]> {
export async function findMdnsAdapter(path: string): Promise<TsType.SerialPortOptions> {
const mdnsDevice = path.substring(7);

if (mdnsDevice.length === 0) {
Expand All @@ -516,7 +528,7 @@ export async function findMdnsAdapter(path: string): Promise<[adapter: Adapter,
logger.info(`Coordinator Radio: ${mdnsAdapter}`, NS);
bj.destroy();

resolve([mdnsAdapter, `tcp://${mdnsAddress}:${mdnsPort}`]);
resolve({adapter: mdnsAdapter, path: `tcp://${mdnsAddress}:${mdnsPort}`});
} else {
bj.destroy();
reject(
Expand All @@ -533,7 +545,7 @@ export async function findMdnsAdapter(path: string): Promise<[adapter: Adapter,
});
}

export function findTcpAdapter(path: string, adapter?: Adapter): [adapter: Adapter, path: string] {
export function findTcpAdapter(path: string, adapter?: Adapter): TsType.SerialPortOptions {
try {
const url = new URL(path);
assert(url.port !== "");
Expand All @@ -546,7 +558,7 @@ export function findTcpAdapter(path: string, adapter?: Adapter): [adapter: Adapt
}

// always use `tcp://` format
return [adapter, path.replace(/^socket/, "tcp")];
return {adapter, path: path.replace(/^socket/, "tcp")};
}

/**
Expand All @@ -563,7 +575,7 @@ export function findTcpAdapter(path: string, adapter?: Adapter): [adapter: Adapt
* @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]> {
export async function discoverAdapter(adapter?: Adapter, path?: string): Promise<TsType.SerialPortOptions> {
if (path) {
if (path.startsWith("mdns://")) {
return await findMdnsAdapter(path);
Expand All @@ -574,17 +586,21 @@ export async function discoverAdapter(adapter?: Adapter, path?: string): Promise
}

if (adapter) {
const result: TsType.SerialPortOptions = {adapter, path};
try {
const matched = await matchUsbAdapter(adapter, path);

if (!matched) {
logger.debug(`Unable to match USB adapter: ${adapter} | ${path}`, NS);
} else {
result.rtscts = matched.serialPortOptions?.rtscts;
result.baudRate = matched.serialPortOptions?.baudRate;
}
} catch (error) {
logger.debug(`Error while trying to match USB adapter (${(error as Error).message}).`, NS);
}

return [adapter, path];
return result;
}
}

Expand All @@ -597,7 +613,7 @@ export async function discoverAdapter(adapter?: Adapter, path?: string): Promise
}

// keep adapter if `ezsp` since findUSBAdapter returns DiscoverableUSBAdapter
return adapter && adapter === "ezsp" ? [adapter, match[1]] : match;
return adapter && adapter === "ezsp" ? {adapter, path: match.path, baudRate: match.baudRate, rtscts: match.rtscts} : match;
} catch (error) {
throw new Error(`USB adapter discovery error (${(error as Error).message}). Specify valid 'adapter' and 'port' in your configuration.`);
}
Expand Down
1 change: 1 addition & 0 deletions src/adapter/tstype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type UsbAdapterFingerprint = {
productId: string;
manufacturer?: string;
pathRegex: string;
serialPortOptions?: Pick<SerialPortOptions, "baudRate" | "rtscts">;
};

export interface NetworkOptions {
Expand Down
50 changes: 50 additions & 0 deletions test/adapter/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
EMBER_ZBDONGLE_E,
EMBER_ZBDONGLE_E_CP,
ZBOSS_NORDIC,
ZBT_2,
ZIGATE_PLUSV2,
ZSTACK_CC2538,
ZSTACK_SMLIGHT_SLZB_06P10,
Expand Down Expand Up @@ -488,6 +489,38 @@ describe("Adapter", () => {
});
});

it("uses default serialPortOptions of adapter", async () => {
listSpy.mockReturnValueOnce([ZBT_2]);

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({
adapter: "ember",
baudRate: 460800,
path: "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE58D6C-if00",
rtscts: true,
});
});

it("uses provided options instead of default serialPortOptions of adapter", async () => {
listSpy.mockReturnValueOnce([ZBT_2]);

const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {rtscts: false, baudRate: 1}, "test.db.backup", {
disableLED: false,
});

expect(adapter).toBeInstanceOf(EmberAdapter);
// @ts-expect-error protected
expect(adapter.serialPortOptions).toStrictEqual({
adapter: "ember",
baudRate: 1,
path: "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE58D6C-if00",
rtscts: false,
});
});

it("detects with pnpId instead of path", async () => {
listSpy.mockReturnValueOnce([{...ZBOSS_NORDIC, path: "/dev/ttyUSB0", pnpId: "usb-ZEPHYR_Zigbee_NCP_54ACCFAFA6DADC49-if00"}]);

Expand Down Expand Up @@ -730,6 +763,23 @@ describe("Adapter", () => {
});
});

it("uses default serialPortOptions of adapter", async () => {
listSpy.mockReturnValueOnce([{...ZBT_2, path: "/dev/ttyUSB0"}]);

const adapter = await Adapter.create({panID: 0x1a62, channelList: [11]}, {adapter: "ember", path: "/dev/ttyUSB0"}, "test.db.backup", {
disableLED: false,
});

expect(adapter).toBeInstanceOf(EmberAdapter);
// @ts-expect-error protected
expect(adapter.serialPortOptions).toStrictEqual({
adapter: "ember",
baudRate: 460800,
path: "/dev/ttyUSB0",
rtscts: true,
});
});

it("detects with conflict vendor+product IDs", async () => {
listSpy.mockReturnValueOnce([{...EMBER_SKYCONNECT, manufacturer: undefined}]);

Expand Down
6 changes: 6 additions & 0 deletions test/mockAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ export const ZIGATE_PLUSV2 = {
vendorId: "0403",
productId: "6015",
};
export const ZBT_2 = {
path: "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_10B41DE58D6C-if00",
vendorId: "303a",
productId: "4001",
manufacturer: "Nabu Casa",
};