From d827be45d832401537bca183c46e37a35df09a86 Mon Sep 17 00:00:00 2001 From: sungmpar <72352750+sungmpar@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:33:08 +0900 Subject: [PATCH] feat: add hub filter to devices command --- .changeset/swift-hubs-connect.md | 5 + src/__tests__/commands/devices.test.ts | 336 ++++++++++++++++++++++++- src/commands/devices.ts | 94 ++++++- 3 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 .changeset/swift-hubs-connect.md diff --git a/.changeset/swift-hubs-connect.md b/.changeset/swift-hubs-connect.md new file mode 100644 index 00000000..adb980f4 --- /dev/null +++ b/.changeset/swift-hubs-connect.md @@ -0,0 +1,5 @@ +--- +"@smartthings/cli": patch +--- + +Add a hub filter to the devices command, including nested child devices. diff --git a/src/__tests__/commands/devices.test.ts b/src/__tests__/commands/devices.test.ts index b5322cbc..a40ce931 100644 --- a/src/__tests__/commands/devices.test.ts +++ b/src/__tests__/commands/devices.test.ts @@ -5,6 +5,7 @@ import type { ArgumentsCamelCase, Argv, Options } from 'yargs' import { type Device, type DeviceHealth, + type DeviceListOptions, DeviceHealthState, DeviceIntegrationType, DevicesEndpoint, @@ -70,6 +71,8 @@ describe('builder', () => { outputItemOrListBuilderMock.mockReturnValue(argvMock) const builder = cmd.builder as (yargs: Argv) => Argv + const optionFor = (name: string): Options | undefined => + (optionMock as OptionMock).mock.calls.find(([key]) => key === name)?.[1] it('calls correct parent and yargs functions', () => { expect(builder(yargsMock)).toBe(argvMock) @@ -79,7 +82,7 @@ describe('builder', () => { expect(outputItemOrListBuilderMock).toHaveBeenCalledTimes(1) expect(outputItemOrListBuilderMock).toHaveBeenCalledWith(apiCommandBuilderArgvMock) expect(positionalMock).toHaveBeenCalledTimes(1) - expect(optionMock).toHaveBeenCalledTimes(9) + expect(optionMock).toHaveBeenCalledTimes(10) expect(exampleMock).toHaveBeenCalledTimes(1) expect(buildEpilogMock).toHaveBeenCalledTimes(1) expect(epilogMock).toHaveBeenCalledTimes(1) @@ -91,13 +94,24 @@ describe('builder', () => { it('accepts upper or lowercase types', () => { expect(builder(yargsMock)).toBe(argvMock) - const typeCoerce = (optionMock as OptionMock).mock.calls[7][1]?.coerce + const typeCoerce = optionFor('type')?.coerce expect(typeCoerce).toBeDefined() expect(typeCoerce?.(['ZIGBEE', 'zwave'])) .toStrictEqual([DeviceIntegrationType.ZIGBEE, DeviceIntegrationType.ZWAVE]) expect(typeCoerce?.(['zigbee', 'ZWAVE'])) .toStrictEqual([DeviceIntegrationType.ZIGBEE, DeviceIntegrationType.ZWAVE]) }) + + it('adds long-form --hub and preserves -H for --health', () => { + expect(builder(yargsMock)).toBe(argvMock) + + expect(optionFor('hub')).toEqual(expect.objectContaining({ + describe: 'filter results by hub', + type: 'string', + })) + expect(optionFor('hub')).not.toHaveProperty('alias') + expect(optionFor('health')).toEqual(expect.objectContaining({ alias: 'H' })) + }) }) @@ -117,6 +131,7 @@ describe('handler', () => { tableGenerator: tableGeneratorMock, } as APICommand> apiCommandMock.mockResolvedValue(command) + beforeEach(() => apiDevicesListMock.mockReset()) const defaultInputArgv = { profile: 'default', @@ -124,6 +139,18 @@ describe('handler', () => { health: false, verbose: false, } as ArgumentsCamelCase + const selectedHubId = 'selected-hub-id' + const otherHubId = 'other-hub-id' + const selectedHub = { + deviceId: selectedHubId, + label: 'Selected Hub', + type: DeviceIntegrationType.HUB, + } as Device + const otherHub = { + deviceId: otherHubId, + label: 'Other Hub', + type: DeviceIntegrationType.HUB, + } as Device const device1 = { deviceId: 'device-1-id' } as Device const device2 = { deviceId: 'device-2-id' } as Device @@ -165,7 +192,11 @@ describe('handler', () => { }) it('lists details of a specified device', async () => { - const inputArgv = { ...defaultInputArgv, idOrIndex: 'device-from-arg' } as ArgumentsCamelCase + const inputArgv = { + ...defaultInputArgv, + hub: selectedHubId, + idOrIndex: 'device-from-arg', + } as ArgumentsCamelCase await expect(cmd.handler(inputArgv)).resolves.not.toThrow() @@ -184,6 +215,7 @@ describe('handler', () => { expect(await getFunction('chosen-device-id')).toStrictEqual(device1) expect(apiDevicesGetMock).toHaveBeenCalledWith('chosen-device-id', { includeStatus: false }) + expect(apiDevicesListMock).not.toHaveBeenCalled() buildTableOutputMock.mockReturnValueOnce('build table output') const config = outputItemOrListMock.mock.calls[0][1] as @@ -340,4 +372,302 @@ describe('handler', () => { expect(apiDevicesGetMock).toHaveBeenCalledWith('chosen-device-id', { includeStatus: true }) }) + + it('filters all supported direct hub-id integrations using the declared device type', async () => { + const directDevices = [ + { deviceId: 'dth-device', label: 'DTH Device', type: DeviceIntegrationType.DTH, + dth: { hubId: selectedHubId } }, + { deviceId: 'lan-device', label: 'LAN Device', type: DeviceIntegrationType.LAN, + lan: { hubId: selectedHubId } }, + { deviceId: 'zigbee-device', label: 'Zigbee Device', type: DeviceIntegrationType.ZIGBEE, + zigbee: { hubId: selectedHubId } }, + { deviceId: 'zwave-device', label: 'Z-Wave Device', type: DeviceIntegrationType.ZWAVE, + zwave: { hubId: selectedHubId } }, + { deviceId: 'matter-device', label: 'Matter Device', type: DeviceIntegrationType.MATTER, + matter: { hubId: selectedHubId } }, + { deviceId: 'edge-child-device', label: 'Edge Child Device', type: DeviceIntegrationType.EDGE_CHILD, + edgeChild: { hubId: selectedHubId } }, + { deviceId: 'virtual-device', label: 'Virtual Device', type: DeviceIntegrationType.VIRTUAL, + virtual: { hubId: selectedHubId } }, + ] as unknown as Device[] + const deviceOnOtherHub = { + deviceId: 'other-hub-device', + label: 'Other Hub Device', + type: DeviceIntegrationType.ZIGBEE, + zigbee: { hubId: otherHubId }, + } as unknown as Device + const deviceWithIrrelevantHubField = { + deviceId: 'irrelevant-hub-field-device', + label: 'Irrelevant Hub Field Device', + type: DeviceIntegrationType.LAN, + lan: {}, + zigbee: { hubId: selectedHubId }, + } as unknown as Device + const inputArgv = { ...defaultInputArgv, hub: selectedHubId } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock.mockResolvedValueOnce([ + selectedHub, + ...directDevices, + otherHub, + deviceOnOtherHub, + deviceWithIrrelevantHubField, + ]) + + expect(await listFunction()).toStrictEqual(directDevices) + expect(apiDevicesListMock).toHaveBeenCalledExactlyOnceWith({ + capability: undefined, + capabilitiesMode: 'and', + locationId: undefined, + deviceId: undefined, + installedAppId: undefined, + type: undefined, + includeHealth: false, + includeStatus: false, + }) + }) + + it('treats declared-type hub ownership as authoritative over ancestry', async () => { + const selectedDespiteOtherParent = { + deviceId: 'selected-despite-other-parent', + label: 'Selected Despite Other Parent', + type: DeviceIntegrationType.ZIGBEE, + parentDeviceId: otherHubId, + zigbee: { hubId: selectedHubId }, + } as unknown as Device + const excludedDespiteSelectedParent = { + deviceId: 'excluded-despite-selected-parent', + label: 'Excluded Despite Selected Parent', + type: DeviceIntegrationType.LAN, + parentDeviceId: selectedHubId, + lan: { hubId: otherHubId }, + zigbee: { hubId: selectedHubId }, + } as unknown as Device + const inputArgv = { ...defaultInputArgv, hub: selectedHubId } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock.mockResolvedValueOnce([ + selectedHub, + otherHub, + selectedDespiteOtherParent, + excludedDespiteSelectedParent, + ]) + + expect(await listFunction()).toStrictEqual([selectedDespiteOtherParent]) + }) + + it('handles nested ancestry, missing parents, cycles, and duplicate devices safely', async () => { + const directDevice = { + deviceId: 'direct-device', + label: 'Direct Device', + type: DeviceIntegrationType.LAN, + lan: { hubId: selectedHubId }, + } as unknown as Device + const intermediateDevice = { + deviceId: 'intermediate-device', + label: 'Intermediate Device', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: selectedHubId, + edgeChild: {}, + } as unknown as Device + const nestedDevice = { + deviceId: 'nested-device', + label: 'Nested Device', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: intermediateDevice.deviceId, + edgeChild: {}, + } as unknown as Device + const otherHubChild = { + deviceId: 'other-hub-child', + label: 'Other Hub Child', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: otherHubId, + edgeChild: {}, + } as unknown as Device + const missingParentDevice = { + deviceId: 'missing-parent-device', + label: 'Missing Parent Device', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: 'missing-parent-id', + edgeChild: {}, + } as unknown as Device + const cycleA = { + deviceId: 'cycle-a', + label: 'Cycle A', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: 'cycle-b', + edgeChild: {}, + } as unknown as Device + const cycleB = { + deviceId: 'cycle-b', + label: 'Cycle B', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: 'cycle-a', + edgeChild: {}, + } as unknown as Device + const duplicateFirst = { + deviceId: 'duplicate-device', + label: 'Duplicate First', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: selectedHubId, + edgeChild: {}, + } as unknown as Device + const duplicateSecond = { ...duplicateFirst, label: 'Duplicate Second' } + const inputArgv = { ...defaultInputArgv, hub: selectedHubId } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock.mockResolvedValueOnce([ + selectedHub, + directDevice, + intermediateDevice, + nestedDevice, + otherHub, + otherHubChild, + missingParentDevice, + cycleA, + cycleB, + duplicateFirst, + duplicateSecond, + ]) + + expect(await listFunction()).toStrictEqual([ + directDevice, + intermediateDevice, + nestedDevice, + duplicateFirst, + ]) + expect(apiDevicesListMock).toHaveBeenCalledTimes(1) + }) + + const narrowingFilterCases: [string, Partial, Partial][] = [ + ['location', { location: ['location-id'] }, { locationId: ['location-id'] }], + ['capability', { capability: ['switch'], capabilitiesMode: 'or' }, + { capability: ['switch'], capabilitiesMode: 'or' }], + ['device', { device: ['filtered-leaf-id'] }, { deviceId: ['filtered-leaf-id'] }], + ['installed app', { installedApp: 'installed-app-id' }, { installedAppId: 'installed-app-id' }], + ['type', { type: [DeviceIntegrationType.EDGE_CHILD] }, { type: [DeviceIntegrationType.EDGE_CHILD] }], + ] + + it.each(narrowingFilterCases)('loads complete topology for a %s filter', async (_name, flags, expectedOptions) => { + const filteredLeaf = { + deviceId: 'filtered-leaf-id', + label: 'Filtered Leaf', + type: DeviceIntegrationType.EDGE_CHILD, + edgeChild: {}, + healthState: { state: DeviceHealthState.ONLINE }, + } as unknown as OutputDevice + const topologyIntermediate = { + deviceId: 'topology-intermediate-id', + label: 'Topology Intermediate', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: selectedHubId, + edgeChild: {}, + } as unknown as Device + const topologyLeaf = { + deviceId: filteredLeaf.deviceId, + label: 'Lean Topology Leaf', + type: DeviceIntegrationType.EDGE_CHILD, + parentDeviceId: topologyIntermediate.deviceId, + edgeChild: {}, + } as unknown as Device + const inputArgv = { + ...defaultInputArgv, + hub: selectedHubId, + ...flags, + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock + .mockResolvedValueOnce([filteredLeaf]) + .mockResolvedValueOnce([selectedHub, topologyIntermediate, topologyLeaf]) + + const result = await listFunction() + expect(result).toStrictEqual([filteredLeaf]) + expect(result[0]).toBe(filteredLeaf) + expect(apiDevicesListMock).toHaveBeenCalledTimes(2) + expect(apiDevicesListMock).toHaveBeenNthCalledWith(1, { + capability: undefined, + capabilitiesMode: 'and', + locationId: undefined, + deviceId: undefined, + installedAppId: undefined, + type: undefined, + includeHealth: false, + includeStatus: false, + ...expectedOptions, + }) + expect(apiDevicesListMock.mock.calls[1]).toStrictEqual([]) + }) + + it('uses the hub-filtered list for a numeric index without extra health or status topology calls', async () => { + const matchingDevice = { + deviceId: 'matching-index-device', + label: 'Matching Index Device', + type: DeviceIntegrationType.MATTER, + matter: { hubId: selectedHubId }, + } as unknown as Device + const otherDevice = { + deviceId: 'other-index-device', + label: 'Other Index Device', + type: DeviceIntegrationType.MATTER, + matter: { hubId: otherHubId }, + } as unknown as Device + const inputArgv = { + ...defaultInputArgv, + hub: selectedHubId, + health: true, + status: true, + idOrIndex: '1', + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + expect(outputItemOrListMock.mock.calls[0][2]).toBe('1') + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock.mockResolvedValueOnce([selectedHub, matchingDevice, otherDevice]) + + expect(await listFunction()).toStrictEqual([matchingDevice]) + expect(apiDevicesListMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + includeHealth: true, + includeStatus: true, + })) + }) + + it('adds verbose location data only after filtering devices by hub', async () => { + const matchingDevice = { + deviceId: 'matching-verbose-device', + label: 'Matching Verbose Device', + type: DeviceIntegrationType.ZIGBEE, + zigbee: { hubId: selectedHubId }, + } as unknown as Device + const otherDevice = { + deviceId: 'other-verbose-device', + label: 'Other Verbose Device', + type: DeviceIntegrationType.ZIGBEE, + zigbee: { hubId: otherHubId }, + } as unknown as Device + const matchingDeviceWithLocation = { ...matchingDevice, location: 'Home' } as OutputDevice + const inputArgv = { + ...defaultInputArgv, + hub: selectedHubId, + verbose: true, + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + apiDevicesListMock.mockResolvedValueOnce([matchingDevice, otherDevice]) + withLocationsAndRoomsMock.mockResolvedValueOnce([matchingDeviceWithLocation]) + + expect(await listFunction()).toStrictEqual([matchingDeviceWithLocation]) + expect(withLocationsAndRoomsMock).toHaveBeenCalledExactlyOnceWith(clientMock, [matchingDevice]) + }) }) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 37c44735..a8f15978 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -30,6 +30,7 @@ export type CommandArgs = capabilitiesMode?: 'and' | 'or' device?: string[] installedApp?: string + hub?: string status: boolean health: boolean type?: DeviceIntegrationType[] @@ -73,6 +74,10 @@ const builder = (yargs: Argv): Argv => describe: 'filter results by installed app that created the device', type: 'string', }) + .option('hub', { + describe: 'filter results by hub', + type: 'string', + }) .option('status', { alias: 's', describe: 'include attribute values in the response', @@ -110,6 +115,7 @@ const builder = (yargs: Argv): Argv => '$0 devices --capability button --capability temperatureMeasurement --capabilitiesMode or', 'list devices with either the button or temperatureMeasurement capability', ], + ['$0 devices --hub a9108ab1-7087-4c10-9781-a0627b084fce', 'list devices joined to the specified hub'], ['$0 devices --verbose', 'include location and room names in the output'], ['$0 devices --type zigbee --type zwave', 'list Zigbee and Z-Wave devices'], ]) @@ -118,6 +124,81 @@ const builder = (yargs: Argv): Argv => // type that includes extra fields sometimes included when requested via command line flags export type OutputDevice = Device & WithNamedRoom & Pick +const directHubId = (device: Device): string | undefined => { + switch (device.type) { + case DeviceIntegrationType.DTH: + return device.dth?.hubId ?? undefined + case DeviceIntegrationType.LAN: + return device.lan?.hubId ?? undefined + case DeviceIntegrationType.MATTER: + return device.matter?.hubId ?? undefined + case DeviceIntegrationType.ZIGBEE: + return device.zigbee?.hubId ?? undefined + case DeviceIntegrationType.ZWAVE: + return device.zwave?.hubId ?? undefined + case DeviceIntegrationType.EDGE_CHILD: + return device.edgeChild?.hubId ?? undefined + case DeviceIntegrationType.VIRTUAL: + return device.virtual?.hubId ?? undefined + default: + return undefined + } +} + +const isAssociatedWithHub = ( + device: Device, + devicesById: ReadonlyMap, + hubId: string, +): boolean => { + const deviceHubId = directHubId(device) + if (deviceHubId !== undefined) { + return deviceHubId === hubId + } + + let currentDevice = devicesById.get(device.deviceId) ?? device + const visitedDeviceIds = new Set() + while (!visitedDeviceIds.has(currentDevice.deviceId)) { + visitedDeviceIds.add(currentDevice.deviceId) + + const currentHubId = directHubId(currentDevice) + if (currentHubId !== undefined) { + return currentHubId === hubId + } + + const parentDeviceId = currentDevice.parentDeviceId + if (parentDeviceId === undefined) { + return false + } + if (parentDeviceId === hubId) { + return true + } + + const parentDevice = devicesById.get(parentDeviceId) + if (parentDevice === undefined) { + return false + } + currentDevice = parentDevice + } + + return false +} + +const filterDevicesByHub = (devices: Device[], topology: Device[], hubId: string): Device[] => { + const devicesById = new Map(topology.map(device => [device.deviceId, device])) + const includedDeviceIds = new Set() + return devices.filter(device => { + if ( + device.deviceId === hubId + || includedDeviceIds.has(device.deviceId) + || !isAssociatedWithHub(device, devicesById, hubId) + ) { + return false + } + includedDeviceIds.add(device.deviceId) + return true + }) +} + const handler = async (argv: ArgumentsCamelCase): Promise => { const command = await apiCommand(argv) @@ -155,10 +236,21 @@ const handler = async (argv: ArgumentsCamelCase): Promise => includeHealth: argv.health, ...deviceGetOptions, } + const hasNarrowingFilter = Boolean( + argv.capability?.length + || argv.location?.length + || argv.device?.length + || argv.installedApp !== undefined + || argv.type?.length, + ) await outputItemOrList(command, config, argv.idOrIndex, async () => { - const devices = await command.client.devices.list(deviceListOptions) + let devices = await command.client.devices.list(deviceListOptions) + if (argv.hub !== undefined) { + const topology = hasNarrowingFilter ? await command.client.devices.list() : devices + devices = filterDevicesByHub(devices, topology, argv.hub) + } if (argv.verbose) { return await withLocationsAndRooms(command.client, devices) }