Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions thinkpad-fan/LICENSE

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions thinkpad-fan/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ThinkPad Fan & Thermal Control

A fan monitor and manual speed control for **Noctalia v5**, for ThinkPads using
the `thinkpad_acpi` kernel module. The bar widget shows the current fan RPM and
turns a warning color when the fans are forced off or set to a manual level.
Clicking it opens a panel to pick a fan level — no root needed at runtime.

> Ported from the Noctalia v4 (Quickshell/QML) plugin of the same name to the v5
> Luau plugin runtime.

![ThinkPad Fan & Thermal Control widget and panel](screenshot.png)

## Plugin

Manifest id `piero-93/thinkpad-fan`. Three entries share one state snapshot (no
Lua memory is shared between them):

- `service` — headless: polls `/proc/acpi/ibm/fan` and the thermal zone, and
performs the fan-level writes.
- `widget` — the bar widget: fan glyph + `NNNN RPM`, tinted by status. Click it
to open the panel.
- `panel` — the control surface: a grid of fan levels (Auto, Full, 0–7). Toggle
it from a keybind with:

```sh
noctalia msg panel-toggle piero-93/thinkpad-fan:panel
```

## Features

- **Live RPM** in the bar, with a tooltip showing speed, level, and temperature.
- **Status coloring** — the widget turns red when the fans are forced off
(level 0) and uses the accent color for any manual override.
- **Manual level control** — Auto, Full (disengaged), or fixed levels 0–7.
- **Temperature readout** from a configurable thermal zone.

## Setup (required)

Manual fan control needs two things, handled once by the included script:

1. the `thinkpad_acpi` module loaded with `fan_control=1`, and
2. write access to `/proc/acpi/ibm/fan`.

```sh
cd ~/Documents/Projects/community-plugins/thinkpad-fan/scripts
sudo ./setup-fan-permissions.sh
```

If the script had to enable `fan_control=1`, reboot (or reload the module) once.
Without this, the RPM/temperature readout still works, but changing the level
will fail (the panel shows a notification).

> ⚠️ Forcing the fans off (level 0) or to a fixed low level can let the machine
> overheat. Use manual levels with care; **Auto** returns control to firmware.

## Usage

Install this checkout as a development source and enable the plugin:

```sh
noctalia msg plugins source add dev path ~/Documents/Projects/community-plugins
noctalia msg plugins enable piero-93/thinkpad-fan
```

Then add the **ThinkPad Fan & Thermal Control** widget from the bar's Add-widget
picker. `.luau` edits hot-reload; `plugin.toml` changes apply on the next config
reload.

## Settings

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| Colorize by status | bool | `true` | Tint the widget when forced off / manual |
| Left-click opens the control panel | bool | `true` | Open the panel on left-click |
| Thermal zone | string | `thermal_zone0` | sysfs thermal zone for the temperature |

## What it does to your system

For review transparency (this plugin is trusted, unsandboxed Luau):

- **Reads** `/proc/acpi/ibm/fan` and `/sys/class/thermal/<zone>/temp` (poll ~2.5 s).
- **Writes** `level <value>` to `/proc/acpi/ibm/fan` only when you pick a level in
the panel.
- **No external commands, no network access.**

## License

GPL-3.0
74 changes: 74 additions & 0 deletions thinkpad-fan/panel.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
--!nonstrict

local SNAPSHOT = "fan.snapshot"
local COMMAND = "fan.command"

local snap = noctalia.state.get(SNAPSHOT) or { rpm = 0, level = "auto", temp = 0, available = false }
local reqId = 0

local function dispatch(level)
reqId += 1
noctalia.state.set(COMMAND, {
action = "set_level",
value = level,
requestId = tostring(os.time()) .. "-" .. tostring(reqId),
})
end

-- variant: current level highlighted; "0" (fans off) flagged as destructive.
local function levelButton(level, label, handler)
local variant = "ghost"
if snap.level == level then
variant = (level == "0") and "destructive" or "primary"
end
return ui.button({ text = label, variant = variant, onClick = handler, flexGrow = 1 })
end

