sane-sysinfo: port to Python

it's a pretty literal port; probably has some bugs
This commit is contained in:
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 }:
static-nix-shell.mkBash { static-nix-shell.mkPython3Bin {
pname = "sane-sysinfo"; pname = "sane-sysinfo";
srcRoot = ./.; srcRoot = ./.;
} }

View File

@@ -1,183 +1,229 @@
#!/usr/bin/env nix-shell #!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash #!nix-shell -i python3 -p "python3.withPackages (ps: [ ])"
"""
usage: sane-sysinfo [options...]
usage() { pretty-prints a battery estimate (icon to indicate state, and a duration estimate)
echo "usage: sane-sysinfo [options...]"
echo options:
echo "pretty-prints a battery estimate (icon to indicate state, and a duration estimate)" --debug: output additional information, to stderr
echo --minute-suffix <string>: use the provided string as a minutes suffix
echo "options:" --hour-suffix <string>: use the provided string as an hours suffix
echo " --debug: output additional information, to stderr" --icon-suffix <string>: use the provided string as an icon suffix
echo " --minute-suffix <string>: use the provided string as a minutes suffix" --percent-suffix <string>: use the provided string when displaying percents
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" import argparse
} import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
# these icons may only render in nerdfonts # these icons may only render in nerdfonts
icon_bat_chg=("󰢟" "󱊤" "󱊥" "󰂅") ICON_BAT_CHG = ["󰢟", "󱊤", "󱊥", "󰂅"]
icon_bat_dis=("󰂎" "󱊡" "󱊢" "󱊣") ICON_BAT_DIS = ["󰂎", "󱊡", "󱊢", "󱊣"]
suffix_icon="" # thin space SUFFIX_ICON = "" # thin space
suffix_percent="%" SUFFIX_PERCENT = "%"
# suffix_icon=" " # SUFFIX_ICON=" "
# render time like: 2ʰ08ᵐ # render time like: 2ʰ08ᵐ
# unicode sub/super-scripts: <https://en.wikipedia.org/wiki/Unicode_subscripts_and_superscripts> # unicode sub/super-scripts: <https://en.wikipedia.org/wiki/Unicode_subscripts_and_superscripts>
# symbol_hr="ʰ" # SUFFIX_HR="ʰ"
# symbol_min="ᵐ" # SUFFIX_MIN="ᵐ"
# render time like: 2ₕ08ₘ # render time like: 2ₕ08ₘ
# symbol_hr="ₕ" # SUFFIX_HR="ₕ"
# symbol_min="ₘ" # SUFFIX_MIN="ₘ"
# render time like: 2h08m # render time like: 2h08m
# symbol_hr="h" # SUFFIX_HR="H"
# symbol_min="m" # SUFFIX_MIN="M"
# render time like: 2:08 # render time like: 2:08
# symbol_hr=":" # SUFFIX_HR=":"
# symbol_min= # SUFFIX_MIN=
# render time like: 208⧗ # render time like: 208⧗
symbol_hr="" SUFFIX_HR=""
symbol_min="⧗" SUFFIX_MIN="⧗"
# variants: # variants:
# symbol_hr=":" # SUFFIX_HR=":"
# symbol_min="⧖" # SUFFIX_MIN="⧖"
# symbol_min="⌛" # SUFFIX_MIN="⌛"
# render time like: 2'08" # render time like: 2'08"
# symbol_hr="'" # SUFFIX_HR="'"
# symbol_min='"' # SUFFIX_MIN='"'
log() { class ChargeDirection(Enum):
if [ "$BATTERY_ESTIMATE_DEBUG" = "1" ]; then Charging = "Charging"
printf "$@" >&2 Discharging = "Discharging"
echo >&2
fi
}
render_icon() { @dataclass
# args: class Formatter:
# 1: "chg" or "dis" suffix_icon: str = SUFFIX_ICON
# 2: current battery percentage suffix_percent: str = SUFFIX_PERCENT
level=$(($2 / 25)) suffix_hr: str = SUFFIX_HR
level=$(($level > 3 ? 3 : $level)) suffix_min: str = SUFFIX_MIN
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
}
try_path() { @dataclass
# assigns output variables: class ParsedPowerSupply:
# - perc, perc_from_full (0-100) percent_charged: int | None = None
# - full, rate (pos means charging) # unitless: could be joules, could be something else
if [ -f "$1/capacity" ]; then charge_full: int | None = None
log "perc, perc_from_full from %s" "$1/capacity" # charge per hour
perc=$(cat "$1/capacity") charge_rate: int | None = None
perc_from_full=$((100 - $perc))
fi
if [ -f "$1/charge_full_design" ] && [ -f "$1/current_now" ]; then def is_valid(self) -> bool:
log "full, rate from %s and %s" "$1/charge_full_design" "$1/current_now" return self.percent_charged is not None and \
# current is positive when charging self.charge_full is not None and \
full=$(cat "$1/charge_full_design") self.charge_rate is not None
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
}
try_all_paths() { @property
try_path "/sys/class/power_supply/axp20x-battery" # Pinephone def percent_discharged(self) -> int | None:
try_path "/sys/class/power_supply/BAT0" # Thinkpad if self.percent_charged is not None:
log "perc: %d, perc_from_full: %d" "$perc" "$perc_from_full" return 100 - self.percent_charged
log "full: %f, rate: %f" "$full" "$rate"
log " rate > 0 means charging, else discharging"
}
fmt_minutes() { @property
# args: def discharge_rate(self) -> int | None:
# 1: icon to render if self.charge_rate is not None:
# 2: string to show if charge/discharge time is indefinite return -self.charge_rate
# 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
}
pretty_output() { @property
if [ -n "$perc" ]; then def minutes_to_charged(self) -> int | None:
duration="" if self.percent_discharged is not None and self.charge_full and self.charge_rate > 0:
if [ "$rate" -gt 0 ]; then return int(self.charge_full * self.percent_discharged/100 / self.charge_rate * 60)
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
}
while [ "$#" -gt 0 ]; do @property
case "$1" in def minutes_to_discharged(self) -> int | None:
"--debug") if self.percent_charged is not None and self.charge_full and self.charge_rate < 0:
shift return int(self.charge_full * self.percent_charged/100 / self.discharge_rate * 60)
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
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()