From c3993b537b1e8c4daccefafd0b5b35a9181d3256 Mon Sep 17 00:00:00 2001 From: javalikescript Date: Sun, 2 Feb 2025 16:24:09 +0100 Subject: [PATCH] Fix Hue actions --- extensions/hue-v2/HueBridgeV2.lua | 169 ++++++++++++++++++++---------- extensions/hue-v2/hue-v2.lua | 40 +++---- extensions/hue-v2/manifest.json | 2 +- extensions/hue-v2/readme.md | 7 +- 4 files changed, 135 insertions(+), 83 deletions(-) diff --git a/extensions/hue-v2/HueBridgeV2.lua b/extensions/hue-v2/HueBridgeV2.lua index 2cb182a..def6205 100644 --- a/extensions/hue-v2/HueBridgeV2.lua +++ b/extensions/hue-v2/HueBridgeV2.lua @@ -14,6 +14,101 @@ local strings = require('jls.util.strings') local Thing = require('lha.Thing') local utils = require('lha.utils') +local function formatBody(body) + if type(body) == 'table' then + return json.encode(body) + elseif type(body) == 'string' then + return body + end +end + +local function processResponse(response) + local status, reason = response:getStatusCode() + logger:finer('response status %d "%s"', status, reason) + local contentType = response:getHeader('content-type') + if not strings.equalsIgnoreCase(contentType, 'application/json') then + return response:text():next(function(text) + logger:fine('response body "%s"', text) + return Promise.reject('Invalid or missing content type') + end) + end + return response:json():next(function(content) + if not (type(content) == 'table' and type(content.data) == 'table' and type(content.errors) == 'table') then + logger:fine('response content %T', content) + return Promise.reject('Invalid or missing content') + end + if status == 200 then + return content.data + end + local descriptions = {} + for _, item in ipairs(content.errors) do + if type(item.description) == 'string' then + table.insert(descriptions, item.description) + end + end + local description = table.concat(descriptions, ', ') + if status == 207 then + if #description > 0 then + logger:info('Errors in response: %s', description) + end + return content.data + end + return Promise.reject(string.format('Bad status (%d) %s', status, description)) + end) +end + +local function processResponseV1(response) + local status, reason = response:getStatusCode() + logger:finer('response status %d "%s"', status, reason) + local contentType = response:getHeader('content-type') + if not strings.equalsIgnoreCase(contentType, 'application/json') then + return response:text():next(function(text) + logger:fine('response body "%s"', text) + return Promise.reject('Invalid or missing content type') + end) + end + return response:json():next(function(content) + logger:finer('response content %T', content) + if type(content) ~= 'table' then + return Promise.reject('Invalid or missing content') + end + if status ~= 200 then + return Promise.reject(string.format('Bad status (%d) %s', status, reason)) + end + local descriptions = {} + for _, item in ipairs(content) do + if type(item.error) == 'table' and type(item.error.description) == 'string' then + table.insert(descriptions, item.error.description) + end + end + if #content == #descriptions then + return Promise.reject(table.concat(descriptions, ', ')) + end + if #content == 1 and content[1].success then + return content[1].success + end + return content + end) +end + +local function createHttpClient(url) + return HttpClient:new({ + url = url, + secureContext = { + alpnProtos = {'h2'} + }, + }) +end + +local function httpRequest(client, method, path, headers, body) + logger:fine('httpRequest(%s, %s, %T, %T)', method, path, headers, body) + return utils.timeout(client:fetch(path or '/', { + method = method or 'GET', + headers = headers, + body = formatBody(body), + })) +end + return require('jls.lang.class').create(function(hueBridge) function hueBridge:initialize(url, key, mapping) @@ -59,70 +154,32 @@ return require('jls.lang.class').create(function(hueBridge) function hueBridge:getHttpClient() if not self.client then - self.client = self:createHttpClient() + self.client = createHttpClient(self.url) end return self.client end - function hueBridge:createHttpClient() - return HttpClient:new({ - url = self.url, - secureContext = { - alpnProtos = {'h2'} - }, + function hueBridge:httpRequest(method, path, body) + return httpRequest(self:getHttpClient(), method, path, self.headers, body):next(processResponse) + end + + function hueBridge:httpRequestV1(method, path, body) + local headers = Map.assign({ + ['Content-Type'] = 'application/json' }) + return httpRequest(self:getHttpClient(), method, '/api/'..self.key..path, headers, body):next(processResponseV1) end - local function formatBody(body) - if type(body) == 'table' then - return json.encode(body) - elseif type(body) == 'string' then - return body - end + function hueBridge:getConfig() + return self:httpRequestV1('GET', '/config') end - local function formatErrors(errors) - local descriptions = {} - for _, item in errors do - table.insert(descriptions, item.description) - end - return table.concat(descriptions, ', ') + function hueBridge:putConfig(config) + return self:httpRequestV1('PUT', '/config', config) end - function hueBridge:httpRequest(method, path, body) - local client = self:getHttpClient() - logger:fine('httpRequest(%s, %s)', method, path) - return utils.timeout(client:fetch(path or '/', { - method = method or 'GET', - headers = self.headers, - body = formatBody(body), - })):next(function(response) - local status, reason = response:getStatusCode() - logger:finer('response status %d "%s"', status, reason) - local contentType = response:getHeader('content-type') - if not strings.equalsIgnoreCase(contentType, 'application/json') then - return response:text():next(function(text) - logger:fine('response body "%s"', text) - return Promise.reject('Invalid or missing content type') - end) - end - return response:json():next(function(content) - if not (type(content) == 'table' and type(content.data) == 'table' and type(content.errors) == 'table') then - return Promise.reject('Invalid or missing content') - end - if status == 200 then - return content.data - end - local description = formatErrors(content.errors) - if status == 207 then - if #description > 0 then - logger:info('%s on %s has errors: %s', method, path, description) - end - return content.data - end - return Promise.reject(string.format('Bad status (%d) %s', status, description)) - end) - end) + function hueBridge:deleteUser(id) + return self:httpRequestV1('DELETE', '/config/whitelist/'..tostring(id)) end function hueBridge:getResourceMapById(name) @@ -392,4 +449,10 @@ return require('jls.lang.class').create(function(hueBridge) return Promise.reject(string.format('cannot set value "%s" for resource id "%s"', name, id)) end +end, function(HueBridge) + + HueBridge.processResponse = processResponse + HueBridge.processResponseV1 = processResponseV1 + HueBridge.createHttpClient = createHttpClient + end) diff --git a/extensions/hue-v2/hue-v2.lua b/extensions/hue-v2/hue-v2.lua index 005737d..331b97f 100644 --- a/extensions/hue-v2/hue-v2.lua +++ b/extensions/hue-v2/hue-v2.lua @@ -258,42 +258,30 @@ function extension:generateKey() if not configuration.url then return Promise.reject('Bridge URL not available') end - local client = HttpClient:new({ url = configuration.url, secureContext = { alpnProtos = {'h2'} } }) + local client = HueBridgeV2.createHttpClient(configuration.url) return client:fetch('/api', { method = 'POST', headers = { ['Content-Type'] = 'application/json' }, - body = '{"devicetype":"lha#default","generateclientkey":true}', - }):next(function(response) - return response:text() - end):next(function(text) - logger:fine('generateKey: %s', text) - -- [{"error":{"type":101,"address":"","description":"link button not pressed"}}] - -- configuration.user - end):catch(function(reason) - logger:warn('generateKey failed, %s', reason) + body = json.encode({ + devicetype = 'lha#default', -- existing key will be replaced + generateclientkey = true + }) + }):next(HueBridgeV2.processResponseV1):next(function(response) + configuration.user = response.username + --configuration.clientkey = response.clientkey + return 'OK' end):finally(function() client:close() end) end function extension:touchlink() - if not hueBridge then - return Promise.reject('Bridge not available') + if hueBridge then + return hueBridge:putConfig({touchlink = true}):next(function(response) + return 'OK' + end) end - local client = hueBridge:createHttpClient() - return client:fetch('/api/config', { - method = 'PUT', - headers = hueBridge.headers, - body = '{touchlink: true}', - }):next(function(response) - return response.text() - end):next(function(text) - logger:fine('touchlink: %s', text) - end):catch(function(reason) - logger:warn('touchlink failed, %s', reason) - end):finally(function() - client:close() - end) + return Promise.reject('Bridge not available') end diff --git a/extensions/hue-v2/manifest.json b/extensions/hue-v2/manifest.json index 00354ab..81a2a0a 100644 --- a/extensions/hue-v2/manifest.json +++ b/extensions/hue-v2/manifest.json @@ -38,7 +38,7 @@ "active": false }, { "name": "Touchlink", - "description": "Pair a device with the Bridge", + "description": "Adds the closest lamp (within range) to the ZigBee network", "method": "touchlink", "active": true } diff --git a/extensions/hue-v2/readme.md b/extensions/hue-v2/readme.md index 4365427..84f0747 100644 --- a/extensions/hue-v2/readme.md +++ b/extensions/hue-v2/readme.md @@ -4,10 +4,11 @@ This extension allows to connect to the Hue bridge. ## Setup -You will need to acquire an application key. +Use the `Discover Bridge` action to find the Hue Bridge URL or retrieve the local IP from your account on [philips-hue.com](https://www.philips-hue.com/). -## Usage +After discovering the Bridge URL, you use the `Generate API key` action to create a new user. +The link button on the bridge must be pressed and this action triggered within 30 seconds. -You need to add your device in the Hue bridge prior adding it through this extension. +## Usage The JSON mapping defines the how the Hue devices are mapped to things.