local function render()
panel.render(ui.column({ gap = 8, padding = 10 }, {
ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = "car-fan", color = "primary" }),
ui.label({ text = noctalia.tr("panel.title"), fontWeight = "bold", flexGrow = 1 }),
ui.label({ text = (snap.rpm or 0) .. " RPM · " .. (snap.temp or 0) .. " °C", fontSize = 12, color = "on_surface_variant" }),
}),
ui.row({ gap = 6 }, {
levelButton("auto", noctalia.tr("panel.auto"), "onFanAuto"),
levelButton("disengaged", noctalia.tr("panel.full"), "onFanFull"),
}),
ui.row({ gap = 6 }, {
levelButton("0", "0", "onFan0"),
levelButton("1", "1", "onFan1"),
levelButton("2", "2", "onFan2"),
levelButton("3", "3", "onFan3"),
}),
ui.row({ gap = 6 }, {
levelButton("4", "4", "onFan4"),
levelButton("5", "5", "onFan5"),
levelButton("6", "6", "onFan6"),
levelButton("7", "7", "onFan7"),
}),
}))
end

function onOpen(_context)
snap = noctalia.state.get(SNAPSHOT) or snap
render()
end

noctalia.state.watch(SNAPSHOT, function(value)
if type(value) == "table" then
snap = value
render()
end
end)

function onFanAuto() dispatch("auto") end
function onFanFull() dispatch("disengaged") end
function onFan0() dispatch("0") end
function onFan1() dispatch("1") end
function onFan2() dispatch("2") end
function onFan3() dispatch("3") end
function onFan4() dispatch("4") end
function onFan5() dispatch("5") end
function onFan6() dispatch("6") end
function onFan7() dispatch("7") end
48 changes: 48 additions & 0 deletions thinkpad-fan/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
id = "piero-93/thinkpad-fan"
name = "ThinkPad Fan & Thermal Control"
version = "1.0.0"
plugin_api = 3
author = "piero-93"
license = "GPL-3.0"
icon = "car-fan"
description = "ThinkPad fan speed monitor and manual level control with temperature readout — no root at runtime."
tags = ["bar", "panel", "system", "hardware", "indicator", "utility"]
dependencies = []

[[setting]]
key = "colorize_by_status"
type = "bool"
label_key = "settings.colorize_by_status.label"
description_key = "settings.colorize_by_status.description"
default = true

[[setting]]
key = "allow_popup"
type = "bool"
label_key = "settings.allow_popup.label"
description_key = "settings.allow_popup.description"
default = true

[[setting]]
key = "thermal_zone"
type = "string"
label_key = "settings.thermal_zone.label"
description_key = "settings.thermal_zone.description"
default = "thermal_zone0"

[[widget]]
id = "widget"
entry = "widget.luau"

[[panel]]
id = "panel"
entry = "panel.luau"
width = 280
height = 200
placement = "attached"
position = "auto"
open_near_click = true

[[service]]
id = "service"
entry = "service.luau"
Binary file added thinkpad-fan/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions thinkpad-fan/scripts/setup-fan-permissions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# ThinkPad fan-control permissions setup.
#
# Lets the plugin write /proc/acpi/ibm/fan at runtime WITHOUT root:
# 1. enables thinkpad_acpi fan_control=1 (needed for manual control),
# 2. installs a udev rule that chmods the file world-writable on every boot.
#
# Usage: sudo ./setup-fan-permissions.sh
# A reboot (or module reload) may be needed if fan_control was just enabled.
# ---------------------------------------------------------------------------
set -euo pipefail

if [ "${EUID}" -ne 0 ]; then
echo "Error: run as root, e.g. sudo $0" >&2
exit 1
fi

MODPROBE_FILE="/etc/modprobe.d/thinkpad_acpi.conf"
RULE_FILE="/etc/udev/rules.d/99-thinkpad-fan.rules"

# 1. Enable manual fan control in the kernel module.
if [ -f /sys/module/thinkpad_acpi/parameters/fan_control ] \
&& [ "$(cat /sys/module/thinkpad_acpi/parameters/fan_control)" = "N" ]; then
echo "Enabling thinkpad_acpi fan_control=1..."
echo "options thinkpad_acpi fan_control=1" >"${MODPROBE_FILE}"
echo " -> reboot (or reload thinkpad_acpi) required to apply."
fi

