22 Commits
Author SHA1 Message Date
sankalp 8bfb0dda39 fix: include LICENSE file in release tarball 2026-05-29 17:33:55 +05:45
sankalp ae32ce9c8d fix: always upload tarball to release regardless of ref_type 2026-05-29 17:21:18 +05:45
sankalp 11c3cec99d fix: force-overwrite files with stash instead of merge 2026-05-29 17:13:00 +05:45
sankalp f69b74ab73 fix: stash changes before checking out master in AUR action 2026-05-29 17:06:37 +05:45
sankalp bdf1312dd1 fix: upgrade AUR action to v4.1.3, checkout master before push 2026-05-29 16:59:42 +05:45
sankalp 2544f44309 fix: add contents: write permission for release upload 2026-05-29 16:47:30 +05:45
sankalp 34a57007d1 refactor(src/commands): Cleanup command printing and simplify GPU environment handling
Enhancements:
- Remove unnecessary multiline formatting in GPU, status, and temperature commands
- Consolidate zone type checks into a single condition
- Simplify environment variable tuple definitions
2026-05-29 16:38:24 +05:45
sankalp 3684c5b071 chore(root): bump version to 0.3.5Updated version bump and project metadata enrichment.
New Features
- None

Enhancements
- Updated edition to 2024
- Added license and repository fields
- Refined dependency versions

Bug Fixes
- None
2026-05-29 16:35:31 +05:45
sankalp 8faccc117a feat(src): add temperature, fan, power commands handling
- New Features
  - Added `temperature` subcommand to display system temperatures.
  - Added `fan` subcommand to display fan speeds.
  - Added `power` subcommand under GPU commands to show GPU power state and consumption.
- Enhancements
  - Integrated new subcommands into the command dispatcher.
- Bug Fixes
  - None
2026-05-29 16:35:26 +05:45
sankalp 92806e2843 feat(src/commands): add fan speed monitoring and enhance GPU temperature command
Refactor fan monitoring and improve GPU temperature reporting for better hardware visibility.

- New Features:
  - Add `fan` command to enumerate and display fan speeds via hwmon and thermal cooling devices.
  - Expand temperature detection across hwmon, thermal zones, and NVIDIA GPU sensors.
- Enhancements:
  - Strengthen GPU temperature output with power and clock information.
  - Improve error messages and logging consistency.
- Bug Fixes:
  - Correct error message wording.
  - Gracefully handle missing sensor files.
2026-05-29 16:35:21 +05:45
sankalp bdc95801c3 fix: reorder workflow to create tarball and update sha256 before AUR push 2026-05-29 16:14:29 +05:45
sankalp 2efa7a64f1 feat: auto-create and upload release tarball in workflow 2026-05-29 16:12:09 +05:45
sankalp c8561bf4ff docs(root):update README with new installation methods and project manifest 2026-05-29 16:01:16 +05:45
sankalp f202103a06 fix: exclude unsupported dsa key type from ssh_keyscan 2026-05-29 15:43:46 +05:45
sankalp e0a589d441 fix: update AUR push action inputs for v2.5.0 API 2026-05-29 15:37:41 +05:45
sankalp 151f5ad303 fix: run makepkg as non-root user in docker container 2026-05-29 15:31:51 +05:45
sankalp 92298ddce8 fix: add authors field to Cargo.toml (required by cargo-aur) 2026-05-29 15:25:28 +05:45
sankalp c223fea5f4 fix: replace removed archlinux/setup-pacman action with docker for .SRCINFO 2026-05-29 15:19:22 +05:45
sankalp 80899602e5 fix: make crates.io publish non-blocking, add workflow_dispatch, fix AUR action 2026-05-29 15:12:50 +05:45
sankalp 21defdb70a fix(.github/workflows): correctsetup-pacman action version
- Bug Fixes: Fixed CI workflow by using the correct archlinux/setup-pacman@v1 action instead of archlinux/actions/setup-pacman@v1
2026-05-28 20:36:01 +05:45
sankalp 90965f13b9 feat(root): bump version to 0.3.4New Features
- Upgrade lapctl to 0.3.4, adding new hardware management commands.
- Added clap and ctrlc dependencies to enhance CLI functionality.

