Fan Control OpenWrt OrangePi Zero 3

12 Juli, 2025   albawid

# OpenWrt

# LuCI

NOTE !!!!

Almost 90% of this project are generated by Microsoft Copilot, what i do are just type the prompt project-related or describing feature that i want to add. Fork and edit by yourself if you want to give an improvements, and also don’t pull request. Thank You

Fan Control System for OpenWrt

A modular and configurable smart fan controller designed for embedded systems running OpenWrt. It supports temperature-based logic, time-based scheduling, manual control via LuCI, and debug logging.

Fan Control LuCI Dashboard

Features

Requirements

How to Find the Right GPIO Pin on Orange Pi Zero 3

The Orange Pi Zero 3 features a 26-pin GPIO header that provides access to multiple interfaces including GPIO, UART, SPI, and I2C. To control a fan via a MOSFET, you’ll need to select a GPIO pin that supports digital output.

Finding the Line Number for Your GPIO Pin

Run:

$ gpiodetect
$ gpioinfo gpiochip0

Look for the bunch of line (287 Line in my case) and note its line number.

Here are some general-purpose output pins you can use:

Physical Pin GPIO Name Line Number Notes
Pin 3 PH5 229 I2C3_SDA
Pin 5 PH4 228 I2C3_SCK
Pin 7 PC9 73 General-purpose GPIO
Pin 8 PH2 226 UART5_TX
Pin 10 PH3 227 UART5_RX (I’m Using this)
Pin 11 PC6 70 General-purpose GPIO
Pin 12 PC11 75 General-purpose GPIO
Pin 13 PC5 69 General-purpose GPIO
Pin 15 PC8 72 General-purpose GPIO
Pin 16 PC15 79 Recommended for fan control
Pin 18 PC14 78 Recommended for fan control
Pin 22 PC7 71 General-purpose GPIO

Recommended: Use GPIO23 (Pin 16) or GPIO24 (Pin 18) for fan control. These are safe, general-purpose output pins.

Testing GPIO Output

To test manually:

gpioset gpiochip0 <line_number>=1  # Set HIGH
gpioset gpiochip0 <line_number>=0  # Set LOW

Wiring Diagram

Orange Pi Zero 3 (26-pin)        Dual MOSFET Module         Fan
--------------------------       -------------------        ------
Pin 2  (5V) ------------->       VCC (Vin +) ------->       [Fan V+]
Pin 6  (GND) ------------>       GND (Vin -) ------->       [Fan GND]
Pin 10 (Line 227) ------->       PWM (MOSFET Channel 1)

Optional:
Pin 18 (GPIO24) --------->       PWM (MOSFET Channel 2)    (for second fan)

Correct real-world wired scheme will look like this

Correct Real-World Wired Scheme

Make sure your MOSFET is wired correctly, you can directly jumper PWM from Mosfet to 5V pin to make sure your wiring is correct and fan should spining at this moment.

How to Use GPIO with gpioset

Orange Pi Zero 3 uses the Linux GPIO character device interface via gpiod. To control a pin:

$ gpioset gpiochip0 227=1  # Turn fan ON
$ gpioset gpiochip0 227=0  # Turn fan OFF

Replace 227 with the correct line number of your GPIO pin. You can find this using gpiodetect and gpioinfo. or if you still don’t know which the right line number, just try it one by one. Do not using line number that already used to avoid system fail or your devices electricity will be short in worse case

File Structure

/fan-control.service.sh                         # Main logic script
/fan-on.sh                                      # Manual ON script
/fan-off.sh                                     # Manual OFF script

/usr/lib/lua/luci/controller/fan/fan.lua        # LuCI controller
/usr/lib/lua/luci/model/cbi/fan/status.lua      # Define Dashboard Layout
/usr/lib/lua/luci/view/fan/logview.htm          # Last 10 Fan Log View 
/usr/lib/lua/luci/view/fan/statusinfo.htm       # Status Fan Info
/usr/lib/lua/luci/view/fan/buttonrow.htm        # Three manual button
/usr/lib/lua/luci/view/fan/cronjobtoggle.htm    # Enable/disable fan cronjob service

/tmp/fan.log                                    # Runtime log
/tmp/fan.state                                  # Current fan state
/tmp/fan.last_rule                              # Last rule applied
/tmp/fan.debug                                  # Debug mode flag
/tmp/fan-service.state                          # Tracks fan cronjob service

Main Logic Script (Hardcoded)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/bin/sh
# === Parse --debug flag ===
DEBUG=0
[ -f /tmp/fan.debug ] && DEBUG=1
[ "$1" = "--debug" ] && DEBUG=1

