Reverse Engineering Lutron Clear Connect Type X (2.4 GHz)

Reverse Engineering Lutron Clear Connect Type X (2.4 GHz)

In my previous post I wrote about reverse engineering Lutron's CCA protocol on 433 MHz — an unencrypted, one-way radio protocol behind most of their older product lines. At the end I mentioned CCX as the next target. This is that post.

Clear Connect Type X is the protocol behind RadioRA3, Homeworks QSX, and the Ketra lighting line. It's what Lutron is building everything new on. Unlike CCA, it's encrypted, bidirectional, and mesh-networked over 2.4 GHz. Considerably more sophisticated, and considerably more interesting to take apart.

The code is available at github.com/alxgmpr/lutron-tools.

CCX is Thread

The single most important thing I learned is that CCX is Thread. Not "Thread-like" or "Thread-inspired" — it is standard IEEE 802.15.4 with standard 6LoWPAN and standard IPv6 mesh networking. The first time I captured traffic with a sniffer I saw fd00:: prefixes and EUI-64 addresses, and that was the whole mystery solved at the transport layer. Every piece of existing Thread tooling — Wireshark dissectors, OpenThread, Nordic's SDK — works out of the box.

There's a pleasant irony here. Lutron's marketing positions their systems as the reliable alternative to the open smart home ecosystem and the interoperability challenges of Matter. But CCX runs on Thread, which is one of the two transport layers that Matter itself uses. The proprietary system is built on the open standard.

Getting on the mesh

The network credentials come from the LEAP API, which is the RadioRA3 processor's local HTTP interface (the /link endpoint). There are four values:

  • Channel 25 (2480 MHz)
  • PAN ID 0x62EF
  • Extended PAN ID 0D 02 EF A8 2C 98 92 31
  • Master key: 16 bytes, AES-128

With these, any Thread radio can join the mesh. This is just how Thread works — you provide credentials, you're a participant. If you're on the same LAN as the processor, getting the credentials is straightforward.

Hardware

My research setup is an STM32H723ZG Nucleo-144 running FreeRTOS with an nRF52840 dongle connected over UART (4 wires, 460800 baud). The nRF runs OpenThread as a Network Co-Processor; the STM32 drives it with wpanctl commands and exposes packets over Ethernet on TCP:9433 so my laptop can interact with the mesh programmatically. A second nRF52840 dongle runs Nordic's sniffer firmware for Wireshark captures.

The test environment is a RadioRA3 installation with 13 Sunnata hybrid keypads, multiple Sunnata dimmer modules, 39 dimmed zones, 4 switched zones, a fan, and a CCO relay.

Protocol structure

CCX splits communication across two UDP ports.

Port 9190 handles runtime control: dimming, button presses, scene recalls, status reports. Traffic here is mostly multicast to ff03::1. Messages are CBOR arrays with the shape [msgType, bodyMap], where the body map uses integer keys (not strings, which tripped me up since most CBOR libraries default to string keys).

Port 5683 handles CoAP programming: device commissioning, preset assignments, trim levels, zone membership. This traffic is unicast to individual devices.

Something that confused me for a while: when you press a scene button on a keypad, the keypad sends a single BUTTON_PRESS multicast containing the preset ID. No per-zone level commands follow. The dimmer modules already have the scene definitions loaded via CoAP during commissioning. They see the preset ID and recall their locally stored assignments. One multicast packet triggers dozens of zones simultaneously.

Every runtime message is sent 7 times, spaced about 80ms apart. Deduplication uses an 8-bit sequence number and a 2-second ring buffer.

Message types

I've decoded 10 message types so far. The ones that matter most:

LEVEL_CONTROL (0) sets a zone to a level with a fade time:

[0, {0: {0: level16, 3: fade_qs}, 1: [16, zone_id], 5: seq}]

Level encoding is percent × 0xFEFF / 100. The maximum is 0xFEFF, not 0xFFFF — the same ceiling as CCA, interestingly. Fade is in quarter-seconds.

BUTTON_PRESS (1) is emitted by keypads on physical press:

[1, {0: {0: [preset_hi, preset_lo, 0xEF, 0x20], 1: [cnt1, cnt2, cnt3]}, 5: seq}]

The first two bytes of the device field encode the LEAP preset ID in big-endian. Figuring this out unlocked the entire button→scene→zone chain, because I could cross-reference captures against the LEAP database and the Lutron Designer project file.

SCENE_RECALL (36) triggers a scene programmatically:

[36, {0: {0: [4]}, 1: [0], 3: {0: scene_id}, 5: seq}]

DIM_HOLD (2) / DIM_STEP (3) are the hold-to-dim and step-dim commands from keypads. DEVICE_REPORT (27) carries unicast state reports from devices back to the processor. COMPONENT_CMD (40) appears to handle shades and fans — I've seen parameters like [10, 4800] and [5, 60] but haven't fully decoded it. ACK (7), STATUS (41), and PRESENCE (65535) round out the set. STATUS carries a binary payload I haven't cracked yet; PRESENCE is a periodic heartbeat.

Decryption

Thread uses AES-128-CCM at the link layer, which is standard 802.15.4 security. Since the master key is available from LEAP, decrypting captured traffic is a matter of following the Thread key derivation specification:

HMAC-SHA256(master_key, keySequence_BE[4] || "Thread")
  → bytes[0:16]  = MLE key
  → bytes[16:32] = MAC key (frame encryption)

The HMAC suffix is the literal ASCII string "Thread", and keySequence = keyIndex - 1.

The nonce is 13 bytes:

EUI-64 [8] || frameCounter_BE [4] || secLevel [1]

There's a byte-order mismatch that cost me a lot of time: the frame counter is stored little-endian in the 802.15.4 auxiliary security header but must be big-endian in the nonce. Once I got the ordering right, every frame decrypted cleanly.

CoAP programming

When the RadioRA3 processor transfers configuration to a device, it uses this CoAP sequence on port 5683:

DELETE /cg/db                    — wipe the device database
PUT    /cg/db/ct/c/<bucket>*     — config table writes
POST   /cg/db/mc/c/AAI           — zone membership
POST   /cg/db/pr/c/AAI           — preset assignments

Bucket tokens are base64url-encoded 2-byte IDs. The ones I've decoded so far:

  • AAI (0x0002) — dimmer trim: high end, low end, dimming profile
  • AHA (0x0070) — status LED brightness: active and inactive levels, 0–255
  • AFE–AFQ (0x0051–0x0054) — LED link indices, mapping buttons to indicator LEDs

I confirmed that writes work by pushing new AHA values to a keypad via CoAP — the status LEDs changed brightness on the physical device. There are dozens of other bucket tokens I haven't identified yet.

Practical application: a CCX-to-WiZ bridge

The tangible result of all this is a bridge that connects Lutron CCX to WiZ smart bulbs. WiZ bulbs are inexpensive ESP32-based lights that accept UDP commands on port 38899. The bridge listens for multicast LEVEL_CONTROL messages on the Thread mesh, maps zone IDs to WiZ bulbs, applies warm dimming curves and dim-level scaling, and forwards the translated command. It runs in production (my house).