diff --git a/packages/edge/src/__tests__/commands/edge/drivers/switch.test.ts b/packages/edge/src/__tests__/commands/edge/drivers/switch.test.ts deleted file mode 100644 index 23e58963..00000000 --- a/packages/edge/src/__tests__/commands/edge/drivers/switch.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Device, DeviceIntegrationType, HubdevicesEndpoint, SmartThingsClient } from '@smartthings/core-sdk' - -import { chooseDevice } from '@smartthings/cli-lib' - -import DriversSwitchCommand from '../../../../commands/edge/drivers/switch.js' -import { chooseDriver, chooseHub, listAllAvailableDrivers, listMatchingDrivers } - from '../../../../lib/commands/drivers-util.js' - - -const edgeDeviceIntegrationTypes = [ - DeviceIntegrationType.LAN, - DeviceIntegrationType.MATTER, - DeviceIntegrationType.ZIGBEE, - DeviceIntegrationType.ZWAVE, -] - -jest.mock('@smartthings/cli-lib', () => { - const originalLib = jest.requireActual('@smartthings/cli-lib') - - return { - ...originalLib, - chooseDevice: jest.fn(), - } -}) -jest.mock('../../../../../src/lib/commands/drivers-util') - -describe('DriversSwitchCommand', () => { - const switchDriverSpy = jest.spyOn(HubdevicesEndpoint.prototype, 'switchDriver').mockImplementation() - const chooseHubMock = jest.mocked(chooseHub).mockResolvedValue('chosen-hub-id') - const chooseDeviceMock = jest.mocked(chooseDevice).mockResolvedValue('chosen-device-id') - const chooseDriverMock = jest.mocked(chooseDriver).mockResolvedValue('chosen-driver-id') - - const matchingDriver = { driverId: 'matching-driver-id', name: 'Matching Driver' } - const matchingDrivers = [matchingDriver] - const listMatchingDriversMock = jest.mocked(listMatchingDrivers) - .mockResolvedValue(matchingDrivers) - - it('calls switchDriver', async () => { - await expect(DriversSwitchCommand.run([ - '--hub', 'arg-hub-id', - '--driver', 'arg-driver-id', - 'arg-device-id', - ])).resolves.not.toThrow() - - expect(chooseHubMock).toHaveBeenCalledTimes(1) - expect(chooseHubMock).toHaveBeenCalledWith(expect.any(DriversSwitchCommand), - 'Which hub is the device connected to?', 'arg-hub-id', - { useConfigDefault: true }) - - expect(chooseDeviceMock).toHaveBeenCalledTimes(1) - expect(chooseDeviceMock).toHaveBeenCalledWith( - expect.any(DriversSwitchCommand), - 'arg-device-id', - expect.objectContaining({ - deviceListOptions: expect.objectContaining({ - type: edgeDeviceIntegrationTypes }), - deviceListFilter: expect.any(Function) }, - ), - ) - - expect(chooseDriverMock).toHaveBeenCalledTimes(1) - expect(chooseDriverMock).toHaveBeenCalledWith( - expect.any(DriversSwitchCommand), - 'Choose a driver to use.', - 'arg-driver-id', - expect.objectContaining({ listItems: expect.any(Function) }), - ) - - expect(switchDriverSpy).toHaveBeenCalledTimes(1) - expect(switchDriverSpy).toHaveBeenCalledWith('chosen-driver-id', 'chosen-hub-id', 'chosen-device-id', undefined) - }) - - it('uses forceUpdate when switching to a non-matching device', async () => { - chooseDriverMock.mockResolvedValueOnce('non-matching-driver-id') - - await expect(DriversSwitchCommand.run([ - '--hub', 'arg-hub-id', - '--include-non-matching', - 'arg-device-id', - ])).resolves.not.toThrow() - - expect(chooseHubMock).toHaveBeenCalledTimes(1) - expect(chooseDeviceMock).toHaveBeenCalledTimes(1) - expect(chooseDriverMock).toHaveBeenCalledTimes(1) - expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) - expect(listMatchingDriversMock).toHaveBeenCalledWith(expect.any(SmartThingsClient), 'chosen-device-id', 'chosen-hub-id') - - expect(switchDriverSpy).toHaveBeenCalledTimes(1) - expect(switchDriverSpy).toHaveBeenCalledWith('non-matching-driver-id', 'chosen-hub-id', 'chosen-device-id', true) - }) - - it('does not use forceUpdate when a matching device is chosen even though non-matching devices were listed', async () => { - chooseDriverMock.mockResolvedValueOnce('matching-driver-id') - - await expect(DriversSwitchCommand.run([ - '--hub', 'arg-hub-id', - '--include-non-matching', - 'arg-device-id', - ])).resolves.not.toThrow() - - expect(chooseHubMock).toHaveBeenCalledTimes(1) - expect(chooseDeviceMock).toHaveBeenCalledTimes(1) - expect(chooseDriverMock).toHaveBeenCalledTimes(1) - expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) - expect(listMatchingDriversMock).toHaveBeenCalledWith(expect.any(SmartThingsClient), 'chosen-device-id', 'chosen-hub-id') - - expect(switchDriverSpy).toHaveBeenCalledTimes(1) - expect(switchDriverSpy).toHaveBeenCalledWith('matching-driver-id', 'chosen-hub-id', 'chosen-device-id', false) - }) - - describe('deviceFilter', () => { - it.each(edgeDeviceIntegrationTypes)('includes %s devices on specified hub', async (deviceIntegrationType) => { - await expect(DriversSwitchCommand.run([])).resolves.not.toThrow() - - const deviceListFilter = chooseDeviceMock.mock.calls[0][2]?.deviceListFilter - expect(deviceListFilter).toBeDefined() - - const device = { - type: deviceIntegrationType, - [deviceIntegrationType.toString().toLowerCase()]: { hubId: 'chosen-hub-id' }, - } as unknown as Device - expect(deviceListFilter?.(device, 0, [])).toBe(true) - }) - - it('rejects non-edge devices', async () => { - await expect(DriversSwitchCommand.run([])).resolves.not.toThrow() - - const deviceListFilter = chooseDeviceMock.mock.calls[0][2]?.deviceListFilter - expect(deviceListFilter).toBeDefined() - - const device = { - type: DeviceIntegrationType.ENDPOINT_APP, - app: { hubId: 'chosen-hub-id' }, - } as unknown as Device - expect(deviceListFilter?.(device, 0, [])).toBe(false) - }) - - it('rejects edge devices on different hub', async () => { - await expect(DriversSwitchCommand.run([])).resolves.not.toThrow() - - const deviceListFilter = chooseDeviceMock.mock.calls[0][2]?.deviceListFilter - expect(deviceListFilter).toBeDefined() - - const device = { - type: DeviceIntegrationType.ZIGBEE, - zigbee: { hubId: 'other-hub-id' }, - } as unknown as Device - expect(deviceListFilter?.(device, 0, [])).toBe(false) - }) - }) - - describe('listItems', () => { - const otherDriver = { driverId: 'other-driver-id', name: 'Other Driver' } - const allDrivers = [matchingDriver, otherDriver] - const listAllAvailableDriversMock = jest.mocked(listAllAvailableDrivers) - .mockResolvedValue(allDrivers) - it('uses listMatchingDrivers normally', async () => { - await expect(DriversSwitchCommand.run([])).resolves.not.toThrow() - - const listItems = chooseDriverMock.mock.calls[0][3]?.listItems - expect(listItems).toBeDefined() - expect(await listItems?.()).toStrictEqual(matchingDrivers) - - expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) - expect(listMatchingDriversMock).toHaveBeenCalledWith( - expect.any(SmartThingsClient), - 'chosen-device-id', 'chosen-hub-id') - expect(listAllAvailableDriversMock).toHaveBeenCalledTimes(0) - }) - - it('lists all drivers when requested', async () => { - await expect(DriversSwitchCommand.run(['--include-non-matching'])).resolves.not.toThrow() - - const listItems = chooseDriverMock.mock.calls[0][3]?.listItems - expect(listItems).toBeDefined() - expect(await listItems?.()).toStrictEqual(allDrivers) - - expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) - expect(listAllAvailableDriversMock).toHaveBeenCalledTimes(1) - expect(listAllAvailableDriversMock).toHaveBeenCalledWith( - expect.any(SmartThingsClient), - 'chosen-device-id', 'chosen-hub-id') - }) - }) -}) diff --git a/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts b/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts deleted file mode 100644 index e5ec10e5..00000000 --- a/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - Device, - DeviceIntegrationType, - DriverChannelDetails, - EdgeDriverSummary, - InstalledDriver, - Location, - OrganizationResponse, - SmartThingsClient, -} from '@smartthings/core-sdk' - -import { - APICommand, - ChooseOptions, - chooseOptionsWithDefaults, - forAllOrganizations, - selectFromList, - stringTranslateToId, - TableGenerator, - WithOrganization, -} from '@smartthings/cli-lib' - -import { - chooseDriver, - chooseDriverFromChannel, - DriverChannelDetailsWithName, - getDriverDevices, - listAllAvailableDrivers, - listMatchingDrivers, - withoutCurrentDriver, -} from '../../../lib/commands/drivers-util.js' -import * as driversUtil from '../../../lib/commands/drivers-util.js' - - -jest.mock('@smartthings/cli-lib', () => ({ - chooseOptionsDefaults: jest.fn(), - chooseOptionsWithDefaults: jest.fn(), - stringTranslateToId: jest.fn(), - selectFromList: jest.fn(), - forAllOrganizations: jest.fn(), -})) - -const selectFromListMock = jest.mocked(selectFromList) -const stringTranslateToIdMock = jest.mocked(stringTranslateToId) - -const client = { - drivers: { listDefault: jest.fn() }, - devices: { get: jest.fn(), list: jest.fn() }, - hubdevices: { listInstalled: jest.fn() }, -} as unknown as SmartThingsClient -const apiDriversListMock = jest.mocked(client.drivers.list) -const apiDriversListDefaultMock = jest.mocked(client.drivers.listDefault) -const apiDevicesGetMock = jest.mocked(client.devices.get) -const apiDevicesListMock = jest.mocked(client.devices.list) -const apiHubdevicesListInstalledMock = jest.mocked(client.hubdevices.listInstalled) - -test.each` - deviceType | expectedCount - ${'lan'} | ${3} - ${'matter'} | ${3} - ${'zigbee'} | ${3} - ${'zwave'} | ${3} - ${'other'} | ${4} -`('withoutCurrentDriver filters ', async ({ deviceType, expectedCount }) => { - const drivers = [ - { name: 'lan', driverId: 'lan-driver-id' }, - { name: 'matter', driverId: 'matter-driver-id' }, - { name: 'zigbee', driverId: 'zigbee-driver-id' }, - { name: 'zwave', driverId: 'zwave-driver-id' }, - ] - - const driverId = `${deviceType}-driver-id` - const device = { [deviceType]: { driverId } } as unknown as Device - apiDevicesGetMock.mockResolvedValueOnce(device) - - const result = await withoutCurrentDriver(client, 'device-id', drivers) - expect(result.length).toBe(expectedCount) - expect(result.find(driver => driver.driverId === driverId)).toBeUndefined() - - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') -}) - -const installedDriver = { driverId: 'installed-driver-id', name: 'Installed Driver' } as InstalledDriver -const defaultDriver = { driverId: 'default-driver-id', name: 'Default Driver' } as EdgeDriverSummary -const currentDriver = { driverId: 'current-driver-id', name: 'Current Driver' } as InstalledDriver -const device = { zigbee: { driverId: 'current-driver-id' } } as Device - -describe('listAllAvailableDrivers', () => { - it('combines default and installed drivers lists', async () => { - apiDevicesGetMock.mockResolvedValueOnce(device) - - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver]) - apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) - - const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') - expect(result.length).toBe(2) - expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id') - expect(apiDriversListDefaultMock).toHaveBeenCalledTimes(1) - expect(apiDriversListDefaultMock).toHaveBeenCalledWith() - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') - }) - - it('filters out current driver', async () => { - apiDevicesGetMock.mockResolvedValueOnce(device) - - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver, currentDriver]) - apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) - - const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') - expect(result.length).toBe(2) - expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id') - expect(apiDriversListDefaultMock).toHaveBeenCalledTimes(1) - expect(apiDriversListDefaultMock).toHaveBeenCalledWith() - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') - }) - - it('filters out duplicates', async () => { - apiDevicesGetMock.mockResolvedValueOnce(device) - - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver, defaultDriver as unknown as InstalledDriver]) - apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) - - const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') - expect(result.length).toBe(2) - expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id') - expect(apiDriversListDefaultMock).toHaveBeenCalledTimes(1) - expect(apiDriversListDefaultMock).toHaveBeenCalledWith() - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') - }) -}) - -describe('listMatchingDrivers', () => { - it('lists matching drivers', async () => { - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver]) - apiDevicesGetMock.mockResolvedValueOnce(device) - - expect(await listMatchingDrivers(client, 'device-id', 'hub-id')) - .toStrictEqual([installedDriver]) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id', 'device-id') - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') - }) - - it('filters out current driver', async () => { - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver, currentDriver]) - apiDevicesGetMock.mockResolvedValueOnce(device) - - expect(await listMatchingDrivers(client, 'device-id', 'hub-id')) - .toStrictEqual([installedDriver]) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id', 'device-id') - expect(apiDevicesGetMock).toHaveBeenCalledTimes(1) - expect(apiDevicesGetMock).toHaveBeenCalledWith('device-id') - }) -}) - -test('chooseDriverFromChannel presents user with list of drivers with names', async () => { - const command = { client } as APICommand - selectFromListMock.mockResolvedValueOnce('chosen-driver-id') - - expect(await chooseDriverFromChannel(command, 'channel-id', 'preselected-driver-id')).toBe('chosen-driver-id') - - expect(selectFromListMock).toHaveBeenCalledTimes(1) - expect(selectFromListMock).toHaveBeenCalledWith(command, - expect.objectContaining({ primaryKeyName: 'driverId', sortKeyName: 'name' }), - expect.objectContaining({ - preselectedId: 'preselected-driver-id', - promptMessage: 'Select a driver to install.', - })) - - const drivers = [{ name: 'driver' }] as DriverChannelDetailsWithName[] - const listAssignedDriversWithNamesSpy = jest.spyOn(driversUtil, 'listAssignedDriversWithNames') - .mockResolvedValueOnce(drivers) - - const listItems = selectFromListMock.mock.calls[0][2].listItems - - expect(await listItems()).toBe(drivers) - - expect(listAssignedDriversWithNamesSpy).toHaveBeenCalledTimes(1) - expect(listAssignedDriversWithNamesSpy).toHaveBeenCalledWith(client, 'channel-id') -}) diff --git a/packages/edge/src/commands/edge/drivers/switch.ts b/packages/edge/src/commands/edge/drivers/switch.ts deleted file mode 100644 index 6d96695a..00000000 --- a/packages/edge/src/commands/edge/drivers/switch.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Flags } from '@oclif/core' - -import { Device, DeviceIntegrationType } from '@smartthings/core-sdk' - -import { chooseDevice } from '@smartthings/cli-lib' - -import { EdgeCommand } from '../../../lib/edge-command.js' -import { chooseDriver, chooseHub, listAllAvailableDrivers, listMatchingDrivers } - from '../../../lib/commands/drivers-util.js' - - -export default class DriversSwitchCommand extends EdgeCommand { - static description = 'change the driver used by an installed device' + - this.apiDocsURL('updateHubDevice') - - static examples = [`# switch driver, prompting user for all necessary input -$ smartthings edge:drivers:switch - -# switch driver, including all necessary input on the command line -$ smartthings edge:drivers:switch --hub --driver `, - ` -# include all available drivers in prompt, even if they don't match the chosen device -$ smartthings edge:drivers:switch --include-non-matching`, - ] - - static flags = { - ...EdgeCommand.flags, - hub: Flags.string({ - char: 'H', - description: 'hub id', - helpValue: '', - }), - driver: Flags.string({ - char: 'd', - description: 'id of new driver to use', - helpValue: '', - }), - // eslint-disable-next-line @typescript-eslint/naming-convention - 'include-non-matching': Flags.boolean({ - char: 'I', - description: 'when presenting a list of drivers to switch to, include drivers that do not match the device', - }), - } - - static args = [{ - name: 'deviceId', - description: 'id of device to update', - }] - - async run(): Promise { - const hubId = await chooseHub(this, 'Which hub is the device connected to?', - this.flags.hub, { useConfigDefault: true }) - const deviceListFilter = (device: Device): boolean => - device.type === DeviceIntegrationType.LAN && device.lan?.hubId === hubId || - device.type === DeviceIntegrationType.MATTER && device.matter?.hubId === hubId || - device.type === DeviceIntegrationType.ZIGBEE && device.zigbee?.hubId === hubId || - device.type === DeviceIntegrationType.ZWAVE && device.zwave?.hubId === hubId - - const deviceListOptions = { - type: [ - DeviceIntegrationType.LAN, - DeviceIntegrationType.MATTER, - DeviceIntegrationType.ZIGBEE, - DeviceIntegrationType.ZWAVE, - ], - } - const deviceId = await chooseDevice(this, this.args.deviceId, { deviceListOptions, deviceListFilter }) - - const listItems = this.flags['include-non-matching'] - ? () => listAllAvailableDrivers(this.client, deviceId, hubId) - : () => listMatchingDrivers(this.client, deviceId, hubId) - const driverId = await chooseDriver(this, 'Choose a driver to use.', this.flags.driver, - { listItems }) - const forceUpdate = this.flags['include-non-matching'] && - !(await listMatchingDrivers(this.client, deviceId, hubId)).find(driver => driver.driverId === driverId) - - await this.client.hubdevices.switchDriver(driverId, hubId, deviceId, forceUpdate) - this.log(`updated driver for device ${deviceId} to ${driverId}`) - } -} diff --git a/packages/edge/src/lib/commands/drivers-util.ts b/packages/edge/src/lib/commands/drivers-util.ts deleted file mode 100644 index bb162d86..00000000 --- a/packages/edge/src/lib/commands/drivers-util.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Device, - DeviceIntegrationType, - DriverChannelDetails, - EdgeDriver, - EdgeDriverSummary, - InstalledDriver, - LanDeviceDetails, - SmartThingsClient, -} from '@smartthings/core-sdk' - -import { - APICommand, - ChooseOptions, - chooseOptionsDefaults, - chooseOptionsWithDefaults, - forAllOrganizations, - selectFromList, - SelectFromListConfig, - stringTranslateToId, - TableFieldDefinition, - TableGenerator, -} from '@smartthings/cli-lib' - - -/** - * Filter the driver currently in use by a device out of a list of drivers. - */ -export const withoutCurrentDriver = async (client: SmartThingsClient, deviceId: string, drivers: DriverChoice[]): Promise => { - const device = await client.devices.get(deviceId) - const currentDriverId = device.lan?.driverId ?? - device.matter?.driverId ?? - device.zigbee?.driverId ?? - device.zwave?.driverId - - return drivers.filter(driver => driver.driverId !== currentDriverId) -} - -export const listAllAvailableDrivers = async (client: SmartThingsClient, deviceId: string, hubId: string): Promise => { - const installedDrivers = await client.hubdevices.listInstalled(hubId) - const defaultDrivers = (await client.drivers.listDefault()) - .filter(driver => !installedDrivers.find(installed => installed.driverId === driver.driverId)) - return withoutCurrentDriver(client, deviceId, [...installedDrivers, ...defaultDrivers ]) -} - -export const listMatchingDrivers = async (client: SmartThingsClient, deviceId: string, hubId: string): Promise => - withoutCurrentDriver(client, deviceId, await client.hubdevices.listInstalled(hubId, deviceId)) diff --git a/src/__tests__/commands/edge/drivers/switch.test.ts b/src/__tests__/commands/edge/drivers/switch.test.ts new file mode 100644 index 00000000..3e123fcf --- /dev/null +++ b/src/__tests__/commands/edge/drivers/switch.test.ts @@ -0,0 +1,210 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import { type Device, DeviceIntegrationType, type HubdevicesEndpoint } from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../../commands/edge/drivers/switch.js' +import type { APICommand, APICommandFlags } from '../../../../lib/command/api-command.js' +import type { chooseDeviceFn } from '../../../../lib/command/util/devices-choose.js' +import type { chooseDriver } from '../../../../lib/command/util/drivers-choose.js' +import { + type DriverChoice, + edgeDeviceTypes, + type listAllAvailableDrivers, + type listMatchingDrivers, +} from '../../../../lib/command/util/edge-drivers.js' +import type { chooseHub } from '../../../../lib/command/util/hubs-choose.js' +import type { ChooseFunction } from '../../../../lib/command/util/util-util.js' +import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' +import { buildArgvMock } from '../../../test-lib/builder-mock.js' + + +const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../../../..') + +const chooseDeviceMock = jest.fn>().mockResolvedValue('chosen-device') +const chooseDeviceFnMock = jest.fn().mockReturnValue(chooseDeviceMock) +jest.unstable_mockModule('../../../../lib/command/util/devices-choose.js', () => ({ + chooseDeviceFn: chooseDeviceFnMock, +})) + +const chooseDriverMock = jest.fn().mockResolvedValue('chosen-driver') +jest.unstable_mockModule('../../../../lib/command/util/drivers-choose.js', () => ({ + chooseDriver: chooseDriverMock, +})) + +const listAllAvailableDriversMock = jest.fn() +const listMatchingDriversMock = jest.fn() +jest.unstable_mockModule('../../../../lib/command/util/edge-drivers.js', () => ({ + edgeDeviceTypes, + listAllAvailableDrivers: listAllAvailableDriversMock, + listMatchingDrivers: listMatchingDriversMock, +})) + +const chooseHubMock = jest.fn().mockResolvedValue('chosen-hub') +jest.unstable_mockModule('../../../../lib/command/util/hubs-choose.js', () => ({ + chooseHub: chooseHubMock, +})) + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /* do nothing */ }) + + +const { default: cmd } = await import('../../../../commands/edge/drivers/switch.js') + + +test('builder', () => { + const { + yargsMock, + positionalMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiCommandBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(3) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +describe('handler', () => { + const apiHubDevicesSwitchDriverMock = jest.fn() + const command = { + client: { + hubdevices: { + switchDriver: apiHubDevicesSwitchDriverMock, + }, + }, + } as unknown as APICommand + apiCommandMock.mockResolvedValue(command) + + const driver1 = { driverId: 'driver-id-1' } as DriverChoice + const driver2 = { driverId: 'driver-id-2' } as DriverChoice + const matchingDrivers = [driver2] + const allDrivers = [driver1, driver2] + listMatchingDriversMock.mockResolvedValue(matchingDrivers) + listAllAvailableDriversMock.mockResolvedValue(allDrivers) + + const inputArgv = { + profile: 'default', + } as ArgumentsCamelCase + + it('prompts for all information by default', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseHubMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + { promptMessage: 'Which hub is the device connected to?', useConfigDefault: true }, + ) + expect(chooseDeviceFnMock).toHaveBeenCalledExactlyOnceWith({ type: edgeDeviceTypes }) + expect(chooseDeviceMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + { listFilter: expect.any(Function) }, + ) + expect(listMatchingDriversMock).toHaveBeenCalledExactlyOnceWith(command.client, 'chosen-device', 'chosen-hub') + expect(chooseDriverMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + { promptMessage: 'Choose a driver to use.', listItems: expect.any(Function) }, + ) + expect(apiHubDevicesSwitchDriverMock).toHaveBeenCalledExactlyOnceWith( + 'chosen-driver', + 'chosen-hub', + 'chosen-device', + undefined, + ) + expect(consoleLogSpy).toHaveBeenCalledWith('Updated driver for device chosen-device to chosen-driver.') + + expect(listAllAvailableDriversMock).not.toHaveBeenCalled() + + const driversListItems = chooseDriverMock.mock.calls[0][2]?.listItems + + // listMatchingDriversMock should not get called a second time + expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) + expect(await driversListItems?.(command)).toBe(matchingDrivers) + expect(listMatchingDriversMock).toHaveBeenCalledTimes(1) + }) + + it('does not include non-edge devices', async () => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const deviceListFilter = chooseDeviceMock.mock.calls[0][2]?.listFilter + + expect(deviceListFilter?.({} as Device, 0, [])).toBe(false) + expect(deviceListFilter?.({ type: DeviceIntegrationType.BLE } as Device, 0, [])).toBe(false) + }) + + it.each([ + DeviceIntegrationType.LAN, + DeviceIntegrationType.MATTER, + DeviceIntegrationType.ZIGBEE, + DeviceIntegrationType.ZWAVE, + ])('only includes edge devices on chosen hub', async type => { + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + const deviceListFilter = chooseDeviceMock.mock.calls[0][2]?.listFilter + const matchingDevice = { type, [type.toLowerCase()]: { hubId: 'chosen-hub' } } as unknown as Device + + expect(deviceListFilter?.({ type } as Device, 0, [])).toBe(false) + expect(deviceListFilter?.(matchingDevice, 0, [])).toBe(true) + }) + + it('includes even non-matching drivers when requested', async () => { + await expect(cmd.handler({ ...inputArgv, includeNonMatching: true })).resolves.not.toThrow() + + expect(apiHubDevicesSwitchDriverMock).toHaveBeenCalledExactlyOnceWith( + 'chosen-driver', + 'chosen-hub', + 'chosen-device', + true, + ) + + expect(listAllAvailableDriversMock).not.toHaveBeenCalled() + + const driversListItems = chooseDriverMock.mock.calls[0][2]?.listItems + + expect(await driversListItems?.(command)).toBe(allDrivers) + expect(listAllAvailableDriversMock).toHaveBeenCalledExactlyOnceWith( + command.client, + 'chosen-device', + 'chosen-hub', + ) + }) + + it('accepts input from command line', async () => { + await expect(cmd.handler({ + ...inputArgv, + deviceId: 'cmd-line-device', + hub: 'cmd-line-hub', + driver: 'cmd-line-driver', + })).resolves.not.toThrow() + + expect(chooseHubMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-hub', + { promptMessage: 'Which hub is the device connected to?', useConfigDefault: true }, + ) + expect(chooseDeviceMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-device', + { listFilter: expect.any(Function) }, + ) + expect(chooseDriverMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-driver', + { promptMessage: 'Choose a driver to use.', listItems: expect.any(Function) }, + ) + }) +}) diff --git a/src/__tests__/lib/command/util/edge-drivers.test.ts b/src/__tests__/lib/command/util/edge-drivers.test.ts index 10ba7980..5c26f07a 100644 --- a/src/__tests__/lib/command/util/edge-drivers.test.ts +++ b/src/__tests__/lib/command/util/edge-drivers.test.ts @@ -4,13 +4,15 @@ import { type ChannelsEndpoint, type Device, DeviceIntegrationType, - DevicesEndpoint, + type DevicesEndpoint, type DriverChannelDetails, type DriversEndpoint, type EdgeDeviceIntegrationProfileKey, type EdgeDriver, type EdgeDriverPermissions, type EdgeDriverSummary, + type HubdevicesEndpoint, + type InstalledDriver, type OrganizationResponse, type SmartThingsClient, } from '@smartthings/core-sdk' @@ -31,14 +33,22 @@ jest.unstable_mockModule('../../../../lib/api-helpers.js', () => ({ forAllOrganizations: forAllOrganizationsMock, })) +const apiDevicesGetMock = jest.fn() const apiDevicesListMock = jest.fn() const apiDriversListMock = jest.fn() +const apiDriversListDefaultMock = jest.fn() +const apiHubDevicesListInstalledMock = jest.fn() const client = { devices: { + get: apiDevicesGetMock, list: apiDevicesListMock, }, drivers: { list: apiDriversListMock, + listDefault: apiDriversListDefaultMock, + }, + hubdevices: { + listInstalled: apiHubDevicesListInstalledMock, }, } as unknown as SmartThingsClient const driverList = [{ name: 'Driver' }] as EdgeDriverSummary[] @@ -48,9 +58,12 @@ const { buildTableOutput, edgeDeviceTypes, getDriverDevices, + listAllAvailableDrivers, listAssignedDriversWithNames, listDrivers, + listMatchingDrivers, permissionsValue, + withoutCurrentDriver, } = await import('../../../../lib/command/util/edge-drivers.js') @@ -308,3 +321,105 @@ describe('getDriverDevices', () => { expect(apiDevicesListMock).toHaveBeenCalledWith({ type: edgeDeviceTypes }) }) }) + +test.each` + deviceType | expectedCount + ${'lan'} | ${3} + ${'matter'} | ${3} + ${'zigbee'} | ${3} + ${'zwave'} | ${3} + ${'other'} | ${4} +`('withoutCurrentDriver filters ', async ({ deviceType, expectedCount }) => { + const drivers = [ + { name: 'lan', driverId: 'lan-driver-id' }, + { name: 'matter', driverId: 'matter-driver-id' }, + { name: 'zigbee', driverId: 'zigbee-driver-id' }, + { name: 'zwave', driverId: 'zwave-driver-id' }, + ] + + const driverId = `${deviceType}-driver-id` + const device = { [deviceType]: { driverId } } as unknown as Device + apiDevicesGetMock.mockResolvedValueOnce(device) + + const result = await withoutCurrentDriver(client, 'device-id', drivers) + expect(result.length).toBe(expectedCount) + expect(result.find(driver => driver.driverId === driverId)).toBeUndefined() + + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') +}) + +const installedDriver = { driverId: 'installed-driver-id', name: 'Installed Driver' } as InstalledDriver +const defaultDriver = { driverId: 'default-driver-id', name: 'Default Driver' } as EdgeDriverSummary +const currentDriver = { driverId: 'current-driver-id', name: 'Current Driver' } as InstalledDriver +const device = { zigbee: { driverId: 'current-driver-id' } } as Device + +describe('listAllAvailableDrivers', () => { + it('combines default and installed drivers lists', async () => { + apiDevicesGetMock.mockResolvedValueOnce(device) + + apiHubDevicesListInstalledMock.mockResolvedValueOnce([installedDriver]) + apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) + + const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') + expect(result.length).toBe(2) + expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) + + expect(apiHubDevicesListInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-id') + expect(apiDriversListDefaultMock).toHaveBeenCalledExactlyOnceWith() + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') + }) + + it('filters out current driver', async () => { + apiDevicesGetMock.mockResolvedValueOnce(device) + + apiHubDevicesListInstalledMock.mockResolvedValueOnce([installedDriver, currentDriver]) + apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) + + const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') + expect(result.length).toBe(2) + expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) + + expect(apiHubDevicesListInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-id') + expect(apiDriversListDefaultMock).toHaveBeenCalledExactlyOnceWith() + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') + }) + + it('filters out duplicates', async () => { + apiDevicesGetMock.mockResolvedValueOnce(device) + + apiHubDevicesListInstalledMock.mockResolvedValueOnce([installedDriver, defaultDriver as unknown as InstalledDriver]) + apiDriversListDefaultMock.mockResolvedValueOnce([defaultDriver]) + + const result = await listAllAvailableDrivers(client, 'device-id', 'hub-id') + expect(result.length).toBe(2) + expect(result).toEqual(expect.arrayContaining([defaultDriver, installedDriver])) + + expect(apiHubDevicesListInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-id') + expect(apiDriversListDefaultMock).toHaveBeenCalledExactlyOnceWith() + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') + }) +}) + +describe('listMatchingDrivers', () => { + it('lists matching drivers', async () => { + apiHubDevicesListInstalledMock.mockResolvedValueOnce([installedDriver]) + apiDevicesGetMock.mockResolvedValueOnce(device) + + expect(await listMatchingDrivers(client, 'device-id', 'hub-id')) + .toStrictEqual([installedDriver]) + + expect(apiHubDevicesListInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-id', 'device-id') + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') + }) + + it('filters out current driver', async () => { + apiHubDevicesListInstalledMock.mockResolvedValueOnce([installedDriver, currentDriver]) + apiDevicesGetMock.mockResolvedValueOnce(device) + + expect(await listMatchingDrivers(client, 'device-id', 'hub-id')) + .toStrictEqual([installedDriver]) + + expect(apiHubDevicesListInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-id', 'device-id') + expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('device-id') + }) +}) diff --git a/src/__tests__/lib/command/util/edge/drivers-choose.test.ts b/src/__tests__/lib/command/util/edge/drivers-choose.test.ts index d4487fa5..e38b14b7 100644 --- a/src/__tests__/lib/command/util/edge/drivers-choose.test.ts +++ b/src/__tests__/lib/command/util/edge/drivers-choose.test.ts @@ -3,9 +3,9 @@ import { jest } from '@jest/globals' import type { EdgeDriver } from '@smartthings/core-sdk' import type { APICommand } from '../../../../../lib/command/api-command.js' -import type { DriverChoice } from '../../../../../lib/command/util/drivers-choose.js' import type { DriverChannelDetailsWithName, + DriverChoice, listAssignedDriversWithNames, listDrivers, } from '../../../../../lib/command/util/edge-drivers.js' diff --git a/src/commands/edge/drivers/switch.ts b/src/commands/edge/drivers/switch.ts new file mode 100644 index 00000000..7f3074b2 --- /dev/null +++ b/src/commands/edge/drivers/switch.ts @@ -0,0 +1,92 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type Device, DeviceIntegrationType } from '@smartthings/core-sdk' + +import { apiCommand, apiCommandBuilder, type APICommandFlags, apiDocsURL } from '../../../lib/command/api-command.js' +import { chooseDeviceFn } from '../../../lib/command/util/devices-choose.js' +import { chooseDriver } from '../../../lib/command/util/drivers-choose.js' +import { edgeDeviceTypes, listAllAvailableDrivers, listMatchingDrivers } from '../../../lib/command/util/edge-drivers.js' +import { chooseHub } from '../../../lib/command/util/hubs-choose.js' + + +export type CommandArgs = + & APICommandFlags + & { + deviceId?: string + hub?: string + driver?: string + includeNonMatching?: boolean + } + +const command = 'edge:drivers:switch [device-id]' + +const describe = 'change the driver used by an installed device' + +const builder = (yargs: Argv): Argv => + apiCommandBuilder(yargs) + .positional('device-id', { describe: 'id of device to update', type: 'string' }) + .option('hub', { alias: 'H', describe: 'hub id', type: 'string' }) + .option('driver', { alias: 'd', describe: 'id of new driver to use', type: 'string' }) + .option( + 'include-non-matching', + { + alias: 'I', + describe: 'when presenting a list of drivers, include drivers that do not match the device', + type: 'boolean', + default: false, + }, + ) + .example([ + ['$0 edge:drivers:switch', 'prompt for all necessary input'], + [ + '$0 edge:drivers:switch --hub b0cd47c6-2bbd-45f7-9726-6dcd374b8eb3' + + ' --driver a5b0af0c-234a-4a8c-a927-6d70f91ad690 261131fb-916f-4936-a671-51b95360fe8e', + 'switch to driver a5b0af... for device 261131... on hub b0cd47...', + ], + [ + '$0 edge:drivers:switch --include-non-matching', + "include all available drivers in prompt, even if they don't match the chosen device", + ], + ]) + .epilog(apiDocsURL('updateHubDevice')) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiCommand(argv) + + const hubId = await chooseHub( + command, + argv.hub, + { promptMessage: 'Which hub is the device connected to?', useConfigDefault: true }, + ) + const deviceListFilter = (device: Device): boolean => + device.type === DeviceIntegrationType.LAN && device.lan?.hubId === hubId || + device.type === DeviceIntegrationType.MATTER && device.matter?.hubId === hubId || + device.type === DeviceIntegrationType.ZIGBEE && device.zigbee?.hubId === hubId || + device.type === DeviceIntegrationType.ZWAVE && device.zwave?.hubId === hubId + + const deviceListOptions = { + type: edgeDeviceTypes, + } + const deviceId = await chooseDeviceFn(deviceListOptions)( + command, + argv.deviceId, + { listFilter: deviceListFilter }, + ) + + const matchingDrivers = await listMatchingDrivers(command.client, deviceId, hubId) + const listItems = argv.includeNonMatching + ? () => listAllAvailableDrivers(command.client, deviceId, hubId) + : async () => matchingDrivers + const driverId = await chooseDriver( + command, + argv.driver, + { promptMessage: 'Choose a driver to use.', listItems }, + ) + const forceUpdate = argv.includeNonMatching && !matchingDrivers.find(driver => driver.driverId === driverId) + + await command.client.hubdevices.switchDriver(driverId, hubId, deviceId, forceUpdate) + console.log(`Updated driver for device ${deviceId} to ${driverId}.`) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index e0222fc9..3df73c56 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -76,6 +76,7 @@ import edgeDriversInstallCommand from './edge/drivers/install.js' import edgeDriversInstalledCommand from './edge/drivers/installed.js' import edgeDriversLogcatCommand from './edge/drivers/logcat.js' import edgeDriversPackageCommand from './edge/drivers/package.js' +import edgeDriversSwitchCommand from './edge/drivers/switch.js' import edgeDriversUninstallCommand from './edge/drivers/uninstall.js' import installedappsCommand from './installedapps.js' import installedappsDeleteCommand from './installedapps/delete.js' @@ -206,6 +207,7 @@ export const commands: CommandModule[] = [ edgeDriversInstalledCommand, edgeDriversLogcatCommand, edgeDriversPackageCommand, + edgeDriversSwitchCommand, edgeDriversUninstallCommand, installedappsCommand, installedappsDeleteCommand, diff --git a/src/lib/command/util/drivers-choose.ts b/src/lib/command/util/drivers-choose.ts index d6c562cc..d2cbb9ff 100644 --- a/src/lib/command/util/drivers-choose.ts +++ b/src/lib/command/util/drivers-choose.ts @@ -1,16 +1,12 @@ -import { type EdgeDriverSummary } from '@smartthings/core-sdk' - -import { type DriverChannelDetailsWithName, listAssignedDriversWithNames, listDrivers } from './edge-drivers.js' +import { + type DriverChannelDetailsWithName, + type DriverChoice, + listAssignedDriversWithNames, + listDrivers, +} from './edge-drivers.js' import { type ChooseFunction, createChooseFn } from './util-util.js' -/** - * When presenting a list of drivers to choose from, we only use the `driverId` and `name` fields. - * Using this type instead of `EdgeDriverSummary` allows the caller of `chooseDriver` (below) - * to use functions that return other objects as long as they include these two fields. - */ -export type DriverChoice = Pick - export const chooseDriverFn = ( options?: { includeAllOrganizations: boolean }, ): ChooseFunction => createChooseFn( diff --git a/src/lib/command/util/edge-drivers.ts b/src/lib/command/util/edge-drivers.ts index 2ebb18a8..c6963668 100644 --- a/src/lib/command/util/edge-drivers.ts +++ b/src/lib/command/util/edge-drivers.ts @@ -127,3 +127,45 @@ export const getDriverDevices = async (client: SmartThingsClient): Promise deviceToDeviceDriverInfo(device, hubDevices)) } + +/** + * When presenting a list of drivers to choose from, we only use the `driverId` and `name` fields. + * Using this type instead of `EdgeDriverSummary` allows the caller of `chooseDriver` (below) + * to use functions that return other objects as long as they include these two fields. + */ +export type DriverChoice = Pick + +/** + * Filter the driver currently in use by a device out of a list of drivers. + */ +export const withoutCurrentDriver = async ( + client: SmartThingsClient, + deviceId: string, + drivers: DriverChoice[], +): Promise => { + const device = await client.devices.get(deviceId) + const currentDriverId = device.lan?.driverId ?? + device.matter?.driverId ?? + device.zigbee?.driverId ?? + device.zwave?.driverId + + return drivers.filter(driver => driver.driverId !== currentDriverId) +} + +export const listAllAvailableDrivers = async ( + client: SmartThingsClient, + deviceId: string, + hubId: string, +): Promise => { + const installedDrivers = await client.hubdevices.listInstalled(hubId) + const defaultDrivers = (await client.drivers.listDefault()) + .filter(driver => !installedDrivers.find(installed => installed.driverId === driver.driverId)) + return withoutCurrentDriver(client, deviceId, [...installedDrivers, ...defaultDrivers ]) +} + +export const listMatchingDrivers = async ( + client: SmartThingsClient, + deviceId: string, + hubId: string, +): Promise => + withoutCurrentDriver(client, deviceId, await client.hubdevices.listInstalled(hubId, deviceId))