log_debug() {
  [ "$DEBUG" -eq 1 ] && echo "[DEBUG] $1" >> /tmp/fan.log
}
# === Configurable Temp Thresholds and Active Hours ===
TEMP_ON_THRESHOLD=61
TEMP_OFF_THRESHOLD=41
ACTIVE_START=8
ACTIVE_END=22
# === Smart Fan Controller with Rule Memory & Hysteresis ===
GPIO="/usr/bin/gpioset gpiochip0 227"
TEMP_RAW=$(cat /sys/class/thermal/thermal_zone0/temp)
TEMP_C=$(expr $TEMP_RAW / 1000)
HOUR=$(date +%H)
LOG="/tmp/fan.log"
STATE_FILE="/tmp/fan.state"
RULE_FILE="/tmp/fan.last_rule"
MAX_LINES=100
# === Log rotation ===
[ -f "$LOG" ] && LINE_COUNT=$(wc -l < "$LOG")
[ "$LINE_COUNT" -gt "$MAX_LINES" ] && tail -n $MAX_LINES "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
# === Load current state and last rule ===
CURRENT_STATE="unknown"
LAST_RULE="none"
[ -f "$STATE_FILE" ] && CURRENT_STATE=$(cat "$STATE_FILE")
[ -f "$RULE_FILE" ] && LAST_RULE=$(cat "$RULE_FILE")

log_debug "TEMP_C=$TEMP_C, HOUR=$HOUR, STATE=$CURRENT_STATE, RULE=$LAST_RULE"
log_debug "Running fan-control.service.sh at $(date)"
# === Temperature Override with Hysteresis ===
if [ "$TEMP_C" -ge "$TEMP_ON_THRESHOLD" ]; then
  if [ "$CURRENT_STATE" != "on" ]; then
    $GPIO=1
    echo "on" > $STATE_FILE
    echo "temp" > $RULE_FILE
    echo "[Temp Override] Fan ON @ ${TEMP_C}°C - $(date)" >> $LOG
  fi
  exit 0
elif [ "$TEMP_C" -le "$TEMP_OFF_THRESHOLD" ]; then
  if [ "$HOUR" -ge "$ACTIVE_START" ] && [ "$HOUR" -lt "$ACTIVE_END" ]; then
    echo "[Temp Override] Skipped OFF due to active hours @ ${TEMP_C}°C - $(date)" >> $LOG
    exit 0
  fi
  if [ "$CURRENT_STATE" != "off" ]; then
    $GPIO=0
    echo "off" > $STATE_FILE
    echo "temp" > $RULE_FILE
    echo "[Temp Override] Fan OFF @ ${TEMP_C}°C - $(date)" >> $LOG
  fi
  exit 0
fi
# === Time Logic (only if last rule was time or fan is off) ===
if [ "$LAST_RULE" = "time" ] || [ "$CURRENT_STATE" = "off" ]; then
  if [ "$HOUR" -ge "$ACTIVE_START" ] && [ "$HOUR" -lt "$ACTIVE_END" ]; then
    if [ "$CURRENT_STATE" != "on" ]; then
      $GPIO=1
      echo "on" > $STATE_FILE
      echo "time" > $RULE_FILE
      echo "[Time Logic] Fan ON @ ${TEMP_C}°C - $(date)" >> $LOG
    fi
  else
    if [ "$CURRENT_STATE" != "off" ]; then
      $GPIO=0
      echo "off" > $STATE_FILE
      echo "time" > $RULE_FILE
      echo "[Time Logic] Fan OFF @ ${TEMP_C}°C - $(date)" >> $LOG
    fi
  fi
fi

Logic Overview

Temperature Override

Time Logic

Manual Control

Also you can change Active Hour or Temp Threshold directly in the script base on you environment if you want

LuCI Integration

LuCI Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
module("luci.controller.fan.fan", package.seeall)

function index()
  entry({"admin", "system", "fan"}, cbi("fan/status"), _("Fan Control"), 90)
  entry({"admin", "system", "fan", "on"}, call("fan_on"), nil).leaf = true
  entry({"admin", "system", "fan", "off"}, call("fan_off"), nil).leaf = true
  entry({"admin", "system", "fan", "debug"}, call("toggle_debug"), nil).leaf = true
  entry({"admin", "system", "fan", "cronjob_enable"}, call("cronjob_enable"), nil).leaf = true
  entry({"admin", "system", "fan", "cronjob_disable"}, call("cronjob_disable"), nil).leaf = true
end

