nix-files/pkgs/additional/sane-sysinfo/sane-sysinfo

342 lines
10 KiB
Plaintext
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])"
"""
usage: sane-sysinfo [options...]
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 = ["󰂎", "󱊡", "󱊢", "󱊣"]
ICON_MEM="☵"
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>
# SUFFIX_HR="ʰ"
# SUFFIX_MIN="ᵐ"
# render time like: 2ₕ08ₘ
# SUFFIX_HR="ₕ"
# SUFFIX_MIN="ₘ"
# render time like: 2h08m
# SUFFIX_HR="H"
# SUFFIX_MIN="M"
# render time like: 2:08
# SUFFIX_HR=":"
# SUFFIX_MIN=
# render time like: 208⧗
SUFFIX_HR=""
SUFFIX_MIN="⧗"
# variants:
# SUFFIX_HR=":"
# SUFFIX_MIN="⧖"
# SUFFIX_MIN="⌛"
# render time like: 2'08"
# SUFFIX_HR="'"
# SUFFIX_MIN='"'
class ChargeDirection(Enum):
Charging = "Charging"
Discharging = "Discharging"
@dataclass
class Formatter:
suffix_icon: str = SUFFIX_ICON
suffix_percent: str = SUFFIX_PERCENT
suffix_hr: str = SUFFIX_HR
suffix_min: str = SUFFIX_MIN
def render_charge_icon(self, direction: ChargeDirection, percentage: float) -> str:
return f"{self._choose_icon(direction, percentage)}{self.suffix_icon}"
def render_mem_icon(self) -> str:
return f"{ICON_MEM}{self.suffix_icon}"
def render_hours_minutes(self, minutes: int) -> str:
hr = minutes // 60
min = minutes % 60
return f"{hr}{self.suffix_hr}{min:02}{self.suffix_min}"
def render_percent(self, pct: int) -> str:
return f"{pct}{self.suffix_percent}"
def _choose_icon(self, direction: ChargeDirection, percentage: float) -> str:
level = percentage / 25
level = max(0, min(3, level))
level = int(round(level))
logger.debug(f"render_charge_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}")
class MemInfo:
"""
reads values from /proc/meminfo
"""
def __init__(self):
try:
lines = open("/proc/meminfo").readlines()
except Exception as e:
logger.info(f"failed to open /proc/meminfo: {e}")
lines = []
# lines are like:
# MemTotal: 16262708 kB
# HugePages_Total: 0
self.entries = {}
for l in lines:
if ":" not in l: continue
key_len = l.index(":")
key, value_str = l[:key_len].strip(), l[key_len+1:].strip()
unit_str = ""
if " " in value_str:
value_len = value_str.index(" ")
value_str, unit_str = value_str[:value_len].strip(), value_str[value_len+1:].strip()
try:
value = int(value_str)
except:
logger.info(f"unexpected /proc/meminfo line: {l}")
continue
if unit_str == "kB":
value = value * 1024
self.entries[key] = value
def get(self, entry):
v = self.entries.get(entry)
logger.debug(f"/proc/meminfo: {entry}={v}")
return v
class PowerSupply:
"""
reads values from /sys/class/power_supply/$dev/ API
"""
def __init__(self, sysfs_node: str):
self.sysfs_node = sysfs_node
self._cached_reads = {}
def try_read(self, rel_path: str) -> str | None:
if rel_path not in self._cached_reads:
self._cached_reads[rel_path] = self.try_read_uncached(rel_path)
return self._cached_reads[rel_path]
def try_read_uncached(self, rel_path: str) -> str | None:
try:
v = open(f"{self.sysfs_node}/{rel_path}").read()
logger.debug(f"{self.sysfs_node}/{rel_path}: {v}")
return v
except:
return None
def try_read_int(self, rel_path: str) -> int | None:
s = self.try_read(rel_path)
if s is None: return None
return int(s)
@property
def capacity(self) -> int | None:
return self.try_read_int("capacity") # percent
@property
def charge_full_design(self) -> int | None:
return self.try_read_int("charge_full_design") # micro-Ah
@property
def current_now(self) -> int | None:
return self.try_read_int("current_now") # micro-A
@property
def energy_full(self) -> int | None:
return self.try_read_int("energy_full") # micro-Wh
# @property
# def energy_now(self) -> int | None:
# return self.try_read_int("energy_now") # micro-Wh
@property
def power_now(self) -> int | None:
return self.try_read_int("power_now") # micro-W (?)
@property
def status(self) -> str | None:
return self.try_read("status") # "Charging"/"Discharging"/"Not charging"
# @property
# def voltage_now(self) -> int | None:
# return self.try_read_int("voltage_now") # micro-V
class BatteryInfo:
"""
higher-level battery info derived from the underlying power supply
"""
percent_charged: int #< always available
minutes_to_charged: int | None = None
minutes_to_discharged: int | None = None
def __init__(self, capacity: int, charges_per_hour: float | None, status: str | None):
self.percent_charged = capacity
# correct some batteries which flip signs in places
if status.lower().strip() == "charging" and charges_per_hour:
charges_per_hour = abs(charges_per_hour)
logger.debug("status==charging => forcing charges_per_hour positive")
elif status.lower().strip() == "discharging" and charges_per_hour:
charges_per_hour = -abs(charges_per_hour)
logger.debug("status==discharging => forcing charges_per_hour negative")
if charges_per_hour is not None and charges_per_hour < 0:
self.minutes_to_discharged = int(
60
* self.percent_charged/100
/ -charges_per_hour
)
if charges_per_hour is not None and charges_per_hour > 0:
self.minutes_to_charged = int(
60
* (100-self.percent_charged)/100
/ charges_per_hour
)
def try_battery_path(p: str) -> BatteryInfo | None:
"""
try to read battery information from some p = "/sys/class/power_supply/$node" path
"""
ps = PowerSupply(p)
if ps.capacity is None:
return None
if ps.charge_full_design and ps.current_now is not None:
# current_now is positive when charging
charges_per_hour = ps.current_now / ps.charge_full_design
elif ps.energy_full and ps.power_now is not None:
# power_now is positive when discharging
charges_per_hour = -ps.power_now / ps.energy_full
else:
charges_per_hour = None
return BatteryInfo(ps.capacity, charges_per_hour, ps.status)
def try_all_batteries() -> BatteryInfo | None:
p = try_battery_path("/sys/class/power_supply/axp20x-battery") # Pinephone
if p is None:
p = try_battery_path("/sys/class/power_supply/BAT0") # Thinkpad
logger.debug(f"perc: {p.percent_charged if p else None}")
logger.debug(f"charge: {p.percent_charged if p else None}")
logger.debug(f"min-to-charge: {p.minutes_to_charged if p else None}, min-to-discharge: {p.minutes_to_discharged if p else None}")
return p
@dataclass
class AllInfo:
_fmt: Formatter
_mem: MemInfo | None
_bat: BatteryInfo | None
@property
def mem_icon(self) -> str:
if self._mem is None: return ""
return self._fmt.render_mem_icon()
@property
def mem_pct(self) -> str:
if self._mem is None: return ""
total = self._mem.get("MemTotal")
free = self._mem.get("MemAvailable")
if total is None or free is None or free > total:
return ""
mem_use_pct = int((total - free) / total * 100)
return self._fmt.render_percent(mem_use_pct)
@property
def bat_icon(self) -> str:
if self._bat is None: return ""
elif self._bat.minutes_to_charged != None:
logger.debug("bat_icon: charging")
return self._fmt.render_charge_icon(ChargeDirection.Charging, self._bat.percent_charged)
else:
logger.debug("bat_icon: discharging")
return self._fmt.render_charge_icon(ChargeDirection.Discharging, self._bat.percent_charged)
@property
def bat_time(self) -> str:
if self._bat is None: return ""
elif self._bat.minutes_to_charged != None:
duration = self._bat.minutes_to_charged
else:
duration = self._bat.minutes_to_discharged
if duration is not None and duration < 1440:
return self._fmt.render_hours_minutes(duration)
else:
return self._fmt.render_percent(self._bat.percent_charged)
def main() -> None:
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)
parser.add_argument("--template", default="{_.bat_icon}{_.bat_time}")
# parser.add_argument("--template", default="{_.mem_icon}{_.mem_pct}")
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
info = AllInfo(
f,
MemInfo(),
try_all_batteries(),
)
print(args.template.format(_=info))
if __name__ == "__main__":
main()