Skip to content

Commit

Permalink
Fix Hue actions
Browse files Browse the repository at this point in the history
  • Loading branch information
javalikescript committed Feb 2, 2025
1 parent 1c45a0f commit c3993b5
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 83 deletions.
169 changes: 116 additions & 53 deletions extensions/hue-v2/HueBridgeV2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
40 changes: 14 additions & 26 deletions extensions/hue-v2/hue-v2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion extensions/hue-v2/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 4 additions & 3 deletions extensions/hue-v2/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

0 comments on commit c3993b5

Please sign in to comment.