function cronjob_enable()
  local fs = require "nixio.fs"
  local timestamp = os.date("%Y-%m-%d %H:%M:%S")
  os.execute("grep -q '/fan-control.service.sh' /etc/rc.local || sed -i '/^exit 0/i /fan-control.service.sh' /etc/rc.local")
  os.execute("(crontab -l 2>/dev/null | grep -v '/fan-control.service.sh'; echo '*/2 * * * * /fan-control.service.sh') | crontab -")
  fs.writefile("/tmp/fan-service.state", "enabled")
  os.execute(string.format("echo 'Fan Cronjob Service ENABLED via LuCI at %s' >> /tmp/fan.log", timestamp))
  luci.http.redirect(luci.dispatcher.build_url("admin/system/fan"))
end

function cronjob_disable()
  local fs = require "nixio.fs"
  local timestamp = os.date("%Y-%m-%d %H:%M:%S")
  os.execute("sed -i '/\\/fan-control\\.service\\.sh/d' /etc/rc.local")
  os.execute("crontab -l 2>/dev/null | grep -v '/fan-control.service.sh' | crontab -")
  fs.writefile("/tmp/fan-service.state", "disabled")
  os.execute(string.format("echo 'Fan Cronjob Service DISABLED via LuCI at %s' >> /tmp/fan.log", timestamp))
  luci.http.redirect(luci.dispatcher.build_url("admin/system/fan"))
end

function fan_on()
  os.execute("/fan-on.sh")
  luci.http.redirect(luci.dispatcher.build_url("admin/system/fan"))
end

function fan_off()
  os.execute("/fan-off.sh")
  luci.http.redirect(luci.dispatcher.build_url("admin/system/fan"))
end

function toggle_debug()
  local fs = require "nixio.fs"
  if fs.access("/tmp/fan.debug") then
    fs.remove("/tmp/fan.debug")
  else
    fs.writefile("/tmp/fan.debug", "1")
  end
  luci.http.redirect(luci.dispatcher.build_url("admin/system/fan"))
end

Cronjob Logic

Manual Scripts

1
2
3
4
5
#!/bin/sh
echo "on" >> /tmp/fan.state
echo "manual" >> /tmp/fan.last_rule
echo "[Manual] Fan ON @ $(date)" >> /tmp/fan.log
/usr/bin/gpioset gpiochip0 227=1
1
2
3
4
5
#!/bin/sh
echo "off" >> /tmp/fan.state
echo "manual" >> /tmp/fan.last_rule
echo "[Manual] Fan OFF @ $(date)" >> /tmp/fan.log
/usr/bin/gpioset gpiochip0 227=0

Last 10 Fan Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<h3 style="margin-bottom:4px;">Last 10 Fan Actions</h3>
<div style="
  font-family: monospace;
  white-space: pre-wrap;
  background-color: #2c2f3a;
  color: #e0e0e0;
  padding: 10px;
  border-radius: 6px;
  max-height: 300px;
  overflow-y: auto;
  box-shadow: inset 0 0 5px rgba(0,0,0,0.5);
  border: 1px solid #444;
