Skip to content

Instantly share code, notes, and snippets.

@sanmai
Last active May 14, 2026 12:16
Show Gist options
  • Select an option

  • Save sanmai/cf2580224297962d5a9ec08cd4d808e4 to your computer and use it in GitHub Desktop.

Select an option

Save sanmai/cf2580224297962d5a9ec08cd4d808e4 to your computer and use it in GitHub Desktop.

Corsair Link Node Documentation

USB ID 1b1c:0c04 is shared by several legacy Corsair Link devices, including at least:

  • Corsair H80i, H100i, H110i GT (AIO coolers)
  • Corsair Link Cooling Node
  • Corsair Commander Mini (fan + temperature + RGB controller)

They all present as a single "Integrated USB Bridge" HID endpoint that fronts the actual Corsair Link device over an internal serial daisy chain.

What does NOT discriminate variants

The USB descriptor is identical across all observed variants:

Field Value
idVendor 0x1B1C
idProduct 0x0C04
manufacturer_string "Corsair Memory, Inc."
product_string "Integrated USB Bridge"
serial_number (empty)
bcdDevice 0x0200

Source: hid.enumerate(0x1b1c, 0x0c04) against a Commander Mini. The fields above are also what every other variant is expected to expose, so liquidctl cannot dispatch on USB metadata alone.

Protocol framing (verified)

Commands are variable-length packages packed into a single 64-byte HID OUT report:

report = [length] [pkg]+ [pad to 64 bytes]
pkg    = [seq] [op] [cmd] [params...]

seq is a per-instance sequence number cycled 2..32. op selects a read/write width:

op meaning
0x06 WRITE_ONE_BYTE
0x07 READ_ONE_BYTE
0x08 WRITE_TWO_BYTES
0x09 READ_TWO_BYTES
0x0A WRITE_THREE_BYTES
0x0B READ_THREE_BYTES

The HID IN report packs responses back-to-back starting at byte 0 (no length prefix). Per-package response sizes:

op response payload
WRITE_ONE [seq, 0x06] — 2 bytes, no echo
READ_ONE [seq, 0x07, data] — 3 bytes
READ_TWO [seq, 0x09, lo, hi] — 4 bytes
READ_THREE [seq, 0x0B, b0, b1, b2] — 5 bytes

A (SELECT, READ_TWO) pair therefore takes 7 request bytes and 6 response bytes. The 63-byte report payload (after the length byte) caps a single batch at ~9 such pairs; reads beyond that must be split across multiple HID exchanges.

Evidence: probe_coolit.py against a Commander Mini. Sample combined response for TEMP_SELECT 0 + TEMP_READ + FAN_SELECT 0 + FAN_READ_RPM:

17 06 18 09 f7 26 19 06 1a 09 4d 03
[seq,06]    [seq,06]                  -- WRITE_ONE acks
      [seq,09,lo,hi]    [seq,09,lo,hi]  -- READ_TWO payloads
       data 0x26f7=38.97 C  data 0x034d=845 rpm

Known commands

Names below are from OpenCorsairLink's include/protocol/coolit.h (enum COOLIT_Commands).

cmd name width(s) notes
0x00 DeviceID READ_ONE variant byte; see table above
0x01 FirmwareID READ_TWO [minor.patch_byte, major_byte] (e.g. 0x06 0x11 = 1.1.6)
0x02 ProductName READ_ONE/TWO always 4 ASCII bytes (e.g. "CLCC"); READ_ONE/TWO put data at bytes 2-5, READ_THREE prepends a stray 0x00
0x03 Status (unknown) name from header; not exercised
0x04 LED_SelectCurrent WRITE_ONE selects active LED channel
0x05 LED_Count READ_ONE LED-channel count (1 on Commander Mini and H110i)
0x06 LED_Mode READ/WRITE_ONE Static=0x00, TwoColorCycle=0x40, FourColorCycle=0x80, TemperatureColor=0xC0
0x07 LED_CurrentColor READ_THREE RGB triplet of currently displayed color
0x08 LED_TemperatureColor (unknown) name from header
0x09 LED_TemperatureMode (unknown) name from header
0x0A LED_TemperatureModeColors WRITE_THREE curve of (temp, R, G, B) points
0x0B LED_CycleColors WRITE_THREE sets the 4 cycle-mode colors; payload [0x0C, R0,G0,B0, R1,G1,B1, R2,G2,B2, R3,G3,B3] (leading 0x0C constant; static = all 4 triplets the same)
0x0C TEMP_SelectActiveSensor WRITE_ONE selects active temperature sensor
0x0D TEMP_CountSensors READ_ONE discoverable temp_count — Commander Mini: 4, H110i: 1
0x0E TEMP_Read READ_TWO [frac_byte, integer_C]
0x0F TEMP_Limit (unknown) name from header
0x10 FAN_Select WRITE_ONE selects active fan/pump slot
0x11 FAN_Count READ_ONE discoverable fan-slot count — Commander Mini: 6, H110i: 3 (2 fans + pump)
0x12 FAN_Mode READ/WRITE_ONE enum on writes (FixedPWM/FixedRPM/Default/Quiet/Balanced/Performance/Custom); on reads, 0x03 = empty slot, 0x8f = fan present (default/auto); H110i pump reads 0x85
0x13 FAN_FixedPWM WRITE_ONE duty 0–255
0x14 FAN_FixedRPM WRITE_TWO u16le rpm
0x15 FAN_ReportExtTemp (unknown) name from header
0x16 FAN_ReadRPM READ_TWO u16le RPM (0x0000 = empty slot)
0x17 FAN_MaxRecordedRPM READ_TWO per-slot historic max
0x18 FAN_UnderSpeedThreshold (unknown) name from header
0x19 FAN_RPMTable WRITE_THREE custom curve: 5 (rpm-lo, rpm-hi) pairs after a leading 0x0A
0x1A FAN_TempTable WRITE_THREE custom curve: 5 (0x00, temp_C) pairs after a leading 0x0A

