From 9a5adb61faab985799c66f6c10fba36f99e80080 Mon Sep 17 00:00:00 2001 From: ricardo Date: Fri, 31 Jul 2020 16:26:23 -0400 Subject: [PATCH] :sparkles: PhilipHue-Node --- .../PhilipHueOAuth2Api.credentials.ts | 45 +++ .../nodes/PhilipHue/GenericFunctions.ts | 66 ++++ .../nodes/PhilipHue/LightDescription.ts | 341 ++++++++++++++++++ .../nodes/PhilipHue/PhilipHue.node.ts | 184 ++++++++++ .../nodes-base/nodes/PhilipHue/philiphue.png | Bin 0 -> 7166 bytes packages/nodes-base/package.json | 2 + 6 files changed, 638 insertions(+) create mode 100644 packages/nodes-base/credentials/PhilipHueOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/PhilipHue/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/PhilipHue/LightDescription.ts create mode 100644 packages/nodes-base/nodes/PhilipHue/PhilipHue.node.ts create mode 100644 packages/nodes-base/nodes/PhilipHue/philiphue.png diff --git a/packages/nodes-base/credentials/PhilipHueOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PhilipHueOAuth2Api.credentials.ts new file mode 100644 index 0000000000..cef236a8ac --- /dev/null +++ b/packages/nodes-base/credentials/PhilipHueOAuth2Api.credentials.ts @@ -0,0 +1,45 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class PhilipHueOAuth2Api implements ICredentialType { + name = 'philipHueOAuth2Api'; + 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/PhilipHue/GenericFunctions.ts b/packages/nodes-base/nodes/PhilipHue/GenericFunctions.ts new file mode 100644 index 0000000000..d092018e8a --- /dev/null +++ b/packages/nodes-base/nodes/PhilipHue/GenericFunctions.ts @@ -0,0 +1,66 @@ +import { + OptionsWithUri, + } from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function philiphueApiRequest(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, 'philipHueOAuth2Api', 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 philiphueApiRequest.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 philiphueApiRequest.call(this, 'PUT', '/bridge/0/config', { linkbutton: true }); + const { success } = await philiphueApiRequest.call(this, 'POST', '/bridge', { devicetype: 'n8n' }); + return success.username; +} diff --git a/packages/nodes-base/nodes/PhilipHue/LightDescription.ts b/packages/nodes-base/nodes/PhilipHue/LightDescription.ts new file mode 100644 index 0000000000..b2d9cf059e --- /dev/null +++ b/packages/nodes-base/nodes/PhilipHue/LightDescription.ts @@ -0,0 +1,341 @@ +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: '', + description: 'On/Off state of the light. On=true, Off=false', + }, + { + 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. bri_inc is ignored if the bri 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: '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_inc 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. sat_inc is ignored if the sat 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', + }, + { + 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. xy_inc is ignored if the xy attribute is provided. Any ongoing color transition is stopped. Max value [0.5, 0.5]`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/PhilipHue/PhilipHue.node.ts b/packages/nodes-base/nodes/PhilipHue/PhilipHue.node.ts new file mode 100644 index 0000000000..7420bda3f4 --- /dev/null +++ b/packages/nodes-base/nodes/PhilipHue/PhilipHue.node.ts @@ -0,0 +1,184 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeTypeDescription, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + philiphueApiRequest, + getUser, +} from './GenericFunctions'; + +import { + lightOperations, + lightFields, +} from './LightDescription'; + +export class PhilipHue implements INodeType { + description: INodeTypeDescription = { + displayName: 'Philip Hue', + name: 'philipHue', + icon: 'file:philiphue.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Philip Hue API.', + defaults: { + name: 'Philip Hue', + color: '#063c9a', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'philipHueOAuth2Api', + 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 philiphueApiRequest.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 philiphueApiRequest.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 philiphueApiRequest.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 philiphueApiRequest.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 philiphueApiRequest.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/PhilipHue/philiphue.png b/packages/nodes-base/nodes/PhilipHue/philiphue.png new file mode 100644 index 0000000000000000000000000000000000000000..a59b33ef8fb22aca4f8e8d717306960707ad2a2e GIT binary patch literal 7166 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