// Proof of concept: Lian Li UNI FAN SL-INF Wireless through the SLV3 TX dongle. // Protocol from github.com/sgtaziz/lian-li-linux (MIT). Every colour change is a // full RF upload, so the fan is updated at most a few times per second. export function Name() { return "Lian Li SL-INF Wireless (PoC)"; } export function VendorId() { return 0x0416; } export function ProductId() { return 0x8040; } export function Publisher() { return "PoC"; } export function Size() { return [7, 7]; } export function DefaultPosition() { return [0, 0]; } export function DefaultScale() { return 8.0; } export function Type() { return "rawusb"; } export function DeviceType() { return "lightingcontroller"; } /* global shutdownColor:readonly LightingMode:readonly forcedColor:readonly updateInterval:readonly */ export function ControllableParameters() { return [ {"property":"shutdownColor", "group":"lighting", "label":"Shutdown Color", description: "This color is applied to the device when the System, or SignalRGB is shutting down", "min":"0", "max":"360", "type":"color", "default":"#8000ff"}, {"property":"LightingMode", "group":"lighting", "label":"Lighting Mode", description: "Determines where the device's RGB comes from. Canvas will pull from the active Effect, while Forced will override it to a specific color", "type":"combobox", "values":["Canvas", "Forced"], "default":"Canvas"}, {"property":"forcedColor", "group":"lighting", "label":"Forced Color", description: "The color used when 'Forced' Lighting Mode is enabled", "min":"0", "max":"360", "type":"color", "default":"#009bde"}, {"property":"updateInterval", "group":"", "label":"Update Interval (ms)", description: "Minimum time between RF uploads", "step":"50", "type":"number", "min":"100", "max":"2000", "default":"200"}, ]; } // Bound fan, as reported by `lianli_poc.py scan`. const FAN_MAC = [0xe8, 0xb5, 0x62, 0x62, 0x32, 0xe1]; const FAN_RX_SLOT = 1; const FAN_COUNT = 1; const LEDS_PER_FAN = 44; const USB_CMD_SEND_RF = 0x10; const USB_CMD_GET_MAC = 0x11; const RF_SELECT = 0x12; const RF_SET_RGB = 0x20; const RF_DATA_SIZE = 240; const RF_CHUNK_SIZE = 60; const CHUNK_BYTES = 220; const HEADER_REPEATS = 2; const STATIC_INTERVAL_MS = 5000; // Per fan, in wire order: center ring (8), inner A (10), outer A (8), inner B (10), outer B (8). // Sides A and B get the same canvas positions. const vLedNames = []; const vLedPositions = []; const ledIndexToPosition = []; function ring(count, radius, phase) { const out = []; for (let i = 0; i < count; i++) { const a = phase + (2 * Math.PI * i) / count; out.push([Math.round(3 + radius * Math.cos(a)), Math.round(3 + radius * Math.sin(a))]); } return out; } function buildLayout() { const center = ring(8, 1.2, 0); const inner = ring(10, 2.1, 0); const outer = ring(8, 3, Math.PI / 8); const segments = [["Center", center], ["Inner A", inner], ["Outer A", outer], ["Inner B", inner], ["Outer B", outer]]; for (const [label, positions] of segments) { positions.forEach((pos, i) => { ledIndexToPosition.push(pos); if (!label.endsWith("B")) { vLedNames.push(`${label} ${i + 1}`); vLedPositions.push(pos); } }); } } buildLayout(); export function LedNames() { return vLedNames; } export function LedPositions() { return vLedPositions; } let masterMac = null; let masterChannel = 8; let lastColors = null; let lastSend = 0; export function Initialize() { findMaster(); if (masterMac) { device.log(`TX master ${masterMac.map(b => b.toString(16).padStart(2, "0")).join(":")} channel ${masterChannel}`); } else { device.log("TX master did not answer; is L-Connect or the Python script using the dongle?"); } } export function Render() { if (!masterMac) { return; } sendColors(currentColors(), false); } export function Shutdown(SystemSuspending) { if (!masterMac) { return; } const color = hexToRgb(SystemSuspending ? "#000000" : shutdownColor); sendColors(new Array(LEDS_PER_FAN * FAN_COUNT).fill(color), true); } function currentColors() { if (LightingMode === "Forced") { return new Array(LEDS_PER_FAN * FAN_COUNT).fill(hexToRgb(forcedColor)); } return ledIndexToPosition.map(([x, y]) => device.color(x, y)); } function sendColors(colors, force) { const now = Date.now(); if (!force && now - lastSend < updateInterval) { return; } if (!force && lastColors && !changedEnough(lastColors, colors)) { return; } lastColors = colors; lastSend = now; uploadFrame(colors); } function changedEnough(a, b) { for (let i = 0; i < a.length; i++) { if (Math.abs(a[i][0] - b[i][0]) + Math.abs(a[i][1] - b[i][1]) + Math.abs(a[i][2] - b[i][2]) > 6) { return true; } } return false; } function findMaster() { const channels = [8]; for (let c = 2; c <= 38; c += 2) { if (c !== 8) { channels.push(c); } } for (let c = 1; c <= 39; c += 2) { channels.push(c); } for (const channel of channels) { flushReads(); const cmd = new Array(64).fill(0); cmd[0] = USB_CMD_GET_MAC; cmd[1] = channel; device.bulk_transfer(0x01, cmd, 64, 1000); const r = readPacket(500); if (r.length >= 13 && r[0] === USB_CMD_GET_MAC && r.slice(1, 7).some(b => b !== 0)) { masterMac = r.slice(1, 7); masterChannel = channel; return; } } } function readPacket(timeout) { try { const raw = device.bulk_transfer(0x81, new Array(64).fill(0), 64, timeout); const data = (raw && raw.data !== undefined) ? raw.data : raw; return Array.isArray(data) ? data : (data ? Array.from(data) : []); } catch (_) { return []; } } function flushReads() { for (let i = 0; i < 16 && readPacket(5).length > 0; i++) { /* drain */ } } function uploadFrame(colors) { const raw = []; for (const c of colors) { raw.push(c[0] & 0xff, c[1] & 0xff, c[2] & 0xff); } const compressed = tuzCompress(raw); const hundredths = STATIC_INTERVAL_MS * 160; const ticks = Math.floor(hundredths / 100); const fraction = hundredths % 100; const ledCount = colors.length; let hash = 0x811c9dc5; const tail = [ticks >> 8, ticks & 0xff, fraction, 0, 1, ledCount, 0, 0, 0, 0, 0]; for (const b of compressed.concat(tail)) { hash = Math.imul(hash ^ b, 0x01000193) >>> 0; } const effect = [(hash >>> 24) | 0x80, (hash >>> 16) & 0xff, (hash >>> 8) & 0xff, hash & 0xff]; const dataPackets = Math.ceil(compressed.length / CHUNK_BYTES); for (let index = 0; index <= dataPackets; index++) { const p = new Array(RF_DATA_SIZE).fill(0); p[0] = RF_SELECT; p[1] = RF_SET_RGB; for (let k = 0; k < 6; k++) { p[2 + k] = FAN_MAC[k]; p[8 + k] = masterMac[k]; } for (let k = 0; k < 4; k++) { p[14 + k] = effect[k]; } p[18] = index; p[19] = dataPackets + 1; if (index === 0) { const n = compressed.length; p[20] = (n >>> 24) & 0xff; p[21] = (n >>> 16) & 0xff; p[22] = (n >>> 8) & 0xff; p[23] = n & 0xff; p[25] = 0; p[26] = 1; // one frame p[27] = ledCount; p[32] = ticks >> 8; p[33] = ticks & 0xff; p[34] = fraction; } else { const chunk = compressed.slice((index - 1) * CHUNK_BYTES, index * CHUNK_BYTES); for (let k = 0; k < chunk.length; k++) { p[20 + k] = chunk[k]; } } const repeats = index === 0 ? HEADER_REPEATS : 1; for (let r = 0; r < repeats; r++) { sendRf(p); device.pause(2); } } } function sendRf(rf) { for (let i = 0; i < RF_DATA_SIZE / RF_CHUNK_SIZE; i++) { const pkt = [USB_CMD_SEND_RF, i, masterChannel, FAN_RX_SLOT].concat(rf.slice(i * RF_CHUNK_SIZE, (i + 1) * RF_CHUNK_SIZE)); device.bulk_transfer(0x01, pkt, 64, 1000); } } function hexToRgb(hex) { const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 0, 0]; } // ---- Minimal tinyuz encoder (greedy LZ77), format of github.com/sisong/tinyuz ---- const kMinDictMatchLen = 2; const kBigPosForLen = (1 << 11) + (1 << 9) + (1 << 7) - 1; const kMaxDist = 4096; const kCtrlStreamEnd = 3; function tuzCompress(data) { const code = [0, 0, 0, 0]; // dict size, patched at the end let typesIndex = 0; let typeCount = 0; let haveDataBack = false; let posBack = 1; let dictSizeMax = 1; const outType = (bit) => { if (typeCount === 0) { typesIndex = code.length; code.push(0); } code[typesIndex] |= bit << typeCount; typeCount = (typeCount + 1) & 7; }; const outLen = (v, packBit) => { let count = 1; while (v >= 2 ** (count * packBit)) { v -= 2 ** (count * packBit); count++; } while (count--) { for (let i = 0; i < packBit; i++) { outType(Math.floor(v / 2 ** (count * packBit + i)) & 1); } outType(count > 0 ? 1 : 0); } }; const outDictPos = (pos) => { const big = pos >= 128 ? 1 : 0; if (big) { pos -= 128; } code.push((pos & 0x7f) | (big << 7)); if (big) { outLen(pos >> 7, 2); } }; const outLiteral = (b) => { outType(1); code.push(b); haveDataBack = true; }; const outDict = (len, dist) => { outType(0); const same = haveDataBack && dist === posBack; let saved = len - kMinDictMatchLen; if (!same && dist > kBigPosForLen) { saved--; } outLen(saved, 1); if (haveDataBack) { outType(same ? 1 : 0); } if (!same) { outDictPos(dist); } dictSizeMax = Math.max(dictSizeMax, dist); haveDataBack = false; posBack = dist; }; let i = 0; while (i < data.length) { let bestLen = 0; let bestDist = 0; for (let dist = 1; dist <= Math.min(i, kMaxDist); dist++) { let len = 0; while (i + len < data.length && data[i + len] === data[i + len - dist]) { len++; } if (len > bestLen) { bestLen = len; bestDist = dist; } } const minLen = bestDist > kBigPosForLen ? 3 : kMinDictMatchLen; if (bestLen >= minLen && (bestLen > 2 || bestDist < 128)) { outDict(bestLen, bestDist); i += bestLen; } else { outLiteral(data[i]); i++; } } outType(0); outLen(kCtrlStreamEnd, 1); if (haveDataBack) { outType(0); } outDictPos(0); for (let k = 0; k < 4; k++) { code[k] = (dictSizeMax >> (8 * k)) & 0xff; } return code; } export function ImageUrl() { return "https://assets.signalrgb.com/devices/default/misc/usb-drive-render.png"; }