Installed Linux for a project.
Ryzen 9 9950X hit ~90°C.
Fans sat at ~700 RPM.
BIOS curves were configured correctly.
Nothing was technically broken.
The issue was structural: the fans were USB-controlled and not connected to PWM headers.
Which means the BIOS was never in the control path to begin with.
1) System Info
CPU: Ryzen 9 9950X
OS: Ubuntu 22.04.5 LTS
Fans: Lian Li UNI FAN SL Wireless (USB-only via wireless TX/RX dongle)
0416:8040 (SLV3TX)
0416:8041 (SLV3RX)
Controller: Corsair iCUE Commander Core
1b1c:0c32
Wiring: Zero PWM leads to the motherboard (intentional)
Boot: Dual boot (Linux + Windows)
Linux could read temperatures fine. Nothing was issuing fan commands to the USB controllers. That gap--sensors exist, control doesn’t--is the whole problem.
2) Lian Li SL Wireless (USB-Only, No PWM Lead)
2.1 Verifying the Control Path
First: make sure Linux can see the dongle.
Quick context:
USB devices show up as VID:PID (Vendor ID : Product ID).
Seeing those IDs means the dongle exists on the USB bus.
It doesn’t prove you can control fans yet--but it proves where control must happen: userspace → USB.
2.2 What Didn’t Work
BIOS fan curves
“Motherboard sync”
Uni-sync (empty config)
Why: those approaches assume either (a) PWM wiring, or (b) a different controller behavior. In USB-only mode, the BIOS can observe temperatures but cannot actuate the fans.
2.3 What uwscli Is
uwscli (uws) is an open-source CLI that talks directly to the Lian Li SL Wireless controller over USB. No vendor GUI required. It sends the actual control commands.
What it does, practically:
Enumerates receivers
Sets fan PWM values
Gives you control entirely in Linux
Here’s the proof command:
If RPM jumps immediately, that means you have authority over the controller.
Everything after this is just curve design.
2.4 Install + Permissions
Create a venv and install:
Add udev rules so you don’t have to run it as root:
Reload and replug:
2.5 Calibration + Curve (PWM ≠ RPM)
PWM is the command. RPM is what you get. They correlate, but not perfectly, so I measured.
Measured (approx):
PWM → RPM
40 → ~340
80 → ~675
137 → ~1110
160 → ~1275
240 → ~1880
My Windows curve was defined in RPM targets. I mapped it to PWM (0–255) using calibration.
Windows curve (RPM targets):
34°C → 500 RPM
55°C → 699 RPM
65°C → 950 RPM
78°C → 1250 RPM
90°C → 2000 RPM (max)
Mapped to PWM (via measurements):
34°C → PWM 59
55°C → PWM 83
65°C → PWM 116
78°C → PWM 156
90°C → PWM 255 (max)
2.6 The Control Loop
Loop logic:
Read CPU temperature from /sys/class/hwmon (AMD k10temp)
Interpolate target PWM from curve points (piecewise-linear)
Send it to the controller via uws
Stability / noise control choices:
Ramp up: immediate (safety first)
Ramp down: delayed by 5 seconds (prevents yo-yo noise)
Poll interval: ~1 second
Ignore tiny PWM deltas: < 3 (reduces micro-chatter + USB spam)
The actual command being issued inside the loop is basically:
2.7 systemd Unit (What It Is + Minimal Example)
A systemd unit is a Linux service definition. It allows your script to run automatically at boot and restart if it crashes.
Example:
Load test:
Portable equivalent (all logical cores):
If temps rise and the fans follow, it’s done.
3) Corsair Commander Core (USB Control, But Quirky)
3.1 What liquidctl Is
liquidctl is a CLI that communicates with USB cooling devices: AIO pumps, fan controllers, RGB controllers, etc.
Here, it reliably:
Reads pump RPM, fan RPMs, water temp
Sets pump duty (%)
Sets fan duty (%)
It did not reliably handle device-stored curve mode on my unit.
liquidctl reported mine as: “Corsair Commander ST (broken)”
Which is not reassuring, but it was also not wrong.
3.2 What Failed (Device-Side Curves)
Trying to use hardware-stored curves failed with:
IndexError('index out of range')
Since fixed duty control works reliably, a software-driven CPU temperature loop became the stable solution.
Working commands:
3.3 Pump + Fan Strategy (Same as Lian Li)
Pump mapping I used (Windows → Linux mental model):
Quiet: 75%
Balanced: 85%
Extreme: 100%
On Linux, I chose 75%:
Fan curve (duty %), Lian Li-like shape:
30°C → 23%
40°C → 33%
50°C → 45%
60°C → 61%
70°C → 100%
Minimum clamp:
FAN_MIN = 33%
Meaning 23% becomes 33% in practice, because very low duty can behave weirdly on some setups.
Stability parameters I used:
POLL_S = 2.5s
HYST_C = 1.0°C
MIN_CHANGE = 5%
WINDOW = 7 (median smoothing)
Definitions:
Polling interval: how often you check CPU temp
Hysteresis: ignores tiny temp fluctuations so you don’t flap around thresholds
Min change: avoids spamming USB updates for tiny duty changes
Median window: smooths noisy sensor reads before making decisions
RGB can flash when the controller receives updates. Reducing update frequency and adding hysteresis helps, but it may not fully eliminate it.
Measured RPMs at fixed duty:
Duty (%) → RPM
33% → ~885 RPM
45% → ~1175 RPM
61% → ~1520 RPM
3.4 Concurrency Warning
If your systemd service is running and you manually run liquidctl, you may see:
AssertionError('response does not match command')
That’s because two processes are talking to the same USB controller at once. It’s not a “Linux problem”; it’s a “stop pestering the same hardware” problem.
Optional mitigation: serialize access with flock:
Or just don’t run manual liquidctl while the daemon is active.
4) Dual Boot Safety
Some tools can write settings to non-volatile controller memory.
I did not use --non-volatile.
Meaning:
Linux changes are runtime-only
Boot into Windows → iCUE / L-Connect reasserts your Windows profiles
Worst case: the last Linux duty persists briefly until Windows software loads, then it gets overwritten
This is how you avoid accidentally “fixing Linux” and breaking your Windows setup.
5) Stability Notes
Minimum clamp: floor for fan speed so you don’t hit unstable low-duty zones
Polling interval: how often you read temperature
Hysteresis: ignore small fluctuations around thresholds
Delta threshold / min change: avoid spamming USB commands for tiny adjustments
Boot note: These curves apply only after Linux userspace starts. The systemd services launch automatically at boot, then keep enforcing the curve while the machine is running.
Fin.