Skip to content

Instantly share code, notes, and snippets.

@JJTech0130
Created June 20, 2026 18:51
Show Gist options
  • Select an option

  • Save JJTech0130/742fe75f0f2a06d74d82445d4e812558 to your computer and use it in GitHub Desktop.

Select an option

Save JJTech0130/742fe75f0f2a06d74d82445d4e812558 to your computer and use it in GitHub Desktop.
Lutron Clear Connect - Type X dissector
-- Wireshark Lua dissector for Lutron Clear Connect - Type X (CCX)
-- UDP/9190 over a Thread/802.15.4 mesh
local cbor = require("simple_cbor")
local ccx_proto = Proto("clearconnectx", "Lutron Clear Connect - Type X")
local f_msg_id = ProtoField.uint32("clearconnectx.msg_id", "Message ID", base.DEC)
local f_note = ProtoField.string("clearconnectx.note", "Note")
ccx_proto.fields = { f_msg_id, f_note }
local cbor_dissector = Dissector.get("cbor")
function ccx_proto.dissector(buffer, pinfo, tree)
local len = buffer:len()
if len == 0 then return end
pinfo.cols.protocol = "CCX"
local subtree = tree:add(ccx_proto, buffer(), "Clear Connect X")
-- Field extraction via our own decoder
local raw = buffer:raw()
local ok, value, _, ctype = pcall(cbor.decode, raw, 1)
local msg_id = nil
if ok and ctype == "ARRAY" and type(value[1]) == "number" then
msg_id = value[1]
elseif not ok then
subtree:add(f_note, "ccx_cbor decode error: " .. tostring(value))
elseif ctype == nil or tostring(ctype):match("^ERROR") then
subtree:add(f_note, "ccx_cbor decode failed: " .. tostring(ctype))
end
if msg_id ~= nil then
subtree:add(f_msg_id, msg_id)
pinfo.cols.info:set(string.format("Message (%d) len=%d", msg_id, len))
else
pinfo.cols.info:set(string.format("Message ??? len=%d", len))
end
-- Display tree via the built-in cbor dissector
local disp_ok, disp_err = pcall(function()
cbor_dissector:call(buffer, pinfo, subtree)
end)
if not disp_ok then
subtree:add(f_note, "cbor dissector call failed: " .. tostring(disp_err))
end
end
DissectorTable.get("udp.port"):add(9190, ccx_proto)
-- there isn't really a good way to call Wireshark's built-in CBOR dissector from Lua
local M = {}
local MAX_DEPTH = 32 -- guard against malformed/adversarial nesting
-- Reads a big-endian unsigned integer of `n` bytes from `data` starting
-- at 1-based position `pos`. Returns value, next_pos, or nil on
-- out-of-range.
local function read_uint(data, pos, n)
if pos + n - 1 > #data then return nil end
local v = 0
for i = 0, n - 1 do
v = v * 256 + data:byte(pos + i)
end
return v, pos + n
end
-- Decodes the "additional info" length/value encoding shared by several
-- major types (uint, negint, bstr/tstr length, array/map count, tag).
-- Returns value, next_pos, or nil, nil on failure / unsupported width.
local function read_arg(data, pos, info)
if info < 24 then
return info, pos
elseif info == 24 then
return read_uint(data, pos, 1)
elseif info == 25 then
return read_uint(data, pos, 2)
elseif info == 26 then
return read_uint(data, pos, 4)
elseif info == 27 then
return read_uint(data, pos, 8) -- may exceed Lua's exact integer range on some builds
else
return nil, nil -- info 28-30 reserved, 31 = indefinite length (not supported)
end
end
local decode -- forward declaration (recursive)
decode = function(data, pos, depth)
depth = depth or 0
if depth > MAX_DEPTH then
return nil, nil, "ERROR_DEPTH"
end
if pos > #data then
return nil, nil, "ERROR_EOF"
end
local b0 = data:byte(pos)
local major = math.floor(b0 / 32)
local info = b0 % 32
pos = pos + 1
if major == 0 then
local v, npos = read_arg(data, pos, info)
if not v then return nil, nil, "ERROR_UINT" end
return v, npos, "UINT"
elseif major == 1 then
local v, npos = read_arg(data, pos, info)
if not v then return nil, nil, "ERROR_NINT" end
return -1 - v, npos, "NINT"
elseif major == 2 then
local len, npos = read_arg(data, pos, info)
if not len then return nil, nil, "ERROR_BIN" end
if npos + len - 1 > #data then return nil, nil, "ERROR_BIN_RANGE" end
return data:sub(npos, npos + len - 1), npos + len, "BIN"
elseif major == 3 then
local len, npos = read_arg(data, pos, info)
if not len then return nil, nil, "ERROR_TEXT" end
if npos + len - 1 > #data then return nil, nil, "ERROR_TEXT_RANGE" end
return data:sub(npos, npos + len - 1), npos + len, "TEXT"
elseif major == 4 then
local count, npos = read_arg(data, pos, info)
if not count then return nil, nil, "ERROR_ARRAY" end
local acc = {}
for i = 1, count do
local v, p2, ct = decode(data, npos, depth + 1)
if ct == nil or ct:match("^ERROR") then
return nil, nil, ct or "ERROR_ARRAY_ITEM"
end
acc[i] = v
npos = p2
end
return acc, npos, "ARRAY"
elseif major == 5 then
local count, npos = read_arg(data, pos, info)
if not count then return nil, nil, "ERROR_MAP" end
local acc = {}
for _ = 1, count do
local k, p2, kct = decode(data, npos, depth + 1)
if kct == nil or kct:match("^ERROR") then
return nil, nil, kct or "ERROR_MAP_KEY"
end
local v, p3, vct = decode(data, p2, depth + 1)
if vct == nil or vct:match("^ERROR") then
return nil, nil, vct or "ERROR_MAP_VALUE"
end
acc[k] = v
npos = p3
end
return acc, npos, "MAP"
elseif major == 6 then
-- Tag: decode and return the wrapped value, with ctype "TAG" and
-- the tag number attached via a wrapper table, since we don't
-- special-case any specific tag semantics (no bignums, dates,
-- etc. -- none observed in ClearConnectX traffic so far).
local tagnum, npos = read_arg(data, pos, info)
if not tagnum then return nil, nil, "ERROR_TAG" end
local v, p2, ct = decode(data, npos, depth + 1)
if ct == nil or ct:match("^ERROR") then
return nil, nil, ct or "ERROR_TAG_VALUE"
end
return { tag = tagnum, value = v, inner_ctype = ct }, p2, "TAG"
elseif major == 7 then
if info < 20 then
return info, pos, "SIMPLE"
elseif info == 20 then
return false, pos, "SIMPLE"
elseif info == 21 then
return true, pos, "SIMPLE"
elseif info == 22 then
return nil, pos, "NULL"
elseif info == 23 then
return nil, pos, "UNDEFINED"
elseif info == 25 or info == 26 or info == 27 then
-- half/single/double float -- not needed for ClearConnectX so
-- far; skip the right number of bytes and report as unsupported
-- rather than guessing at a bit-accurate float decode.
local nbytes = (info == 25) and 2 or (info == 26) and 4 or 8
local npos = pos + nbytes
if npos - 1 > #data then return nil, nil, "ERROR_FLOAT_RANGE" end
return nil, npos, "FLOAT_UNSUPPORTED"
else
return nil, nil, "ERROR_SIMPLE"
end
end
return nil, nil, "ERROR_UNKNOWN_MAJOR_TYPE"
end
-- Public entry point.
M.decode = function(data, pos)
return decode(data, pos or 1, 0)
end
return M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment