diff --git a/functions/commands/appselect.js b/functions/commands/appselect.js new file mode 100644 index 00000000..19b97029 --- /dev/null +++ b/functions/commands/appselect.js @@ -0,0 +1,49 @@ +const DefaultCommand = require('./default.js'); +const TV = require('../devices/tv.js'); + +class AppSelect extends DefaultCommand { + static get type() { + return 'action.devices.commands.appSelect'; + } + + static validateParams(params) { + return ( + ('newApplication' in params && typeof params.newApplication === 'string') || + ('newApplicationName' in params && typeof params.newApplicationName === 'string') + ); + } + + static requiresItem() { + return true; + } + + static getItemName(item) { + const members = TV.getMembers(item); + if ('tvApplication' in members) { + return members.tvApplication.name; + } + throw { statusCode: 400 }; + } + + static convertParamsToValue(params, item) { + const applicationMap = TV.getApplicationMap(item); + if (params.newApplication && params.newApplication in applicationMap) { + return params.newApplication; + } + const search = params.newApplicationName; + for (const key in applicationMap) { + if (applicationMap[key].includes(search)) { + return key; + } + } + throw { errorCode: 'noAvailableApp' }; + } + + static getResponseStates(params, item) { + return { + currentApplication: this.convertParamsToValue(params, item) + }; + } +} + +module.exports = AppSelect; diff --git a/functions/commands/default.js b/functions/commands/default.js index 4b5f9d73..c2e39991 100644 --- a/functions/commands/default.js +++ b/functions/commands/default.js @@ -184,7 +184,13 @@ class DefaultCommand { ids: [device.id], status: 'ERROR', errorCode: - error.statusCode == 404 ? 'deviceNotFound' : error.statusCode == 400 ? 'notSupported' : 'deviceOffline' + typeof error.errorCode === 'string' + ? error.errorCode + : error.statusCode == 404 + ? 'deviceNotFound' + : error.statusCode == 400 + ? 'notSupported' + : 'deviceOffline' }); }); }); diff --git a/functions/commands/selectchannel.js b/functions/commands/selectchannel.js index f47556fb..ae1bf0df 100644 --- a/functions/commands/selectchannel.js +++ b/functions/commands/selectchannel.js @@ -27,16 +27,17 @@ class SelectChannel extends DefaultCommand { } static convertParamsToValue(params, item) { - if (params.channelNumber) { + const channelMap = TV.getChannelMap(item); + if (params.channelNumber && params.channelNumber in channelMap) { return params.channelNumber; } const search = params.channelName || params.channelCode; - const channelMap = TV.getChannelMap(item); for (const number in channelMap) { if (channelMap[number].includes(search)) { return number; } } + throw { errorCode: 'noAvailableChannel' }; } static getResponseStates(params, item) { diff --git a/functions/devices/tv.js b/functions/devices/tv.js index 3df92c93..abdc0ba6 100644 --- a/functions/devices/tv.js +++ b/functions/devices/tv.js @@ -13,6 +13,7 @@ class TV extends DefaultDevice { if ('tvChannel' in members) traits.push('action.devices.traits.Channel'); if ('tvInput' in members) traits.push('action.devices.traits.InputSelector'); if ('tvTransport' in members) traits.push('action.devices.traits.TransportControl'); + if ('tvApplication' in members) traits.push('action.devices.traits.AppSelector'); return traits; } @@ -70,6 +71,21 @@ class TV extends DefaultDevice { }); }); } + if ('tvApplication' in members && 'availableApplications' in config) { + attributes.availableApplications = []; + config.availableApplications.split(',').forEach((application) => { + const [key, synonyms] = application.split('='); + attributes.availableApplications.push({ + key: key, + names: [ + { + name_synonym: synonyms.split(':'), + lang: config.lang || 'en' + } + ] + }); + }); + } return attributes; } @@ -96,13 +112,15 @@ class TV extends DefaultDevice { state.channelName = this.getChannelMap(item)[members[member].state][0]; } catch {} break; + case 'tvApplication': + state.currentApplication = members[member].state; } } return state; } static getMembers(item) { - const supportedMembers = ['tvChannel', 'tvVolume', 'tvInput', 'tvTransport', 'tvPower', 'tvMute']; + const supportedMembers = ['tvApplication', 'tvChannel', 'tvVolume', 'tvInput', 'tvTransport', 'tvPower', 'tvMute']; const members = Object(); if (item.members && item.members.length) { item.members.forEach((member) => { @@ -128,6 +146,18 @@ class TV extends DefaultDevice { } return channelMap; } + + static getApplicationMap(item) { + const config = this.getConfig(item); + const applicationMap = {}; + if ('availableApplications' in config) { + config.availableApplications.split(',').forEach((application) => { + const [key, synonyms] = application.split('='); + applicationMap[key] = [...synonyms.split(':'), key]; + }); + } + return applicationMap; + } } module.exports = TV; diff --git a/tests/commands/appselect.test.js b/tests/commands/appselect.test.js new file mode 100644 index 00000000..9b91666e --- /dev/null +++ b/tests/commands/appselect.test.js @@ -0,0 +1,70 @@ +const Command = require('../../functions/commands/appselect.js'); + +describe('appSelect Command', () => { + const paramsKey = { newApplication: 'netflix' }; + const paramsName = { newApplicationName: 'Net Flix' }; + + test('validateParams', () => { + expect(Command.validateParams({})).toBe(false); + expect(Command.validateParams(paramsKey)).toBe(true); + expect(Command.validateParams(paramsName)).toBe(true); + }); + + test('requiresItem', () => { + expect(Command.requiresItem()).toBe(true); + }); + + test('getItemName', () => { + expect(() => { + Command.getItemName({ name: 'Item' }); + }).toThrow(); + const item = { + members: [ + { + name: 'ApplicationItem', + metadata: { + ga: { + value: 'tvApplication' + } + } + } + ] + }; + expect(Command.getItemName(item)).toBe('ApplicationItem'); + }); + + test('convertParamsToValue', () => { + const item = { + metadata: { + ga: { + config: { + availableApplications: 'youtube=YouTube:Tube,netflix=Net Flix:Flix' + } + } + } + }; + expect(Command.convertParamsToValue(paramsKey, item)).toBe('netflix'); + expect(Command.convertParamsToValue(paramsName, item)).toBe('netflix'); + expect(Command.convertParamsToValue({ newApplicationName: 'Tube' }, item)).toBe('youtube'); + expect(() => { + Command.convertParamsToValue({ newApplication: 'wrong' }, item); + }).toThrow(); + expect(() => { + Command.convertParamsToValue({ newApplicationName: 'wrong' }, item); + }).toThrow(); + }); + + test('getResponseStates', () => { + const item = { + metadata: { + ga: { + config: { + availableApplications: 'youtube=YouTube,netflix=Net Flix' + } + } + } + }; + expect(Command.getResponseStates(paramsKey, item)).toStrictEqual({ currentApplication: 'netflix' }); + expect(Command.getResponseStates(paramsName, item)).toStrictEqual({ currentApplication: 'netflix' }); + }); +}); diff --git a/tests/commands/default.test.js b/tests/commands/default.test.js index e1db8c6f..2c0702e2 100644 --- a/tests/commands/default.test.js +++ b/tests/commands/default.test.js @@ -308,5 +308,20 @@ describe('Default Command', () => { } ]); }); + + test('execute with errorCode', async () => { + sendCommandMock.mockReturnValue(Promise.reject({ errorCode: 'noAvailableChannel' })); + const devices = [{ id: 'Item1' }]; + const result = await TestCommand1.execute(apiHandler, devices, { on: true }, {}); + expect(getItemMock).toHaveBeenCalledTimes(0); + expect(sendCommandMock).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual([ + { + errorCode: 'noAvailableChannel', + ids: ['Item1'], + status: 'ERROR' + } + ]); + }); }); }); diff --git a/tests/commands/selectchannel.test.js b/tests/commands/selectchannel.test.js index 6684ded7..35f30b13 100644 --- a/tests/commands/selectchannel.test.js +++ b/tests/commands/selectchannel.test.js @@ -44,6 +44,12 @@ describe('selectChannel Command', () => { expect(Command.convertParamsToValue({ channelCode: 'channel1' }, item)).toBe('1'); expect(Command.convertParamsToValue({ channelName: 'ARD' }, item)).toBe('1'); expect(Command.convertParamsToValue({ channelNumber: '1' }, item)).toBe('1'); + expect(() => { + Command.convertParamsToValue({ channelNumber: '0' }, item); + }).toThrow(); + expect(() => { + Command.convertParamsToValue({ channelName: 'wrong' }, item); + }).toThrow(); }); test('getResponseStates', () => { diff --git a/tests/devices/tv.test.js b/tests/devices/tv.test.js index 3d53e5f1..634eb413 100644 --- a/tests/devices/tv.test.js +++ b/tests/devices/tv.test.js @@ -97,6 +97,14 @@ describe('TV Device', () => { value: 'tvMute' } } + }, + { + state: 'youtube', + metadata: { + ga: { + value: 'tvApplication' + } + } } ] }; @@ -105,7 +113,8 @@ describe('TV Device', () => { 'action.devices.traits.Volume', 'action.devices.traits.Channel', 'action.devices.traits.InputSelector', - 'action.devices.traits.TransportControl' + 'action.devices.traits.TransportControl', + 'action.devices.traits.AppSelector' ]); }); }); @@ -284,6 +293,50 @@ describe('TV Device', () => { }); }); + test('getAttributes applications', () => { + const item = { + metadata: { + ga: { + config: { + availableApplications: 'youtube=YouTube,netflix=Netflix' + } + } + }, + members: [ + { + metadata: { + ga: { + value: 'tvApplication' + } + } + } + ] + }; + expect(Device.getAttributes(item)).toStrictEqual({ + availableApplications: [ + { + key: 'youtube', + names: [ + { + lang: 'en', + name_synonym: ['YouTube'] + } + ] + }, + { + key: 'netflix', + names: [ + { + lang: 'en', + name_synonym: ['Netflix'] + } + ] + } + ], + volumeCanMuteAndUnmute: false + }); + }); + test('getMembers', () => { expect(Device.getMembers({ members: [{}] })).toStrictEqual({}); expect(Device.getMembers({ members: [{ metadata: { ga: { value: 'invalid' } } }] })).toStrictEqual({}); @@ -342,6 +395,15 @@ describe('TV Device', () => { value: 'tvMute' } } + }, + { + name: 'Application', + state: 'youtube', + metadata: { + ga: { + value: 'tvApplication' + } + } } ] }; @@ -369,6 +431,10 @@ describe('TV Device', () => { tvVolume: { name: 'Volume', state: '50' + }, + tvApplication: { + name: 'Application', + state: 'youtube' } }); }); @@ -377,7 +443,6 @@ describe('TV Device', () => { const item = { metadata: { ga: { - value: 'TV', config: { availableChannels: '20=channel1=Channel 1:Kanal 1,10=channel2=Channel 2:Kanal 2' } @@ -390,6 +455,22 @@ describe('TV Device', () => { }); }); + test('getApplicationMap', () => { + const item = { + metadata: { + ga: { + config: { + availableApplications: 'youtube=YouTube:Tube,netflix=Net Flix:Flix' + } + } + } + }; + expect(Device.getApplicationMap(item)).toStrictEqual({ + youtube: ['YouTube', 'Tube', 'youtube'], + netflix: ['Net Flix', 'Flix', 'netflix'] + }); + }); + describe('getState', () => { test('getState', () => { const item = { @@ -400,7 +481,8 @@ describe('TV Device', () => { config: { transportControlSupportedCommands: 'PAUSE,RESUME', availableInputs: 'input1=hdmi1,input2=hdmi2', - availableChannels: '1=channel1=ARD,2=channel2=ZDF' + availableChannels: '1=channel1=ARD,2=channel2=ZDF', + availableApplications: 'youtube=YouTube' } } }, @@ -452,6 +534,14 @@ describe('TV Device', () => { value: 'tvMute' } } + }, + { + state: 'youtube', + metadata: { + ga: { + value: 'tvApplication' + } + } } ] }; @@ -459,6 +549,7 @@ describe('TV Device', () => { channelName: 'ARD', channelNumber: '1', currentInput: 'input1', + currentApplication: 'youtube', currentVolume: 50, isMuted: false, on: true