Skip to content

Commit

Permalink
Add Hue actions
Browse files Browse the repository at this point in the history
  • Loading branch information
javalikescript committed Feb 1, 2025
1 parent 9719559 commit 0f9f6c5
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 13 deletions.
16 changes: 10 additions & 6 deletions extensions/hue-v2/HueBridgeV2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,20 @@ return require('jls.lang.class').create(function(hueBridge)

function hueBridge:getHttpClient()
if not self.client then
self.client = HttpClient:new({
url = self.url,
secureContext = {
alpnProtos = {'h2'}
},
})
self.client = self:createHttpClient()
end
return self.client
end

function hueBridge:createHttpClient()
return HttpClient:new({
url = self.url,
secureContext = {
alpnProtos = {'h2'}
},
})
end

local function formatBody(body)
if type(body) == 'table' then
return json.encode(body)
Expand Down
142 changes: 142 additions & 0 deletions extensions/hue-v2/hue-v2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ local extension = ...
local logger = extension:getLogger()
local Promise = require('jls.lang.Promise')
local File = require('jls.io.File')
local dns = require('jls.net.dns')
local HttpClient = require('jls.net.http.HttpClient')
local UdpSocket = require('jls.net.UdpSocket')
local json = require('jls.util.json')
local Map = require('jls.util.Map')
local List = require('jls.util.List')

local HueBridgeV2 = extension:require('HueBridgeV2')

Expand Down Expand Up @@ -155,3 +159,141 @@ extension:subscribeEvent('shutdown', function()
hueBridge:close()
end
end)

local function discover(name, dnsType)
local mdnsAddress, mdnsPort = '224.0.0.251', 5353
local id = math.random(0xfff)
local messages = {}
local onMessage
local function onReceived(err, data, addr)
if data then
logger:finer('received data: (%l) %x %t', #data, data, addr)
local _, message = pcall(dns.decodeMessage, data)
logger:finer('message: %t', message)
if message.id == id then
message.addr = addr
table.insert(messages, message)
if type(onMessage) == 'function' then
local status, e = pcall(onMessage, message)
if not status then
logger:warn('error on message %s', e)
end
end
end
elseif err then
logger:warn('receive error %s', err)
else
logger:fine('receive no data')
end
end
local data = dns.encodeMessage({
id = id,
questions = {{
name = name or '_services._dns-sd._udp.local',
type = dnsType or dns.TYPES.PTR,
class = dns.CLASSES.IN,
unicastResponse = true,
}}
})
local senders = {}
return {
messages = messages,
onMessage = function(fn)
onMessage = fn
end,
start = function()
local addresses = dns.getInterfaceAddresses()
logger:fine('Interface addresses: %t', addresses)
for _, address in ipairs(addresses) do
local sender = UdpSocket:new()
sender:bind(address, 0)
logger:fine('sender bound to %s', address)
sender:receiveStart(onReceived)
table.insert(senders, sender)
end
end,
send = function()
for _, sender in ipairs(senders) do
sender:send(data, mdnsAddress, mdnsPort):catch(function(reason)
logger:warn('Error while sending UDP, %s', reason)
sender:close()
List.removeAll(senders, sender)
end)
end
end,
close = function()
local sendersToClose = senders
senders = {}
for _, sender in ipairs(sendersToClose) do
sender:close()
end
end
}
end

function extension:discoverBridge()
logger:info('Looking for Hue Bridge...')
return Promise:new(function(resolve, reject)
local discovery = discover('_hue._tcp.local')
discovery.onMessage(function(message)
local ip = message.addr and message.addr.ip
logger:fine('on message %s', ip)
if ip then
resolve('Found '..ip)
configuration.url = 'https://'..ip..'/'
discovery.close()
extension:clearTimer('discovery')
end
end)
discovery.start()
discovery.send()
extension:setTimer(function()
reject('Discovery timeout')
discovery.close()
end, 5000, 'discovery')
end)
end

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'} } })
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)
end):finally(function()
client:close()
end)
end

function extension:touchlink()
if not hueBridge then
return Promise.reject('Bridge not available')
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)
end
20 changes: 19 additions & 1 deletion extensions/hue-v2/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,23 @@
"default": true
}
}
}
},
"actions": [
{
"name": "Discover Bridge",
"description": "Look for the Bridge URL on the network",
"method": "discoverBridge",
"active": false
}, {
"name": "Generate API key",
"description": "Generate an API application key, press touchlink first",
"method": "generateKey",
"active": false
}, {
"name": "Touchlink",
"description": "Pair a device with the Bridge",
"method": "touchlink",
"active": true
}
]
}
6 changes: 3 additions & 3 deletions extensions/web-base/www/app/extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ function onShowExtension(extensionId) {
function triggerAction(index) {
return fetch('/engine/extensions/' + this.extensionId + '/action/' + index, {
method: 'POST',
headers: { "Content-Type": 'application/json' },
body: '[]' // TODO ask arguments
}).then(assertIsOk).then(function() {
toaster.toast('Action triggered');
}).then(assertIsOk).then(getResponseText).then(function(text) {
toaster.toast('Action triggered: ' + text);
});
}


new Vue({
el: '#extension',
data: Object.assign({}, EXTENSION_DATA),
Expand Down
2 changes: 1 addition & 1 deletion lha/Extension.lua
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ return require('jls.lang.class').create(require('jls.util.EventPublisher'), func
end

function extension:isActive()
return self.loaded and self.configuration.active
return self.loaded and self.configuration.active == true
end

function extension:setActive(value)
Expand Down
4 changes: 2 additions & 2 deletions lha/restEngine.lua
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,9 @@ local REST_EXTENSIONS = {
local method = extension[action.method]
if type(method) ~= 'function' then
HttpExchange.internalServerError(exchange, 'The action method is not available')
elseif extension:isActive() ~= action.active then
elseif action.active ~= nil and action.active ~= extension:isActive() then
HttpExchange.badRequest(exchange, 'The extension active state does not match')
elseif (action.arguments and #action.arguments or 0) == #arguments then
elseif (action.arguments and #action.arguments or 0) ~= (arguments and #arguments or 0) then
HttpExchange.badRequest(exchange, 'The action arguments are invalid')
else
return method(extension, arguments)
Expand Down

0 comments on commit 0f9f6c5

Please sign in to comment.