sane-sysinfo: port to Python

it's a pretty literal port; probably has some bugs
This commit is contained in:
Colin 2024-06-15 08:59:50 +00:00
parent c50a4d1d71
commit a4f5343fb5
2 changed files with 203 additions and 157 deletions

View File

@ -1,5 +1,5 @@
{ static-nix-shell }:
static-nix-shell.mkBash {
static-nix-shell.mkPython3Bin {
pname = "sane-sysinfo";
srcRoot = ./.;
}

View File

@ -1,183 +1,229 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])"
"""
usage: sane-sysinfo [options...]
usage() {
echo "usage: sane-sysinfo [options...]"
echo
echo "pretty-prints a battery estimate (icon to indicate state, and a duration estimate)"
echo
echo "options:"
echo " --debug: output additional information, to stderr"
echo " --minute-suffix <string>: use the provided string as a minutes suffix"
echo " --hour-suffix <string>: use the provided string as an hours suffix"
echo " --icon-suffix <string>: use the provided string as an icon suffix"
echo " --percent-suffix <string>: use the provided string when displaying percents"
}
pretty-prints a battery estimate (icon to indicate state, and a duration estimate)
options:
--debug: output additional information, to stderr
--minute-suffix <string>: use the provided string as a minutes suffix
--hour-suffix <string>: use the provided string as an hours suffix
--icon-suffix <string>: use the provided string as an icon suffix
--percent-suffix <string>: use the provided string when displaying percents
"""
import argparse
import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
# these icons may only render in nerdfonts
icon_bat_chg=("󰢟" "󱊤" "󱊥" "󰂅")
icon_bat_dis=("󰂎" "󱊡" "󱊢" "󱊣")
suffix_icon="" # thin space
suffix_percent="%"
# suffix_icon=" "
ICON_BAT_CHG = ["󰢟", "󱊤", "󱊥", "󰂅"]
ICON_BAT_DIS = ["󰂎", "󱊡", "󱊢", "󱊣"]
SUFFIX_ICON = "" # thin space
SUFFIX_PERCENT = "%"
# SUFFIX_ICON=" "
# render time like: 2ʰ08ᵐ
# unicode sub/super-scripts: <https://en.wikipedia.org/wiki/Unicode_subscripts_and_superscripts>
# symbol_hr="ʰ"
# symbol_min="ᵐ"
# SUFFIX_HR="ʰ"
# SUFFIX_MIN="ᵐ"
# render time like: 2ₕ08ₘ
# symbol_hr="ₕ"
# symbol_min="ₘ"
# SUFFIX_HR="ₕ"
# SUFFIX_MIN="ₘ"
# render time like: 2h08m
# symbol_hr="h"
# symbol_min="m"
# SUFFIX_HR="H"
# SUFFIX_MIN="M"
# render time like: 2:08
# symbol_hr=":"
# symbol_min=
# SUFFIX_HR=":"
# SUFFIX_MIN=
# render time like: 208⧗
symbol_hr=""
symbol_min="⧗"
SUFFIX_HR=""
SUFFIX_MIN="⧗"
# variants:
# symbol_hr=":"
# symbol_min="⧖"
# symbol_min="⌛"
# SUFFIX_HR=":"
# SUFFIX_MIN="⧖"
# SUFFIX_MIN="⌛"
# render time like: 2'08"
# symbol_hr="'"
# symbol_min='"'
# SUFFIX_HR="'"
# SUFFIX_MIN='"'
log() {
if [ "$BATTERY_ESTIMATE_DEBUG" = "1" ]; then
printf "$@" >&2
echo >&2
fi
}
class ChargeDirection(Enum):
Charging = "Charging"
Discharging = "Discharging"
render_icon() {
# args:
# 1: "chg" or "dis"
# 2: current battery percentage
level=$(($2 / 25))
level=$(($level > 3 ? 3 : $level))
level=$(($level < 0 ? 0 : $level))
log "icon: %s %d" "$1" "$level"
if [ "$1" = "dis" ]; then
printf "%s" "${icon_bat_dis[$level]}"
elif [ "$1" = "chg" ]; then
printf "%s" "${icon_bat_chg[$level]}"
fi
}
@dataclass
class Formatter:
suffix_icon: str = SUFFIX_ICON
suffix_percent: str = SUFFIX_PERCENT
suffix_hr: str = SUFFIX_HR
suffix_min: str = SUFFIX_MIN
try_path() {
# assigns output variables:
# - perc, perc_from_full (0-100)
# - full, rate (pos means charging)
if [ -f "$1/capacity" ]; then
log "perc, perc_from_full from %s" "$1/capacity"
perc=$(cat "$1/capacity")
perc_from_full=$((100 - $perc))
fi
@dataclass
class ParsedPowerSupply:
percent_charged: int | None = None
# unitless: could be joules, could be something else
charge_full: int | None = None
# charge per hour
charge_rate: int | None = None
if [ -f "$1/charge_full_design" ] && [ -f "$1/current_now" ]; then
log "full, rate from %s and %s" "$1/charge_full_design" "$1/current_now"
# current is positive when charging
full=$(cat "$1/charge_full_design")
rate=$(cat "$1/current_now")
elif [ -f "$1/energy_full" ] && [ -f "$1/power_now" ]; then
log "full, rate from %s and %s" "$1/energy_full" "$1/power_now"
# power_now is positive when discharging
full=$(cat "$1/energy_full")
rate=-$(cat "$1/power_now")
elif [ -f "$1/energy_full" ] && [ -f "$1/energy_now" ]; then
log "full, rate from %s and %s" "$1/energy_full" "$1/energy_now"
log " this is a compatibility path for legacy Thinkpad batteries which do not populate the 'power_now' field, and incorrectly populate 'energy_now' with power info"
# energy_now is positive when discharging
full=$(cat "$1/energy_full")
rate=-$(cat "$1/energy_now")
fi
}
def is_valid(self) -> bool:
return self.percent_charged is not None and \
self.charge_full is not None and \
self.charge_rate is not None
try_all_paths() {
try_path "/sys/class/power_supply/axp20x-battery" # Pinephone
try_path "/sys/class/power_supply/BAT0" # Thinkpad
log "perc: %d, perc_from_full: %d" "$perc" "$perc_from_full"
log "full: %f, rate: %f" "$full" "$rate"
log " rate > 0 means charging, else discharging"
}
@property
def percent_discharged(self) -> int | None:
if self.percent_charged is not None:
return 100 - self.percent_charged
fmt_minutes() {
# args:
# 1: icon to render
# 2: string to show if charge/discharge time is indefinite
# 3: minutes to stable state (i.e. to full charge or full discharge)
# - we work in minutes instead of hours for precision: bash math is integer-only
log "charge/discharge time: %f min" "$3"
# args: <battery symbol> <text if ludicrous estimate> <estimated minutes to full/empty>
if [ -n "$3" ] && [ "$3" -lt 1440 ]; then
hr=$(($3 / 60))
hr_in_min=$(($hr * 60))
min=$(($3 - $hr_in_min))
printf "%s%s%d%s%02d%s" "$1" "$suffix_icon" "$hr" "$symbol_hr" "$min" "$symbol_min"
else
log "charge/discharge duration > 1d"
printf "%s%s%s" "$1" "$suffix_icon" "$2" # more than 1d
fi
}
@property
def discharge_rate(self) -> int | None:
if self.charge_rate is not None:
return -self.charge_rate
pretty_output() {
if [ -n "$perc" ]; then
duration=""
if [ "$rate" -gt 0 ]; then
log "charging"
icon="$(render_icon chg $perc)"
duration="$(($full * 60 * $perc_from_full / (100 * $rate)))"
else
log "discharging"
icon="$(render_icon dis $perc)"
if [ "$rate" -lt 0 ]; then
duration="$(($full * 60 * $perc / (-100 * $rate)))"
fi
fi
fmt_minutes "$icon" "$perc$suffix_percent" "$duration"
fi
}
@property
def minutes_to_charged(self) -> int | None:
if self.percent_discharged is not None and self.charge_full and self.charge_rate > 0:
return int(self.charge_full * self.percent_discharged/100 / self.charge_rate * 60)
while [ "$#" -gt 0 ]; do
case "$1" in
"--debug")
shift
BATTERY_ESTIMATE_DEBUG=1
;;
"--icon-suffix")
shift
suffix_icon="$1"
shift
;;
"--hour-suffix")
shift
symbol_hr="$1"
shift
;;
"--minute-suffix")
shift
symbol_min="$1"
shift
;;
"--percent-suffix")
shift
suffix_percent="$1"
shift
;;
*)
usage
exit 1
;;
esac
done
@property
def minutes_to_discharged(self) -> int | None:
if self.percent_charged is not None and self.charge_full and self.charge_rate < 0:
return int(self.charge_full * self.percent_charged/100 / self.discharge_rate * 60)
try_all_paths
pretty_output
def render_icon(direction: ChargeDirection, percentage: float) -> str:
level = percentage / 25
level = max(0, min(3, level))
level = int(round(level))
logger.debug(f"render_icon: direction={direction} level={level}")
if direction == ChargeDirection.Charging:
return ICON_BAT_CHG[level]
elif direction == ChargeDirection.Discharging:
return ICON_BAT_DIS[level]
raise RuntimeError(f"invalid ChargeDirection {direction}")
def try_path(p: str) -> ParsedPowerSupply | None:
"""
try to read battery information from some p = "/sys/class/power_supply/$node" path
"""
state = ParsedPowerSupply()
try:
capacity_text = open(f"{p}/capacity").read()
except: pass
else:
logger.debug(f"read from {p}/capacity: {capacity_text}")
state.percent_charged = int(capacity_text)
try:
charge_full_design_text = open(f"{p}/charge_full_design").read()
current_now_text = open(f"{p}/current_now").read()
except: pass
else:
logger.debug(f"read from {p}/charge_full_design: {charge_full_design_text}")
logger.debug(f"read from {p}/charge_now: {charge_now_text}")
state.charge_full = int(charge_full_design_text)
# current_now is positive when charging
state.charge_rate = int(current_now_text)
if state.is_valid(): return state
try:
energy_full_text = open(f"{p}/energy_full").read()
power_now_text = open(f"{p}/power_now").read()
except: pass
else:
logger.debug(f"read from {p}/energy_full: {energy_full_text}")
logger.debug(f"read from {p}/power_now: {power_now_text}")
state.charge_full = int(energy_full_text)
# power_now is positive when discharging
state.charge_rate = -int(power_now_text)
if state.is_valid(): return state
try:
energy_full_text = open(f"{p}/energy_full").read()
energy_now_text = open(f"{p}/energy_now").read()
except: pass
else:
logger.debug(f"read from {p}/energy_full: {energy_full_text}")
logger.debug(f"read from {p}/energy_now: {energy_now_text}")
state.charge_full = int(energy_full_text)
# energy_now is positive when discharging
state.charge_rate = -int(energy_now_text)
return state if state.percent_charged is not None else None
def try_all_paths() -> ParsedPowerSupply | None:
p = try_path("/sys/class/power_supply/axp20x-battery") # Pinephone
if p is None:
p = try_path("/sys/class/power_supply/BAT0") # Thinkpad
logger.debug(f"perc: {p.percent_charged if p else None}")
logger.debug(f"full: {p.charge_full if p else None}, rate: {p.charge_rate if p else None}")
logger.debug(" rate > 0 means charging, else discharging")
return p
def fmt_minutes(f: Formatter, icon: str, if_indefinite: str, minutes: int | None) -> str:
logger.debug(f"charge/discharge time: {minutes} min")
if minutes < 1440:
hr = minutes // 60
min = minutes % 60
return f"{icon}{f.suffix_icon}{hr}{f.suffix_hr}{min:02}{f.suffix_min}"
else:
logger.debug("charge/discharge duration > 1d")
return f"{icon}{f.suffix_icon}{if_indefinite}"
def pretty_output(f: Formatter, p: ParsedPowerSupply) -> str:
if p.charge_rate > 0:
logger.debug("charging")
icon = render_icon(ChargeDirection.Charging, p.percent_charged)
duration = p.minutes_to_charged
else:
logger.debug("discharging")
icon = render_icon(ChargeDirection.Discharging, p.percent_discharged)
duration = p.minutes_to_discharged
return fmt_minutes(f, icon, f"{p.percent_charged}{f.suffix_percent}", duration)
def main():
logging.basicConfig()
logging.getLogger().setLevel(logging.INFO)
parser = argparse.ArgumentParser(usage=__doc__)
parser.add_argument("--debug", action="store_true")
parser.add_argument("--icon-suffix", default=SUFFIX_ICON)
parser.add_argument("--hour-suffix", default=SUFFIX_HR)
parser.add_argument("--minute-suffix", default=SUFFIX_MIN)
parser.add_argument("--percent-suffix", default=SUFFIX_PERCENT)
args = parser.parse_args()
if args.debug:
logger.setLevel(logging.DEBUG)
f = Formatter()
f.suffix_icon = args.icon_suffix
f.suffix_percent = args.percent_suffix
f.suffix_hr = args.hour_suffix
f.suffix_min = args.minute_suffix
p = try_all_paths()
print(pretty_output(f, p))
if __name__ == "__main__":
main()