Casambi integration with LogicMachine #
Task #
This example integrates Casambi devices with LogicMachine over websocket (TCP/IP) and adds a possibility to interact with Casambi devices from KNX, Modbus, BACnet and other supported protocols
Integration guide #
Step 1 #
Create a user library named websocket
and put user.websocket.lua
file contents into it.
local bit = require('bit')
local ssl = require('ssl')
local socket = require('socket')
local encdec = require('encdec')
local parse_url = require('socket.url').parse
local bxor = bit.bxor
local bor = bit.bor
local band = bit.band
local lshift = bit.lshift
local rshift = bit.rshift
local ssub = string.sub
local sbyte = string.byte
local schar = string.char
local tinsert = table.insert
local tconcat = table.concat
local mmin = math.min
local mfloor = math.floor
local mrandom = math.random
local base64enc = encdec.base64enc
local sha1 = encdec.sha1
local unpack = unpack
local CONTINUATION = 0
local TEXT = 1
local BINARY = 2
local CLOSE = 8
local PING = 9
local PONG = 10
local guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
local read_n_bytes = function(str, pos, n)
pos = pos or 1
return pos+n, string.byte(str, pos, pos + n - 1)
end
local read_int8 = function(str, pos)
return read_n_bytes(str, pos, 1)
end
local read_int16 = function(str, pos)
local new_pos,a,b = read_n_bytes(str, pos, 2)
return new_pos, lshift(a, 8) + b
end
local read_int32 = function(str, pos)
local new_pos,a,b,c,d = read_n_bytes(str, pos, 4)
return new_pos,
lshift(a, 24) +
lshift(b, 16) +
lshift(c, 8 ) +
d
end
local write_int8 = schar
local write_int16 = function(v)
return schar(rshift(v, 8), band(v, 0xFF))
end
local write_int32 = function(v)
return schar(
band(rshift(v, 24), 0xFF),
band(rshift(v, 16), 0xFF),
band(rshift(v, 8), 0xFF),
band(v, 0xFF)
)
end
local generate_key = function()
math.randomseed(os.time())
local r1 = mrandom(0,0xfffffff)
local r2 = mrandom(0,0xfffffff)
local r3 = mrandom(0,0xfffffff)
local r4 = mrandom(0,0xfffffff)
local key = write_int32(r1)..write_int32(r2)..write_int32(r3)..write_int32(r4)
return base64enc(key)
end
local bits = function(...)
local n = 0
for _,bitn in pairs{...} do
n = n + 2^bitn
end
return n
end
local bit_7 = bits(7)
local bit_0_3 = bits(0,1,2,3)
local bit_0_6 = bits(0,1,2,3,4,5,6)
-- TODO: improve performance
local xor_mask = function(encoded,mask,payload)
local transformed,transformed_arr = {},{}
-- xor chunk-wise to prevent stack overflow.
-- sbyte and schar multiple in/out values
-- which require stack
for p=1,payload,2000 do
local last = mmin(p+1999,payload)
local original = {sbyte(encoded,p,last)}
for i=1,#original do
local j = (i-1) % 4 + 1
transformed[i] = bxor(original[i],mask[j])
end
local xored = schar(unpack(transformed,1,#original))
tinsert(transformed_arr,xored)
end
return tconcat(transformed_arr)
end
local encode_header_small = function(header, payload)
return schar(header, payload)
end
local encode_header_medium = function(header, payload, len)
return schar(header, payload, band(rshift(len, 8), 0xFF), band(len, 0xFF))
end
local encode_header_big = function(header, payload, high, low)
return schar(header, payload)..write_int32(high)..write_int32(low)
end
local encode = function(data,opcode,masked,fin)
local header = opcode or 1-- TEXT is default opcode
if fin == nil or fin == true then
header = bor(header,bit_7)
end
local payload = 0
if masked then
payload = bor(payload,bit_7)
end
local len = #data
local chunks = {}
if len < 126 then
payload = bor(payload,len)
tinsert(chunks,encode_header_small(header,payload))
elseif len <= 0xffff then
payload = bor(payload,126)
tinsert(chunks,encode_header_medium(header,payload,len))
elseif len < 2^53 then
local high = mfloor(len/2^32)
local low = len - high*2^32
payload = bor(payload,127)
tinsert(chunks,encode_header_big(header,payload,high,low))
end
if not masked then
tinsert(chunks,data)
else
local m1 = mrandom(0,0xff)
local m2 = mrandom(0,0xff)
local m3 = mrandom(0,0xff)
local m4 = mrandom(0,0xff)
local mask = {m1,m2,m3,m4}
tinsert(chunks,write_int8(m1,m2,m3,m4))
tinsert(chunks,xor_mask(data,mask,#data))
end
return tconcat(chunks)
end
local decode = function(encoded)
local encoded_bak = encoded
if #encoded < 2 then
return nil,2-#encoded
end
local pos,header,payload
pos,header = read_int8(encoded,1)
pos,payload = read_int8(encoded,pos)
local high,low
encoded = ssub(encoded,pos)
local bytes = 2
local fin = band(header,bit_7) > 0
local opcode = band(header,bit_0_3)
local mask = band(payload,bit_7) > 0
payload = band(payload,bit_0_6)
if payload > 125 then
if payload == 126 then
if #encoded < 2 then
return nil,2-#encoded
end
pos,payload = read_int16(encoded,1)
elseif payload == 127 then
if #encoded < 8 then
return nil,8-#encoded
end
pos,high = read_int32(encoded,1)
pos,low = read_int32(encoded,pos)
payload = high*2^32 + low
if payload < 0xffff or payload > 2^53 then
assert(false,'INVALID PAYLOAD '..payload)
end
else
assert(false,'INVALID PAYLOAD '..payload)
end
encoded = ssub(encoded,pos)
bytes = bytes + pos - 1
end
local decoded
if mask then
local bytes_short = payload + 4 - #encoded
if bytes_short > 0 then
return nil,bytes_short
end
local m1,m2,m3,m4
pos,m1 = read_int8(encoded,1)
pos,m2 = read_int8(encoded,pos)
pos,m3 = read_int8(encoded,pos)
pos,m4 = read_int8(encoded,pos)
encoded = ssub(encoded,pos)
local mask = {
m1,m2,m3,m4
}
decoded = xor_mask(encoded,mask,payload)
bytes = bytes + 4 + payload
else
local bytes_short = payload - #encoded
if bytes_short > 0 then
return nil,bytes_short
end
if #encoded > payload then
decoded = ssub(encoded,1,payload)
else
decoded = encoded
end
bytes = bytes + payload
end
return decoded,fin,opcode,encoded_bak:sub(bytes+1),mask
end
local encode_close = function(code,reason)
if code then
local data = write_int16(code)
if reason then
data = data..tostring(reason)
end
return data
end
return ''
end
local decode_close = function(data)
local _,code,reason
if data then
if #data > 1 then
_,code = read_int16(data,1)
end
if #data > 2 then
reason = data:sub(3)
end
end
return code,reason
end
local sec_websocket_accept = function(sec_websocket_key)
local enc = sha1(sec_websocket_key..guid, true)
return base64enc(enc)
end
local http_headers = function(request)
local headers = {}
if not request:match('.*HTTP/1%.1') then
return headers
end
request = request:match('[^\r\n]+\r\n(.*)')
for line in request:gmatch('[^\r\n]*\r\n') do
local name,val = line:match('([^%s]+)%s*:%s*([^\r\n]+)')
if name and val then
name = name:lower()
if not name:match('sec%-websocket') then
val = val:lower()
end
if not headers[name] then
headers[name] = val
else
headers[name] = headers[name]..','..val
end
elseif line ~= '\r\n' then
assert(false,line..'('..#line..')')
end
end
return headers,request:match('\r\n\r\n(.*)')
end
local upgrade_request = function(req, key, protocol)
local format = string.format
local lines = {
format('GET %s HTTP/1.1',req.path or ''),
format('Host: %s',req.host),
'Upgrade: websocket',
'Connection: Upgrade',
format('Sec-WebSocket-Key: %s',key),
'Sec-WebSocket-Version: 13',
}
if protocol then
tinsert(lines, format('Sec-WebSocket-Protocol: %s', protocol))
end
if req.port and req.port ~= 80 then
lines[2] = format('Host: %s:%d',req.host,req.port)
end
if req.userinfo then
local auth = format('Authorization: Basic %s', base64enc(req.userinfo))
tinsert(lines, auth)
end
tinsert(lines,'\r\n')
return tconcat(lines,'\r\n')
end
local receive = function(self)
if self.state ~= 'OPEN' and not self.is_closing then
return nil,nil,false,1006,'wrong state'
end
local first_opcode
local frames
local bytes = 3
local encoded = ''
local clean = function(was_clean,code,reason)
self.state = 'CLOSED'
self:sock_close()
if self.on_close then
self:on_close()
end
return nil,nil,was_clean,code,reason or 'closed'
end
while true do
local chunk,err = self:sock_receive(bytes)
if err then
if err == 'timeout' then
return nil,nil,false,1006,err
else
return clean(false,1006,err)
end
end
encoded = encoded..chunk
local decoded,fin,opcode,_,masked = decode(encoded)
if masked then
return clean(false,1006,'Websocket receive failed: frame was not masked')
end
if decoded then
if opcode == CLOSE then
if not self.is_closing then
local code,reason = decode_close(decoded)
-- echo code
local msg = encode_close(code)
local encoded = encode(msg,CLOSE,true)
local n,err = self:sock_send(encoded)
if n == #encoded then
return clean(true,code,reason)
else
return clean(false,code,err)
end
else
return decoded,opcode
end
end
if not first_opcode then
first_opcode = opcode
end
if not fin then
if not frames then
frames = {}
elseif opcode ~= CONTINUATION then
return clean(false,1002,'protocol error')
end
bytes = 3
encoded = ''
tinsert(frames,decoded)
elseif not frames then
return decoded,first_opcode
else
tinsert(frames,decoded)
return tconcat(frames),first_opcode
end
else
assert(type(fin) == 'number' and fin > 0)
bytes = fin
end
end
end
local send = function(self,data,opcode)
if self.state ~= 'OPEN' then
return nil,false,1006,'wrong state'
end
local encoded = encode(data,opcode or TEXT,true)
local n,err = self:sock_send(encoded)
if n ~= #encoded then
return nil,self:close(1006,err)
end
return true
end
local close = function(self,code,reason)
if self.state ~= 'OPEN' then
return false,1006,'wrong state'
end
if self.state == 'CLOSED' then
return false,1006,'wrong state'
end
local msg = encode_close(code or 1000,reason)
local encoded = encode(msg,CLOSE,true)
local n,err = self:sock_send(encoded)
local was_clean = false
code = 1005
reason = ''
if n == #encoded then
self.is_closing = true
local rmsg,opcode = self:receive()
if rmsg and opcode == CLOSE then
code,reason = decode_close(rmsg)
was_clean = true
end
else
reason = err
end
self:sock_close()
if self.on_close then
self:on_close()
end
self.state = 'CLOSED'
return was_clean,code,reason or ''
end
local DEFAULT_PORTS = {ws = 80, wss = 443}
local connect = function(self,ws_url,ssl_params)
if self.state ~= 'CLOSED' then
return nil,'wrong state',nil
end
local parsed = parse_url(ws_url)
if parsed.scheme ~= 'wss' and parsed.scheme ~= 'ws' then
return nil, 'bad protocol'
end
if not parsed.port then
parsed.port = DEFAULT_PORTS[ parsed.scheme ]
end
-- Preconnect (for SSL if needed)
local _,err = self:sock_connect(parsed.host, parsed.port)
if err then
return nil,err,nil
end
if parsed.scheme == 'wss' then
if type(ssl_params) ~= 'table' then
ssl_params = {
protocol = 'tlsv1',
options = {'all', 'no_sslv2', 'no_sslv3'},
verify = 'none',
}
end
ssl_params.mode = 'client'
self.sock = ssl.wrap(self.sock, ssl_params)
self.sock:dohandshake()
elseif parsed.scheme ~= 'ws' then
return nil, 'bad protocol'
end
local key = generate_key()
local req = upgrade_request(parsed, key, self.protocol)
local n,err = self:sock_send(req)
if n ~= #req then
return nil,err,nil
end
local resp = {}
repeat
local line,err = self:sock_receive('*l')
resp[#resp+1] = line
if err then
return nil,err,nil
end
until line == ''
local response = tconcat(resp,'\r\n')
local headers = http_headers(response)
local expected_accept = sec_websocket_accept(key)
if headers['sec-websocket-accept'] ~= expected_accept then
local msg = 'Websocket Handshake failed: Invalid Sec-Websocket-Accept (expected %s got %s)'
return nil,msg:format(expected_accept,headers['sec-websocket-accept'] or 'nil'),headers
end
self.state = 'OPEN'
return true,headers['sec-websocket-protocol'],headers
end
local extend = function(obj)
obj.state = 'CLOSED'
obj.receive = receive
obj.send = send
obj.close = close
obj.connect = connect
return obj
end
local client_copas = function(timeout)
local copas = require('copas')
local self = {}
self.sock_connect = function(self,host,port)
self.sock = socket.tcp()
self.sock:settimeout(timeout or 5)
local _,err = copas.connect(self.sock,host,port)
if err and err ~= 'already connected' then
self.sock:close()
return nil,err
end
end
self.sock_send = function(self,...)
return copas.send(self.sock,...)
end
self.sock_receive = function(self,...)
return copas.receive(self.sock,...)
end
self.sock_close = function(self)
self.sock:close()
end
self = extend(self)
return self
end
local client_sync = function(timeout)
local self = {}
self.sock_connect = function(self,host,port)
self.sock = socket.tcp()
self.sock:settimeout(timeout or 5)
local _,err = self.sock:connect(host,port)
if err then
self.sock:close()
return nil,err
end
end
self.sock_send = function(self,...)
return self.sock:send(...)
end
self.sock_receive = function(self,...)
return self.sock:receive(...)
end
self.sock_close = function(self)
self.sock:close()
end
self = extend(self)
return self
end
local client = function(mode, timeout)
if mode == 'copas' then
return client_copas(timeout)
else
return client_sync(timeout)
end
end
return {
client = client,
CONTINUATION = CONTINUATION,
TEXT = TEXT,
BINARY = BINARY,
CLOSE = CLOSE,
PING = PING,
PONG = PONG
}
Step 2 #
Create a user library named casambi and put user.casambi.lua file contents into it.
local _M = {}
local ws = require('user.websocket')
local json = require('json')
local socket = require('socket')
local http = require('socket.http')
local wire = os.time()
local wsconn, credentials, closed
local lb, lbfd, timer, tmfd, wsfd
local mapping = {}
local values = {}
http.TIMEOUT = 5
local function enckey(key, value)
return {
[ key ] = { value = value }
}
end
local encoders = {
onoff = function(value)
return enckey('Dimmer', value)
end,
dimmer = function(value)
return {
Dimmer = { value = value / 255 }
}
end,
color = function(value)
local rgb = string.format('rgb(%d, %d, %d)',
bit.band(bit.rshift(value, 16), 0xFF),
bit.band(bit.rshift(value, 8), 0xFF),
bit.band(value, 0xFF)
)
return {
RGB = { rgb = rgb },
Colorsource = { source = 'RGB' },
}
end,
cct = function(value)
return {
ColorTemperature = { value = value },
Colorsource = { source = 'TW' },
}
end,
casarolloup = function(value)
return enckey('Hoch', value)
end,
casarollodown = function(value)
return enckey('Runter', value)
end,
ligacurtainup = function(value)
return enckey('UP', value)
end,
ligacurtaindown = function(value)
return enckey('DOWN', value)
end,
ligacurtainmaxup = function(value)
return enckey('MAX UP', value)
end,
ligacurtainmaxdown = function(value)
return enckey('MAX DOWN', value)
end,
}
local decoders = {
onoff = function(control)
return control.value > 0, dt.bool
end,
dimmer = function(control)
return math.round(control.value * 255), dt.uint8
end,
color = function(control)
local r, g, b = control.rgb:match('(%d+),%s*(%d+),%s*(%d+)')
local v = tonumber(r or 0) * 0x10000 + tonumber(g or 0) * 0x100 + tonumber(b or 0)
return v, dt.rgb
end,
cct = function(control)
return control.value, dt.uint16
end
}
local function eventcb(event)
if not wsconn then
return
elseif event.sender == grp.sender then
return
end
local props = mapping.control[ event.dst ]
if not props then
return
end
local id, method
if props.sceneid then
id = props.sceneid
method = 'controlScene'
elseif props.groupid then
id = props.groupid
method = 'controlGroup'
else
id = props.id
method = 'controlUnit'
end
local value = tonumber(event.datahex, 16) or 0
local message = {
id = id,
wire = wire,
method = method,
}
if props.sceneid or props.groupid then
message.level = props.type == 'onoff' and value or (value / 255)
else
message.targetControls = encoders[ props.type ](value)
end
local payload = json.encode(message)
-- log('tx', payload)
wsconn:send(payload)
end
local function request(endpoint, payload)
local method, body
local headers = {}
headers['X-Casambi-Key'] = credentials.api_key
if credentials.session_id then
headers['X-Casambi-Session'] = credentials.session_id
end
if payload then
method = 'POST'
headers['Content-Type'] = 'application/json'
headers['Content-Length'] = #payload
body = json.encode(payload)
else
method = 'GET'
end
local res, code = http.request({
url = 'https://door.casambi.com/' .. endpoint,
method = method,
headers = headers,
body = body,
})
if res and code == 200 then
res = json.pdecode(res)
end
return res, code
end
local function getusersession()
local payload = {
email = credentials.email,
password = credentials.user_password
}
return request('v1/users/session/', payload)
end
local function getnetworksession()
local payload = {
email = credentials.email,
password = credentials.network_password
}
return request('v1/networks/session/', payload)
end
_M.getnetworkunits = function()
local endpoint = 'v1/networks/' .. credentials.network_id .. '/units'
return request(endpoint)
end
local ping = json.encode({
method = 'ping',
wire = wire
})
local function wscontrol(prefix, control)
local ctype = control.type:lower()
local key = prefix .. '_' .. ctype
local addr = mapping.status[ key ]
if addr then
local value, dpt = decoders[ ctype ](control)
if values[ addr ] ~= value then
grp.write(addr, value, dpt)
values[ addr ] = value
end
end
end
local function wsparse(data)
data = json.pdecode(data)
if type(data) ~= 'table' then
return
end
local status = data.wireStatus
if status then
if status ~= 'openWireSucceed' then
log('casambi: websocket error: ' .. tostring(data.message or status))
wsconn:close()
closed = true
end
return
end
if data.method ~= 'unitChanged' then
return
end
local prefix
if type(data.groupId) == 'number' and data.groupId > 0 then
prefix = 'group_' .. data.groupId
else
prefix = 'id_' .. data.id
end
for _, control in ipairs(data.controls) do
wscontrol(prefix, control)
if control.type == 'Dimmer' then
control.type = 'onoff'
wscontrol(prefix, control)
end
end
end
local function wsconnect()
wsconn = ws.client('sync', 10)
wsconn.protocol = credentials.api_key
local res, err = wsconn:connect('wss://door.casambi.com/v1/bridge/')
if not res then
log('casambi: websocket connection failed, error: ' .. tostring(err))
return nil, err
end
wsfd = socket.fdmaskset(wsconn.sock:getfd(), 'r')
wsconn.on_close = function()
log('casambi: websocket connection closed')
closed = true
end
local open = json.encode({
method = 'open',
id = credentials.network_id,
session = credentials.session_id,
wire = wire,
type = 1 -- fixed client type
})
wsconn:send(open)
return true
end
_M.setcredentials = function(cred)
credentials = cred
end
_M.setmapping = function(mode, map)
if mode == 'control' then
mapping.control = map
elseif mode == 'status' then
mapping.status = {}
for addr, props in pairs(map) do
local key
if props.groupid then
key = 'group_' .. props.groupid
else
key = 'id_' .. props.id
end
key = key .. '_' .. props.type
mapping.status[ key ] = addr
end
end
end
_M.init = function()
lb = require('localbus').new()
lb:sethandler('groupwrite', eventcb)
lb:sethandler('groupresponse', eventcb)
lbfd = socket.fdmaskset(lb:getfd(), 'r')
timer = require('timerfd').new(60)
tmfd = socket.fdmaskset(timer:getfd(), 'r')
end
local function initsession()
local res, err
res, err = getusersession()
if type(res) ~= 'table' then
return nil, err, 'user'
end
credentials.session_id = res.sessionId
res, err = getnetworksession()
if type(res) ~= 'table' then
return nil, err, 'network'
end
credentials.network_id = next(res)
return true
end
_M.initsession = initsession
local function connect()
local res, err, state = initsession()
if not res then
log('casambi: get ' .. state .. ' session failed, error: ' .. tostring(err))
return
end
return wsconnect()
end
local function loop()
local res, lbstat, tmstat, wsstat
if wsconn then
res, lbstat, tmstat, wsstat = socket.selectfds(120, lbfd, tmfd, wsfd)
else
res, lbstat, tmstat = socket.selectfds(120, lbfd, tmfd)
end
if not res then
wsconn:close()
closed = true
return
end
if lbstat then
lb:step()
end
if tmstat then
timer:read()
if wsconn then
wsconn:send(ping)
end
end
if wsstat then
local data = wsconn:receive()
-- log('rx', data)
if data then
wsparse(data)
end
end
end
_M.run = function()
if closed then
wsconn = nil
closed = nil
end
if wsconn then
loop()
elseif not connect() then
os.sleep(10)
end
end
return _M
Step 3 #
Create a resident script with 0 seconds sleep time (use resident.lua as a template):
if not casambi then
casambi = require('user.casambi')
local control = {
-- on/off control for device with id 2
['1/1/1'] = {
id = 2,
type = 'onoff'
},
-- 0..100% control for group with id 1
['1/1/3'] = {
groupid = 1,
type = 'dimmer'
}
}
local status = {
-- on/off status for device with id 2
['1/1/2'] = {
id = 2,
type = 'onoff'
},
-- 0..100% status for group with id 1
['1/1/4'] = {
groupid = 1,
type = 'dimmer'
}
}
casambi.setmapping('control', control)
casambi.setmapping('status', status)
-- Casambi access credentials
casambi.setcredentials({
api_key = 'API_KEY',
email = 'user@example.com',
network_password = 'NET_PASS',
user_password = 'USER_PASS',
})
casambi.init()
end
casambi.run()
Step 4 #
Modify the resident script as follows:
-
Provide all credentials (api key, email, network password and user password) in
casambi.setcredentials({…})
-
Fill control and status mapping as needed. One group address can control a single Casambi device (id field must be set) or a single Casambi group (groupid field must be set). The type field specifies control or status type. Note that group control is only supported by onoff and dimmer types.
-
When changing control/status mapping or credentials make sure to do a full resident script restart via disable/enable.
-
Any connection or credentials errors will be visible in the Logs tab.
Available control/status types and according object data types:
onoff
= 1 bit booleandimmer
= 1 byte scalecolor
= 3 bytes RGBcct
= 2 bytes unsigned
Casambi device discovery #
The following script can be used to get all network and group IDs. Provide credentials and run the script once. Network structure will be visible in the Logs tab.
casambi = require('user.casambi')
casambi.setcredentials({
api_key = 'API_KEY',
email = 'user@example.com',
network_password = 'NET_PASS',
user_password = 'USER_PASS',
})
casambi.initsession()
units = casambi.getnetworkunits()
log(units)
Example:
* table:
["2"]
* table:
["address"]
* string: 8e2f6de63640
["firmwareVersion"]
* string: 26.40
["position"]
* number: 0
["id"]
* number: 2
["type"]
* string: Luminaire
["fixtureId"]
* number: 1000
["name"]
* string: CBU-ASD (0/1-10)
["groupId"]
* number: 0
Network has a device with ID 2. It does not belong to any group (groupId
is 0).