Self-discovery commands are the load-bearing finding here. TEMP_CountSensors (0x0D) and FAN_Count (0x11) mean the driver does not need to hardcode temp_count and fan_count per variant — the device tells us. The variant table only has to carry what the device can't report: human-readable description and pump details.

Fan-mode enum (cmd 0x12 writes), from OpenCorsairLink/include/protocol/coolit.h enum COOLIT_Fan_Modes: FixedPWM=0x02, FixedRPM=0x04, Default=0x06, Quiet=0x08, Balanced=0x0A, Performance=0x0C, Custom=0x0E. The current coolit.py _FanMode enum only covers FixedPWM/FixedRPM/Custom.

LED-mode enum (cmd 0x06 writes), from enum COOLIT_Led_Modes: Static=0x00, TwoColorCycle=0x40, FourColorCycle=0x80, TemperatureColor=0xC0. To set a static color: write LED_SelectCurrent to pick the channel, write LED_Mode=0x00, then LED_CycleColors with all 4 triplets equal to the desired RGB. (Driver does not yet implement set_color.)

Hypothesis: cmd 0x00 distinguishes variants

If true, the driver can read 0x00 at connect() time and dispatch to the right (has_pump, fan_count, temp_count, ...) config without requiring the user to pick with --match.

device DEVICE_TYPE byte source
H80 0x37 OpenCorsairLink device.c
Cooling Node 0x38 OpenCorsairLink device.c
Lighting Node 0x39 OpenCorsairLink device.c
H100 0x3A OpenCorsairLink device.c
H80i 0x3B OpenCorsairLink device.c
H100i 0x3C OpenCorsairLink device.c
Commander Mini 0x3D direct probe (PRODUCT_NAME="CLCC")
H100i GT 0x40 OpenCorsairLink device.c
H110i GT 0x41 OpenCorsairLink device.c
H110i (also labeled "H110i GT" by the original liquidctl driver) 0x42 parsed pcap + OpenCorsairLink device.c

Two rows are validated against real hardware: 0x3D (Commander Mini) by direct probe, and 0x42 (H110i / H110i GT) via the OpenCorsairLink pcap in collected-device-data/. The other eight come from OpenCorsairLink's device.c table on the testing branch. The driver loads them as a best-effort default; users on those variants will see auto-detection succeed but if a per-variant detail (fan_count, pump_index) is wrong they should report.

Naming note for 0x42: OpenCorsairLink calls 0x42 "H110i" (no GT) with pump at fan slot 2; "H110i GT" is 0x41 with pump at slot 3. The historical liquidctl driver was labeled "Corsair H110i GT" and hardcoded pump_index = 2 — so the driver was actually targeting OpenCorsairLink's "H110i". The contributor whose pcap lives in collected-device-data/Corsair H110i GT/ likely had an H110i and named the directory wrong. We label 0x42 as "Corsair H110i / H110i GT" so substring --match works for either name and existing user scripts don't break.

Firmware versions also differ, secondary signal:

device firmware (cmd 0x01)
Commander Mini 1.1.6 (06 11)
H110i GT 2.0.0 (00 20)

