Casambi integration with LogicMacine
Example: Casambi integration with LogicMacine
Task
This example will do Casambi device integration into LogicMachine5 over TCP/IP and add a possibility to interact with Casambi devices from KNX, Modbus, Bacnet and other protocols supported in LM5.
Step by step guide
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
- }
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
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()
The type field specifies control or status type. Note that group control is only supported by onoff and dimmer types.
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
4. getnetworkunits.lua script can be used to get all network and group IDs. Provide credentials and run the script once. Network structure will be visible in Logs entry.
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).