# 2. Persistent udev rule so the file is writable on every boot.
echo "Writing ${RULE_FILE}..."
echo 'SUBSYSTEM=="platform", DRIVERS=="thinkpad_acpi", RUN+="/bin/chmod 0666 /proc/acpi/ibm/fan"' >"${RULE_FILE}"

echo "Reloading udev rules..."
udevadm control --reload-rules
udevadm trigger

# 3. Apply immediately for the current session.
if [ -f /proc/acpi/ibm/fan ]; then
chmod 0666 /proc/acpi/ibm/fan || true
echo "Done. If manual control still fails, reboot to apply fan_control=1."
else
echo "Note: /proc/acpi/ibm/fan not found — is the thinkpad_acpi module loaded?"
fi
135 changes: 135 additions & 0 deletions thinkpad-fan/service.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
--!nonstrict

local SNAPSHOT = "fan.snapshot"
local COMMAND = "fan.command"

local FAN_PATH = "/proc/acpi/ibm/fan"
local POLL_MS = 2500
local READ_TIMEOUT_MS = 4000
local WRITE_TIMEOUT_MS = 4000
local SEP = "__NTF_SEP__"

local VALID_LEVEL = {
auto = true,
disengaged = true,
["0"] = true,
["1"] = true,
["2"] = true,
["3"] = true,
["4"] = true,
["5"] = true,
["6"] = true,
["7"] = true,
}

local snapshot = {
rpm = 0,
level = "auto", -- auto / disengaged / 0..7
temp = 0, -- °C
available = false, -- /proc/acpi/ibm/fan readable
}

local reading = false

local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end

local function thermalPath()
local z = noctalia.getConfig("thermal_zone")
if type(z) ~= "string" or z == "" then
z = "thermal_zone0"
end
z = noctalia.string.trim(z):gsub("/+$", ""):match("[^/]+$") or "thermal_zone0"
return "/sys/class/thermal/" .. z .. "/temp"
end

local function publish()
noctalia.state.set(SNAPSHOT, {
rpm = snapshot.rpm,
level = snapshot.level,
temp = snapshot.temp,
available = snapshot.available,
})
end

local function parseFan(content)
if not content or content == "" then
snapshot.available = false
return
end
snapshot.available = true
for line in content:gmatch("[^\n]+") do
local rpm = line:match("^speed:%s*(%d+)")
if rpm then
snapshot.rpm = tonumber(rpm) or 0
end
local level = line:match("^level:%s*(%S+)")
if level then
snapshot.level = noctalia.string.trim(level):lower()
end
end
end

local function parseTemp(content)
local raw = content and tonumber(noctalia.string.trim(content))
if raw then
snapshot.temp = math.floor(raw / 1000 + 0.5)
end
end

local function readSysfs()
if reading then
return
end
reading = true
local cmd = "cat " .. shellQuote(FAN_PATH) .. " 2>/dev/null; echo " .. SEP
.. "; cat " .. shellQuote(thermalPath()) .. " 2>/dev/null"
local launched = noctalia.runAsync(cmd, function(res)
reading = false
local out = res.stdout or ""
local sepAt = out:find(SEP, 1, true)
parseFan(sepAt and out:sub(1, sepAt - 1) or out)
parseTemp(sepAt and out:sub(sepAt + #SEP) or "")
publish()
end, READ_TIMEOUT_MS)
if not launched then
reading = false
end
end

noctalia.state.watch(COMMAND, function(cmd)
if type(cmd) ~= "table" or cmd.action ~= "set_level" then
return
end
local lvl = noctalia.string.trim(tostring(cmd.value or "")):lower()
if not VALID_LEVEL[lvl] then
return
end
snapshot.level = lvl -- optimistic; confirmed by the next read
publish()
noctalia.runAsync("echo level " .. lvl .. " > " .. shellQuote(FAN_PATH), function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(
noctalia.tr("notify.fan_error_title"),
noctalia.tr("notify.fan_error_body")
)
noctalia.log("fan level write failed: " .. (res.stderr or ""))
end
readSysfs()
end, WRITE_TIMEOUT_MS)
end)

function update()
readSysfs()
end

function onConfigChanged()
reading = false
readSysfs()
end

noctalia.setUpdateInterval(POLL_MS)

publish()
readSysfs()
Binary file added thinkpad-fan/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading