Skip to content
Bobby Comet edited this page Jul 22, 2026 · 1 revision

Motherboard fan headers

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.

GPU fans

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.

AIO coolers and USB hubs (via liquidctl)

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)

What Fan Hub cannot control

  • 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.

Fans that spin backwards (inverted PWM)

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.


Interface

Dashboard

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.

Fan Control

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.

Fan Curves

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.

Profiles

Named configurations that save the complete curve assignment and fixed speed state. Export/Import as plain JSON, shareable, editable, backupable.

RGB Lighting

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.

Liquid/AIO

Per-device panels for every liquidctl device showing pump speed, fan speeds, liquid temperature, and controls for pump mode and fan speed.

Settings

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

Background Daemon

The daemon (fanhub-daemon) is a headless process, no windows, no display, that loads config, polls hardware, and applies fan curves continuously.

Supported init systems

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.

How the GUI and daemon stay in sync

When you drag a slider in Fan Control, the GUI:

  1. Marks the fan as MANUAL in a shared override registry
  2. Writes the new speed to hardware immediately
  3. 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.


Background Daemon Priority Order

For any given fan, every poll cycle:

  1. MANUAL — user set this fan directly. Nothing else touches it.
  2. Emergency — any sensor at or above the emergency threshold → this fan (if not MANUAL) jumps to 100%.
  3. Curve / fixed — the assigned curve computes a target from the current temperature.
  4. Hardware auto — no assignment at all → the fan keeps the motherboard's own automatic control.

Installation

From the tarball

tar -xzf fanhub_v1.6.0.tar.gz
cd fanhub
sudo ./install.sh

Detects 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.

From the AppImage

chmod +x FanHub-1.6.0-x86_64.AppImage
./FanHub-1.6.0-x86_64.AppImage

On 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 --install

From the .deb

sudo apt install ./fanhub_1.6.0_amd64.deb

Runs 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.

Uninstalling a tarball install

sudo ./uninstall.sh

Removes 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 the AppImage or .deb yourself

./build_appimage.sh   # produces FanHub-1.6.0-x86_64.AppImage
./build_deb.sh         # produces fanhub_1.6.0_amd64.deb

First Run

Fan 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):

  1. Configure System (skipped if already configured by the installer) runs the same setup as install.sh, in-app
  2. Hardware scan — shows every detected fan with ✓ controllable / ○ read-only status and current RPM
  3. Choose a curve — four preset cards; selecting one assigns it to all controllable fans immediately
  4. Done — confirms what was applied, with tips for what to explore next

Test suite

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.pyFanCurve interpolation, blend modes, preset monotonicity, CurveEngine hysteresis/emergency/serialization
  • tests/test_app_state.py — config persistence, atomic write, corruption recovery, DaemonController status 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.pyFanOverrideRegistry priority logic including the exact race-condition scenario from the bug report, and FanEntry-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

Dependencies

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 psutil

Troubleshooting

No fans detected

ls /sys/class/hwmon/*/fan*_input
sudo modprobe nct6775    # or it87 for MSI/ASRock/some Gigabyte

Open 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 fanhub

A 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.


Technical Reference

Architecture overview

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

Core subsystems

The GUI/daemon race condition fix

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().

Mirror-chip sensor deduplication

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.

Position-based sensor naming

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.

Auto-calibrating PWM inversion

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.

Wall-clock-gap suspend detection

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.

DaemonController and multi-init support

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.

AppImage system configuration (pkexec FUSE-mount workaround)

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.

Signal/slot type safety

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.

FanEntry object vs dict payloads

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.

Config file format

{
  "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.

Clone this wiki locally