">
<%
  local fs = require "nixio.fs"
  local log_content = fs.readfile("/tmp/fan.log") or ""
  local log_lines = {}
  for line in log_content:gmatch("[^\r\n]+") do
    table.insert(log_lines, line)
  end
  for i = math.max(1, #log_lines - 9), #log_lines do
    local line = log_lines[i]
    if line:match("Temp Override") then
      write('<div style="color:#ff6b6b;">' .. line .. '</div>')
    elseif line:match("Time Logic") then
      write('<div style="color:#6bcf6b;">' .. line .. '</div>')
    else
      write('<div style="color:#aaa;">' .. line .. '</div>')
    end
  end
%>
</div>

Fan Cronjob Service Button

1
2
3
4
5
<h3 style="margin-bottom:4px;">Fan Cronjob Service</h3>
<div style="display:flex; gap:10px; margin-bottom:8px;">
  <a href="<%=luci.dispatcher.build_url('admin/system/fan/cronjob_enable')%>" class="cbi-button cbi-button-save">Enable Cronjob</a>
  <a href="<%=luci.dispatcher.build_url('admin/system/fan/cronjob_disable')%>" class="cbi-button cbi-button-remove">Disable Cronjob</a>
</div>

Info Status Fan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<h3 style="margin-bottom:4px;">Fan Status</h3>
<div style="margin-bottom:8px;">
  <table style="
    font-family: monospace;
    border-collapse: collapse;
    margin: 0 auto;
    table-layout: auto;
  ">
    <tr>
      <td style="padding:4px; width: 90px; white-space: nowrap;">CPU Temp</td>
      <td style="padding:4px;"><strong>: <%=luci.sys.exec("expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000")%>°C</strong></td>
    </tr>
    <tr>
      <td style="padding:4px; width: 90px; white-space: nowrap;">Fan State</td>
      <td style="padding:4px;">
        <% if luci.sys.exec("cat /tmp/fan.state"):match("on") then %>
        <strong>: <span style="color:#6bcf6b;">on</span></strong>
        <% else %>
        <strong>: <span style="color:#ff6b6b;">off</span></strong>
        <% end %>
      </td>
    </tr>
    <tr>
      <td style="padding:4px; width: 90px; white-space: nowrap;">Last Rule</td>
      <td style="padding:4px;"><strong>: <%=luci.sys.exec("cat /tmp/fan.last_rule 2>/dev/null")%></strong></td>
    </tr>
    <tr>
      <td style="padding:4px; width: 90px; white-space: nowrap;">Debug Mode</td>
      <td style="padding:4px;"><strong>: <%=luci.sys.exec("[ -f /tmp/fan.debug ] && echo ON || echo OFF")%></strong></td>
    </tr>
  </table>
</div>
<hr style="border-top: 1px solid #444; margin: 8px 0;" />

Manual Button

1
2
3
4
5
6
7
<h3 style="margin-bottom:4px;">Manual Controls</h3>
<div style="display:flex; gap:10px; margin-bottom:8px;">
  <a href="<%=luci.dispatcher.build_url('admin/system/fan/on')%>" class="cbi-button cbi-button-save">Turn Fan ON</a>
  <a href="<%=luci.dispatcher.build_url('admin/system/fan/off')%>" class="cbi-button cbi-button-remove">Turn Fan OFF</a>
  <a href="<%=luci.dispatcher.build_url('admin/system/fan/debug')%>" class="cbi-button cbi-button-reload">Toggle Debug Mode</a>
</div>
<hr style="border-top: 1px solid #444; margin: 8px 0;" />

LuCI Dashboard layout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
local fs = require "nixio.fs"
local log_path = "/tmp/fan.log"
local log_content = fs.readfile(log_path) or "No log found"

local f = SimpleForm("fan", "Fan Control Dashboard")
f.reset = false
f.submit = false

-- === Status Section ===
f:section(SimpleSection).template = "fan/statusinfo"
-- === Fan Cronjob Service ===
f:section(SimpleSection).template = "fan/cronjobtoggle"
-- === Manual Controls Section ===
f:section(SimpleSection).template = "fan/buttonrow"
-- === Log Viewer Section ===
local log_lines = {}
for line in log_content:gmatch("[^\r\n]+") do
  table.insert(log_lines, line)
end
local last_10 = ""
for i = math.max(1, #log_lines - 9), #log_lines do
  last_10 = last_10 .. log_lines[i] .. "\n"
end
f:section(SimpleSection).template = "fan/logview"

return f

Log Format

Example entries in fan.log

...
[Temp Override] Fan ON @ 62°C - Fri Jul 11 19:10:00 WIB 2025
[Time Logic] Fan OFF @ 45°C - Fri Jul 11 22:01:00 WIB 2025
[Manual] Fan ON @ Fri Jul 11 19:15:00 WIB 2025
[Temp Override] Skipped OFF due to active hours @ 39°C - Fri Jul 11 19:20:00 WIB 2025
Fan Cronjob Service ENABLED via LuCI at 2025-07-15 21:44:24
...

Debug Mode

Enable with:

$ touch /tmp/fan.debug

Disable with:

$ rm /tmp/fan.debug

Adds [DEBUG] entries to /tmp/fan.log.

Bonus!!! Runtime Fallback

Since the runtime (log file) are placed in /tmp it will auto deleted once your device is reboot so, put this code inside /etc/rc.local:

1
2
3
4
5
6
7
...
[ ! -f /tmp/fan.log ] && touch /tmp/fan.log
[ ! -f /tmp/fan.state ] && echo "off" > /tmp/fan.state
[ ! -f /tmp/fan.last_rule ] && echo "none" > /tmp/fan.last_rule
[ ! -f /tmp/fan-service.state ] && echo "disabled" > /tmp/fan-service.state
...
exit 0

Always make sure exit 0 in the last line or you command wont be executed

Future Improvements

Refference

https://arrhythmicobsession.wordpress.com/attaching-a-temperature-controlled-cooling-fan

Lisensi

Lisensi CC BY-NC-SA

Tulisan ini dilisensikan dengan:
Atribusi-NonKomersial-BerbagiSerupa 4.0 Internasional (CC BY-NC-SA 4.0)

Komentar