-
Notifications
You must be signed in to change notification settings - Fork 0
FAQs
Any 3-pin or 4-pin fan plugged into a SYS_FAN, CHA_FAN, CPU_FAN, or PUMP header is controllable if the board's SuperIO chip is supported by the kernel. Common chips:
| Chip family | Boards |
|---|---|
| Nuvoton NCT6775/NCT6798 and variants | Most ASUS, Gigabyte |
| ITE IT87xx and variants | Most MSI, ASRock, and some Gigabyte AM4 boards |
| Fintek F71xxx | Some older boards |
If your fans appear in sensors output, Fan Hub can control them. If your board exposes the same sensors twice, once through the SuperIO chip and once through a vendor WMI wrapper (common on Gigabyte boards), Fan Hub automatically suppresses the duplicate so you only see each physical sensor once.
| Vendor | Temperature | RPM | Speed control |
|---|---|---|---|
| AMD (amdgpu) | ✓ | ✓ | ✓ Full PWM via hwmon |
| NVIDIA | ✓ | ✓ via fan_input (when CoolBits/nvidia-settings available) | ✓ via CoolBits or nvidia-settings |
| Intel Arc (xe) | ✓ | — | — Driver managed |
| Intel iGPU (i915) | ✓ | — | — Firmware managed |
NVIDIA GPU fans default to the Performance curve automatically. If CoolBits is not enabled, Fan Hub reads the fan percentage from nvidia-smi and shows it honestly as a percentage rather than pretending it has RPM data; a 0 RPM reading on an NVIDIA fan is normal, not a fault.
Fan Hub uses the liquidctl Python API directly, not the CLI, for lower latency and type-safe status reads. This integration is still in beta within Fan Hub, but the underlying Python package is mature, widely used, and well-tested by the community. Hardware supported by this package:
- NZXT Kraken X/Z (all generations), Kraken 2023/2024
- Corsair Hydro Platinum, Pro XT, Elite RGB, iCUE Elite Capellix
- EVGA CLC
- Aquacomputer D5 Next, Octo, Quadro, Farbwerk 360
- Corsair Commander Pro, Commander Core/Core XT/ST
- NZXT Smart Device V1/V2, Grid+ V3, RGB & Fan Controller
- Lian Li GA II LCD, Uni SL/AL/SL-Infinity
- NZXT E-series PSUs (monitoring only)
- Fans with internal controllers — budget RGB fans (Apevia, Rosewill, no-name) that plug into a Molex or SATA power connector and manage their own speed internally have no connection to the motherboard's PWM system
- 2-pin Molex fans — always full voltage, no tachometer, no control
- Daisy-chained fans on a single header — appear as one channel, all spin together; this is hardware behaviour
- Intel integrated GPU fans — managed entirely by the driver and firmware stack
Every uncontrollable fan in the Fan Control tab has a ? button that explains the specific reason for that fan and what (if anything) can be done about it.
A small number of boards, mostly IT8686E-based Gigabyte AM4 boards, have inverted PWM polarity: commanding 100% actually spins the fan at minimum speed, and 0% spins it at maximum. Rather than asking you to understand PWM polarity, Fan Hub fixes this with one click:
Click ⚙ Calibrate on the fan. Fan Hub commands 100%, waits, reads the RPM; commands 20%, waits, reads the RPM; and compares. If the fan is wired backwards, it's corrected automatically, and you're told in plain English. If it's already correct, you're told that too. Nothing about "inverting" anything is ever exposed in the UI.
A System Overview strip across the top shows CPU (usage, clock, cores), GPU (usage, VRAM, power draw), Memory, Storage (usage + read/write throughput), and Network (up/down speed, active interface), so you can see why a fan is ramping, not just that it is.
Below that: live temperature gauges for every sensor on the system, fan RPM/percent cards, and a scrolling 120-second history graph with a proper legend (no more hw0, hw1, sensor names). A "Updated Xs ago" indicator in the status bar ticks every second so you always know the app is alive and polling.
One card per detected fan. Each card shows the fan label, chip source, current RPM (or percentage for fans without a tachometer), a live speed bar, and a mode selector:
- Auto — restore the hardware's built-in automatic control
- Curve — drive from a named fan curve based on temperature
- Fixed % — set a constant duty cycle and leave it
- Manual slider — drag to any speed for immediate testing
Dragging the slider is a genuine manual override: the fan is immediately marked as user-controlled, and if the background daemon is running, it's notified instantly over a local socket so it stops computing a curve target for that fan. The daemon and the GUI never fight over the same fan; see Background Daemon below.
GPU fans show a coloured badge: green for NVIDIA, red for AMD, blue for Intel.
A canvas editor where you click to add control points, drag to reshape the curve, and right-click to remove points. The canvas shows:
- The curve line from 0°C to 100°C on the X axis, 0–100% speed on the Y axis
- A live vertical temperature marker — a line at the current temperature with a dot on the curve showing the speed being commanded right now, updated every poll cycle
- A resizable splitter between the curve list/settings panel and the canvas, with the settings panel in its own scroll area; the editor stays usable at any window size, including narrow windows where it previously became unusable
Each curve has a sensor selector (specific sensor, highest of all, or average), a blend mode (Highest/Average/Weighted), hysteresis, minimum speed, and fan-stop threshold. Fan-stop bypasses the minimum speed clamp so the fan actually reaches zero.
Six built-in presets: Silent, Balanced, Performance, Gaming, Full Speed, Fixed 30%.
How curves, manual overrides, and the daemon interact: assigning a curve to a fan puts it in AUTO mode; the curve engine computes its target every poll cycle based on temperature. Manually setting a speed (slider or Fixed %) puts it in MANUAL mode; the curve engine skips that fan entirely until you click Auto again. There is no ambiguity about which one "wins": MANUAL always overrides AUTO, full stop, whether the daemon is running or not.
Named configurations that save the complete curve assignment and fixed speed state. Export/Import as plain JSON, shareable, editable, backupable.
OpenRGB SDK integration. Connects to a running OpenRGB server, lists all detected devices, and lets you set colours and effects. Temperature-reactive mode changes colour based on the hottest sensor.
Per-device panels for every liquidctl device showing pump speed, fan speeds, liquid temperature, and controls for pump mode and fan speed.
Poll interval, temperature unit, safe mode, emergency temperature, hysteresis, system tray, OpenRGB host/port, and:
-
System Configuration — shows detected distro, package manager, and init system, with a Configure Fan Hub for This System button that runs the full setup (udev rules, groups, kernel modules, daemon service) via
pkexec, usable any time, not just on first launch - Background Daemon — Enable/Disable at boot, Start/Stop/Restart now, Reload curves; all dispatched correctly whether you're on systemd, runit, or OpenRC
- Software Updates — checks GitHub for new Fan Hub releases and Codeberg for new OpenRGB releases, with direct download buttons for AppImage/.deb/tarball
The daemon (fanhub-daemon) is a headless process, no windows, no display, that loads config, polls hardware, and applies fan curves continuously.
| Init system | Distros | Service location | Enable command |
|---|---|---|---|
| systemd | Ubuntu, Debian, Fedora, Arch, openSUSE, most others | /etc/systemd/system/fanhub-daemon.service |
systemctl enable --now fanhub-daemon |
| runit | Void Linux | /etc/sv/fanhub-daemon/ |
ln -s /etc/sv/fanhub-daemon /var/service/ |
| OpenRC | Alpine Linux, Gentoo | /etc/init.d/fanhub-daemon |
rc-update add fanhub-daemon default |
install.sh (and the in-app Configure Fan Hub for This System button) detects which init system is running and installs the correct service automatically. The Settings → Background Daemon controls work identically regardless of init system; they call sv up/down, rc-service start/stop, or systemctl start/stop under the hood.
When you drag a slider in Fan Control, the GUI:
- Marks the fan as MANUAL in a shared override registry
- Writes the new speed to hardware immediately
- Sends the override to the daemon over a local UNIX-domain socket, instantly; no waiting for a config save or a
SIGHUP
The daemon receives the override and stops computing a curve target for that fan until you release it back to Auto (which sends a release message the same way). This is what makes manual control an actual override rather than a suggestion that gets overwritten on the next poll cycle.
Saving a profile, loading a preset, or changing curve settings still saves to config.json and sends SIGHUP for a full reload; the instant socket path is specifically for the moment-to-moment "I'm dragging a slider right now" case.
Suspend/resume handling: the daemon and GUI rescan hardware after the system wakes from sleep. Detection uses actual wall-clock timing; if the process's own 5-second check-in arrives many multiples late, the process itself was frozen (a real suspend), which is a much more reliable signal than the kernel's wakeup_count alone, since that counter also increments from routine USB/RTC activity while the machine is fully awake. This avoids spurious rescans (and the disruptive full UI rebuild that comes with them) on systems where wakeup sources are noisy.
For any given fan, every poll cycle:
- MANUAL — user set this fan directly. Nothing else touches it.
- Emergency — any sensor at or above the emergency threshold → this fan (if not MANUAL) jumps to 100%.
- Curve / fixed — the assigned curve computes a target from the current temperature.
- Hardware auto — no assignment at all → the fan keeps the motherboard's own automatic control.
tar -xzf fanhub_v1.6.0.tar.gz
cd fanhub
sudo ./install.shDetects your distro's package manager (apt/pacman/dnf/zypper/xbps/apk) and init system (systemd/runit/OpenRC), installs dependencies, udev rules, kernel modules, and the daemon service automatically.
chmod +x FanHub-1.6.0-x86_64.AppImage
./FanHub-1.6.0-x86_64.AppImageOn first launch, a wizard detects that the system hasn't been configured and walks you through it; same one-time setup as the tarball installer, run via pkexec with no terminal required. You can also configure ahead of time from the command line:
sudo ./FanHub-1.6.0-x86_64.AppImage --installsudo apt install ./fanhub_1.6.0_amd64.debRuns the same system configuration automatically via postinst. Uninstall with sudo apt purge fanhub; this stops the daemon cleanly (restoring fans to auto) before removing anything.
sudo ./uninstall.shRemoves everything install.sh created (daemon service, udev rules, launchers, icons) and stops the daemon cleanly first. Your ~/.config/fanhub/ settings are kept; remove them separately with rm -rf ~/.config/fanhub if you want a truly clean slate.
./build_appimage.sh # produces FanHub-1.6.0-x86_64.AppImage
./build_deb.sh # produces fanhub_1.6.0_amd64.debFan Hub shows a guided setup wizard the first time it opens, and keeps showing it on every launch until setup is actually complete (skipping it doesn't mark it done; this avoids the wizard silently giving up on a system that was never actually configured):
-
Configure System (skipped if already configured by the installer) runs the same setup as
install.sh, in-app - Hardware scan — shows every detected fan with ✓ controllable / ○ read-only status and current RPM
- Choose a curve — four preset cards; selecting one assigns it to all controllable fans immediately
- Done — confirms what was applied, with tips for what to explore next
Four test modules, 129 tests total, all run without hardware, display, or network (PyQt6-dependent widget tests skipped if PyQt6 isn't installed in the environment running them):
-
tests/test_fan_curves.py—FanCurveinterpolation, blend modes, preset monotonicity,CurveEnginehysteresis/emergency/serialization -
tests/test_app_state.py— config persistence, atomic write, corruption recovery,DaemonControllerstatus parsing via mocks -
tests/test_hardware_monitor.py— label translation for every chip family, GPU classification, PWM inversion (write-boundary inversion, readback un-inversion, config round-trip), mirror-chip suppression (reconstructs the exact reported duplicate-sensor scenario), NVIDIA label deduplication -
tests/test_fan_override.py—FanOverrideRegistrypriority logic including the exact race-condition scenario from the bug report, andFanEntry-vs-dict payload handling for the UI adapter methods
Run with:
python3 -m unittest tests.test_fan_curves tests.test_app_state tests.test_hardware_monitor tests.test_fan_override -v| Package | Required | Purpose |
|---|---|---|
PyQt6 |
Yes | UI framework |
PyQt6-Charts |
Optional | Temperature history graph |
liquidctl |
Optional | AIO and USB hub control |
openrgb-python |
Optional | OpenRGB SDK |
psutil |
Optional | System Overview (CPU/RAM/disk/network stats) |
pip install PyQt6 PyQt6-Charts liquidctl openrgb-python psutilNo fans detected
ls /sys/class/hwmon/*/fan*_input
sudo modprobe nct6775 # or it87 for MSI/ASRock/some GigabyteOpen Diagnostics — it shows exactly which module is missing with a Load Module button.
Permission denied writing fan speed
sudo ./install.sh # re-run to set up the fanhub group and udev rules
# then log out and back in, or: sudo fanhubA fan seems to run backwards — click Calibrate on that fan in Fan Control. See Fans that spin backwards above.
Fans revert to auto when app closes — enable the background daemon in Settings → Background Daemon.
NVIDIA fans show 0 RPM — this is normal on Linux without CoolBits. Set a percentage in Fan Control and listen for the fan to change speed to confirm it's working.
fanhub/
├── main.py Entry point, QApplication, audio-glitch fix, first-run gate
├── fanhub_daemon.py Headless daemon — no QApplication, IPC server, own poll loop
├── install.sh Multi-distro, multi-init installer
├── uninstall.sh Clean removal, matches install.sh's init detection
├── build_appimage.sh AppImage builder (bundles Python venv, supports --install)
├── build_deb.sh .deb builder (postinst/prerm/postrm handle setup/tear-down)
├── core/
│ ├── app_state.py Config persistence, atomic write
│ ├── app_context.py Shared context object injected into every tab
│ ├── daemon_controller.py All init-system interactions for fanhub-daemon
│ ├── init_system.py Runtime detection: systemd / runit / OpenRC
│ ├── ipc.py QLocalSocket/QLocalServer instant GUI↔daemon overrides
│ ├── fan_override.py FanOverrideRegistry single source of truth for MANUAL vs AUTO
│ ├── fan_curves.py FanCurve, CurveEngine, BlendMode, presets
│ ├── hardware_monitor.py hwmon reader/writer, GPU backends, mirror-chip suppression
│ ├── liquidctl_manager.py liquidctl Python API with CLI fallback
│ ├── rgb_manager.py OpenRGB SDK + CLI
│ ├── system_stats.py psutil-based CPU/GPU/RAM/disk/network collector
│ ├── polling_worker.py QThread background loop, per-fan control priority
│ ├── sleep_monitor.py D-Bus PrepareForSleep + wall-clock-gap suspend detection
│ └── update_checker.py GitHub (Fan Hub) + Codeberg (OpenRGB) release checking
└── ui/
├── main_window.py Main window, tray, IPC client, wizard/startup sequencing
├── appimage_configure_dialog.py System configuration dialog (detect + pkexec install.sh)
├── first_run_wizard.py Guided setup: configure → scan → preset → done
├── hardware_summary_dialog.py Live diagnostics + Fix It buttons, untruncated sensor list
├── system_overview_widget.py CPU/GPU/RAM/Storage/Network stat card strip
├── dashboard_tab.py Gauges, history chart, fan cards, showEvent layout fix
├── fan_control_tab.py Per-fan cards, Calibrate button, MANUAL override wiring
├── fan_curves_tab.py QSplitter + scrollable settings panel, live temp overlay
├── fan_warning_dialog.py Startup 0-RPM warning, NVIDIA-aware messaging
├── profiles_tab.py Profile CRUD, import/export
├── settings_tab.py Settings form, daemon controls, system config, updates
├── rgb_tab.py OpenRGB device list and controls
└── liquid_tab.py liquidctl device panels
The bug this fixes: dragging a fan speed slider used to write hardware directly and call CurveEngine.assign_fixed() on the GUI's local curve engine. The daemon runs its own separate CurveEngine instance, which only learned about that assignment after the next SIGHUP reload. In the window between the slider drag and the reload, the GUI and daemon wrote conflicting PWM values to the same fan every poll cycle, visible as the fan flapping between two speeds, which could look enough like an uncontrolled fan to trip emergency mode.
FanOverrideRegistry (core/fan_override.py) is a thread-safe registry mapping fan_id → FanControlMode (AUTO | MANUAL) plus the manual speed value. Both the GUI's PollingWorker and the daemon's poll loop consult this registry, or their own copy of it, kept in sync via IPC, before computing anything for a fan. If a fan is MANUAL, curve computation is skipped entirely for it.
core/ipc.py provides IPCClient (GUI side) and IPCServer (daemon side) over a QLocalSocket/QLocalServer UNIX-domain socket named fanhub-daemon. Messages are newline-delimited JSON: {"type": "override", "fan_id": "...", "speed": 45.0}, {"type": "release", "fan_id": "..."}, {"type": "release_all"}, {"type": "reload"}. When you drag a slider, PollingWorker.set_fan_manual() marks the registry, writes hardware immediately, and if the daemon is running, sends the override over the socket instantly, with no disk round-trip.
Priority in PollingWorker._apply_fan_control() (identical logic runs in the daemon's own poll loop): MANUAL fans are written at their stored speed and nothing else is computed for them; AUTO fans check emergency first, then fall through to CurveEngine.compute_speed().
Some boards (notably Gigabyte AM4 boards with an IT8686E SuperIO chip) expose the same physical EC/thermal registers through two separate hwmon chips: the SuperIO chip directly, and a vendor WMI wrapper (gigabyte_wmi, and similarly asus_wmi_sensors, asrock_wmi) that mirrors the identical values for Windows-tool compatibility. Without suppression, this produced visibly duplicate temperature cards, same value, different label.
HardwareMonitor._discover_hwmon_temps() does a first pass collecting every chip name present on the system, then before processing each chip checks _MIRROR_CHIP_SUPPRESSION, a map of mirror-chip prefixes to the primary SuperIO prefixes that supersede them. If a primary is present anywhere on the system, the mirror chip's sensors are skipped entirely during discovery.
When a chip exposes no meaningful label (common on many boards' tempN_label files simply don't exist, or return temp1/temp2), _SUPERIO_POSITION_NAMES provides a per-chip-family table mapping sensor position to a plain-English name, verified against real hardware (a Gigabyte B450M DS3H/IT8686E): position 1 = Motherboard, 2 = CPU Socket, 3 = VRM, 4 = Chipset. Coverage includes SuperIO families (nct6775/it87/f71xxx/w83795), ACPI thermal zones, ThinkPad EC, Gigabyte/ASUS/ASRock WMI, and Wi-Fi adapters (mt7921/iwlwifi); the last of these previously fell through to a generic two-line "Source — Temp N" label that the UI would visually split; now they resolve to a single clean name.
FanEntry.pwm_inverted is applied only at the sysfs read/write boundary in HardwareMonitor; every other layer (curve engine, UI, IPC) always deals in normal 0%=off / 100%=full terms. Rather than exposing a manual "Invert" checkbox (confusing, nobody intuitively maps "invert" to "100% is slow"), FanControlTab._on_calibrate_requested() runs a short automatic test: command 100% (raw, uninverted, to observe true hardware behaviour), wait max(3.5s, poll_interval × 3) for the reading to settle, read RPM; command ~20%, wait again, read RPM; compare. If the "100%" command produced meaningfully lower RPM (more than a 15% gap, to avoid false positives from measurement noise or settle-time), the fan is flagged inverted and corrected automatically, with the result explained in plain language.
SleepMonitor tries D-Bus PrepareForSleep first (systemd-logind, or elogind on OpenRC/runit systems that run it — same interface). Where D-Bus isn't available, it falls back to polling /sys/power/wakeup_count every 5 seconds — but the counter alone is unreliable, since it also increments from routine USB/RTC wakeup-source activity while the machine is fully awake, which was observed to cause a disruptive rescan-and-rebuild roughly every 30 seconds on affected hardware.
The fix: track the wall-clock gap between consecutive 5-second timer ticks. A process frozen by real suspend-to-RAM cannot service its own QTimer; the tick after resume arrives many multiples late. USB/RTC churn never freezes the process, so the tick stays on schedule regardless of how often the counter changes. A resume is only declared when the gap is more than 3× the expected interval and the counter changed — requiring both signals rules out a single slow poll cycle from a busy CPU being mistaken for a suspend.
core/init_system.py detects systemd / runit / openrc at runtime via /proc/1/comm, systemd's /run/systemd/private, runit's /etc/sv, and binary presence as fallbacks. core/daemon_controller.py dispatches every operation (status, start, stop, enable, disable, reload) to the correct backend. reload() is init-agnostic: rather than relying on systemctl kill, it finds the daemon's PID directly (pidfile, or a /proc scan for fanhub_daemon.py in the command line) and sends SIGHUP via os.kill(), which works identically regardless of init system.
install.sh, uninstall.sh, and build_appimage.sh's --install flag all perform the same three-way init detection independently (in bash, since they run before Python is guaranteed available) and install/remove the correct service type: a systemd unit file, a runit run script under /etc/sv/fanhub-daemon/, or an OpenRC init script under /etc/init.d/fanhub-daemon.
pkexec refuses to execute scripts inside a FUSE-mounted filesystem — which is exactly what an AppImage's runtime mount is. AppImageConfigureDialog's worker extracts install.sh (and the application source tree, minus the bundled venv) to /tmp before invoking pkexec bash /tmp/fanhub_install_XXXX.sh, passing FANHUB_SOURCE_DIR as an environment variable so install.sh knows where to copy the real application files from even though the script itself is now running from a temporary location. Both temp locations are cleaned up in a finally block regardless of outcome.
PyQt6 enforces exact type matching between pyqtSignal declarations and @pyqtSlot decorators; a mismatch raises TypeError at .connect() time, not when the signal fires, which can mean the mismatch is only discovered the first time that particular signal is actually emitted. PollingWorker's signals (sensors_updated: dict, fans_updated: dict, liquid_updated: list, emergency_triggered: float, emergency_cleared: <no args>, error_occurred: str, stats_updated: dict) and their connected slots in main_window.py are kept in exact correspondence; undecorated plain-Python-method slots are used where strict PyQt type-checking isn't needed.
PollingWorker.fans_updated emits pyqtSignal(dict) where the dict is {fan_id: FanEntry} — FanEntry dataclass objects, not plain dicts. UI methods receiving this payload use attribute access (fan.current_rpm, fan.label) rather than .get(). Two consumer methods (FanCard.update_data(), FanChannelWidget.update_live()) accept either a FanEntry or a plain dict via an isinstance() check, for robustness and testability.
{
"settings": {
"poll_interval_ms": 1000,
"temp_unit": "C",
"safe_mode": true,
"emergency_temp": 90.0,
"hysteresis": 2.0,
"tray_icon": true,
"daemon_enabled": false,
"system_configure_done": true,
"first_run_done": true,
"pwm_inverted_fans": { "hwmon0_pwm1": true }
},
"profiles": {
"Gaming": {
"name": "Gaming",
"curves": {
"fan_assignments": { "hwmon2_fan1": "gaming", "hwmon3_fan1": "performance" },
"fixed_speeds": { "hwmon2_fan2": 45.0 },
"custom_curves": {}
}
}
},
"active_profile": "Gaming"
}pwm_inverted_fans and system_configure_done/first_run_done are loaded and applied by both the GUI (MainWindow._init_hw_backends) and the daemon (fanhub_daemon.main) independently, and re-applied on every SIGHUP reload.
Fan Hub is not affiliated with NZXT, Corsair, Argus Monitor, HWiNFO, OpenRGB, or any hardware manufacturer. All product names are trademarks of their respective owners.