diff --git a/asus-fan/LICENSE b/asus-fan/LICENSE new file mode 100644 index 00000000..d8f11cb2 --- /dev/null +++ b/asus-fan/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kv7499 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/asus-fan/README.md b/asus-fan/README.md new file mode 100644 index 00000000..e55fd5e8 --- /dev/null +++ b/asus-fan/README.md @@ -0,0 +1,44 @@ +# ASUS TUF Fan Speed & Profile Switcher + +Live CPU and GPU fan RPM monitor and profile switcher for ASUS TUF and ROG laptops on Noctalia Shell. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `kv7499/asus-fan` | +| Entries | Bar widget: `asus_fan`; panel: `panel`; service: `service` | + +## Requirements + +Install `asusctl` on `PATH` (available via `asusctl` on Arch / CachyOS). + +## Usage + +Add the **ASUS Fan Monitor** bar widget to your bar in `config.toml`: + +```toml +[widget.asus_fan] +type = "kv7499/asus-fan:asus_fan" +``` + +* **Live Monitoring:** Displays your active fan RPM and color-codes the icon based on thermal intensity. +* **Left Click:** Cycles your laptop through its platform profiles (`Quiet` ➔ `Balanced` ➔ `Performance`). +* **Right Click:** Opens the quick settings panel to directly toggle between **RPM**, **Profile Name**, or **Both**, pick a platform profile, and view dual fan speeds. +* **Panel Toggle Command:** + +```sh +noctalia msg panel-toggle kv7499/asus-fan:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `display_mode` | `select` | `"rpm"` | Display live RPM (`"rpm"`), profile name (`"profile"`), or both (`"both"`). | +| `show_label` | `bool` | `true` | Show or hide text next to the fan icon. | +| `poll_interval_ms` | `int` | `2500` | Hardware polling interval in milliseconds. | + +## Notes + +Reads fan speeds directly from the kernel hardware monitoring subsystem (`/sys/class/hwmon`) using non-blocking asynchronous reads. Safe across suspend/resume cycles. diff --git a/asus-fan/panel.luau b/asus-fan/panel.luau new file mode 100644 index 00000000..c60b4dcb --- /dev/null +++ b/asus-fan/panel.luau @@ -0,0 +1,111 @@ +--!nonstrict + +local SNAPSHOT = "asus_fan.status" +local COMMAND = "asus_fan.command" + +local snap = noctalia.state.get(SNAPSHOT) or { + cpu_rpm = 0, + gpu_rpm = 0, + profile = "Balanced", + display_mode = "rpm", + available = false, +} + +local function setProfile(name) + noctalia.state.set(COMMAND, { action = "set_profile", profile = name }) +end + +local function setDisplayMode(mode) + noctalia.state.set(COMMAND, { action = "set_display_mode", mode = mode }) +end + +local function profileButton(name, label) + local isSelected = string.lower(snap.profile or "") == string.lower(name) + return ui.button({ + text = label, + variant = isSelected and "primary" or "ghost", + flexGrow = 1, + onClick = function() + setProfile(name) + end, + }) +end + +local function modeButton(mode, label) + local currentMode = snap.display_mode or "rpm" + local isSelected = currentMode == mode + return ui.button({ + text = label, + variant = isSelected and "primary" or "ghost", + flexGrow = 1, + onClick = function() + setDisplayMode(mode) + end, + }) +end + +local function render() + panel.render(ui.column({ flexGrow = 1, gap = 12 }, { + -- Header + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.row({ align = "center", gap = 6 }, { + ui.glyph({ name = "car-fan", color = "primary", size = 18 }), + ui.label({ text = "ASUS Fan & Profiles", fontSize = 15, fontWeight = "bold", color = "on_surface" }), + }), + ui.button({ glyph = "close", variant = "ghost", onClick = function() panel.close() end }), + }), + + -- Display Mode Section + ui.column({ gap = 6 }, { + ui.label({ text = "BAR DISPLAY TEXT", fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }), + ui.row({ gap = 6 }, { + modeButton("rpm", "RPM"), + modeButton("profile", "Profile"), + modeButton("both", "Both"), + }), + }), + + -- Profile Switcher Section + ui.column({ gap = 6 }, { + ui.label({ text = "PLATFORM PROFILE", fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }), + ui.row({ gap = 6 }, { + profileButton("Quiet", "Quiet"), + profileButton("Balanced", "Balanced"), + profileButton("Performance", "Performance"), + }), + }), + + -- Live Telemetry Cards + ui.column({ gap = 6 }, { + ui.label({ text = "HARDWARE TELEMETRY", fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }), + ui.row({ gap = 8, align = "center", justify = "space_between" }, { + ui.row({ fill = "secondary/0.15", radius = 6, paddingH = 8, paddingV = 4, gap = 4, flexGrow = 1, justify = "center" }, { + ui.label({ text = "CPU Fan: ", fontSize = 12, color = "on_surface_variant" }), + ui.label({ text = string.format("%d RPM", snap.cpu_rpm or 0), fontSize = 12, fontWeight = "bold", color = "primary" }), + }), + ui.row({ fill = "secondary/0.15", radius = 6, paddingH = 8, paddingV = 4, gap = 4, flexGrow = 1, justify = "center" }, { + ui.label({ text = "GPU Fan: ", fontSize = 12, color = "on_surface_variant" }), + ui.label({ text = string.format("%d RPM", snap.gpu_rpm or 0), fontSize = 12, fontWeight = "bold", color = "primary" }), + }), + }), + }), + })) +end + +function onOpen(_context) + snap = noctalia.state.get(SNAPSHOT) or snap + render() +end + +noctalia.state.watch(SNAPSHOT, function(val) + if type(val) == "table" then + snap = val + render() + end +end) + +function update() + render() +end + +render() diff --git a/asus-fan/plugin.toml b/asus-fan/plugin.toml new file mode 100644 index 00000000..62ef9ee7 --- /dev/null +++ b/asus-fan/plugin.toml @@ -0,0 +1,56 @@ +id = "kv7499/asus-fan" +name = "ASUS TUF Fan Speed and Profile Switcher" +version = "1.0.0" +plugin_api = 9 +author = "kv7499" +license = "MIT" +icon = "car-fan" +description = "Live CPU and GPU fan RPM monitor and profile switcher for ASUS TUF and ROG laptops." +dependencies = ["asusctl"] +tags = ["hardware", "bar", "system", "indicator"] + +[[setting]] +key = "poll_interval_ms" +type = "int" +default = 2500 +min = 500 +max = 10000 +step = 250 +label_key = "settings.poll_interval_ms.label" +description_key = "settings.poll_interval_ms.description" + +[[setting]] +key = "show_label" +type = "bool" +default = true +label_key = "settings.show_label.label" +description_key = "settings.show_label.description" + +[[setting]] +key = "display_mode" +type = "select" +default = "rpm" +label_key = "settings.display_mode.label" +description_key = "settings.display_mode.description" +options = [ + { label_key = "settings.display_mode.options.rpm", value = "rpm" }, + { label_key = "settings.display_mode.options.profile", value = "profile" }, + { label_key = "settings.display_mode.options.both", value = "both" } +] + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "asus_fan" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 300 +height = 240 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/asus-fan/service.luau b/asus-fan/service.luau new file mode 100644 index 00000000..dc7f5057 --- /dev/null +++ b/asus-fan/service.luau @@ -0,0 +1,112 @@ +--!nonstrict + +local SNAPSHOT = "asus_fan.status" +local COMMAND = "asus_fan.command" + +local status = { + cpu_rpm = 0, + gpu_rpm = 0, + profile = "Balanced", + display_mode = "rpm", + available = false, +} + +local reading = false + +local function publish() + noctalia.state.set(SNAPSHOT, { + cpu_rpm = status.cpu_rpm, + gpu_rpm = status.gpu_rpm, + profile = status.profile, + display_mode = status.display_mode, + available = status.available, + }) +end + +local function parseOutput(stdout) + if not stdout or stdout == "" then + return + end + status.available = true + + local cpu, gpu = stdout:match("FANS:(%d+):(%d+)") + if cpu and gpu then + status.cpu_rpm = tonumber(cpu) or 0 + status.gpu_rpm = tonumber(gpu) or 0 + end + + local prof = stdout:match("Active profile:%s*([%a%d_-]+)") + if prof and prof ~= "" then + status.profile = prof + end +end + +local function refresh() + if reading then + return + end + reading = true + + local cmd = "sh -c 'for h in /sys/class/hwmon/hwmon*; do if [ \"$(cat $h/name 2>/dev/null)\" = \"asus\" ]; then echo \"FANS:$(cat $h/fan1_input 2>/dev/null || echo 0):$(cat $h/fan2_input 2>/dev/null || echo 0)\"; break; fi; done; asusctl profile get 2>/dev/null | grep \"Active profile:\" || true'" + + local ok = noctalia.runAsync(cmd, function(result) + reading = false + if result.exitCode == 0 then + parseOutput(result.stdout or "") + else + status.available = false + end + publish() + end, 3000) + + if not ok then + reading = false + end +end + +local function cycleProfile() + noctalia.runAsync("asusctl profile next", function() + refresh() + end, 2000) +end + +local function setProfile(name) + noctalia.runAsync("asusctl profile set " .. name, function() + refresh() + end, 2000) +end + +noctalia.state.watch(COMMAND, function(cmd) + if type(cmd) == "table" then + if cmd.action == "cycle" then + cycleProfile() + elseif cmd.action == "set_profile" and type(cmd.profile) == "string" then + setProfile(cmd.profile) + elseif cmd.action == "set_display_mode" and type(cmd.mode) == "string" then + status.display_mode = cmd.mode + publish() + elseif cmd.action == "refresh" then + refresh() + end + elseif cmd == "cycle" then + cycleProfile() + elseif cmd == "refresh" then + refresh() + end +end) + +function update() + refresh() +end + +function onConfigChanged() + local poll = tonumber(noctalia.getConfig("poll_interval_ms")) or 2500 + noctalia.setUpdateInterval(poll) + refresh() +end + +local pollInterval = tonumber(noctalia.getConfig("poll_interval_ms")) or 2500 +noctalia.setUpdateInterval(pollInterval) + +publish() +refresh() diff --git a/asus-fan/thumbnail.webp b/asus-fan/thumbnail.webp new file mode 100644 index 00000000..731f66a5 Binary files /dev/null and b/asus-fan/thumbnail.webp differ diff --git a/asus-fan/translations/en.json b/asus-fan/translations/en.json new file mode 100644 index 00000000..3403d556 --- /dev/null +++ b/asus-fan/translations/en.json @@ -0,0 +1,26 @@ +{ + "settings": { + "poll_interval_ms": { + "label": "Polling Interval (ms)", + "description": "How often to read hardware fan sensors" + }, + "show_label": { + "label": "Show Label", + "description": "Show RPM or profile text next to the fan icon" + }, + "display_mode": { + "label": "Display Mode", + "description": "Whether to display live RPM, active profile, or both", + "options": { + "rpm": "Live RPM", + "profile": "Profile Name", + "both": "Both" + } + } + }, + "tooltip": { + "cpu_fan": "CPU Fan", + "gpu_fan": "GPU Fan", + "profile": "Profile" + } +} diff --git a/asus-fan/widget.luau b/asus-fan/widget.luau new file mode 100644 index 00000000..a04c3ac4 --- /dev/null +++ b/asus-fan/widget.luau @@ -0,0 +1,69 @@ +--!nonstrict + +local SNAPSHOT = "asus_fan.status" +local COMMAND = "asus_fan.command" + +local snap = noctalia.state.get(SNAPSHOT) or { + cpu_rpm = 0, + gpu_rpm = 0, + profile = "Balanced", + display_mode = "rpm", + available = false, +} + +local function getDisplayText() + local mode = snap.display_mode or noctalia.getConfig("display_mode") or "rpm" + local showLabel = noctalia.getConfig("show_label") + if showLabel == false then + return "" + end + + local maxRpm = math.max(snap.cpu_rpm or 0, snap.gpu_rpm or 0) + if mode == "profile" then + return snap.profile or "Auto" + elseif mode == "both" then + return string.format("%s %d", snap.profile or "", maxRpm) + end + return string.format("%d RPM", maxRpm) +end + +local function tooltipRows() + return { + { key = noctalia.tr("tooltip.cpu_fan"), value = string.format("%d RPM", snap.cpu_rpm or 0) }, + { key = noctalia.tr("tooltip.gpu_fan"), value = string.format("%d RPM", snap.gpu_rpm or 0) }, + { key = noctalia.tr("tooltip.profile"), value = snap.profile or "Unknown" }, + } +end + +local function render() + local text = getDisplayText() + barWidget.setGlyph("car-fan") + barWidget.setText(text) + barWidget.setTooltip(tooltipRows()) +end + +noctalia.state.watch(SNAPSHOT, function(val) + if type(val) == "table" then + snap = val + render() + end +end) + +function update() + render() +end + +function onConfigChanged() + render() +end + +function onClick() + noctalia.state.set(COMMAND, { action = "cycle" }) +end + +function onRightClick() + noctalia.togglePanel("kv7499/asus-fan:panel") +end + +noctalia.setUpdateInterval(2500) +render()