Add Lian Li SL-INF wireless tools and SignalRGB plugin
Proof-of-concept tools for the UNI FAN SL-INF Wireless kit (SLV3 TX/RX dongles 0416:8040/8041): a Python scan/colour script, tinyuz helpers, a JavaScript tinyuz encoder, the SignalRGB plugin and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
35240e5dfc
13 files changed
+971
No files matched your search
@@ -0,0 +1,346 @@
|
||||
// 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"; }
|
||||
Reference in new issue
Block a user