12 Juli, 2025 albawid
# OpenWrt
# LuCI
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
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.
crontab
package for automatedgpioset
utility (via gpiod
package)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.
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.
To test manually:
gpioset gpiochip0 <line_number>=1 # Set HIGH
gpioset gpiochip0 <line_number>=0 # Set LOW
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
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.
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 usinggpiodetect
andgpioinfo
. 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
/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
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
TEMP_ON_THRESHOLD
TEMP_OFF_THRESHOLD
, unless within active hours
ACTIVE_START
and ACTIVE_END
if last rule was time or fan is OFFAlso you can change Active Hour or Temp Threshold directly in the script base on you environment if you want
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
/fan-control.service.sh
to /etc/rc.local
crontab
every 2 minutes/tmp/fan-service.state
rc.local
and crontab
/tmp/fan-service.state
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
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>
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>
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;" />
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;" />
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
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
...
Enable with:
$ touch /tmp/fan.debug
Disable with:
$ rm /tmp/fan.debug
Adds [DEBUG]
entries to /tmp/fan.log
.
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
https://arrhythmicobsession.wordpress.com/attaching-a-temperature-controlled-cooling-fan
Tulisan ini dilisensikan dengan:
Atribusi-NonKomersial-BerbagiSerupa 4.0 Internasional (CC BY-NC-SA 4.0)