Casambi

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  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:

  1. Provide all credentials (api key, email, network password and user password) in casambi.setcredentials({…})

  2. 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.

  3. When changing control/status mapping or credentials make sure to do a full resident script restart via disable/enable.

  4. Any connection or credentials errors will be visible in the Logs tab.

Available control/status types and according object data types:

  • onoff = 1 bit boolean
  • dimmer = 1 byte scale
  • color = 3 bytes RGB
  • cct = 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).