diff --git a/.changeset/purple-steaks-behave.md b/.changeset/purple-steaks-behave.md new file mode 100644 index 000000000..9ae939868 --- /dev/null +++ b/.changeset/purple-steaks-behave.md @@ -0,0 +1,7 @@ +--- +"@smartthings/cli": minor +"@smartthings/cli-lib": patch +"@smartthings/cli-testlib": patch +--- + +Added commands to create virtual devices and generate events on their behalf diff --git a/packages/cli/src/__tests__/commands/devices.test.ts b/packages/cli/src/__tests__/commands/devices.test.ts index 62829bc9d..e19ad39c4 100644 --- a/packages/cli/src/__tests__/commands/devices.test.ts +++ b/packages/cli/src/__tests__/commands/devices.test.ts @@ -144,6 +144,23 @@ describe('DevicesCommand', () => { })) expect(withLocationsAndRoomsMock).toHaveBeenCalledTimes(0) }) + + it('uses type flag in devices.list', async () => { + await expect(DevicesCommand.run(['--type', 'VIRTUAL'])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + expect(outputListingMock.mock.calls[0][1].listTableFieldDefinitions) + .toEqual(['label', 'name', 'type', 'deviceId']) + + const listDevices = outputListingMock.mock.calls[0][3] + + expect(await listDevices()).toBe(devices) + + expect(listSpy).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'VIRTUAL' })) + expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({ capability: undefined })) + expect(withLocationsAndRoomsMock).toHaveBeenCalledTimes(0) + }) }) it('uses devices.get to get device', async () => { diff --git a/packages/cli/src/__tests__/commands/devices/virtual.test.ts b/packages/cli/src/__tests__/commands/devices/virtual.test.ts new file mode 100644 index 000000000..abacbcaab --- /dev/null +++ b/packages/cli/src/__tests__/commands/devices/virtual.test.ts @@ -0,0 +1,230 @@ +import inquirer from 'inquirer' +import { + APICommand, + APIOrganizationCommand, + FileInputProcessor, + outputListing, + selectFromList, +} from '@smartthings/cli-lib' +import VirtualDevicesCommand, { + chooseDeviceName, + chooseDeviceProfileDefinition, + chooseDevicePrototype, + chooseRoom, +} from '../../../commands/devices/virtual' +import { chooseDeviceProfile } from '../../../commands/deviceprofiles' +import { Device, DeviceIntegrationType, DeviceProfile, DeviceProfileStatus, DevicesEndpoint } from '@smartthings/core-sdk' + + +jest.mock('../../../commands/deviceprofiles') + +describe('chooseDeviceName function', () => { + const command = { } as unknown as APICommand + + it('choose with from prompt', async () => { + const promptSpy = jest.spyOn(inquirer, 'prompt') + promptSpy.mockResolvedValue({ deviceName: 'Device Name' }) + + const value = await chooseDeviceName(command) + expect(promptSpy).toHaveBeenCalledTimes(1) + expect(promptSpy).toHaveBeenCalledWith({ + type: 'input', name: 'deviceName', + message: 'Device Name:', + }) + expect(value).toBeDefined() + expect(value).toBe('Device Name') + }) + + it('choose with default', async () => { + const promptSpy = jest.spyOn(inquirer, 'prompt') + promptSpy.mockResolvedValue({ deviceName: 'Another Device Name' }) + + const value = await chooseDeviceName(command, 'Device Name') + expect(promptSpy).toHaveBeenCalledTimes(0) + expect(value).toBeDefined() + expect(value).toBe('Device Name') + }) +}) + +describe('chooseRoom function', () => { + const selectFromListMock = jest.mocked(selectFromList) + const listRoomsMock = jest.fn() + const client = { rooms: { list: listRoomsMock } } + const command = { client } as unknown as APICommand + + it('choose room from prompt', async () => { + selectFromListMock.mockResolvedValueOnce('room-id') + + const value = await chooseRoom(command, 'location-id', undefined, true) + + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'roomId', sortKeyName: 'name' }), + expect.objectContaining({ autoChoose: true })) + expect(value).toBeDefined() + expect(value).toBe('room-id') + }) + + it('choose room with default', async () => { + selectFromListMock.mockResolvedValueOnce('room-id') + + const value = await chooseRoom(command, 'location-id', 'room-id', true) + + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'roomId', sortKeyName: 'name' }), + expect.objectContaining({ autoChoose: true, preselectedId: 'room-id' })) + expect(value).toBeDefined() + expect(value).toBe('room-id') + }) +}) + +describe('chooseDeviceProfileDefinition function', () => { + const chooseDeviceProfileMock = jest.mocked(chooseDeviceProfile) + const command = { } as unknown as APIOrganizationCommand + + it('choose profile ID from prompt', async() => { + chooseDeviceProfileMock.mockResolvedValueOnce('device-profile-id') + + const value = await chooseDeviceProfileDefinition(command) + + expect(chooseDeviceProfileMock).toHaveBeenCalledTimes(1) + expect(chooseDeviceProfileMock).toHaveBeenCalledWith(command, + undefined, + expect.objectContaining({ allowIndex: true})) + expect(value).toBeDefined() + expect(value).toEqual({deviceProfileId: 'device-profile-id', deviceProfile: undefined}) + }) + + it('choose profile ID from default', async() => { + const value = await chooseDeviceProfileDefinition(command, 'device-profile-id') + + expect(chooseDeviceProfileMock).toHaveBeenCalledTimes(0) + expect(value).toBeDefined() + expect(value).toEqual({deviceProfileId: 'device-profile-id', deviceProfile: undefined}) + }) + + it('choose definition from file argument', async() => { + const deviceProfile: DeviceProfile = { + id: 'device-profile-id', + name: 'name', + components: [], + status: DeviceProfileStatus.PUBLISHED, + } + + const fileSpy = jest.spyOn(FileInputProcessor.prototype, 'read').mockResolvedValueOnce(deviceProfile) + + const value = await chooseDeviceProfileDefinition(command, undefined, 'device-profile-file') + + expect(chooseDeviceProfileMock).toHaveBeenCalledTimes(0) + expect(fileSpy).toHaveBeenCalledTimes(1) + expect(value).toBeDefined() + expect(value).toEqual({deviceProfileId: undefined, deviceProfile}) + }) +}) + +describe('chooseDevicePrototype function', () => { + const selectFromListMock = jest.mocked(selectFromList) + const command = {} as unknown as APICommand + + it('choose from default list prompt', async () => { + selectFromListMock.mockResolvedValueOnce('VIRTUAL_SWITCH') + + const value = await chooseDevicePrototype(command) + + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({primaryKeyName: 'id', sortKeyName: 'name'}), + expect.not.objectContaining({preselectedId: 'VIRTUAL_SWITCH'})) + expect(value).toBeDefined() + expect(value).toBe('VIRTUAL_SWITCH') + }) + + it('choose with command line value', async () => { + selectFromListMock.mockResolvedValueOnce('VIRTUAL_SWITCH') + + const value = await chooseDevicePrototype(command, 'VIRTUAL_SWITCH') + + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({primaryKeyName: 'id', sortKeyName: 'name'}), + expect.objectContaining({preselectedId: 'VIRTUAL_SWITCH'})) + expect(value).toBeDefined() + expect(value).toBe('VIRTUAL_SWITCH') + }) + + it('choose from extended list prompt', async () => { + selectFromListMock.mockResolvedValueOnce('more') + selectFromListMock.mockResolvedValueOnce('VIRTUAL_CONTACT_SENSOR') + + const value = await chooseDevicePrototype(command) + + expect(selectFromListMock).toHaveBeenCalledTimes(2) + expect(selectFromListMock).toHaveBeenNthCalledWith(1, command, + expect.objectContaining({primaryKeyName: 'id', sortKeyName: 'name'}), + expect.toBeObject()) + expect(selectFromListMock).toHaveBeenNthCalledWith(2, command, + expect.objectContaining({primaryKeyName: 'id', sortKeyName: 'name'}), + expect.toBeObject()) + expect(value).toBeDefined() + expect(value).toBe('VIRTUAL_CONTACT_SENSOR') + }) +}) + +describe('VirtualDevicesCommand', () => { + const outputListingMock = jest.mocked(outputListing) + const devices = [{ deviceId: 'device-id' }] as Device[] + const listSpy = jest.spyOn(DevicesEndpoint.prototype, 'list').mockResolvedValue(devices) + + it('virtual devices in all locations', async() => { + await expect(VirtualDevicesCommand.run([])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + }) + + it('use simple fields by default', async() => { + await expect(VirtualDevicesCommand.run([])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + expect(outputListingMock.mock.calls[0][1].listTableFieldDefinitions) + .toEqual(['label', 'deviceId']) + }) + + it('include location and room with verbose flag', async() => { + await expect(VirtualDevicesCommand.run(['--verbose'])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + expect(outputListingMock.mock.calls[0][1].listTableFieldDefinitions) + .toEqual(['label', 'deviceId', 'location', 'room']) + }) + + it('virtual devices uses location id in list', async() => { + outputListingMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction) => { + await listFunction() + }) + + await expect(VirtualDevicesCommand.run(['--location-id=location-id'])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({ + type: DeviceIntegrationType.VIRTUAL, + locationId: ['location-id'], + })) + }) + + it('virtual devices uses installed app id in list', async() => { + outputListingMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction) => { + await listFunction() + }) + + await expect(VirtualDevicesCommand.run(['--installed-app-id=installed-app-id'])).resolves.not.toThrow() + + expect(outputListingMock).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({ + type: DeviceIntegrationType.VIRTUAL, + installedAppId: 'installed-app-id', + })) + }) +}) diff --git a/packages/cli/src/__tests__/commands/devices/virtual/create-standard.test.ts b/packages/cli/src/__tests__/commands/devices/virtual/create-standard.test.ts new file mode 100644 index 000000000..499a9a65f --- /dev/null +++ b/packages/cli/src/__tests__/commands/devices/virtual/create-standard.test.ts @@ -0,0 +1,109 @@ +import { inputAndOutputItem } from '@smartthings/cli-lib' +import { + VirtualDeviceStandardCreateRequest, + VirtualDevicesEndpoint, +} from '@smartthings/core-sdk' +import VirtualDeviceCreateStandardCommand from '../../../../commands/devices/virtual/create-standard' +import { chooseDeviceName, chooseDevicePrototype, chooseRoom } from '../../../../commands/devices/virtual' +import { chooseLocation } from '../../../../commands/locations' + + +jest.mock('../../../../commands/locations') +jest.mock('../../../../commands/devices/virtual') + +describe('VirtualDeviceStandardCreateCommand', () => { + const mockInputAndOutputItem = jest.mocked(inputAndOutputItem) + const createSpy = jest.spyOn(VirtualDevicesEndpoint.prototype, 'createStandard').mockImplementation() + + it('calls correct endpoint', async () => { + const createRequest: VirtualDeviceStandardCreateRequest = { + name: 'Device Name', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + prototype: 'VIRTUAL_SWITCH', + } + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction) => { + await actionFunction(undefined, createRequest) + }) + + await expect(VirtualDeviceCreateStandardCommand.run([])).resolves.not.toThrow() + + expect(createSpy).toBeCalledWith(createRequest) + }) + + it('overwrites name, location, and room from command line', async () => { + const createRequest: VirtualDeviceStandardCreateRequest = { + name: 'Device Name', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + prototype: 'VIRTUAL_SWITCH', + } + + const expectedCreateRequest: VirtualDeviceStandardCreateRequest = { + name: 'NewDeviceName', + owner: { + ownerType: 'LOCATION', + ownerId: 'new-location-id', + }, + roomId: 'new-room-id', + prototype: 'VIRTUAL_SWITCH', + } + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction) => { + await actionFunction(undefined, createRequest) + }) + + await expect(VirtualDeviceCreateStandardCommand.run([ + '--name=NewDeviceName', + '--location-id=new-location-id', + '--room-id=new-room-id', + ])).resolves.not.toThrow() + + expect(createSpy).toBeCalledWith(expectedCreateRequest) + }) + + it('command line flag input', async () => { + const mockChooseDeviceName = jest.mocked(chooseDeviceName) + const mockChooseRoom = jest.mocked(chooseRoom) + const mockChooseLocation = jest.mocked(chooseLocation) + const mockChooseDevicePrototype = jest.mocked(chooseDevicePrototype) + + const expectedCreateRequest: VirtualDeviceStandardCreateRequest = { + name: 'DeviceName', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + roomId: 'room-id', + prototype: 'VIRTUAL_SWITCH', + } + + mockChooseDeviceName.mockResolvedValueOnce('DeviceName') + mockChooseLocation.mockResolvedValueOnce('location-id') + mockChooseRoom.mockResolvedValueOnce('room-id') + mockChooseDevicePrototype.mockResolvedValueOnce('VIRTUAL_SWITCH') + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + await expect(VirtualDeviceCreateStandardCommand.run([ + '--name=DeviceName', + '--location-id=location-id', + '--room-id=room-id', + '--prototype=VIRTUAL_SWITCH', + ])).resolves.not.toThrow() + + expect(mockChooseDeviceName).toBeCalledWith(expect.any(VirtualDeviceCreateStandardCommand), 'DeviceName') + expect(mockChooseLocation).toBeCalledWith(expect.any(VirtualDeviceCreateStandardCommand), 'location-id', true) + expect(mockChooseRoom).toBeCalledWith(expect.any(VirtualDeviceCreateStandardCommand), 'location-id', 'room-id', true) + expect(mockChooseDevicePrototype).toBeCalledWith(expect.any(VirtualDeviceCreateStandardCommand), 'VIRTUAL_SWITCH') + expect(createSpy).toBeCalledWith(expectedCreateRequest) + }) +}) diff --git a/packages/cli/src/__tests__/commands/devices/virtual/create.test.ts b/packages/cli/src/__tests__/commands/devices/virtual/create.test.ts new file mode 100644 index 000000000..de627b471 --- /dev/null +++ b/packages/cli/src/__tests__/commands/devices/virtual/create.test.ts @@ -0,0 +1,157 @@ +import { inputAndOutputItem } from '@smartthings/cli-lib' +import { VirtualDeviceCreateRequest, VirtualDevicesEndpoint } from '@smartthings/core-sdk' +import VirtualDeviceCreateCommand from '../../../../commands/devices/virtual/create' +import { chooseDeviceName, chooseDeviceProfileDefinition, chooseRoom } from '../../../../commands/devices/virtual' +import { chooseLocation } from '../../../../commands/locations' + + +jest.mock('../../../../commands/locations') +jest.mock('../../../../commands/devices/virtual') + +describe('VirtualDeviceCreateCommand', () => { + const mockInputAndOutputItem = jest.mocked(inputAndOutputItem) + const createSpy = jest.spyOn(VirtualDevicesEndpoint.prototype, 'create').mockImplementation() + + it('calls correct endpoint', async () => { + const createRequest: VirtualDeviceCreateRequest = { + name: 'Device Name', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + } + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction) => { + await actionFunction(undefined, createRequest) + }) + + await expect(VirtualDeviceCreateCommand.run([])).resolves.not.toThrow() + expect(mockInputAndOutputItem).toBeCalledWith( + expect.any(VirtualDeviceCreateCommand), + expect.anything(), + expect.any(Function), + expect.anything(), + ) + expect(createSpy).toBeCalledWith(createRequest) + }) + + it('overwrites name, location, and room from command line', async () => { + const createRequest: VirtualDeviceCreateRequest = { + name: 'Device Name', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + } + + const expectedCreateRequest: VirtualDeviceCreateRequest = { + name: 'NewDeviceName', + owner: { + ownerType: 'LOCATION', + ownerId: 'new-location-id', + }, + roomId: 'new-room-id', + } + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction) => { + await actionFunction(undefined, createRequest) + }) + + await expect(VirtualDeviceCreateCommand.run([ + '--name=NewDeviceName', + '--location-id=new-location-id', + '--room-id=new-room-id', + ])).resolves.not.toThrow() + + expect(mockInputAndOutputItem).toBeCalledWith( + expect.any(VirtualDeviceCreateCommand), + expect.anything(), + expect.any(Function), + expect.anything(), + ) + expect(createSpy).toBeCalledWith(expectedCreateRequest) + }) + + it('command line flag input with profile ID', async () => { + const mockChooseDeviceName = jest.mocked(chooseDeviceName) + const mockChooseRoom = jest.mocked(chooseRoom) + const mockChooseLocation = jest.mocked(chooseLocation) + const mockChooseDeviceProfileDefinition = jest.mocked(chooseDeviceProfileDefinition) + + const expectedCreateRequest: VirtualDeviceCreateRequest = { + name: 'DeviceName', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + roomId: 'room-id', + deviceProfileId: 'device-profile-id', + } + + mockChooseDeviceName.mockResolvedValueOnce('DeviceName') + mockChooseLocation.mockResolvedValueOnce('location-id') + mockChooseRoom.mockResolvedValueOnce('room-id') + mockChooseDeviceProfileDefinition.mockResolvedValueOnce({deviceProfileId: 'device-profile-id'}) + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + await expect(VirtualDeviceCreateCommand.run([ + '--name=DeviceName', + '--location-id=location-id', + '--room-id=room-id', + '--device-profile-id=device-profile-id', + ])).resolves.not.toThrow() + + expect(mockChooseDeviceName).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'DeviceName') + expect(mockChooseLocation).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'location-id', true) + expect(mockChooseRoom).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'location-id', 'room-id', true) + expect(mockChooseDeviceProfileDefinition).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'device-profile-id', undefined) + + expect(createSpy).toBeCalledWith(expectedCreateRequest) + }) + + it('command line flag input with profile definition', async () => { + const mockChooseDeviceName = jest.mocked(chooseDeviceName) + const mockChooseRoom = jest.mocked(chooseRoom) + const mockChooseLocation = jest.mocked(chooseLocation) + const mockChooseDeviceProfileDefinition = jest.mocked(chooseDeviceProfileDefinition) + + const expectedCreateRequest: VirtualDeviceCreateRequest = { + name: 'DeviceName', + owner: { + ownerType: 'LOCATION', + ownerId: 'location-id', + }, + roomId: 'room-id', + deviceProfile: {}, + } + + mockChooseDeviceName.mockResolvedValueOnce('DeviceName') + mockChooseLocation.mockResolvedValueOnce('location-id') + mockChooseRoom.mockResolvedValueOnce('room-id') + mockChooseDeviceProfileDefinition.mockResolvedValueOnce({deviceProfile: {}}) + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + await expect(VirtualDeviceCreateCommand.run([ + '--name=DeviceName', + '--location-id=location-id', + '--room-id=room-id', + '--device-profile-file=device-profile-filename', + ])).resolves.not.toThrow() + + expect(mockChooseDeviceName).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'DeviceName') + expect(mockChooseLocation).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'location-id', true) + expect(mockChooseRoom).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), 'location-id', 'room-id', true) + expect(mockChooseDeviceProfileDefinition).toBeCalledWith(expect.any(VirtualDeviceCreateCommand), undefined, 'device-profile-filename') + + expect(createSpy).toBeCalledWith(expectedCreateRequest) + }) + +}) diff --git a/packages/cli/src/__tests__/commands/devices/virtual/events.test.ts b/packages/cli/src/__tests__/commands/devices/virtual/events.test.ts new file mode 100644 index 000000000..e48881e29 --- /dev/null +++ b/packages/cli/src/__tests__/commands/devices/virtual/events.test.ts @@ -0,0 +1,370 @@ +import { + inputAndOutputItem, + selectFromList, +} from '@smartthings/cli-lib' +import { + CapabilitiesEndpoint, + CapabilitySchemaPropertyName, + DeviceEvent, DeviceIntegrationType, + DevicesEndpoint, + VirtualDevicesEndpoint, +} from '@smartthings/core-sdk' +import VirtualDeviceEventsCommand from '../../../../commands/devices/virtual/events' +import { CustomCapabilityStatus } from '@smartthings/core-sdk/dist/endpoint/capabilities' +import { + chooseAttribute, + chooseCapability, + chooseComponent, + chooseUnit, + chooseValue, +} from '../../../../lib/commands/devices/devices-util' + + +jest.mock('../../../../lib/commands/devices/devices-util') + +describe('VirtualDeviceEventsCommand', () => { + const mockSelectFromList = jest.mocked(selectFromList) + + const mockInputAndOutputItem = jest.mocked(inputAndOutputItem) + const createEventsSpy = jest.spyOn(VirtualDevicesEndpoint.prototype, 'createEvents').mockImplementation() + const getCapabilitySpy = jest.spyOn(CapabilitiesEndpoint.prototype, 'get') + const getDeviceSpy = jest.spyOn(DevicesEndpoint.prototype, 'get') + + it('calls correct endpoint', async () => { + const createRequest: DeviceEvent[] = [ + { + component: 'main', + capability: 'switch', + attribute: 'switch', + value: 'on', + }, + ] + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction) => { + await actionFunction(undefined, createRequest) + }) + + mockSelectFromList.mockResolvedValueOnce('device-id') + + await expect(VirtualDeviceEventsCommand.run(['device-id'])).resolves.not.toThrow() + + expect(mockInputAndOutputItem).toBeCalledWith( + expect.any(VirtualDeviceEventsCommand), + expect.anything(), + expect.any(Function), + expect.anything(), + ) + + expect(mockSelectFromList).toHaveBeenCalledTimes(1) + expect(mockSelectFromList).toBeCalledWith( + expect.any(VirtualDeviceEventsCommand), + expect.objectContaining({ + primaryKeyName: 'deviceId', + sortKeyName: 'label', + }), + expect.objectContaining({ + preselectedId: 'device-id', + }), + ) + expect(createEventsSpy).toHaveBeenCalledTimes(1) + expect(createEventsSpy).toBeCalledWith('device-id', createRequest) + }) + + it('string command line argument input', async () => { + const expectedCreateRequest: DeviceEvent[] = [ + { + component: 'main', + capability: 'switch', + attribute: 'switch', + value: 'on', + }, + ] + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + mockSelectFromList.mockResolvedValueOnce('device-id') + getCapabilitySpy.mockResolvedValueOnce({ + id: 'switch', + version: 1, + status: CustomCapabilityStatus.LIVE, + name: 'Switch', + attributes: { + switch: { + schema: { + type: 'object', + properties: { + value: { + title: 'IntegerPercent', + type: 'string', + enum: ['on', 'off'], + }, + }, + additionalProperties: false, + required: [ + CapabilitySchemaPropertyName.VALUE, + ], + }, + enumCommands: [], + }, + }, + commands: {}, + }) + + await expect(VirtualDeviceEventsCommand.run(['device-id', 'switch:switch', 'on'])).resolves.not.toThrow() + + expect(createEventsSpy).toHaveBeenCalledTimes(1) + expect(createEventsSpy).toBeCalledWith('device-id', expectedCreateRequest) + }) + + it('integer command line argument input', async () => { + const expectedCreateRequest: DeviceEvent[] = [ + { + component: 'main', + capability: 'switchLevel', + attribute: 'level', + value: 80, + }, + ] + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + mockSelectFromList.mockResolvedValueOnce('device-id') + getCapabilitySpy.mockResolvedValueOnce({ + id: 'switchLevel', + version: 1, + status: CustomCapabilityStatus.LIVE, + name: 'Switch Level', + attributes: { + level: { + schema: { + title: 'IntegerPercent', + type: 'object', + properties: { + value: { + type: 'integer', + minimum: 0, + maximum: 100, + }, + unit: { + type: 'string', + enum: [ + '%', + ], + default: '%', + }, + }, + additionalProperties: false, + required: [ + CapabilitySchemaPropertyName.VALUE, + ], + }, + enumCommands: [], + }, + }, + commands: {}, + }) + + await expect(VirtualDeviceEventsCommand.run(['device-id', 'switchLevel:level', '80'])).resolves.not.toThrow() + + expect(createEventsSpy).toHaveBeenCalledTimes(1) + expect(createEventsSpy).toBeCalledWith('device-id', expectedCreateRequest) + }) + + it('float command line argument input', async () => { + const expectedCreateRequest: DeviceEvent[] = [ + { + component: 'main', + capability: 'temperatureMeasurement', + attribute: 'temperature', + value: 72.5, + unit: 'F', + }, + ] + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + mockSelectFromList.mockResolvedValueOnce('device-id') + getCapabilitySpy.mockResolvedValueOnce({ + id: 'temperatureMeasurement', + version: 1, + status: CustomCapabilityStatus.LIVE, + name: 'Temperature Measurement', + attributes: { + temperature: { + schema: { + type: 'object', + properties: { + value: { + title: 'TemperatureValue', + type: 'number', + minimum: -460, + maximum: 10000, + }, + unit: { + type: 'string', + enum: [ + 'F', + 'C', + ], + }, + }, + additionalProperties: false, + required: [ + CapabilitySchemaPropertyName.VALUE, + CapabilitySchemaPropertyName.UNIT, + ], + }, + enumCommands: [], + }, + }, + commands: {}, + }) + + await expect(VirtualDeviceEventsCommand.run(['device-id', 'temperatureMeasurement:temperature', '72.5', 'F'])).resolves.not.toThrow() + + expect(createEventsSpy).toHaveBeenCalledTimes(1) + expect(createEventsSpy).toBeCalledWith('device-id', expectedCreateRequest) + }) + + it('interactive input', async () => { + const mockChooseComponent = jest.mocked(chooseComponent) + const mockChooseCapability = jest.mocked(chooseCapability) + const mockChooseAttribute = jest.mocked(chooseAttribute) + const mockChooseValue = jest.mocked(chooseValue) + const mockChooseUnit = jest.mocked(chooseUnit) + const expectedCreateRequest: DeviceEvent[] = [ + { + component: 'main', + capability: 'temperatureMeasurement', + attribute: 'temperature', + value: 72.5, + unit: 'F', + }, + ] + + mockInputAndOutputItem.mockImplementationOnce(async (_command, _config, actionFunction, inputProcessor) => { + const data = await inputProcessor.read() + await actionFunction(undefined, data) + }) + + getDeviceSpy.mockResolvedValueOnce({ + deviceId: 'fba9a4e6-2ec6-4b81-9fe5-f8a0c555797a', + name: 'Temperature Sensor', + label: 'Temperature Sensor', + manufacturerName: 'SmartThings', + type: DeviceIntegrationType.VIRTUAL, + presentationId: '21577123-03b3-4eb2-9bef-8251b87273fd', + restrictionTier: 0, + components: [ + { + id: 'main', + label: 'Main', + capabilities: [ + { + id: 'temperatureMeasurement', + version: 1, + }, + ], + categories: [], + }, + ], + }) + + getCapabilitySpy.mockResolvedValueOnce({ + id: 'temperatureMeasurement', + version: 1, + status: CustomCapabilityStatus.LIVE, + name: 'Temperature Measurement', + attributes: { + temperature: { + schema: { + type: 'object', + properties: { + value: { + title: 'TemperatureValue', + type: 'number', + minimum: -460, + maximum: 10000, + }, + unit: { + type: 'string', + enum: [ + 'F', + 'C', + ], + }, + }, + additionalProperties: false, + required: [ + CapabilitySchemaPropertyName.VALUE, + CapabilitySchemaPropertyName.UNIT, + ], + }, + enumCommands: [], + }, + }, + commands: {}, + }) + + mockSelectFromList.mockResolvedValueOnce('device-id') + mockChooseComponent.mockResolvedValueOnce({ + id: 'main', + capabilities: [ + { + id: 'temperatureMeasurement', + version: 1, + }, + ], + categories: [], + }) + mockChooseCapability.mockResolvedValueOnce({ + id: 'temperatureMeasurement', + version: 1, + }) + mockChooseAttribute.mockResolvedValueOnce({ + attributeName: 'temperature', + attribute: { + schema: { + type: 'object', + properties: { + value: { + title: 'TemperatureValue', + type: 'number', + minimum: -460, + maximum: 10000, + }, + unit: { + type: 'string', + enum: [ + 'F', + 'C', + ], + }, + }, + additionalProperties: false, + required: [ + CapabilitySchemaPropertyName.VALUE, + CapabilitySchemaPropertyName.UNIT, + ], + }, + enumCommands: [], + }, + }) + mockChooseValue.mockResolvedValueOnce('72.5') + mockChooseUnit.mockResolvedValueOnce('F') + + await expect(VirtualDeviceEventsCommand.run(['device-id'])).resolves.not.toThrow() + + expect(createEventsSpy).toHaveBeenCalledTimes(1) + expect(createEventsSpy).toBeCalledWith('device-id', expectedCreateRequest) + }) +}) diff --git a/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts b/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts index 48ea1a932..2d0192bd7 100644 --- a/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts +++ b/packages/cli/src/__tests__/lib/commands/devices/devices-util.test.ts @@ -1,10 +1,19 @@ +import inquirer from 'inquirer' + import Table from 'cli-table' -import { Device } from '@smartthings/core-sdk' +import {Device, DeviceIntegrationType} from '@smartthings/core-sdk' -import { summarizedText, TableGenerator } from '@smartthings/cli-lib' +import {APICommand, selectFromList, summarizedText, TableGenerator} from '@smartthings/cli-lib' -import { buildTableOutput } from '../../../../lib/commands/devices/devices-util' +import { + buildTableOutput, + chooseAttribute, + chooseCapability, + chooseComponent, + chooseUnit, + chooseValue, +} from '../../../../lib/commands/devices/devices-util' describe('devices-util', () => { @@ -255,4 +264,308 @@ describe('devices-util', () => { it.todo('joins multiple component capabilities with newlines') it.todo('joins multiple children with newlines') }) + + describe('chooseComponent', () => { + const command = { } as unknown as APICommand + const selectFromListMock = jest.mocked(selectFromList) + + it('returns single component when only one', async () => { + selectFromListMock.mockImplementation(async () => 'main') + const device: Device = { + deviceId: 'device-id', + presentationId: 'presentation-id', + manufacturerName: 'manufacturer-name', + restrictionTier: 1, + type: DeviceIntegrationType.VIRTUAL, + components: [ + { + id: 'main', + capabilities: [], + categories: [], + }, + ], + } + + const component = await chooseComponent(command, device) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'id', sortKeyName: 'id' }), + expect.objectContaining({ preselectedId: 'main' })) + expect(component).toBeDefined() + expect(component.id).toBe('main') + }) + + it('prompts prompts when multiple components', async () => { + selectFromListMock.mockImplementation(async () => 'channel1') + + const device: Device = { + deviceId: 'device-id', + presentationId: 'presentation-id', + manufacturerName: 'manufacturer-name', + restrictionTier: 1, + type: DeviceIntegrationType.VIRTUAL, + components: [ + { + id: 'main', + capabilities: [], + categories: [], + }, + { + id: 'channel1', + capabilities: [], + categories: [], + }, + { + id: 'channel2', + capabilities: [], + categories: [], + }, + ], + } + + const component = await chooseComponent(command, device) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'id', sortKeyName: 'id' }), + expect.not.objectContaining({ preselectedId: 'main' })) + expect(component).toBeDefined() + expect(component.id).toBe('channel1') + }) + }) + + describe('chooseCapability', () => { + const command = { } as unknown as APICommand + const selectFromListMock = jest.mocked(selectFromList) + + it('returns single capability when only one', async () => { + selectFromListMock.mockImplementation(async () => 'switch') + const component = { + id: 'main', + capabilities: [ + { + id: 'switch', + version: 1, + }, + ], + categories: [], + } + + const capability = await chooseCapability(command, component) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'id', sortKeyName: 'id' }), + expect.objectContaining({ preselectedId: 'switch' })) + expect(capability).toBeDefined() + expect(capability.id).toBe('switch') + }) + + it('prompts when multiple capabilities', async () => { + selectFromListMock.mockImplementation(async () => 'switchLevel') + + const component = { + id: 'main', + capabilities: [ + { + id: 'switch', + version: 1, + }, + { + id: 'switchLevel', + version: 1, + }, + ], + categories: [], + } + + const capability = await chooseCapability(command, component) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'id', sortKeyName: 'id' }), + expect.not.objectContaining({ preselectedId: expect.anything() })) + expect(capability).toBeDefined() + expect(capability.id).toBe('switchLevel') + }) + }) + + describe('chooseAttribute', () => { + const selectFromListMock = jest.mocked(selectFromList) + const getCapabilityMock = jest.fn() + const client = { capabilities: { get: getCapabilityMock } } + const command = { client } as unknown as APICommand + + it('returns single attribute when only one', async () => { + selectFromListMock.mockImplementation(async () => 'switch') + const capabilityReference = { + id: 'switch', + version: 1, + } + const capabilityDefinition = { + id: 'switch', + version: 1, + attributes: { + switch: { + schema: { + type: 'object', + properties: { + value: { + title: 'SwitchState', + type: 'string', + enum: [ + 'on', + 'off', + ], + }, + }, + }, + }, + }, + } + getCapabilityMock.mockImplementation( async() => capabilityDefinition) + + const attribute = await chooseAttribute(command, capabilityReference) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'attributeName', sortKeyName: 'attributeName' }), + expect.objectContaining({ preselectedId: 'switch' })) + expect(attribute).toBeDefined() + expect(attribute.attributeName).toBe('switch') + }) + }) + + describe('chooseValue', () => { + const selectFromListMock = jest.mocked(selectFromList) + const command = { } as unknown as APICommand + + it('enum value', async () => { + selectFromListMock.mockImplementation(async () => 'on') + const attribute = { + schema: { + type: 'object', + properties: { + value: { + title: 'SwitchState', + type: 'string', + enum: [ + 'on', + 'off', + ], + }, + }, + additionalProperties: false, + }, + } + + const value = await chooseValue(command, attribute, 'switch') + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'value', sortKeyName: 'value' }), + expect.toBeObject()) + expect(value).toBeDefined() + expect(value).toBe('on') + }) + + it('numeric value', async () => { + const promptSpy = jest.spyOn(inquirer, 'prompt') + promptSpy.mockResolvedValue({ value: '72' }) + const attribute = { + schema: { + type: 'object', + properties: { + value: { + title: 'TemperatureValue', + type: 'number', + minimum: -460, + maximum: 10000, + }, + unit: { + type: 'string', + enum: [ + 'F', + 'C', + ], + }, + }, + additionalProperties: false, + }, + } + + const value = await chooseValue(command, attribute, 'temperature') + expect(selectFromListMock).toHaveBeenCalledTimes(0) + expect(promptSpy).toHaveBeenCalledTimes(1) + expect(promptSpy).toHaveBeenCalledWith({ + type: 'input', name: 'value', + message: 'Enter \'temperature\' attribute value:', + }) + expect(value).toBeDefined() + expect(value).toBe('72') + }) + }) + + describe('chooseUnit', () => { + const selectFromListMock = jest.mocked(selectFromList) + const command = { } as unknown as APICommand + + it('prompts when multiple units', async () => { + selectFromListMock.mockImplementation(async () => 'F') + const attribute = { + schema: { + type: 'object', + properties: { + value: { + title: 'TemperatureValue', + type: 'number', + minimum: -460, + maximum: 10000, + }, + unit: { + type: 'string', + enum: [ + 'F', + 'C', + ], + }, + }, + additionalProperties: false, + }, + } + + const unit = await chooseUnit(command, attribute) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'unit', sortKeyName: 'unit' }), + expect.not.objectContaining({ preselectedId: expect.anything() })) + expect(unit).toBeDefined() + expect(unit).toBe('F') + }) + + it('no prompt when only one unit', async () => { + selectFromListMock.mockImplementation(async () => 'ppm') + const attribute = { + schema: { + type: 'object', + properties: { + value: { + type: 'number', + minimum: 0, + maximum: 1000000, + }, + unit: { + type: 'string', + enum: ['ppm'], + }, + }, + additionalProperties: false, + }, + } + + const unit = await chooseUnit(command, attribute) + expect(selectFromListMock).toHaveBeenCalledTimes(1) + expect(selectFromListMock).toHaveBeenCalledWith(command, + expect.objectContaining({ primaryKeyName: 'unit', sortKeyName: 'unit' }), + expect.objectContaining({ preselectedId: 'ppm' })) + expect(unit).toBeDefined() + expect(unit).toBe('ppm') + }) + }) }) diff --git a/packages/cli/src/commands/devices/virtual.ts b/packages/cli/src/commands/devices/virtual.ts new file mode 100644 index 000000000..e44a24727 --- /dev/null +++ b/packages/cli/src/commands/devices/virtual.ts @@ -0,0 +1,137 @@ +import { Flags } from '@oclif/core' +import inquirer from 'inquirer' +import { + Device, + DeviceIntegrationType, + DeviceListOptions, DeviceProfile, DeviceProfileCreateRequest, +} from '@smartthings/core-sdk' +import { + APICommand, + APIOrganizationCommand, + FileInputProcessor, + outputListing, + selectFromList, + withLocationsAndRooms, +} from '@smartthings/cli-lib' +import { buildTableOutput } from '../../lib/commands/devices/devices-util' +import { chooseDeviceProfile } from '../deviceprofiles' +import {allPrototypes, locallyExecutingPrototypes} from './virtual/create-standard' + + +export async function chooseDeviceName(command: APICommand, preselectedName?: string): Promise { + if (!preselectedName) { + preselectedName = (await inquirer.prompt({ + type: 'input', + name: 'deviceName', + message: 'Device Name:', + })).deviceName + } + return preselectedName +} + +export async function chooseRoom(command: APICommand, locationId: string, preselectedId?: string, autoChoose?: boolean): Promise { + const config = { + itemName: 'room', + primaryKeyName: 'roomId', + sortKeyName: 'name', + } + return selectFromList(command, config, { + preselectedId, + autoChoose, + listItems: () => command.client.rooms.list(locationId), + }) +} + +export interface DeviceProfileDefinition { + deviceProfileId?: string + deviceProfile?: DeviceProfileCreateRequest +} + +export async function chooseDeviceProfileDefinition(command: APIOrganizationCommand, deviceProfileId?: string, deviceProfileFile?: string): Promise { + let deviceProfile + + if (deviceProfileFile) { + const inputProcessor = new FileInputProcessor(deviceProfileFile) + deviceProfile = await inputProcessor.read() + } else if (!deviceProfileId) { + deviceProfileId = await chooseDeviceProfile(command, deviceProfileId, {allowIndex: true}) + } + + return {deviceProfileId, deviceProfile} +} + +export async function chooseDevicePrototype(command: APICommand, preselectedId?: string): Promise { + const config = { + itemName: 'device prototype', + primaryKeyName: 'id', + sortKeyName: 'name', + } + let prototype = await selectFromList(command, config, { + preselectedId, + listItems: () => Promise.resolve(locallyExecutingPrototypes), + }) + + if (prototype === 'more') { + prototype = await selectFromList(command, config, { + listItems: () => Promise.resolve(allPrototypes), + }) + } + + return prototype +} + +export default class VirtualDevicesCommand extends APICommand { + static description = 'list all devices available in a user account or retrieve a single device' + + static flags = { + ...APICommand.flags, + ...outputListing.flags, + 'location-id': Flags.string({ + char: 'l', + description: 'filter results by location', + multiple: true, + }), + 'installed-app-id': Flags.string({ + char: 'a', + description: 'filter results by installed app that created the device', + }), + verbose: Flags.boolean({ + description: 'include location name in output', + char: 'v', + }), + } + + static args = [{ + name: 'id', + description: 'device to retrieve; UUID or the number of the device from list', + }] + + async run(): Promise { + const config = { + primaryKeyName: 'deviceId', + sortKeyName: 'label', + listTableFieldDefinitions: ['label', 'deviceId'], + buildTableOutput: (data: Device) => buildTableOutput(this.tableGenerator, data), + } + if (this.flags.verbose) { + config.listTableFieldDefinitions.splice(3, 0, 'location', 'room') + } + + const deviceListOptions: DeviceListOptions = { + locationId: this.flags['location-id'], + installedAppId: this.flags['installed-app-id'], + type: DeviceIntegrationType.VIRTUAL, + } + + await outputListing(this, config, this.args.id, + async () => { + const devices = await this.client.devices.list(deviceListOptions) + if (this.flags.verbose) { + return await withLocationsAndRooms(this.client, devices) + } + return devices + }, + id => this.client.devices.get(id), + ) + } +} diff --git a/packages/cli/src/commands/devices/virtual/create-standard.ts b/packages/cli/src/commands/devices/virtual/create-standard.ts new file mode 100644 index 000000000..2979d3e76 --- /dev/null +++ b/packages/cli/src/commands/devices/virtual/create-standard.ts @@ -0,0 +1,123 @@ +import { Flags } from '@oclif/core' +import { + Device, + VirtualDeviceStandardCreateRequest, +} from '@smartthings/core-sdk' +import { + APICommand, + InferredFlagsType, + inputAndOutputItem, + userInputProcessor, +} from '@smartthings/cli-lib' +import { buildTableOutput } from '../../../lib/commands/devices/devices-util' +import { chooseLocation } from '../../locations' +import { chooseDeviceName, chooseDevicePrototype, chooseRoom } from '../virtual' + + +export const locallyExecutingPrototypes = [ + {name: 'Switch', id: 'VIRTUAL_SWITCH'}, + {name: 'Dimmer', id: 'VIRTUAL_DIMMER_SWITCH'}, + {name: 'Testing devices...', id: 'more'}, +] + +export const allPrototypes = [ + {name: 'Switch', id: 'VIRTUAL_SWITCH'}, + {name: 'Dimmer (only)', id: 'VIRTUAL_DIMMER'}, + {name: 'Dimmer Switch', id: 'VIRTUAL_DIMMER_SWITCH'}, + {name: 'Camera', id: 'VIRTUAL_CAMERA'}, + {name: 'Color Bulb', id: 'VIRTUAL_COLOR_BULB'}, + {name: 'Metered Switch', id: 'VIRTUAL_METERED_SWITCH'}, + {name: 'Motion Sensor', id: 'VIRTUAL_MOTION_SENSOR'}, + {name: 'Multi-Sensor', id: 'VIRTUAL_MULTI_SENSOR'}, + {name: 'Refrigerator', id: 'VIRTUAL_REFRIGERATOR'}, + {name: 'RGBW Bulb', id: 'VIRTUAL_RGBW_BULB'}, + {name: 'Button', id: 'VIRTUAL_BUTTON'}, + {name: 'Presence Sensor', id: 'VIRTUAL_PRESENCE_SENSOR'}, + {name: 'Contact Sensor', id: 'VIRTUAL_CONTACT_SENSOR'}, + {name: 'Garage Door Opener', id: 'VIRTUAL_GARAGE_DOOR_OPENER'}, + {name: 'Thermostat', id: 'VIRTUAL_THERMOSTAT'}, + {name: 'Lock', id: 'VIRTUAL_LOCK'}, + {name: 'Siren', id: 'VIRTUAL_SIREN'}, +] + +export default class VirtualDeviceCreateStandardCommand extends APICommand { + static description = 'create a virtual device from a standard prototype' + + static examples = [ + '$ smartthings devices:virtual:create-standard # interactive mode', + '$ smartthings devices:virtual:create-standard -i data.yml # using request body from a YAML file', + '$ smartthings devices:virtual:create-standard -N "My Device" -i data.yml # using file request body with "My Device" for the name', + '$ smartthings devices:virtual:create-standard \\ # using command line parameters for everything\n' + + '> --name="My Second Device" \\ \n' + + '> --prototype=VIRTUAL_SWITCH \\ \n' + + '> --location-id=95bdd473-4498-42fc-b932-974d6e5c236e \\ \n' + + '> --room-id=c7266cb7-7dcc-4958-8bc4-4288f5b50e1b', + ] + + static flags = { + ...APICommand.flags, + ...inputAndOutputItem.flags, + name: Flags.string({ + char: 'N', + description: 'name of the device to be created', + }), + 'location-id': Flags.string({ + char: 'l', + description: 'location into which device should be created', + }), + 'room-id': Flags.string({ + char: 'R', + description: 'the room to put the device into', + }), + prototype: Flags.string({ + char: 'T', + description: 'standard device prototype, e.g. VIRTUAL_SWITCH or VIRTUAL_DIMMER_SWITCH', + }), + } + + async run(): Promise { + const createDevice = async (_: void, data: VirtualDeviceStandardCreateRequest): Promise => { + return this.client.virtualDevices.createStandard(this.mergeCreateFlagValues(this.flags, data)) + } + + await inputAndOutputItem(this, + { + buildTableOutput: (data: Device) => buildTableOutput(this.tableGenerator, data), + }, + createDevice, userInputProcessor(this)) + } + + mergeCreateFlagValues(flags: InferredFlagsType, data: VirtualDeviceStandardCreateRequest) : VirtualDeviceStandardCreateRequest { + if (flags.name) { + data.name = flags.name + } + if (flags['location-id']) { + data.owner.ownerId = flags['location-id'] + } + if (flags['room-id']) { + data.roomId = flags['room-id'] + } + return data + } + + async getInputFromUser(): Promise { + const name = await chooseDeviceName(this, this.flags.name) + const prototype = await chooseDevicePrototype(this, this.flags['prototype']) + const locationId = await chooseLocation(this, this.flags['location-id'], true) + const roomId = await chooseRoom(this, locationId, this.flags['room-id'], true) + + if (name && prototype && locationId) { + return { + name, + roomId, + prototype, + owner: { + ownerType: 'LOCATION', + ownerId: locationId, + }, + } as VirtualDeviceStandardCreateRequest + } else { + throw new Error('Incomplete prototype definition') + } + } +} diff --git a/packages/cli/src/commands/devices/virtual/create.ts b/packages/cli/src/commands/devices/virtual/create.ts new file mode 100644 index 000000000..12ec5b55e --- /dev/null +++ b/packages/cli/src/commands/devices/virtual/create.ts @@ -0,0 +1,110 @@ +import { Flags } from '@oclif/core' +import { + Device, + VirtualDeviceCreateRequest, +} from '@smartthings/core-sdk' +import { + APIOrganizationCommand, + InferredFlagsType, + inputAndOutputItem, + userInputProcessor, +} from '@smartthings/cli-lib' +import { buildTableOutput } from '../../../lib/commands/devices/devices-util' + +import { chooseLocation } from '../../locations' +import { chooseDeviceName, chooseDeviceProfileDefinition, chooseRoom } from '../virtual' + + +export default class VirtualDeviceCreateCommand extends APIOrganizationCommand { + static description = 'Creates a virtual device from a device profile ID or definition\n' + + 'The command can be run interactively, in question & answer mode, with command line parameters,\n' + + 'or with input from a file or standard in. You can also combine command line input with file input\n' + + 'so that you can create multiple devices with different names in different locations and rooms using\n' + + 'the same input file.' + + static examples = [ + '$ smartthings devices:virtual:create # interactive mode', + '$ smartthings devices:virtual:create -i data.yml # using request body from a YAML file', + '$ smartthings devices:virtual:create -N "My Device" -i data.yml # using file request body with "My Device" for the name', + '$ smartthings devices:virtual:create \\ # using command line parameters for everything\n' + + '> --name="My Second Device" \\ \n' + + '> --device-profile-id=7633ef68-6433-47ab-89c3-deb04b8b0d61 \\ \n' + + '> --location-id=95bdd473-4498-42fc-b932-974d6e5c236e \\ \n' + + '> --room-id=c7266cb7-7dcc-4958-8bc4-4288f5b50e1b', + '$ smartthings devices:virtual:create -f profile.yml # using a device profile and prompting for the remaining values', + ] + + static flags = { + ...APIOrganizationCommand.flags, + ...inputAndOutputItem.flags, + name: Flags.string({ + char: 'N', + description: 'name of the device to be created', + }), + 'location-id': Flags.string({ + char: 'l', + description: 'location into which device should be created', + }), + 'room-id': Flags.string({ + char: 'R', + description: 'the room to put the device into', + }), + 'device-profile-id': Flags.string({ + char: 'P', + description: 'the device profile ID', + }), + 'device-profile-file': Flags.string({ + char: 'f', + description: 'a file containing the device profile definition', + }), + } + + async run(): Promise { + const createDevice = async (_: void, data: VirtualDeviceCreateRequest): Promise => { + return this.client.virtualDevices.create(this.mergeCreateFlagValues(this.flags, data)) + } + + await inputAndOutputItem(this, + { + buildTableOutput: (data: Device) => buildTableOutput(this.tableGenerator, data), + }, + createDevice, userInputProcessor(this)) + } + + mergeCreateFlagValues(flags: InferredFlagsType, data: VirtualDeviceCreateRequest) : VirtualDeviceCreateRequest { + + if (flags.name) { + data.name = flags.name + } + if (flags['location-id']) { + data.owner.ownerId = flags['location-id'] + } + if (flags['room-id']) { + data.roomId = flags['room-id'] + } + return data + } + + async getInputFromUser(): Promise { + const name = await chooseDeviceName(this, this.flags.name) + const {deviceProfileId, deviceProfile} = await chooseDeviceProfileDefinition(this, + this.flags['device-profile-id'], this.flags['device-profile-file']) + const locationId = await chooseLocation(this, this.flags['location-id'], true) + const roomId = await chooseRoom(this, locationId, this.flags['room-id'], true) + + if (name && locationId && (deviceProfileId || deviceProfile)) { + return { + name, + roomId, + deviceProfileId, + deviceProfile, + owner: { + ownerType: 'LOCATION', + ownerId: locationId, + }, + } as VirtualDeviceCreateRequest + } else { + throw new Error('Incomplete device definition') + } + } +} diff --git a/packages/cli/src/commands/devices/virtual/events.ts b/packages/cli/src/commands/devices/virtual/events.ts new file mode 100644 index 000000000..53eddbeb1 --- /dev/null +++ b/packages/cli/src/commands/devices/virtual/events.ts @@ -0,0 +1,182 @@ +import { + DeviceEvent, + DeviceIntegrationType, +} from '@smartthings/core-sdk' +import { + APICommand, + inputAndOutputItem, + inputProcessor, + selectFromList, + TableGenerator, +} from '@smartthings/cli-lib' +import { VirtualDeviceEventsResponse } from '@smartthings/core-sdk/dist/endpoint/virtualdevices' +import { + chooseAttribute, + chooseCapability, + chooseComponent, + chooseUnit, + chooseValue, +} from '../../../lib/commands/devices/devices-util' + + +function buildTableOutput(tableGenerator: TableGenerator, data: EventInputOutput): string { + const {input, output} = data + const table = tableGenerator.newOutputTable({head: ['Component','Capability','Attribute','Value','State Change?']}) + const {stateChanges} = output + for (const index in input) { + const event = input[index] + const isStateChange = parseInt(index) < stateChanges.length ? stateChanges[index] : 'undefined' + table.push([event.component, event.capability, event.attribute, event.value, isStateChange]) + } + return table.toString() +} + +interface EventInputOutput { + input: DeviceEvent[] + output: VirtualDeviceEventsResponse +} + +export default class VirtualDeviceEventsCommand extends APICommand { + static description = 'Create events for a virtual device\n' + + 'The command can be run interactively, in question & answer mode, with command line parameters,\n' + + 'or with input from a file or standard in.' + + static examples = [ + '$ smartthings devices:virtual:events # interactive mode', + '$ smartthings devices:virtual:events {id} -i data.yml # from a YAML or JSON file', + '$ smartthings devices:virtual:events {id} switch:switch on # command line input', + '$ smartthings devices:virtual:events {id} temperatureMeasurement:temperature 22.5 C # command line input', + ] + + static flags = { + ...APICommand.flags, + ...inputAndOutputItem.flags, + } + + static args = [ + { + name: 'id', + description: 'the device id', + }, + { + name: 'name', + description: 'the fully qualified attribute name []::', + }, + { + name: 'value', + description: 'the attribute value', + }, + { + name: 'unit', + description: 'optional unit of measure', + }, + ] + + async run(): Promise { + const config = { + primaryKeyName: 'deviceId', + sortKeyName: 'label', + listTableFieldDefinitions: ['label', 'name', 'type', 'deviceId'], + } + + const deviceId = await selectFromList(this, config, { + preselectedId: this.args.id, + listItems: () => this.client.devices.list({type: DeviceIntegrationType.VIRTUAL}), + }) + + const createEvents = async (_: void, input: DeviceEvent[]): Promise => { + const output = await this.client.virtualDevices.createEvents(deviceId, input) + return { + input, + output, + } + } + await inputAndOutputItem(this, { + buildTableOutput: (data: EventInputOutput) => buildTableOutput(this.tableGenerator, data), + }, createEvents, inputProcessor(() => true, () => this.getInputFromUser(deviceId))) + } + + async getInputFromUser(deviceId: string): Promise { + const attributeName = this.args.name + const attributeValue = this.args.value + let events: DeviceEvent[] = [] + + if (attributeName) { + if (attributeValue) { + const event = await this.parseDeviceEvent(attributeName, attributeValue, this.args.unit) + events = [event] + } else { + throw new Error('Attribute name specified without attribute value') + } + } + else { + const device = await this.client.devices.get(deviceId) + const component = await chooseComponent(this, device) + const capability = await chooseCapability(this, component) + const { attributeName, attribute } = await chooseAttribute(this, capability) + const value = await chooseValue(this, attribute, attributeName) + const unit = await chooseUnit(this, attribute) + + events = [ + { + component: component.id, + capability: capability.id, + attribute: attributeName, + value: this.convertAttributeValue(attribute.schema.properties.value.type, value), + unit, + }, + ] + } + return events + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + convertAttributeValue(attributeType: string | undefined, attributeValue: string): any { + switch (attributeType) { + case 'integer': + return parseInt(attributeValue) + case 'number': + return parseFloat(attributeValue) + case 'array': + case 'object': + return JSON.parse(attributeValue) + default: + return attributeValue + } + } + + async parseDeviceEvent(attributeName: string, attributeValue: string, unitOfMeasure: string): Promise { + const segments = attributeName.split(':') + if (segments.length < 2 || segments.length > 3) { + throw new Error('Invalid attribute name') + } + + const event: DeviceEvent = segments.length === 3 ? { + component: segments[0], + capability: segments[1], + attribute: segments[2], + value: attributeValue, + unit: unitOfMeasure, + } : { + component: 'main', + capability: segments[0], + attribute: segments[1], + value: attributeValue, + unit: unitOfMeasure, + } + + const capability = await this.client.capabilities.get(event.capability, 1) + if (!capability || !capability.attributes) { + throw new Error(`Capability ${event.capability} not valid`) + } + + const attribute = capability.attributes[event.attribute] + if (!attribute) { + throw new Error(`Attribute ${attributeName} not found in capability ${capability}`) + } + + event.value = this.convertAttributeValue(attribute.schema.properties.value.type, attributeValue) + + return event + } +} diff --git a/packages/cli/src/commands/locations.ts b/packages/cli/src/commands/locations.ts index 832686447..6a2be9321 100644 --- a/packages/cli/src/commands/locations.ts +++ b/packages/cli/src/commands/locations.ts @@ -8,7 +8,7 @@ export const tableFieldDefinitions = [ 'latitude', 'longitude', 'regionRadius', 'temperatureScale', 'locale', ] -export async function chooseLocation(command: APICommand, preselectedId?: string): Promise { +export async function chooseLocation(command: APICommand, preselectedId?: string, autoChoose?: boolean): Promise { const config = { itemName: 'location', primaryKeyName: 'locationId', @@ -16,6 +16,7 @@ export async function chooseLocation(command: APICommand command.client.locations.list(), }) } diff --git a/packages/cli/src/lib/commands/devices/devices-util.ts b/packages/cli/src/lib/commands/devices/devices-util.ts index fba585301..efe40280e 100644 --- a/packages/cli/src/lib/commands/devices/devices-util.ts +++ b/packages/cli/src/lib/commands/devices/devices-util.ts @@ -1,10 +1,29 @@ -import { Device } from '@smartthings/core-sdk' +import inquirer from 'inquirer' +import {CapabilityAttribute, CapabilityReference, Component, Device} from '@smartthings/core-sdk' -import { summarizedText, TableGenerator } from '@smartthings/cli-lib' +import { + APICommand, + selectFromList, + summarizedText, + TableGenerator, +} from '@smartthings/cli-lib' export type DeviceWithLocation = Device & { location?: string } +export interface CapabilityAttributeItem { + attributeName: string + attribute: CapabilityAttribute +} + +export interface CapabilityUnitItem { + unit: string +} + +export interface CapabilityValueItem { + value: string +} + export const buildTableOutput = (tableGenerator: TableGenerator, device: Device & { profileId?: string }): string => { const table = tableGenerator.newOutputTable() table.push(['Name', device.name]) @@ -76,3 +95,123 @@ export const buildTableOutput = (tableGenerator: TableGenerator, device: Device (infoFrom ? `Device Integration Info (from ${infoFrom})\n${deviceIntegrationInfo}\n\n` : '') + summarizedText } + +export const chooseComponent = async (command: APICommand, device: Device): Promise => { + let component + if (device.components) { + + const config = { + itemName: 'component', + primaryKeyName: 'id', + sortKeyName: 'id', + listTableFieldDefinitions: ['id'], + } + + const listItems = async (): Promise => Promise.resolve(device.components || []) + const preselectedId = device.components.length === 1 ? device.components[0].id : undefined + const componentId = await selectFromList(command, config, { preselectedId, listItems }) + component = device.components.find(comp => comp.id == componentId) + } + + if (!component) { + throw new Error('Component not found') + } + + return component +} + +export const chooseCapability = async (command: APICommand, component: Component): Promise => { + const config = { + itemName: 'capability', + primaryKeyName: 'id', + sortKeyName: 'id', + listTableFieldDefinitions: ['id'], + } + + const listItems = async (): Promise => Promise.resolve(component.capabilities) + const preselectedId = component.capabilities.length === 1 ? component.capabilities[0].id : undefined + const capabilityId = await selectFromList(command, config, { preselectedId, listItems }) + const capability = component.capabilities.find(cap => cap.id === capabilityId) + + if (!capability) { + throw new Error('Capability not found') + } + + return capability +} + +export const chooseAttribute = async (command: APICommand, cap: CapabilityReference): Promise => { + let attributeName + let attribute + const config = { + itemName: 'attribute', + primaryKeyName: 'attributeName', + sortKeyName: 'attributeName', + listTableFieldDefinitions: ['attributeName'], + } + + const capability = await command.client.capabilities.get(cap.id, cap.version || 1) + const attributes = capability.attributes + if (attributes) { + const attributeNames = Object.keys(attributes) + const attributeList: CapabilityAttributeItem[] = attributeNames.map(attributeName => { + return {attributeName, attribute: attributes[attributeName]} + }) + const listItems = async (): Promise => Promise.resolve(attributeList) + const preselectedId = attributeNames.length === 1 ? attributeNames[0] : undefined + attributeName = await selectFromList(command, config, {preselectedId, listItems}) + attribute = attributes[attributeName] + } + + if (!attributeName || !attribute) { + throw new Error(`Attribute ${attributeName} not found`) + } + return {attributeName, attribute} +} + +export const chooseUnit = async (command: APICommand, attribute: CapabilityAttribute): Promise => { + let unit + const units = attribute.schema.properties.unit?.enum + if (units) { + const config = { + itemName: 'unit', + primaryKeyName: 'unit', + sortKeyName: 'unit', + listTableFieldDefinitions: ['unit'], + } + + const listItems = async (): Promise => Promise.resolve(units.map(unit => { + return {unit} + })) + + const preselectedId = units.length === 1 ? units[0] : undefined + unit = await selectFromList(command, config, {preselectedId, listItems}) + } + return unit +} + +export const chooseValue = async (command: APICommand, attribute: CapabilityAttribute, name: string): Promise => { + let value + const values = attribute.schema.properties.value.enum + if (values) { + const config = { + itemName: 'value', + primaryKeyName: 'value', + sortKeyName: 'value', + listTableFieldDefinitions: ['value'], + } + + const listItems = async (): Promise => Promise.resolve(values.map(value => { + return {value} + })) + + value = await selectFromList(command, config, {listItems}) + } else { + value = (await inquirer.prompt({ + type: 'input', + name: 'value', + message: `Enter '${name}' attribute value:`, + })).value + } + return value +} diff --git a/packages/lib/src/__tests__/select.test.ts b/packages/lib/src/__tests__/select.test.ts index e8ec9ef1c..3935c4f2e 100644 --- a/packages/lib/src/__tests__/select.test.ts +++ b/packages/lib/src/__tests__/select.test.ts @@ -37,7 +37,7 @@ describe('select', () => { describe('indefiniteArticleFor', () => { - it.each(['apple', 'Animal', 'egret', 'item', 'orange', 'unicorn'])('returns "an" for "%s"', word => { + it.each(['apple', 'Animal', 'egret', 'item', 'orange'])('returns "an" for "%s"', word => { expect(indefiniteArticleFor(word)).toBe('an') }) diff --git a/packages/lib/src/select.ts b/packages/lib/src/select.ts index 134e49834..a77d6edfc 100644 --- a/packages/lib/src/select.ts +++ b/packages/lib/src/select.ts @@ -10,7 +10,7 @@ import { SmartThingsCommandInterface } from './smartthings-command' export type SelectingConfig = Sorting & Naming & CommonListOutputProducer -export const indefiniteArticleFor = (name: string): string => name.match(/^[aeiou]/i) ? 'an' : 'a' +export const indefiniteArticleFor = (name: string): string => name.match(/^[aeio]/i) ? 'an' : 'a' function promptFromNaming(config: Naming): string | undefined { return config.itemName ? `Select ${indefiniteArticleFor(config.itemName)} ${config.itemName}.` : undefined