ASUS UM5606 Fan State plugin (#21)

* ASUS fan state plugin

* submission guidelines

* fix validation ci

* fix label

* Update plugin.toml

* update thumbnail categories
This commit is contained in:
Kainoa Kanter
2026-07-15 20:25:22 -04:00
committed by GitHub
parent a030a3b99c
commit b1f9588bfd
7 changed files with 291 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Set the fan state on the ZenBook S 16 UM5606 (and related laptops). Requires https://github.com/ThatOneCalculator/asus-5606-fan-state
+69
View File
@@ -0,0 +1,69 @@
-- ASUS UM5606 Fan State - bar widget (thin presentation client).
--
-- The fan control logic lives in the [[service]] (fan_state_service.luau), which
-- runs for the whole session. This widget only reflects the published "status"
-- and cycles states by writing to the "command" channel.
local showLabel = noctalia.getConfig("show_label")
local STATES = {
[0] = { label = "Standard", glyph = "car-fan", color = "on_surface" },
[1] = { label = "Quiet", glyph = "car-fan-1", color = "secondary" },
[2] = { label = "High-Performance", glyph = "car-fan-2", color = "tertiary" },
[3] = { label = "Full", glyph = "car-fan-3", color = "primary" },
}
local fanState = -1
local available = false
local function send(action)
noctalia.state.set("command", action)
end
local function applyState(state)
local info = STATES[state]
if not info then
barWidget.setGlyph("car-fan")
if showLabel then
barWidget.setText("Unknown")
end
barWidget.setGlyphColor("on_surface")
barWidget.setColor("on_surface")
return
end
barWidget.setGlyph(info.glyph)
if showLabel then
barWidget.setText(info.label)
end
barWidget.setGlyphColor(info.color)
barWidget.setColor(info.color)
end
local function applyStatus(status)
if type(status) == "table" then
fanState = tonumber(status.state) or -1
available = status.available and true or false
end
if not available then
barWidget.setGlyph("car-fan")
if showLabel then
barWidget.setText("Unavailable")
end
barWidget.setGlyphColor("on_surface_variant")
barWidget.setColor("on_surface_variant")
return
end
applyState(fanState)
end
function onClick()
if not available then return end
send("cycle")
end
-- Sync the current status on load, then track changes.
applyStatus(noctalia.state.get("status"))
noctalia.state.watch("status", applyStatus)
+129
View File
@@ -0,0 +1,129 @@
-- ASUS UM5606 Fan State - headless service.
-- Owns all fan_state shell calls and runs for the whole session. The bar widget
-- is a thin client: it reads the published "status" and drives this service by
-- writing to the "command" channel.
local STATE_COUNT = 4
local fanState = -1
local available = false
local refreshPending = false
local lastStatusKey
local function publishStatus()
local key = tostring(fanState) .. (available and "/1" or "/0")
if key == lastStatusKey then return end
lastStatusKey = key
noctalia.state.set("status", { state = fanState, available = available })
end
local function setState(state)
if fanState == state then return end
fanState = state
end
local function parseFanState(stdout)
return tonumber((stdout or ""):match("^%s*(.-)%s*$"))
end
local function refreshFanState()
if refreshPending then return end
refreshPending = true
local ok = noctalia.runAsync("fan_state get --int", function(result)
refreshPending = false
if result.exitCode ~= 0 then
available = false
noctalia.log("fan_control: failed to get fan state")
publishStatus()
return
end
local parsed = parseFanState(result.stdout)
if parsed == nil then
available = false
noctalia.log("fan_control: failed to parse fan state")
publishStatus()
return
end
available = true
setState(parsed)
publishStatus()
end)
if not ok then
refreshPending = false
available = false
noctalia.log("fan_control: failed to start fan state command")
publishStatus()
end
end
local function setFanState(nextState)
if type(nextState) ~= "number" then return end
if nextState < 0 or nextState >= STATE_COUNT then return end
available = true
setState(nextState)
publishStatus()
local ok = noctalia.runAsync("fan_state set " .. tostring(nextState), function(result)
if result.exitCode ~= 0 then
noctalia.log("fan_control: failed to set fan state")
end
refreshFanState()
end)
if not ok then
noctalia.log("fan_control: failed to start fan state command")
refreshFanState()
end
end
local function cycleFanState()
if not available then
refreshFanState()
return
end
setFanState((fanState + 1) % STATE_COUNT)
end
local function handleCommand(command)
local action = command
local state = nil
if type(command) == "table" then
action = command.action
state = tonumber(command.state)
end
if action == "cycle" then
cycleFanState()
elseif action == "refresh" then
refreshFanState()
elseif action == "set" then
setFanState(state)
elseif type(action) == "string" then
local parsed = tonumber(action:match("^set%s+([0-3])$"))
if parsed ~= nil then
setFanState(parsed)
else
noctalia.log("fan_control: unknown command '" .. tostring(action) .. "'")
end
end
end
local pollInterval = tonumber(noctalia.getConfig("poll_interval")) or 2000
noctalia.setUpdateInterval(pollInterval)
publishStatus()
refreshFanState()
function update()
refreshFanState()
end
noctalia.state.watch("command", handleCommand)
+38
View File
@@ -0,0 +1,38 @@
id = "thatonecalculator/um5606_fan_state"
name = "ASUS UM5606 Fan State"
version = "1.0.6"
min_noctalia = "5.0.0"
author = "ThatOneCalculator"
license = "MIT"
tags = ["hardware"]
icon = "car-fan"
description = "Set the fan state on the ZenBook S 16 UM5606."
dependencies = ["fan_state"]
[[setting]]
key = "show_label"
type = "bool"
label_key = "settings.show_label.label"
default = false
[[setting]]
key = "poll_interval"
type = "int"
label_key = "settings.poll_interval.label"
description_key="settings.poll_interval.description"
default = 2000
min = 250
max = 60000
advanced = true
[[service]]
id = "service"
entry = "fan_state_service.luau"
[[widget]]
id = "fan_state"
entry = "fan_state_bar.luau"
[[shortcut]]
id = "toggle"
entry = "shortcut.luau"
+42
View File
@@ -0,0 +1,42 @@
-- ASUS UM5606 Fan State - control-center quick tile.
--
-- This tile holds no fan control logic of its own. It mirrors the bar widget
-- through the plugin's shared state:
-- reads noctalia.state "status" -> { state = ..., available = ... }
-- writes noctalia.state "command" -> "cycle"
local STATES = {
[0] = { label = "Standard", glyph = "car-fan" },
[1] = { label = "Quiet", glyph = "car-fan-1" },
[2] = { label = "High-Performance", glyph = "car-fan-2" },
[3] = { label = "Full", glyph = "car-fan-3" },
}
local available = false
local function applyStatus(status)
local state = type(status) == "table" and tonumber(status.state) or nil
available = type(status) == "table" and status.available and true or false
local info = STATES[state]
if info then
shortcut.setLabel(info.label)
shortcut.setIcon(info.glyph, "car-fan")
shortcut.setActive(state ~= 0)
else
shortcut.setLabel(available and "Unknown" or "Unavailable")
shortcut.setIcon("car-fan", "car-fan")
shortcut.setActive(false)
end
shortcut.setEnabled(available)
end
-- Reflect whatever the service last published, then track live changes.
applyStatus(noctalia.state.get("status"))
noctalia.state.watch("status", applyStatus)
function onClick()
if not available then return end
noctalia.state.set("command", "cycle")
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"title": "ASUS UM5606 Fan State",
"settings": {
"show_label": {
"label": "Show label"
},
"poll_interval": {
"label": "Poll interval",
"description": "Poll interval in ms"
}
}
}