Enhancements
- Updated Cargo.toml to edition 2024 and aligned dependency versions.
- Refined project description and repository link.

Bug Fixes
- No bug fixes included in this change.
2026-05-28 20:30:42 +05:45
sankalp 30a89fe2fe chore(root): bump version to 0.3.4 and add repository field
New Features
-

Enhancements
-

Bug Fixes
-
2026-05-28 20:29:37 +05:45
11 changed files with 426 additions and 44 deletions

No files matched your search

+47 -14
View File
@@ -6,6 +6,13 @@ on:
- 'v[0-9]+.[0-9]+.[0-9]+'
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g. 0.3.4)'
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
@@ -35,6 +42,7 @@ jobs:
name: lapctl-${{ github.ref_name }}
path: ./target/release/lapctl
- name: Publish to crates.io
continue-on-error: true
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish
@@ -47,31 +55,56 @@ jobs:
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup pacman
uses: archlinux/actions/setup-pacman@v1
with:
update: true
- name: Install base-devel (for makepkg)
run: sudo pacman -S --noconfirm --needed base-devel
- name: Get version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ github.event.inputs.version || github.ref_name }}
- name: Download release binary
uses: actions/download-artifact@v4
with:
name: lapctl-${{ github.ref_name }}
path: .
- name: Create tarball with binary
run: |
tar czf "lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" \
lapctl LICENSE
- name: Generate PKGBUILD with cargo-aur
run: |
cargo install cargo-aur
mkdir -p aur-pkg
cargo aur --output aur-pkg
- name: Update PKGBUILD checksums
run: |
SHA=$(sha256sum "lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" | cut -d' ' -f1)
sed -i "s/sha256sums=([^)]*)/sha256sums=('$SHA')/" aur-pkg/PKGBUILD
- name: Generate .SRCINFO
working-directory: aur-pkg
run: makepkg --printsrcinfo > .SRCINFO
run: |
docker run --rm -v "$(pwd)/aur-pkg:/build" archlinux:latest \
bash -c "
useradd -m builder
chown -R builder:builder /build
cd /build && su builder -c 'makepkg --printsrcinfo > .SRCINFO'
"
- name: Push to AUR
uses: KSXGitHub/github-actions-deploy-aur@v2.5.0
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
with:
pkgname: lapctl-bin
pkgver: ${{ steps.version.outputs.version }}
assets: |
aur-pkg/PKGBUILD
aur-pkg/.SRCINFO
pkgbuild: aur-pkg/PKGBUILD
assets: aur-pkg/.SRCINFO
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ssh_keyscan_types: rsa,ecdsa,ed25519
commit_username: ${{ github.actor }}
commit_email: ${{ github.actor }}@users.noreply.github.com
allow_empty_commits: true
force_push: true
post_process: |
git add -A && git stash && git checkout master && git checkout stash -- . && git stash drop
- name: Upload tarball to release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v${{ steps.version.outputs.version }}"
gh release create "$TAG" --title "$TAG" --notes "" 2>/dev/null || true
gh release upload "$TAG" \
"lapctl-${{ steps.version.outputs.version }}-x86_64.tar.gz" --clobber
Generated
+1 -1
View File
@@ -644,7 +644,7 @@ dependencies = [
[[package]]
name = "lapctl"
version = "0.3.3"
version = "0.3.5"
dependencies = [
"clap",
"ctrlc",
+2 -1
View File
@@ -1,7 +1,8 @@
[package]
name = "lapctl"
version = "0.3.3"
version = "0.3.5"
edition = "2024"
authors = ["S4NKALP"]
description = "CLI tool for managing laptop hardware features on Linux"
license = "MIT"
repository = "https://github.com/S4NKALP/lapctl"
+12 -11
View File
@@ -35,27 +35,28 @@ Built for speed and simplicity, it talks directly to your system's hardware inte
#### Option 1: Arch Linux (AUR)
```bash
yay -S lapctl
yay -S lapctl-bin
sudo lapctl install-rules
```
#### Option 2: Using `just` (Recommended for other distros)
If you have [just](https://github.com/casey/just) installed:
#### Option 2: crates.io
```bash
cargo install lapctl
sudo lapctl install-rules
```
#### Option 3: From source (using `just`)
```bash
git clone https://github.com/S4NKALP/lapctl.git
cd lapctl
just install
sudo lapctl install-rules
```
_The `install-rules` command automatically sets up the D-Bus policy and starts the `lapctld` background service._
#### Option 3: Using `cargo`
```bash
cargo install --path .
sudo lapctl install-rules
```
#### Requirements
- **GPU Switching (Optional)**: `xrandr` and `nvidia-settings` are strictly required **ONLY** when using the `lapctl gpu` command on X11 (to route proprietary NVIDIA Optimus drivers). Wayland is auto detected so no extra flags are needed.
+6
View File
@@ -44,6 +44,10 @@ pub enum Commands {
},
/// Hardware status
Status,
/// Show system temperatures
Temperature,
/// Show fan speeds
Fan,
/// Install udev rules for rootless operation
InstallRules,
/// Touchpad management
@@ -114,6 +118,8 @@ pub enum GpuCommands {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
/// Show GPU power state and consumption
Power,
}
#[derive(Subcommand, Debug)]
+100
View File
@@ -0,0 +1,100 @@
use std::fs;
use std::path::Path;
pub fn execute() {
println!("--- Fan Speed ---");
let mut found = false;
// Read fan speeds from hwmon
let hwmon_dir = Path::new("/sys/class/hwmon");
if hwmon_dir.exists()
&& let Ok(entries) = fs::read_dir(hwmon_dir)
{
for entry in entries.flatten() {
let path = entry.path();
let name_path = path.join("name");
let Ok(name) = fs::read_to_string(&name_path) else {
continue;
};
let hwmon_name = name.trim().to_string();
let mut idx = 1;
loop {
let input_path = path.join(format!("fan{}_input", idx));
if !input_path.exists() {
break;
}
if let Ok(raw) = fs::read_to_string(&input_path) {
let rpm: u64 = raw.trim().parse().unwrap_or(0);
let label = path.join(format!("fan{}_label", idx));
let label_str = fs::read_to_string(&label)
.map(|l| l.trim().to_string())
.unwrap_or_else(|_| format!("Fan {}", idx));
let display_name = match hwmon_name.as_str() {
"acpi_fan" => "CPU Fan",
other => other,
};
if rpm == 0 {
println!(" {} ({}): Stopped", display_name, label_str);
} else {
println!(" {} ({}): {} RPM", display_name, label_str, rpm);
}
found = true;
}
idx += 1;
}
}
}
// Show fan policy from thermal cooling devices
let thermal_dir = Path::new("/sys/class/thermal");
if thermal_dir.exists()
&& let Ok(entries) = fs::read_dir(thermal_dir)
{
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("cooling_device") {
continue;
}
let type_path = entry.path().join("type");
let state_path = entry.path().join("cur_state");
let max_state_path = entry.path().join("max_state");
if let (Ok(type_content), Ok(state), Ok(max_state)) = (
fs::read_to_string(&type_path),
fs::read_to_string(&state_path),
fs::read_to_string(&max_state_path),
) {
let zone_type = type_content.trim();
let cur_state: u64 = state.trim().parse().unwrap_or(0);
let max: u64 = max_state.trim().parse().unwrap_or(0);
if zone_type == "Fan" {
let policy = if cur_state == 0 {
"Off"
} else if cur_state >= max {
"Full Speed"
} else {
"Variable"
};
println!(
" {} ({}): Level {}/{} ({})",
name_str, zone_type, cur_state, max, policy
);
found = true;
}
}
}
}
if !found {
println!(" No fan sensors found.");
}
}
+102 -17
View File
@@ -854,37 +854,121 @@ fn switch_nvidia(
fn run_on_dgpu(command: &[String]) {
if command.is_empty() {
error!("No command provided to run.");
error!("No command specified.");
return;
}
if get_current_mode() != "hybrid" {
log::warn!(
"Running on dGPU is typically only effective in Hybrid mode. \
Your current mode is '{}'. The application may fail to start or \
not use the NVIDIA GPU.",
get_current_mode()
);
error!("GPU run is only supported in Hybrid mode.");
return;
}
log::info!("Launching '{}' on the discrete GPU...", command.join(" "));
let env_vars = vec![
("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()),
("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()),
("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()),
];
let mut child = Command::new(&command[0]);
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
child.args(&command[1..]);
cmd.args(&command[1..]);
}
// Set PRIME offload variables natively
child.env("__NV_PRIME_RENDER_OFFLOAD", "1");
child.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
child.env("__VK_LAYER_NV_optimus", "NVIDIA_only");
for (key, value) in &env_vars {
cmd.env(key, value);
}
match child.status() {
info!("Running '{}' on discrete GPU", command.join(" "));
match cmd.status() {
Ok(status) => {
log::info!("Application exited with: {}", status);
if !status.success() {
error!("Command exited with status: {}", status);
}
}
Err(e) => {
error!("Failed to launch application: {}", e);
error!("Failed to execute command: {}", e);
}
}
}
fn show_gpu_power_state() {
println!("--- GPU Power State ---");
// Read PCI power state from sysfs
let pci_base = Path::new("/sys/bus/pci/devices");
if let Ok(entries) = fs::read_dir(pci_base) {
for entry in entries.flatten() {
let vendor_path = entry.path().join("vendor");
let class_path = entry.path().join("class");
let Ok(vendor) = fs::read_to_string(&vendor_path) else {
continue;
};
let Ok(class) = fs::read_to_string(&class_path) else {
continue;
};
// NVIDIA vendor = 0x10de, VGA class starts with 0x0300
if vendor.trim() == "0x10de" && class.trim().starts_with("0x0300") {
let pci_addr = entry.file_name().to_string_lossy().to_string();
println!(" Device: NVIDIA ({})", pci_addr);
// Power state
let runtime_status = entry.path().join("power/runtime_status");
if let Ok(status) = fs::read_to_string(&runtime_status) {
println!(" Runtime Status: {}", status.trim());
}
// Power control
let power_control = entry.path().join("power/control");
if let Ok(control) = fs::read_to_string(&power_control) {
println!(" Power Control: {}", control.trim());
}
// Current power state (D0 = fully on, D3 = off)
let current_state = entry.path().join("power_state");
if let Ok(state) = fs::read_to_string(&current_state) {
println!(" PCI Power State: {}", state.trim());
}
// Max power state
let max_state = entry.path().join("max_power_state");
if let Ok(state) = fs::read_to_string(&max_state) {
println!(" Max Power State: {}", state.trim());
}
}
}
}
// NVIDIA power info via nvidia-smi
match std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=power.draw,power.limit,power.default_limit,power.max_limit,clocks.current.graphics,clocks.max.graphics",
"--format=csv,noheader",
])
.output()
{
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split(", ").collect();
if parts.len() >= 5 {
println!();
println!(" Power Draw: {}", parts[0].trim());
println!(" Power Limit: {}", parts[1].trim());
println!(" Default Limit: {}", parts[2].trim());
println!(" Max Limit: {}", parts[3].trim());
println!(" Current Clock: {}", parts[4].trim());
if parts.len() > 5 {
println!(" Max Clock: {}", parts[5].trim());
}
}
}
}
_ => {
log::debug!("nvidia-smi not available or GPU not powered on");
println!(" (nvidia-smi not available - GPU may be powered off)");
}
}
}
@@ -1004,5 +1088,6 @@ pub fn execute_local(cmd: &GpuCommands) {
*no_reboot,
),
GpuCommands::Run { command } => run_on_dgpu(command),
GpuCommands::Power => show_gpu_power_state(),
}
}
+2
View File
@@ -1,8 +1,10 @@
pub mod battery;
pub mod cooling;
pub mod display;
pub mod fan;
pub mod gpu;
pub mod install_rules;
pub mod power;
pub mod status;
pub mod temperature;
pub mod touchpad;
+32
View File
@@ -181,6 +181,38 @@ pub fn execute() {
}
}
// Temperature
if let Ok(temp_output) = fs::read_to_string("/sys/class/hwmon/hwmon5/temp1_input")
&& let Ok(millideg) = temp_output.trim().parse::<i64>()
{
println!("CPU Temp: {:.1}°C", millideg as f64 / 1000.0);
}
// Fan Speed
if let Ok(fan_output) = fs::read_to_string("/sys/class/hwmon/hwmon1/fan1_input")
&& let Ok(rpm) = fan_output.trim().parse::<u64>()
{
println!("Fan Speed: {} RPM", rpm);
}
// GPU Power State
if let Ok(output) = std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=power.draw,temperature.gpu",
"--format=csv,noheader",
])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
let parts: Vec<&str> = line.split(", ").collect();
if parts.len() >= 2 {
println!("GPU: {} @ {}°C (NVIDIA)", parts[0].trim(), parts[1].trim());
}
}
}
// Display Status
let displays = display::get_active_display_info();
if displays.is_empty() {
+120
View File
@@ -0,0 +1,120 @@
use log::debug;
use std::fs;
use std::path::Path;
pub fn execute() {
println!("--- Temperature ---");
let mut found = false;
// Read all hwmon temperature sensors
let hwmon_dir = Path::new("/sys/class/hwmon");
if hwmon_dir.exists()
&& let Ok(entries) = fs::read_dir(hwmon_dir)
{
for entry in entries.flatten() {
let path = entry.path();
let name_path = path.join("name");
let Ok(name) = fs::read_to_string(&name_path) else {
continue;
};
let hwmon_name = name.trim().to_string();
let mut idx = 1;
loop {
let input_path = path.join(format!("temp{}_input", idx));
if !input_path.exists() {
break;
}
if let Ok(raw) = fs::read_to_string(&input_path) {
let millideg: i64 = raw.trim().parse().unwrap_or(0);
let celsius = millideg as f64 / 1000.0;
let label = path.join(format!("temp{}_label", idx));
let label_str = fs::read_to_string(&label)
.map(|l| l.trim().to_string())
.unwrap_or_else(|_| format!("Sensor {}", idx));
let display_name = match hwmon_name.as_str() {
"k10temp" => "CPU",
"acpitz" => "ACPI Thermal Zone",
"nvme" => "NVMe SSD",
"mt7921_phy0" => "WiFi",
other => other,
};
println!(" {} ({}): {:.1}°C", display_name, label_str, celsius);
found = true;
}
idx += 1;
}
}
}
// NVIDIA GPU temperature via nvidia-smi
if let Ok(output) = std::process::Command::new("nvidia-smi")
.args([
"--query-gpu=temperature.gpu,power.draw,clocks.current.graphics",
"--format=csv,noheader",
])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split(", ").collect();
if let Some(temp) = parts.first()
&& let Ok(temp_c) = temp.trim().parse::<f64>()
{
let info = if parts.len() >= 3 {
format!(" [{} @ {}]", parts[1].trim(), parts[2].trim())
} else {
String::new()
};
println!(" GPU (NVIDIA): {:.0}°C{}", temp_c, info);
found = true;
}
}
} else {
debug!("nvidia-smi not available, skipping GPU temperature");
}
// Read thermal zone temperatures
let thermal_dir = Path::new("/sys/class/thermal");
if thermal_dir.exists()
&& let Ok(entries) = fs::read_dir(thermal_dir)
{
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("thermal_zone") {
continue;
}
let type_path = entry.path().join("type");
let temp_path = entry.path().join("temp");
if let (Ok(type_content), Ok(temp_content)) = (
fs::read_to_string(&type_path),
fs::read_to_string(&temp_path),
) {
let zone_type = type_content.trim();
let millideg: i64 = temp_content.trim().parse().unwrap_or(0);
let celsius = millideg as f64 / 1000.0;
// Skip if we already showed this from hwmon
if zone_type == "x86_pkg_temp" || zone_type == "k10temp" || zone_type == "acpitz" {
continue;
}
println!(" {} ({}): {:.1}°C", name_str, zone_type, celsius);
found = true;
}
}
}
if !found {
println!(" No temperature sensors found.");
}
}
+2
View File
@@ -28,6 +28,8 @@ fn main() {
Commands::Cooling { command } => commands::cooling::execute(command),
Commands::Display { command } => commands::display::execute(command),
Commands::Status => commands::status::execute(),
Commands::Temperature => commands::temperature::execute(),
Commands::Fan => commands::fan::execute(),
Commands::InstallRules => commands::install_rules::execute(),
Commands::Touchpad { command } => commands::touchpad::execute(command),
Commands::Daemon => {