This project is under active development and is NOT ready for replication.
Hardware, firmware, protocol, and host software are all still evolving. Pin assignments, schematics, and APIs may change without notice. Nothing here has been fully validated on the final PCB. Building this now may result in damaged hardware, non-working fans, or worse.
Please wait for a tagged release (
v1.0.0or later) before attempting to build or flash anything from this repository.
STM32G031-based 5-channel PWM fan controller with Linux host software for the BKHD-2049NP-6L mini PC.
- 5 independent PWM channels — 25 kHz, 0-100% duty, for 4-pin PC fans
- TACH monitoring — RPM measurement per fan with stall detection
- Optional NTC input — one analog NTC thermistor input (Semitec 104NT)
on PB7, gated behind the
NTC1_ENABLEDbuild flag. Intended as a workaround for OEM 82599ES variants whose on-die thermal sensor is disabled in the vendor NVM image (see docs/hardware-notes.md for the full story). When enabled and populated, the value is surfaced to the host via the STS frame as a virtualntc1sensor. - Buzzer alarm — active buzzer alerts on fan failure or host communication loss
- Host watchdog — all fans ramp to 100% if host goes silent for 60 seconds
- UART protocol — simple ASCII (NMEA-style) for easy debugging with any terminal
- Linux host daemon — reads hwmon temperatures, computes fan curves, sends set-points
- systemd integration — runs as a service with syslog-compatible logging
- MCU: STM32G031K8T6 (Cortex-M0+, 64 MHz, 64 KB Flash, 8 KB RAM)
- Fans: 5x 5V 4-pin PWM via NPN open-collector drivers
- TACH: 5x input with 3.3V pull-up, EXTI-based pulse counting
- NTC (optional, v1 PCB workaround): Semitec 104NT-4-R025H42G (100 kΩ,
B = 4267 K) in a 100 kΩ divider to +3V3, sampled on ADC1_IN11 (PB7). The
v1 PCB does not carry this divider — it has to be retrofitted with
fine wires soldered from PB7 and from +3V3/GND to a leaded NTC cemented
to the 82599ES heatsink. Enable in firmware via
NTC1_ENABLED=1. See docs/hardware-notes.md for why this workaround exists and how to carry it out. - Buzzer: Active 5V buzzer via NPN
- UART: USART2 @ 115200 8N1 with level shifting
See docs/hardware-notes.md for schematics and pin mapping.
Linux (Ubuntu/Debian)
sudo apt install gcc-arm-none-eabi cmake makeLinux (Arch)
sudo pacman -S arm-none-eabi-gcc arm-none-eabi-newlib cmakemacOS
Install Homebrew if not already present, then:
brew install --cask gcc-arm-embedded
brew install cmakeWindows
- Download the Arm GNU Toolchain — choose the
arm-none-eabiAArch32 bare-metal target for Windows:.msiinstaller (recommended) — installs toC:\Program Files (x86)\Arm\GNU Toolchain mingw-w64-i686-arm-none-eabi\bin. After installation, add thisbindirectory to your system PATH manually..zipportable — extract anywhere and add thebindirectory to your PATH.
- Install CMake (
.msiinstaller, check "Add CMake to PATH"). - Install a build system — either:
- Ninja (recommended, drop
ninja.exeinto a directory on your PATH), or mingw32-makevia MSYS2 (pacman -S mingw-w64-x86_64-make) or chocolatey (choco install make) — ensuremingw32-make.exeis on your PATH.
- Ninja (recommended, drop
Verify after installation:
arm-none-eabi-gcc --version
cmake --versiongit clone https://github.com/MrDix/BKHD-FanController.git
cd BKHD-FanController
git submodule update --init --recursiveTwo firmware variants are supported from the same source tree, selected by
the NTC1_ENABLED CMake option:
- Default (
NTC1_ENABLED=0) — unmodified v1 PCB. No NTC soldered on. The firmware leaves PB7 alone, skips ADC init entirely, and reportst1 = -32768in every STS frame. Any fan configured againstntc1transparently falls back to itsfallback_sensoron the host. - Opt-in (
NTC1_ENABLED=1) — v1 PCB with NTC nachrüstung, or a future PCB revision that carries the divider by default. Enables ADC1 sampling and ships real temperature readings int1.
Linux / macOS:
cd firmware
# Default: stock v1 PCB, no NTC
cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake
cmake --build build
# With NTC nachrüstung present (or on a future PCB that carries the divider):
cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake -DNTC1_ENABLED=1
cmake --build buildDo not pass
-DNTC1_ENABLED=1for an unmodified v1 PCB. Without the divider, PB7 is floating and any sampled value is meaningless; a fan configured to followntc1would be driven by noise.
Windows (a generator must be specified — CMake defaults to NMake which requires Visual Studio):
cd firmware
rem With Ninja (recommended):
cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake
rem Or with MinGW Make (if installed via MSYS2/chocolatey):
cmake -B build -G "MinGW Makefiles" -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake
rem Append -DNTC1_ENABLED=1 to either of the above if the NTC is populated.
cmake --build buildOutput: build/fan_controller.bin and build/fan_controller.hex
The STLINK-V3MINIE connects to the MCU via the SWD header (SWDIO, SWCLK, GND, 3.3V).
Important: The STLINK-V3MINIE does not supply power to the target board — it only measures the target voltage. Your board must be powered externally (e.g. via USB or a 5V supply) before flashing.
Install flash tools:
Linux
# Option 1: stlink open-source tools (recommended)
sudo apt install stlink-tools # Ubuntu/Debian
sudo pacman -S stlink # Arch
# Option 2: OpenOCD
sudo apt install openocd # Ubuntu/Debian
sudo pacman -S openocd # Archudev rules (required for non-root access):
sudo cp /usr/lib/udev/rules.d/*stlink* /etc/udev/rules.d/ 2>/dev/null || \
sudo sh -c 'echo "SUBSYSTEM==\"usb\", ATTR{idVendor}==\"0483\", ATTR{idProduct}==\"3754\", GROUP=\"plugdev\", MODE=\"0660\", TAG+=\"uaccess\"" > /etc/udev/rules.d/49-stlink.rules'
sudo udevadm control --reload-rules && sudo udevadm trigger
# If the device is still not accessible, add your user to the plugdev group:
# sudo usermod -a -G plugdev $USER
# (log out and back in for group changes to take effect)macOS
# Option 1: stlink open-source tools (recommended)
brew install stlink
# Option 2: OpenOCD
brew install openocdWindows
Note: The open-source stlink-tools (
st-flash) do not support the STLINK-V3 on Windows. Use OpenOCD or STM32CubeProgrammer instead.
Option 1: OpenOCD (recommended)
Download from openocd.org/releases and extract the archive. Add the bin directory to your PATH (the scripts folder must remain in the same relative location).
Option 2: STM32CubeProgrammer
Download from st.com. The installer is Java-based — the setup wizard may open behind other windows, check the taskbar if it appears to hang.
Default install path: C:\Program Files\STMicroelectronics\STM32Cube\STM32CubeProgrammer\bin
Add this directory to your PATH.
Flash the firmware:
Using stlink tools (Linux / macOS):
st-flash write build/fan_controller.bin 0x08000000Using OpenOCD (Linux / macOS / Windows):
openocd -f interface/stlink.cfg -f target/stm32g0x.cfg -c "program build/fan_controller.bin 0x08000000 verify reset exit"
Using STM32CubeProgrammer (Linux / macOS / Windows):
STM32_Programmer_CLI -c port=SWD -w build/fan_controller.bin 0x08000000 -v --startVerify the connection (optional):
# stlink (Linux / macOS only)
st-info --probe
# OpenOCD (Linux / macOS / Windows)
openocd -f interface/stlink.cfg -f target/stm32g0x.cfg -c "init; shutdown"
# STM32CubeProgrammer (Linux / macOS / Windows)
STM32_Programmer_CLI -c port=SWDThe host daemon is a small Python program (pyserial + pyyaml) that runs as
a systemd service. Modern distributions (Debian 12+, Ubuntu 23.10+, Fedora 38+,
and derivatives such as Proxmox VE 8+) enforce
PEP 668 and will reject a plain
pip3 install -r requirements.txt with externally-managed-environment.
The instructions below use a dedicated virtual environment at
/opt/bkhd-fanctrl/venv so the daemon is fully isolated from the system
Python and the fan-controller.service unit can call the interpreter by
absolute path without any activation step.
# 1. Create the installation directory and virtual environment
sudo mkdir -p /opt/bkhd-fanctrl
sudo python3 -m venv /opt/bkhd-fanctrl/venv
# 2. Install dependencies inside the venv
sudo /opt/bkhd-fanctrl/venv/bin/pip install -r host/requirements.txt
# 3. Deploy the daemon and default config
sudo cp host/fan_controller.py /opt/bkhd-fanctrl/
sudo cp host/config.yaml /etc/fanctrl.yamlOlder distributions without PEP 668 can still use the classic
sudo pip3 install -r host/requirements.txtpath, but the venv approach works everywhere and is strongly recommended.
Edit /etc/fanctrl.yaml to match your sensor layout:
# Find your hwmon sensor names:
for h in /sys/class/hwmon/hwmon*; do echo "$h: $(cat $h/name)"; doneSensor syntax in config.yaml:
| Value | Meaning |
|---|---|
"acpitz", "coretemp" |
Match an hwmon device by its name file |
"nvme:0", "nvme:1" |
Pick the Nth device when multiple share the same name (sorted by hwmon index) |
"ntc1" |
Virtual sensor populated from the MCU STS frame. Requires firmware built with NTC1_ENABLED=1 and the NTC nachrüstung actually soldered on. If either is missing, the value is reported as invalid and affected fans fall back to fallback_sensor automatically — so referencing ntc1 in the config is safe even on unmodified v1 boards. |
DDR5 temperature sensors (spd5118) require the kernel module to be loaded
and the per-DIMM I2C devices to be instantiated. On Proxmox VE / Debian:
echo spd5118 | sudo tee /etc/modules-load.d/spd5118.conf
sudo modprobe spd5118
# Bind DIMM addresses (SMBus bus number is typically i2c-1 for the I801 SMBus):
echo "spd5118 0x50" | sudo tee /sys/bus/i2c/devices/i2c-1/new_device
echo "spd5118 0x52" | sudo tee /sys/bus/i2c/devices/i2c-1/new_deviceFor persistent binding across reboots, add a udev rule (adjust the adapter name match to your hardware):
sudo tee /etc/udev/rules.d/99-spd5118.rules <<'EOF'
ACTION=="add", SUBSYSTEM=="i2c-adapter", ATTR{name}=="SMBus I801 adapter*", \
RUN+="/bin/sh -c 'echo spd5118 0x50 > /sys/bus/i2c/devices/%k/new_device; \
echo spd5118 0x52 > /sys/bus/i2c/devices/%k/new_device'"
EOF
sudo udevadm control --reload-rulessudo /opt/bkhd-fanctrl/venv/bin/python /opt/bkhd-fanctrl/fan_controller.py \
-c /etc/fanctrl.yaml -vsudo cp host/fan-controller.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now fan-controller.service
sudo journalctl -u fan-controller -f # view logsWhen the repo changes, redeploy the script and refresh dependencies:
git -C /usr/local/src/BKHD-FanController pull
sudo cp /usr/local/src/BKHD-FanController/host/fan_controller.py /opt/bkhd-fanctrl/
sudo /opt/bkhd-fanctrl/venv/bin/pip install -U -r \
/usr/local/src/BKHD-FanController/host/requirements.txt
sudo systemctl restart fan-controller.serviceSee docs/protocol.md for the full UART protocol specification.
Quick reference:
Host -> MCU: $SET,80,60,50,40,30*4A Set fan duties (%)
$KA*35 Keep-alive
$ACK*24 Acknowledge error
MCU -> Host: $STS,1200,980,850,720,600,0,0,80,60,50,40,30,523*4C
RPM x5, error mask, watchdog, duty x5, NTC1 (tenths of °C)
# Monitor MCU output:
screen /dev/ttyS0 115200
# Or with minicom:
minicom -D /dev/ttyS0 -b 115200# Create virtual serial pair:
socat -d PTY,raw,echo=0 PTY,raw,echo=0
# Note the two /dev/pts/X paths, use one for the host script and one for minicom- Flash firmware, verify UART output with terminal (STS frames every 500ms, now with
t1field) - Send
$SET,50,50,50,50,50*XX\nmanually, verify PWM with oscilloscope - Block a fan, verify buzzer activates and error bit appears in STS
- Release the fan; verify the buzzer stops again (regression test for the stuck-on bug)
- Stop sending commands for 60s, verify failsafe (all fans 100%)
- Run host script, verify temperature-based fan curve operation
- Kill host script, verify failsafe kicks in after 60s
- Build with
NTC1_ENABLED=1on a board with NTC nachrüstung: heat the NTC with a finger or hot-air station; verifyntc1rises in the host logs and the configured fan ramps up - Build with
NTC1_ENABLED=0(default / stock v1 PCB): verify every STS frame reportst1=-32768and any fan configured againstntc1falls back to itsfallback_sensor
BKHD-FanController/
├── firmware/
│ ├── CMakeLists.txt # Build configuration (NTC1_ENABLED option)
│ ├── arm-none-eabi.cmake # Cross-compilation toolchain
│ ├── STM32G031K8Tx.ld # Linker script
│ ├── Inc/ # Header files
│ └── Src/ # Source files
│ ├── main.c # Clock, GPIO, timer, UART, alarm edge
│ ├── fan_control.c # PWM duty control
│ ├── tach_measure.c # RPM measurement via EXTI
│ ├── uart_protocol.c # NMEA-style protocol parser
│ ├── buzzer.c # Buzzer control
│ ├── watchdog.c # Host timeout + IWDG
│ └── ntc.c # NTC sampling + Beta conversion (opt-in via NTC1_ENABLED)
├── host/
│ ├── fan_controller.py # Linux daemon
│ ├── config.yaml # Fan curve configuration
│ ├── fan-controller.service # systemd unit file
│ └── requirements.txt # Python dependencies
└── docs/
├── protocol.md # UART protocol spec
└── hardware-notes.md # Schematics, pin mapping, NTC workaround rationale
MIT
