diff --git a/packages/nodes-base/credentials/PhilipsHueOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PhilipsHueOAuth2Api.credentials.ts new file mode 100644 index 0000000000..bfb4198ae6 --- /dev/null +++ b/packages/nodes-base/credentials/PhilipsHueOAuth2Api.credentials.ts @@ -0,0 +1,45 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class PhilipsHueOAuth2Api implements ICredentialType { + name = 'philipsHueOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'PhilipHue OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.meethue.com/oauth2/auth', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://api.meethue.com/oauth2/token', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + description: 'Method of authentication.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/PhilipsHue/GenericFunctions.ts b/packages/nodes-base/nodes/PhilipsHue/GenericFunctions.ts new file mode 100644 index 0000000000..73b1e1b7ad --- /dev/null +++ b/packages/nodes-base/nodes/PhilipsHue/GenericFunctions.ts @@ -0,0 +1,66 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function philipsHueApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise { // tslint:disable-line:no-any + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://api.meethue.com${resource}`, + json: true + }; + try { + if (Object.keys(headers).length !== 0) { + options.headers = Object.assign({}, options.headers, headers); + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (Object.keys(qs).length === 0) { + delete options.qs; + } + + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'philipsHueOAuth2Api', options, { tokenType: 'Bearer' }); + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + const errorMessage = error.response.body.error.description; + + // Try to return the error prettier + throw new Error( + `Philip Hue error response [${error.statusCode}]: ${errorMessage}` + ); + } + throw error; + } +} + +export async function getUser(this: IExecuteFunctions | ILoadOptionsFunctions): Promise { // tslint:disable-line:no-any + const { whitelist } = await philipsHueApiRequest.call(this, 'GET', '/bridge/0/config', {}, {}); + //check if there is a n8n user + for (const user of Object.keys(whitelist)) { + if (whitelist[user].name === 'n8n') { + return user; + } + } + // n8n user was not fount then create the user + await philipsHueApiRequest.call(this, 'PUT', '/bridge/0/config', { linkbutton: true }); + const { success } = await philipsHueApiRequest.call(this, 'POST', '/bridge', { devicetype: 'n8n' }); + return success.username; +} diff --git a/packages/nodes-base/nodes/PhilipsHue/LightDescription.ts b/packages/nodes-base/nodes/PhilipsHue/LightDescription.ts new file mode 100644 index 0000000000..b48108deda --- /dev/null +++ b/packages/nodes-base/nodes/PhilipsHue/LightDescription.ts @@ -0,0 +1,345 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const lightOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'light', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete an light', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an light', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all lights', + }, + { + name: 'Update', + value: 'update', + description: 'Update an light', + } + ], + default: 'update', + description: 'The operation to perform.' + } +] as INodeProperties[]; + +export const lightFields = [ + + /* -------------------------------------------------------------------------- */ + /* light:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Light ID', + name: 'lightId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'light', + ], + }, + }, + default: '', + }, + + /* -------------------------------------------------------------------------- */ + /* light:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'light', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'light', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + + /* -------------------------------------------------------------------------- */ + /* light:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Light ID', + name: 'lightId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'light', + ], + }, + }, + default: '', + }, + + /* -------------------------------------------------------------------------- */ + /* light:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Light ID', + name: 'lightId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLights', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'light', + ], + }, + }, + default: '', + }, + { + displayName: 'On', + name: 'on', + type: 'boolean', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'light', + ], + }, + }, + default: true, + description: 'On/Off state of the light.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'light', + ], + operation: [ + 'update', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Alert Effect', + name: 'alert', + type: 'options', + options: [ + { + name: 'none', + value: 'none', + description: 'the light is not performing an alert effect', + }, + { + name: 'Select', + value: 'select', + description: 'The light is performing one breathe cycle.', + }, + { + name: 'LSelect', + value: 'lselect', + description: 'The light is performing breathe cycles for 15 seconds or until an "alert": "none" command is received', + }, + ], + default: '', + description: 'The alert effect, is a temporary change to the bulb’s state', + }, + { + displayName: 'Brightness', + name: 'bri', + type: 'number', + typeOptions: { + minValue: 1, + maxValue: 254, + }, + default: 100, + description: 'The brightness value to set the light to.Brightness is a scale from 1 (the minimum the light is capable of) to 254 (the maximum).', + }, + { + displayName: 'Brightness Increments', + name: 'bri_inc', + type: 'number', + typeOptions: { + minValue: -254, + maxValue: 254, + }, + default: 0, + description: 'Increments or decrements the value of the brightness. This value is ignored if the Brightness attribute is provided.', + }, + { + displayName: 'Color Temperature', + name: 'ct', + type: 'number', + default: 0, + description: 'The Mired color temperature of the light. 2012 connected lights are capable of 153 (6500K) to 500 (2000K).', + }, + { + displayName: 'Color Temperature Increments', + name: 'ct_inc', + type: 'number', + typeOptions: { + minValue: -65534, + maxValue: 65534, + }, + default: 0, + description: 'Increments or decrements the value of the ct. ct_inc is ignored if the ct attribute is provided', + }, + { + displayName: 'Coordinates', + name: 'xy', + type: 'string', + default: '', + placeholder: '0.64394,0.33069', + description: `The x and y coordinates of a color in CIE color space.
+ The first entry is the x coordinate and the second entry is the y coordinate. Both x and y are between 0 and 1`, + }, + { + displayName: 'Coordinates Increments', + name: 'xy_inc', + type: 'string', + default: '', + placeholder: '0.5,0.5', + description: `Increments or decrements the value of the xy. This value is ignored if the Coordinates attribute is provided. Any ongoing color transition is stopped. Max value [0.5, 0.5]`, + }, + { + displayName: 'Dynamic Effect', + name: 'effect', + type: 'options', + options: [ + { + name: 'none', + value: 'none', + }, + { + name: 'Color Loop', + value: 'colorloop', + }, + ], + default: '', + description: 'The dynamic effect of the light.', + }, + { + displayName: 'Hue', + name: 'hue', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 65535, + }, + default: 0, + description: 'The hue value to set light to.The hue value is a wrapping value between 0 and 65535. Both 0 and 65535 are red, 25500 is green and 46920 is blue.', + }, + { + displayName: 'Hue Increments', + name: 'hue_inc', + type: 'number', + typeOptions: { + minValue: -65534, + maxValue: 65534, + }, + default: 0, + description: 'Increments or decrements the value of the hue. Hue Increments is ignored if the Hue attribute is provided.', + }, + { + displayName: 'Saturation', + name: 'sat', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 254, + }, + default: 0, + description: 'Saturation of the light. 254 is the most saturated (colored) and 0 is the least saturated (white).', + }, + { + displayName: 'Saturation Increments', + name: 'sat_inc', + type: 'number', + typeOptions: { + minValue: -254, + maxValue: 254, + }, + default: 0, + description: 'Increments or decrements the value of the sat. This value is ignored if the Saturation attribute is provided.', + }, + { + displayName: 'Transition Time', + name: 'transitiontime', + type: 'number', + typeOptions: { + minVale: 1, + }, + default: 4, + description: 'The duration in seconds of the transition from the light’s current state to the new state', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/PhilipsHue/PhilipsHue.node.ts b/packages/nodes-base/nodes/PhilipsHue/PhilipsHue.node.ts new file mode 100644 index 0000000000..27189d4b3c --- /dev/null +++ b/packages/nodes-base/nodes/PhilipsHue/PhilipsHue.node.ts @@ -0,0 +1,184 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeTypeDescription, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + philipsHueApiRequest, + getUser, +} from './GenericFunctions'; + +import { + lightOperations, + lightFields, +} from './LightDescription'; + +export class PhilipsHue implements INodeType { + description: INodeTypeDescription = { + displayName: 'Philips Hue', + name: 'philipsHue', + icon: 'file:philipshue.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Philips Hue API.', + defaults: { + name: 'Philips Hue', + color: '#063c9a', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'philipsHueOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Light', + value: 'light', + }, + ], + default: 'light', + description: 'The resource to operate on.', + }, + ...lightOperations, + ...lightFields, + ], + }; + + methods = { + loadOptions: { + // Get all the lights to display them to user so that he can + // select them easily + async getLights( + this: ILoadOptionsFunctions + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const user = await getUser.call(this); + + const lights = await philipsHueApiRequest.call( + this, + 'GET', + `/bridge/${user}/lights`, + ); + for (const light of Object.keys(lights)) { + const lightName = lights[light].name; + const lightId = light; + returnData.push({ + name: lightName, + value: lightId + }); + } + return returnData; + }, + } + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'light') { + if (operation === 'update') { + + const lightId = this.getNodeParameter('lightId', i) as string; + + const on = this.getNodeParameter('on', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body = { + on, + }; + + if (additionalFields.transitiontime) { + additionalFields.transitiontime = (additionalFields.transitiontime as number * 100); + } + + if (additionalFields.xy) { + additionalFields.xy = (additionalFields.xy as string).split(',').map((e: string) => parseFloat(e)); + } + + if (additionalFields.xy_inc) { + additionalFields.xy_inc = (additionalFields.xy_inc as string).split(',').map((e: string) => parseFloat(e)); + } + + Object.assign(body, additionalFields); + + const user = await getUser.call(this); + + const data = await philipsHueApiRequest.call( + this, + 'PUT', + `/bridge/${user}/lights/${lightId}/state`, + body, + ); + + responseData = {}; + + for (const response of data) { + Object.assign(responseData, response.success); + } + + } + if (operation === 'delete') { + + const lightId = this.getNodeParameter('lightId', i) as string; + + const user = await getUser.call(this); + + responseData = await philipsHueApiRequest.call(this, 'DELETE', `/bridge/${user}/lights/${lightId}`); + + } + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const user = await getUser.call(this); + + const lights = await philipsHueApiRequest.call(this, 'GET', `/bridge/${user}/lights`); + + responseData = Object.values(lights); + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + if (operation === 'get') { + const lightId = this.getNodeParameter('lightId', i) as string; + + const user = await getUser.call(this); + + responseData = await philipsHueApiRequest.call(this, 'GET', `/bridge/${user}/lights/${lightId}`); + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/PhilipsHue/philipshue.png b/packages/nodes-base/nodes/PhilipsHue/philipshue.png new file mode 100644 index 0000000000..a59b33ef8f Binary files /dev/null and b/packages/nodes-base/nodes/PhilipsHue/philipshue.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9445a63bf1..bca833fb84 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -117,6 +117,7 @@ "dist/credentials/PayPalApi.credentials.js", "dist/credentials/PipedriveApi.credentials.js", "dist/credentials/PipedriveOAuth2Api.credentials.js", + "dist/credentials/PhilipsHueOAuth2Api.credentials.js", "dist/credentials/Postgres.credentials.js", "dist/credentials/PostmarkApi.credentials.js", "dist/credentials/QuestDb.credentials.js", @@ -274,6 +275,7 @@ "dist/nodes/Pipedrive/PipedriveTrigger.node.js", "dist/nodes/Postgres/Postgres.node.js", "dist/nodes/Postmark/PostmarkTrigger.node.js", + "dist/nodes/PhilipsHue/PhilipsHue.node.js", "dist/nodes/QuestDb/QuestDb.node.js", "dist/nodes/ReadBinaryFile.node.js", "dist/nodes/ReadBinaryFiles.node.js",