use anyhow::{Context, Error, Ok, Result};
use clap::builder::styling::{AnsiColor, Effects, Style, Styles};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use regex::Regex;
use std::{
collections::HashMap,
env,
ffi::OsStr,
fs, io,
path::{Path, PathBuf},
process::{self, Stdio},
};
use text_template::*;
const NAME: &str = "batlimit";
const UNITPATH: &str = "/etc/systemd/system/batlimit";
const KEYPATH: &str = "/sys/class/power_supply";
const LIMITKEY: &str = "charge_control_end_threshold";
const STARTKEY: &str = "charge_control_start_threshold";
const INTERVAL: u8 = 2;
const TARGETS: [&str; 6] = ["hibernate", "hybrid-sleep", "multi-user", "sleep", "suspend", "suspend-then-hibernate"];
const ENVVAR: &str = "BATLIMIT_BAT";
const CMD: Style = AnsiColor::Magenta.on_default().effects(Effects::BOLD);
const HEAD: Style = AnsiColor::Cyan.on_default().effects(Effects::BOLD);
const VALUE: Style = AnsiColor::Yellow.on_default().effects(Effects::BOLD);
const NORMAL: Style = AnsiColor::White.on_default().effects(Effects::BOLD);
const GOOD: Style = AnsiColor::Green.on_default().effects(Effects::BOLD);
const ERROR: Style = AnsiColor::Red.on_default().effects(Effects::BOLD);
const STYLE: Styles = Styles::styled()
.header(GOOD)
.usage(GOOD)
.placeholder(AnsiColor::Cyan.on_default())
.error(ERROR)
.literal(HEAD)
.valid(HEAD)
.invalid(VALUE);
#[derive(Parser)]
#[command(
version,
about,
styles = STYLE,
after_help = format!("\
Commands can be abbreviated up to their first letter.\n\
Root privileges required for: {CMD}limit {NORMAL}& {CMD}clear{NORMAL}, {CMD}persist {NORMAL}& {CMD}unpersist"),
infer_subcommands(true),
help_template(
"\
{before-help}{name} {version} - {about}
{usage-heading} {usage}
{all-args}{after-help}
"
))]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Info,
Limit { percent: u8 },
Clear,
Persist { percent: Option<u8> },
Unpersist,
#[command(long_about = "Generate shell completions. Example:
batlimit shell bash > _bash
mkdir -p ~/.local/share/bash_completion/completions
mv _bash ~/.local/share/bash_completion/completions/batlimit")]
Shell { shell: Shell },
Readme,
}
struct Battery {
bat_path: PathBuf,
}
impl Battery {
fn new() -> Result<Self> {
let bat_name = env::var(ENVVAR);
if bat_name.is_ok() && !bat_name.clone().unwrap().is_empty() {
let bat_path = Path::new(KEYPATH).join(bat_name.unwrap());
return Ok(Self { bat_path });
};
for bat_name in ["BAT0", "BAT1", "BATT", "BATC"] {
let bat_path = Path::new(KEYPATH).join(bat_name);
if fs::metadata(&bat_path).is_ok() {
return Ok(Self { bat_path });
};
}
Err(Error::msg(format!("{ERROR}Battery not found").to_owned()))
}
fn sudo_write<P: AsRef<Path>, C: AsRef<OsStr>>(path: P, contents: C) -> Result<()> {
let echo = process::Command::new("echo").arg(contents).stdout(Stdio::piped()).spawn()?;
process::Command::new("sudo")
.arg("tee")
.arg(path.as_ref().as_os_str())
.stdin(Stdio::from(echo.stdout.ok_or(format!("{ERROR}Nothing piped in by echo")).map_err(Error::msg)?))
.stdout(Stdio::null())
.spawn()?
.wait()?;
Ok(())
}
fn get_limit(&self) -> Result<u8> {
fs::read_to_string(self.bat_path.join(LIMITKEY))
.context(format!("Failed to read from {}", self.bat_path.join(LIMITKEY).display()))?
.trim()
.parse::<u8>()
.map_err(|e| Error::msg(format!("{ERROR}Failed to parse battery limit: {VALUE}{e}")))
}
fn limit(&self, limit: u8) -> Result<()> {
if !(1..=99).contains(&limit) {
return Err(Error::msg(format!("{ERROR}Percent must be a number between 1 and 100")));
}
let old_limit = self.get_limit()?;
Self::sudo_write(self.bat_path.join(LIMITKEY), limit.to_string())?;
if old_limit == limit {
println!("Limit unchanged: {VALUE}{limit}%");
} else if old_limit == 100 {
println!("Limit set: {VALUE}{limit}%");
} else {
println!("Limit changed: {VALUE}{old_limit}% {NORMAL}-> {VALUE}{limit}%");
}
Ok(())
}
fn clear(&self) -> Result<()> {
let old_limit = self.get_limit()?;
if old_limit < 100 {
Self::sudo_write(self.bat_path.join(LIMITKEY), "100")?;
println!("Cleared charge limit");
} else {
println!("Charge limit already cleared");
}
Ok(())
}
fn persist(&self, percent: Option<u8>) -> Result<()> {
let limit = if percent.is_none() { self.get_limit()? } else { percent.unwrap() };
if !(1..=99).contains(&limit) {
return Err(Error::msg(format!("{ERROR}Percent must be a number between 1 and 99")));
}
let mut values = HashMap::new();
let startval = limit - INTERVAL;
let startkey = self.bat_path.join(STARTKEY).display().to_string();
let startstr = format!("echo {} >{}; ", startval.to_string().as_str(), &startkey);
if fs::exists(self.bat_path.join(STARTKEY)).unwrap() {
values.insert("start", startstr.as_str());
} else {
values.insert("start", "");
};
let lim = limit.to_string();
values.insert("limit", &lim);
let key = self.bat_path.join(LIMITKEY).display().to_string();
values.insert("path", &key);
let template = Template::from(include_str!("../unit.service"));
for target in TARGETS {
values.insert("target", target);
let content = template.fill_in(&values).to_string();
let path = format!("{UNITPATH}-{target}.service");
Self::sudo_write(&path, content)?;
let unit = format!("{NAME}-{target}.service");
process::Command::new("sudo").args(format!("systemctl enable --now {unit} --quiet").split(' ')).spawn()?.wait()?;
}
println!("Persist systemd services created and enabled with limit {VALUE}{limit}");
Ok(())
}
fn unpersist(&self) -> Result<()> {
for target in TARGETS {
let unit = format!("{NAME}-{target}.service");
let path = format!("{UNITPATH}-{target}.service");
if fs::metadata(&path).is_ok() {
process::Command::new("sudo").args(format!("systemctl disable --now {unit} --quiet").split(' ')).spawn()?.wait()?;
process::Command::new("sudo").arg("rm").arg(path).spawn()?.wait()?;
}
}
println!("Persist systemd services disabled and removed");
Ok(())
}
fn get_persist(&self) -> Option<u8> {
let mut percent: u8 = 0;
for target in TARGETS {
let path = format!("{UNITPATH}-{target}.service");
let file = fs::read_to_string(path).ok()?;
if Some(file.clone()).is_none() {
return Some(0); }
let re = Regex::new(format!("(?m)^ExecStart=/bin/sh -c 'echo ([0-9]+) >{KEYPATH}/BAT./{LIMITKEY}'$").as_str()).unwrap();
let pct = re.captures(&file)?.get(1)?.as_str().parse::<u8>().unwrap();
if percent == 0 {
percent = pct;
} else if percent != pct {
return Some(0); }
}
Some(percent)
}
fn info(&self) {
const INFO: [(&str, &str, &str); 16] = [
("manufacturer", "Brand", ""),
("model_name", "Model", ""),
("technology", "Battery Type", ""),
("status", "Charge Status", ""),
("capacity_level", "Battery State", ""),
("charge_full", "Current Max. Capacity", " μAh"),
("power_now", "Current Max. Capacity", " μAh"),
("energy_now", "Current Max. Capacity", " μAh"),
("energy_full", "Current Max. Capacity", " μAh"),
("charge_full_design", "Design Max. Capacity", " μAh"),
("energy_full_design", "Design Max. Capacity", " μAh"),
("voltage_min_design", "Min. Voltage", " μV"),
("voltage_now", "Current Voltage", " μV"),
("capacity", "Charge Level", "%"),
(STARTKEY, "Charge Start", "%"),
(LIMITKEY, "Charge Limit", "%"),
];
let info = INFO
.iter()
.filter_map(|v| fs::read_to_string(self.bat_path.join(v.0)).ok().map(|value| (v.1, value.trim().to_owned(), v.2)))
.collect::<Vec<_>>();
let pad_size = info.iter().map(|(file, _, _)| file.len()).max().unwrap_or(0);
let info_string = info
.iter()
.map(|(file, value, unit)| format!("{NORMAL}{file:<pad_size$} {VALUE}{value}{unit}"))
.collect::<Vec<_>>()
.join("\n");
let path = &self.bat_path.display().to_string();
let bat = path.split('/').next_back().unwrap();
println!("{HEAD}[{bat}]");
if !info_string.is_empty() {
println!("{info_string}");
};
let persiststr = "Persist state";
let persist = self.get_persist();
if persist != Some(0) {
if persist.is_none() {
println!("{NORMAL}{persiststr:<pad_size$} {VALUE}NO");
} else {
println!("{NORMAL}{persiststr:<pad_size$} {VALUE}{}%", persist.unwrap());
}
} else {
println!("{NORMAL}{persiststr:<pad_size$} {VALUE}INCONSISTENT");
}
let healthstr = "Health / Capacity";
let mut cur = String::new();
let mut des = String::new();
for triple in &info {
if triple.0 == INFO[5].1 {
cur = triple.1.clone();
};
if triple.0 == INFO[9].1 {
des = triple.1.clone();
};
}
if !cur.is_empty() && !des.is_empty() {
let health = 100 * cur.parse::<u32>().unwrap_or(0) / des.parse::<u32>().unwrap_or(1);
println!("{NORMAL}{healthstr:<pad_size$} {VALUE}{}%", health.clamp(0, 100));
} else {
println!("{NORMAL}{healthstr:<pad_size$} {VALUE}NO INFO");
};
}
}
fn main() -> Result<()> {
let args = Cli::parse();
let battery = Battery::new()?;
match args.command {
Some(Command::Limit { percent }) => {
battery.limit(percent)?;
}
Some(Command::Clear) => {
battery.clear()?;
}
Some(Command::Persist { percent }) => {
battery.persist(percent)?;
}
Some(Command::Readme) => {
print!("{}", include_str!("../README.md"));
}
Some(Command::Unpersist) => {
battery.unpersist()?;
}
Some(Command::Shell { shell }) => {
clap_complete::generate(shell, &mut Cli::command(), env!("CARGO_PKG_NAME"), &mut io::stdout());
}
Some(Command::Info) => battery.info(),
_ => battery.info(),
};
Ok(())
}