Evidence sources to mine:

  • collected-device-data/Corsair H110i GT/*.pcapng — Wireshark captures of OpenCorsairLink configuring fan curves on a real H110i GT. Parsed; parse_h110i_pcap.py in the repo root shows OpenCorsairLink reads cmd 0x00 repeatedly and gets 0x42 every time. PRODUCT_NAME is not probed.
  • OpenCorsairLink source — likely has a hardcoded table of device-type bytes; could be cross-referenced for the H80i / H100i / Cooling Node rows.

Per-variant runtime configuration

device has_pump fan_count temp_count
H110i GT True 2 1
Commander Mini False 6 4
H80i / H100i True 2 (?) 1 (?)
Cooling Node False ? (4?) ? (4?)

Question marks indicate values not yet confirmed against real hardware or captures.

Disconnected-fan sentinel (verified — two signals)

A fan slot with nothing connected returns 0x0000 for both RPM bytes (verified on Commander Mini: slots 2, 3 empty read 0; slots 0, 1, 4, 5 populated). The driver reports these as 0 rpm rows (does not hide them) so users can see which slot index is empty.

A second, more direct signal: FAN_MODE (0x12) reads as 0x03 for an empty slot vs 0x8f for a populated one (default/auto mode). This distinguishes "fan stopped" (mode 0x8f, rpm 0) from "no fan" (mode 0x03, rpm 0), which the rpm-only heuristic cannot. The driver does not yet use this signal.

FAN_MAX_RPM (0x17) reads 0x0000 for empty slots and the fan's nominal max RPM for populated ones (Commander Mini sample: slots 0/1 report ~1725, slots 4/5 report ~1315 — a mix of fan models).

Disconnected-sensor sentinel (heuristic — TODO confirm)

When TEMP_READ is issued with no thermistor on the selected header, the Commander Mini returns a value with the integer byte's high bit set. Observed: integer byte 0xb4 on one unplugged port (which would otherwise decode as ~180 °C). The driver currently treats any read where integer_byte & 0x80 is true as "disconnected" and omits that row.

Open questions:

  • Is the sentinel always 0xb4xx, or any value above 127 °C?
  • Does it match across variants (e.g. AIO devices)?

A clean way to verify would be to compare a port's reading immediately before and after physically disconnecting a probe; awkward to do on a running system.

Temperature sensor labeling (Commander Mini)

Protocol index 0 corresponds to the highest-numbered silkscreen port (T4), and index 3 to the lowest (T1). That is:

TEMP_SELECT N  ->  driver labels "Temperature {temp_count - N}"
                ->  matches physical port {temp_count - N}

User-confirmed by plugging a known probe into the silkscreen-labeled port "2" and observing the reading appear at TEMP_SELECT 2, which the driver now labels "Temperature 2" after the reversal.

This reversal is presumed Commander-Mini-specific; the AIO variants only have one temperature probe (the cold-plate liquid sensor) so the question of label ordering does not arise for them.

Out-of-scope / unverified

  • Lighting / RGB control — Commander Mini has 1 LED channel and the AIOs have RGB rings, but the driver currently exposes no color control.
  • Whether the device-type byte changes after firmware updates.
  • Behavior of pump-mode commands on non-AIO variants — gated off by has_pump=False in the driver, so untested.
  • Whether multiple Corsair Link devices can sit behind one bridge (the "Integrated USB Bridge" name hints at this); current driver assumes one.
#!/usr/bin/env python3
"""Probe a Corsair device at 1b1c:0c04 (Commander Mini / Cooling Node / H-series AIO).
Reads all 6 fan slots and probes 4 temp sensors via TEMP_SELECT (0x0C) +
TEMP_READ (0x0E). Prints raw response bytes so we can confirm the protocol
guesses before generalizing the driver.
Run: python3 probe_coolit.py
"""
import sys
from liquidctl.driver.coolit import Coolit
_OP_WRITE_ONE = 0x06
_OP_READ_TWO = 0x09
_CMD_TEMP_SELECT = 0x0C # guess
_CMD_TEMP_READ = 0x0E
_CMD_FAN_SELECT = 0x10
_CMD_FAN_READ_RPM = 0x16
def _hex(buf, n):
return " ".join(f"{b:02x}" for b in buf[:n])
def main():
devices = list(Coolit.find_supported_devices())
if not devices:
print("no Coolit device found", file=sys.stderr)
return 1
drv = devices[0]
drv.connect()
try:
print("=== fan slots 0..5 (FAN_SELECT + FAN_READ_RPM) ===")
for idx in range(6):
# SELECT pkg = 4 bytes at buf[1..5]; READ pkg at buf[5..8];
# READ_TWO response overwrites the op+cmd slots -> res[6], res[7].
res = drv._send_commands([
drv._build_data_package(_CMD_FAN_SELECT, _OP_WRITE_ONE, params=bytes([idx])),
drv._build_data_package(_CMD_FAN_READ_RPM, _OP_READ_TWO),
])
rpm = res[6] | (res[7] << 8)
print(f" fan {idx}: rpm={rpm:5d} raw[0:12]={_hex(res, 12)}")
print()
print("=== TEMP_READ alone (matches current get_status) ===")
res = drv._send_commands([
drv._build_data_package(_CMD_TEMP_READ, _OP_READ_TWO),
])
# READ pkg at buf[1..4]; data at res[2,3].
temp = res[3] + res[2] / 255
print(f" temp={temp:5.2f} C raw[0:8]={_hex(res, 8)}")
print()
print("=== TEMP_SELECT(0x0C) idx N + TEMP_READ ===")
for idx in range(4):
res = drv._send_commands([
drv._build_data_package(_CMD_TEMP_SELECT, _OP_WRITE_ONE, params=bytes([idx])),
drv._build_data_package(_CMD_TEMP_READ, _OP_READ_TWO),
])
# SELECT pkg = 4 bytes at buf[1..5]; READ pkg at buf[5..8];
# READ_TWO response at res[6], res[7].
temp = res[7] + res[6] / 255
print(f" sensor {idx}: temp={temp:5.2f} C raw[0:12]={_hex(res, 12)}")
print()
print("=== combined: TEMP_SELECT 0 + TEMP_READ + FAN_SELECT 0 + FAN_READ_RPM (mirrors get_status start) ===")
res = drv._send_commands([
drv._build_data_package(_CMD_TEMP_SELECT, _OP_WRITE_ONE, params=bytes([0])),
drv._build_data_package(_CMD_TEMP_READ, _OP_READ_TWO),
drv._build_data_package(_CMD_FAN_SELECT, _OP_WRITE_ONE, params=bytes([0])),
drv._build_data_package(_CMD_FAN_READ_RPM, _OP_READ_TWO),
])
print(f" raw[0:24]={_hex(res, 24)}")
print()
print("=== combined: TEMP_SELECT alone, then standalone TEMP_READ in next call ===")
# Maybe the device wants SELECT and READ in separate reports.
drv._send_commands([
drv._build_data_package(_CMD_TEMP_SELECT, _OP_WRITE_ONE, params=bytes([1])),
])
res = drv._send_commands([
drv._build_data_package(_CMD_TEMP_READ, _OP_READ_TWO),
])
# If sticky-select works, this should give sensor 1 (~37.40 C earlier).
temp = res[3] + res[2] / 255
print(f" after SELECT 1, bare READ: temp={temp:5.2f} C raw[0:8]={_hex(res, 8)}")
print()
print("=== identification commands ===")
# cmd 0x00 DEVICE_TYPE
res = drv._send_command(drv._build_data_package(0x00, _OP_READ_ONE := 0x07))
print(f" cmd 0x00 DEVICE_TYPE -> 0x{res[2]:02x} raw={_hex(res, 8)}")
# cmd 0x02 PRODUCT_NAME (try as READ_TWO and READ_THREE — width unknown)
res = drv._send_command(drv._build_data_package(0x02, _OP_READ_TWO))
print(f" cmd 0x02 PRODUCT_NAME (READ_TWO) -> {res[2:4]!r} raw={_hex(res, 8)}")
res = drv._send_command(drv._build_data_package(0x02, 0x0B)) # READ_THREE
print(f" cmd 0x02 PRODUCT_NAME (READ_THREE) -> {res[2:5]!r} raw={_hex(res, 8)}")
print()
print("=== unknown read-only commands seen in H110i GT capture ===")
for cmd, op_name, op in [
(0x05, "READ_ONE", 0x07),
(0x06, "READ_ONE", 0x07),
(0x07, "READ_THREE", 0x0B),
(0x0D, "READ_ONE", 0x07),
(0x11, "READ_ONE", 0x07),
]:
res = drv._send_command(drv._build_data_package(cmd, op))
print(f" cmd 0x{cmd:02x} {op_name} -> raw={_hex(res, 8)}")
print()
print("=== per-fan FAN_MODE (0x12 READ_ONE) and FAN_MAX_RPM (0x17 READ_TWO) ===")
for idx in range(6):
res = drv._send_commands([
drv._build_data_package(_CMD_FAN_SELECT, _OP_WRITE_ONE, params=bytes([idx])),
drv._build_data_package(0x12, 0x07), # FAN_MODE READ_ONE
drv._build_data_package(0x17, _OP_READ_TWO), # FAN_MAX_RPM
])
# response packing: WRITE_ONE (2B) + READ_ONE (3B) + READ_TWO (4B) = 9B
# FAN_MODE byte at res[4]; MAX_RPM at res[7,8]
mode = res[4]
max_rpm = res[7] | (res[8] << 8)
print(f" fan {idx}: mode=0x{mode:02x} max_rpm={max_rpm} raw={_hex(res, 12)}")
finally:
drv.disconnect()
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment