Manual fixes to Python file

To see if I like the rules
This commit is contained in:
Geoffrey Frogeye 2025-05-08 18:29:03 +02:00
parent 34b545890d
commit 8179433c41
10 changed files with 612 additions and 547 deletions

View file

@ -1,3 +1,8 @@
#!/usr/bin/env nix-shell
#! nix-shell -i python3 --pure
#! nix-shell -p python3 python3Packages.colorspacious python3Packages.numpy
# vim: filetype=python
import argparse import argparse
import json import json
@ -90,14 +95,14 @@ if args.polarity == "light":
frogarized_rgb = np.vstack([frogarized_rgb[7::-1], frogarized_rgb[8:]]) frogarized_rgb = np.vstack([frogarized_rgb[7::-1], frogarized_rgb[8:]])
# Output # Output
palette = dict() palette = {}
for i in range(16): for i in range(16):
rgb = frogarized_rgb[i] rgb = frogarized_rgb[i]
r, g, b = rgb r, g, b = rgb
hex = f"#{r:02x}{g:02x}{b:02x}" hexa = f"#{r:02x}{g:02x}{b:02x}"
palette[f"base{i:02X}"] = hex palette[f"base{i:02X}"] = hexa
if args.output == "truecolor": if args.output == "truecolor":
print(f"\033[48;2;{r};{g};{b}m{hex}\033[0m") # ]] print(f"\033[48;2;{r};{g};{b}m{hexa}\033[0m") # ]]
# treesitter is silly and will consider brackets in strings # treesitter is silly and will consider brackets in strings
# as indentation, hence the comment above # as indentation, hence the comment above
if args.output == "json": if args.output == "json":

28
common/update-local-flakes/update-local-flakes.py Executable file → Normal file
View file

@ -1,6 +1,6 @@
import argparse import argparse
import json import json
import os import pathlib
import subprocess import subprocess
GET_INPUTS_CMD = [ GET_INPUTS_CMD = [
@ -12,28 +12,26 @@ GET_INPUTS_CMD = [
] ]
def process_flake(flakeUri: str) -> None: def process_flake(flake_uri: pathlib.Path) -> None:
# get full path # get full path
flakeUri = os.path.normpath(flakeUri) flake_file = flake_uri / "flake.nix"
flakeFile = os.path.join(flakeUri, "flake.nix") if not flake_file.is_file():
if not os.path.isfile(flakeFile): msg = f"Flake not found: {flake_uri}"
raise FileNotFoundError(f"Flake not found: {flakeUri}") raise FileNotFoundError(msg)
# import dependencies # import dependencies
p = subprocess.run(GET_INPUTS_CMD, cwd=flakeUri, stdout=subprocess.PIPE, check=True) p = subprocess.run(
GET_INPUTS_CMD, cwd=flake_uri, stdout=subprocess.PIPE, check=True
)
deps = json.loads(p.stdout) deps = json.loads(p.stdout)
# for each dependency # for each dependency
for dep_name, dep in deps.items(): for dep_name, dep in deps.items():
dep_url = dep["url"] dep_url = dep["url"]
# if not local path, continue # if not local path, continue
if not ( if not dep_url.startswith(("path:", "git+file:")):
dep_url.startswith("path:") or dep_url.startswith("git+file:")
):
continue continue
if dep.get("flake", True): if dep.get("flake", True):
# get flake file corresponding # get flake file corresponding
dep_path = dep_url.split(":")[1] dep_path = pathlib.Path(flake_uri, dep_url.split(":")[1])
if not dep_path.startswith("/"):
dep_path = os.path.join(flakeUri, dep_path)
process_flake(dep_path) process_flake(dep_path)
# update lockfile # update lockfile
cmd = [ cmd = [
@ -46,7 +44,7 @@ def process_flake(flakeUri: str) -> None:
"update", "update",
dep_name, dep_name,
] ]
p = subprocess.run(cmd, cwd=flakeUri, check=True) p = subprocess.run(cmd, cwd=flake_uri, check=True)
if __name__ == "__main__": if __name__ == "__main__":
@ -54,6 +52,6 @@ if __name__ == "__main__":
description="Recursively update lockfiles " description="Recursively update lockfiles "
"of flakes located on the system" "of flakes located on the system"
) )
parser.add_argument("flake", help="Starting flake", default="/") parser.add_argument("flake", help="Starting flake", type=pathlib.Path)
args = parser.parse_args() args = parser.parse_args()
process_flake(args.flake) process_flake(args.flake)

272
curacao/desk/desk_mqtt.py Executable file → Normal file
View file

@ -12,7 +12,9 @@ import usb.util
class Desk: class Desk:
""" """
Controls my Linak desk, which is a CBD4P controller connected via USB2LIN06 Controls my Linak desk.
It is a CBD4P controller connected via USB2LIN06.
This particular combination doesn't seem to report desk height, This particular combination doesn't seem to report desk height,
so it is estimated from the physical controller that does work. so it is estimated from the physical controller that does work.
""" """
@ -69,24 +71,27 @@ class Desk:
# Better estimate a bit slower # Better estimate a bit slower
SPEED = (VALUE_TOP - VALUE_BOT) / FULL_TIME * SPEED_MARGIN # unit / s SPEED = (VALUE_TOP - VALUE_BOT) / FULL_TIME * SPEED_MARGIN # unit / s
def _cmToUnit(self, height: float) -> int: HEADER_STRUCT = struct.Struct("<BB")
REPORT_STRUCT = struct.Struct("<HH14xH36x")
def _cm_to_unit(self, height: float) -> int:
return round((height - self.HEIGHT_OFFSET) * self.HEIGHT_MULT) return round((height - self.HEIGHT_OFFSET) * self.HEIGHT_MULT)
def _unitToCm(self, height: int) -> float: def _unit_to_cm(self, height: int) -> float:
return height / self.HEIGHT_MULT + self.HEIGHT_OFFSET return height / self.HEIGHT_MULT + self.HEIGHT_OFFSET
def _get(self, typ: int, overflow_ok: bool = False) -> bytes: def _get(self, typ: int) -> bytes:
# Magic numbers: get class interface, HID get report # Magic numbers: get class interface, HID get report
raw = self._dev.ctrl_transfer( raw = self._dev.ctrl_transfer(
0xA1, 0x01, 0x300 + typ, 0, self.BUF_LEN 0xA1, 0x01, 0x300 + typ, 0, self.BUF_LEN
).tobytes() ).tobytes()
self.log.debug(f"Received {raw.hex()}") self.log.debug("Received %s", raw.hex())
assert raw[0] == typ typ, size = self.HEADER_STRUCT.unpack(raw[: self.HEADER_STRUCT.size])
size = raw[1] start = self.HEADER_STRUCT.size
end = 2 + size end = self.HEADER_STRUCT.size + size
if not overflow_ok: if end >= self.BUF_LEN:
assert end < self.BUF_LEN raise OverflowError
return raw[2:end] return raw[start:end]
# Non-implemented types: # Non-implemented types:
# 1, 7: some kind of stream when the device isn't initialized? # 1, 7: some kind of stream when the device isn't initialized?
# size reduces the faster you poll, increases when buttons are held # size reduces the faster you poll, increases when buttons are held
@ -96,7 +101,7 @@ class Desk:
buf = bytes([typ]) + buf buf = bytes([typ]) + buf
# The official apps pad, not that it doesn't seem to work without # The official apps pad, not that it doesn't seem to work without
buf = buf + b"\x00" * (self.BUF_LEN - len(buf)) buf = buf + b"\x00" * (self.BUF_LEN - len(buf))
self.log.debug(f"Sending {buf.hex()}") self.log.debug("Sending %s", buf.hex())
# Magic numbers: set class interface, HID set report # Magic numbers: set class interface, HID set report
self._dev.ctrl_transfer(0x21, 0x09, 0x300 + typ, 0, buf) self._dev.ctrl_transfer(0x21, 0x09, 0x300 + typ, 0, buf)
# Non-implemented types: # Non-implemented types:
@ -110,9 +115,11 @@ class Desk:
def _initialize(self) -> None: def _initialize(self) -> None:
""" """
Seems to take the USB2LIN06 out of "boot mode" Seems to take the USB2LIN06 out of "boot mode".
(name according to CBD4 Controller) which it is after reset.
It is like that after reset.
Permits control and reading the report. Permits control and reading the report.
(name according to CBD4 Controller)
""" """
buf = bytes([0x04, 0x00, 0xFB]) buf = bytes([0x04, 0x00, 0xFB])
self._set(3, buf) self._set(3, buf)
@ -122,9 +129,8 @@ class Desk:
self.log = logging.getLogger("Desk") self.log = logging.getLogger("Desk")
self._dev = usb.core.find(idVendor=Desk.VEND, idProduct=Desk.PROD) self._dev = usb.core.find(idVendor=Desk.VEND, idProduct=Desk.PROD)
if not self._dev: if not self._dev:
raise ValueError( msg = f"Device {Desk.VEND}: {Desk.PROD:04d} not found!"
f"Device {Desk.VEND}:" f"{Desk.PROD:04d} " f"not found!" raise ValueError(msg)
)
if self._dev.is_kernel_driver_active(0): if self._dev.is_kernel_driver_active(0):
self._dev.detach_kernel_driver(0) self._dev.detach_kernel_driver(0)
@ -136,9 +142,7 @@ class Desk:
self.fetch_callback: typing.Callable[[Desk], None] | None = None self.fetch_callback: typing.Callable[[Desk], None] | None = None
def _get_report(self) -> bytes: def _get_report(self) -> bytes:
raw = self._get(4) return self._get(4)
assert len(raw) == 0x38
return raw
def _update_estimations(self) -> None: def _update_estimations(self) -> None:
now = time.time() now = time.time()
@ -192,23 +196,19 @@ class Desk:
try: try:
raw = self._get_report() raw = self._get_report()
break break
except usb.USBError as e: except usb.USBError:
self.log.error(e) self.log.exception("USB issue")
else: else:
raw = self._get_report() raw = self._get_report()
# Allegedly, from decompiling: # Most values are from examining the basic software.
# https://www.linak-us.com/products/controls/desk-control-basic-software/ # Never reports anything in practice.
# Never reports anything in practice # Destination is from observation.
self.value = struct.unpack("<H", raw[0:2])[0] self.value, unk, self.destination = self.REPORT_STRUCT.unpack(raw)
unk = struct.unpack("<H", raw[2:4])[0]
self.initalized = (unk & 0xF) != 0 self.initalized = (unk & 0xF) != 0
# From observation. Reliable
self.destination = (struct.unpack("<H", raw[18:20])[0],)[0]
if self.destination != self.last_destination: if self.destination != self.last_destination:
self.log.info(f"Destination changed to {self.destination:04x}") self.log.info("Destination changed to %04x", self.destination)
self.last_destination = self.destination self.last_destination = self.destination
self._update_estimations() self._update_estimations()
@ -224,7 +224,7 @@ class Desk:
position = max(self.VALUE_BOT, position) position = max(self.VALUE_BOT, position)
position = min(self.VALUE_TOP, position) position = min(self.VALUE_TOP, position)
self.log.info(f"Start moving to {position:04x}") self.log.info("Start moving to %04x", position)
self.fetch() self.fetch()
while self.est_value != position: while self.est_value != position:
self._move(position) self._move(position)
@ -234,6 +234,8 @@ class Desk:
def move_to(self, position: float) -> None: def move_to(self, position: float) -> None:
""" """
Move the desk to the given position in cm.
If any button is held during movement, the desk will stop moving, If any button is held during movement, the desk will stop moving,
yet this will think it's still moving, throwing off the estimates. yet this will think it's still moving, throwing off the estimates.
It's not a bug, it's a safety feature. It's not a bug, it's a safety feature.
@ -243,7 +245,7 @@ class Desk:
""" """
# Would to stop for a while before reversing course, without being able # Would to stop for a while before reversing course, without being able
# to read the actual height it's just too annoying to implement # to read the actual height it's just too annoying to implement
return self._move_to(self._cmToUnit(position)) return self._move_to(self._cm_to_unit(position))
def stop(self) -> None: def stop(self) -> None:
self.log.info("Stop moving") self.log.info("Stop moving")
@ -252,128 +254,144 @@ class Desk:
def get_height_bounds(self) -> tuple[float, float]: def get_height_bounds(self) -> tuple[float, float]:
return ( return (
self._unitToCm(int(self.est_value_bot)), self._unit_to_cm(int(self.est_value_bot)),
self._unitToCm(int(self.est_value_top)), self._unit_to_cm(int(self.est_value_top)),
) )
def get_height(self) -> float | None: def get_height(self) -> float | None:
if self.est_value is None: if self.est_value is None:
return None return None
return self._unitToCm(self.est_value) return self._unit_to_cm(self.est_value)
if __name__ == "__main__": class App:
logging.basicConfig() NDIGITS = 1
log = logging.getLogger(__name__)
desk = Desk() def __init__(self) -> None:
serial = "000C-34E7" logging.basicConfig()
self.log = logging.getLogger(__name__)
# Configure the required parameters for the MQTT broker self.desk = Desk()
mqtt_settings = ha_mqtt_discoverable.Settings.MQTT(host="192.168.7.53") self.desk.fetch_callback = self.fetch_callback
ndigits = 1 serial = "000C-34E7"
target_height: float | None = None
device_info = ha_mqtt_discoverable.DeviceInfo( # Configure the required parameters for the MQTT broker
name="Desk", mqtt_settings = ha_mqtt_discoverable.Settings.MQTT(host="192.168.7.53")
identifiers=["Linak", serial], self.target_height: float | None = None
manufacturer="Linak",
model="CBD4P",
suggested_area="Desk",
hw_version="77402",
sw_version="1.91",
serial_number=serial,
)
common_opts = { device_info = ha_mqtt_discoverable.DeviceInfo(
"device": device_info, name="Desk",
"icon": "mdi:desk", identifiers=["Linak", serial],
"unit_of_measurement": "cm", manufacturer="Linak",
"device_class": "distance", model="CBD4P",
"expire_after": 10, suggested_area="Desk",
} hw_version="77402",
# TODO Implement proper availability in hq-mqtt-discoverable sw_version="1.91",
serial_number=serial,
)
height_info = ha_mqtt_discoverable.sensors.NumberInfo( common_opts = {
name="Height ", "device": device_info,
min=desk.HEIGHT_BOT, "icon": "mdi:desk",
max=desk.HEIGHT_TOP, "unit_of_measurement": "cm",
mode="slider", "device_class": "distance",
step=10 ** (-ndigits), "expire_after": 10,
unique_id="desk_height", }
**common_opts, # TODO Implement proper availability in hq-mqtt-discoverable
)
height_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_info
)
def height_callback( height_info = ha_mqtt_discoverable.sensors.NumberInfo(
client: paho.mqtt.client.Client, name="Height ",
user_data: None, min=self.desk.HEIGHT_BOT,
message: paho.mqtt.client.MQTTMessage, max=self.desk.HEIGHT_TOP,
) -> None: mode="slider",
global target_height step=10 ** (-self.NDIGITS),
target_height = float(message.payload.decode()) unique_id="desk_height",
log.info(f"Requested height to {target_height:.1f}") **common_opts,
)
height_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_info
)
height = ha_mqtt_discoverable.sensors.Number( self.height = ha_mqtt_discoverable.sensors.Number(
height_settings, height_callback height_settings, self.height_callback
) )
height_max_info = ha_mqtt_discoverable.sensors.SensorInfo( height_max_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height max", name="Estimated height max",
unique_id="desk_height_max", unique_id="desk_height_max",
entity_category="diagnostic", entity_category="diagnostic",
**common_opts, **common_opts,
) )
height_max_settings = ha_mqtt_discoverable.Settings( height_max_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_max_info mqtt=mqtt_settings, entity=height_max_info
) )
height_max = ha_mqtt_discoverable.sensors.Sensor(height_max_settings) self.height_max = ha_mqtt_discoverable.sensors.Sensor(
height_max_settings
)
height_min_info = ha_mqtt_discoverable.sensors.SensorInfo( height_min_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height min", name="Estimated height min",
unique_id="desk_height_min", unique_id="desk_height_min",
entity_category="diagnostic", entity_category="diagnostic",
**common_opts, **common_opts,
) )
height_min_settings = ha_mqtt_discoverable.Settings( height_min_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_min_info mqtt=mqtt_settings, entity=height_min_info
) )
height_min = ha_mqtt_discoverable.sensors.Sensor(height_min_settings) self.height_min = ha_mqtt_discoverable.sensors.Sensor(
height_min_settings
)
last_published_state = None self.last_published_state: tuple[float | None, float, float] | None = (
None
)
def fetch_callback(desk: Desk) -> None: def fetch_callback(self, desk: Desk) -> None:
hcur = desk.get_height() hcur = desk.get_height()
hmin, hmax = desk.get_height_bounds() hmin, hmax = desk.get_height_bounds()
global last_published_state
state = hcur, hmin, hmax state = hcur, hmin, hmax
if state == last_published_state: if state == self.last_published_state:
return return
last_published_state = state self.last_published_state = state
# If none this will set as unknown # If none this will set as unknown
# Also readings can be a bit outside the boundaries, # Also readings can be a bit outside the boundaries,
# so this skips verification # so this skips verification
if isinstance(hcur, float): if isinstance(hcur, float):
hcur = round(hcur, ndigits=ndigits) hcur = round(hcur, ndigits=self.NDIGITS)
height._update_state(hcur) self.height._update_state(hcur) # noqa: SLF001
height_max._update_state(round(hmax, ndigits=ndigits)) self.height_max._update_state( # noqa: SLF001
height_min._update_state(round(hmin, ndigits=ndigits)) round(hmax, ndigits=self.NDIGITS),
)
self.height_min._update_state( # noqa: SLF001
round(hmin, ndigits=self.NDIGITS),
)
desk.fetch_callback = fetch_callback def height_callback(
self,
_client: paho.mqtt.client.Client,
_user_data: None,
message: paho.mqtt.client.MQTTMessage,
) -> None:
self.target_height = float(message.payload.decode())
self.log.info("Requested height to %.1f", self.target_height)
interval = 0.2 def run(self) -> None:
# Need to be rective to catch interval = 0.2
while True: # Need to be rective to catch
if target_height: while True:
temp_target_height = target_height if self.target_height:
# Allows queuing of other instructions while moving temp_target_height = self.target_height
target_height = None # Allows queuing of other instructions while moving
desk.move_to(temp_target_height) self.target_height = None
else: self.desk.move_to(temp_target_height)
time.sleep(interval) else:
desk.fetch() time.sleep(interval)
self.desk.fetch()
if __name__ == "__main__":
app = App()
app.run()

View file

@ -9,7 +9,7 @@ from frobar.common import Alignment
def main() -> None: def main() -> None:
# TODO Configurable # TODO Configurable
FROGARIZED = [ frogarized = (
"#092c0e", "#092c0e",
"#143718", "#143718",
"#5a7058", "#5a7058",
@ -26,12 +26,12 @@ def main() -> None:
"#008dd1", "#008dd1",
"#5c73c4", "#5c73c4",
"#d43982", "#d43982",
] )
# TODO Not super happy with the color management, # TODO Not super happy with the color management,
# while using an existing library is great, it's limited to ANSI colors # while using an existing library is great, it's limited to ANSI colors
def base16_color(color: int) -> tuple[int, int, int]: def base16_color(color: int) -> tuple[int, int, int]:
hexa = FROGARIZED[color] hexa = frogarized[color]
return tuple(rich.color.parse_rgb_hex(hexa[1:])) return tuple(rich.color.parse_rgb_hex(hexa[1:]))
theme = rich.terminal_theme.TerminalTheme( theme = rich.terminal_theme.TerminalTheme(
@ -62,83 +62,83 @@ def main() -> None:
) )
bar = frobar.common.Bar(theme=theme) bar = frobar.common.Bar(theme=theme)
dualScreen = len(bar.children) > 1 dual_screen = len(bar.children) > 1
leftPreferred = 0 if dualScreen else None left_preferred = 0 if dual_screen else None
rightPreferred = 1 if dualScreen else None right_preferred = 1 if dual_screen else None
workspaces_suffixes = "▲■" workspaces_suffixes = "▲■"
workspaces_names = dict( workspaces_names = {
(str(i + 1), f"{i+1} {c}") for i, c in enumerate(workspaces_suffixes) str(i + 1): f"{i+1} {c}" for i, c in enumerate(workspaces_suffixes)
) }
color = rich.color.Color.parse color = rich.color.Color.parse
bar.addProvider( bar.add_provider(
frobar.providers.I3ModeProvider(color=color("red")), frobar.providers.I3ModeProvider(color=color("red")),
alignment=Alignment.LEFT, alignment=Alignment.LEFT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.I3WorkspacesProvider(custom_names=workspaces_names), frobar.providers.I3WorkspacesProvider(custom_names=workspaces_names),
alignment=Alignment.LEFT, alignment=Alignment.LEFT,
) )
if dualScreen: if dual_screen:
bar.addProvider( bar.add_provider(
frobar.providers.I3WindowTitleProvider(color=color("white")), frobar.providers.I3WindowTitleProvider(color=color("white")),
screenNum=0, screen_num=0,
alignment=Alignment.CENTER, alignment=Alignment.CENTER,
) )
bar.addProvider( bar.add_provider(
frobar.providers.MprisProvider(color=color("bright_white")), frobar.providers.MprisProvider(color=color("bright_white")),
screenNum=rightPreferred, screen_num=right_preferred,
alignment=Alignment.CENTER, alignment=Alignment.CENTER,
) )
else: else:
bar.addProvider( bar.add_provider(
frobar.common.SpacerProvider(), frobar.common.SpacerProvider(),
alignment=Alignment.LEFT, alignment=Alignment.LEFT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.MprisProvider(color=color("bright_white")), frobar.providers.MprisProvider(color=color("bright_white")),
alignment=Alignment.LEFT, alignment=Alignment.LEFT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.CpuProvider(), frobar.providers.CpuProvider(),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.LoadProvider(), frobar.providers.LoadProvider(),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.RamProvider(), frobar.providers.RamProvider(),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.TemperatureProvider(), frobar.providers.TemperatureProvider(),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.BatteryProvider(), frobar.providers.BatteryProvider(),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.PulseaudioProvider(color=color("magenta")), frobar.providers.PulseaudioProvider(color=color("magenta")),
screenNum=rightPreferred, screen_num=right_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.NetworkProvider(color=color("blue")), frobar.providers.NetworkProvider(color=color("blue")),
screenNum=leftPreferred, screen_num=left_preferred,
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )
bar.addProvider( bar.add_provider(
frobar.providers.TimeProvider(color=color("cyan")), frobar.providers.TimeProvider(color=color("cyan")),
alignment=Alignment.RIGHT, alignment=Alignment.RIGHT,
) )

View file

@ -30,15 +30,16 @@ Sortable = str | int
# Display utilities # Display utilities
UNIT_THRESHOLD = 1000
FLOAT_THRESHOLD = 10
def humanSize(numi: int) -> str:
""" def human_size(numi: int) -> str:
Returns a string of width 3+3 """Return a string of width 3+3."""
"""
num = float(numi) num = float(numi)
for unit in ("B ", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB"): for unit in ("B ", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB"):
if abs(num) < 1000: if abs(num) < UNIT_THRESHOLD:
if num >= 10: if num >= FLOAT_THRESHOLD:
return f"{int(num):3d}{unit}" return f"{int(num):3d}{unit}"
return f"{num:.1f}{unit}" return f"{num:.1f}{unit}"
num /= 1024 num /= 1024
@ -64,46 +65,47 @@ def clip(text: str, length: int = 30) -> str:
class ComposableText(typing.Generic[P, C]): class ComposableText(typing.Generic[P, C]):
def __init__( def __init__(
self, self,
parent: typing.Optional[P] = None, parent: P | None = None,
sortKey: Sortable = 0, sort_key: Sortable = 0,
) -> None: ) -> None:
self.parent: typing.Optional[P] = None self.parent: P | None = None
self.children: typing.MutableSequence[C] = list() self.children: typing.MutableSequence[C] = []
self.sortKey = sortKey self.sortKey = sort_key
if parent: if parent:
self.setParent(parent) self.set_parent(parent)
self.bar = self.getFirstParentOfType(Bar) self.bar = self.get_first_parent_of_type(Bar)
def setParent(self, parent: P) -> None: def set_parent(self, parent: P) -> None:
assert self.parent is None assert self.parent is None
parent.children.append(self) parent.children.append(self)
assert isinstance(parent.children, list) assert isinstance(parent.children, list)
parent.children.sort(key=lambda c: c.sortKey) parent.children.sort(key=lambda c: c.sortKey)
self.parent = parent self.parent = parent
self.parent.updateMarkup() self.parent.update_markup()
def unsetParent(self) -> None: def unset_parent(self) -> None:
assert self.parent assert self.parent
self.parent.children.remove(self) self.parent.children.remove(self)
self.parent.updateMarkup() self.parent.update_markup()
self.parent = None self.parent = None
def getFirstParentOfType(self, typ: typing.Type[T]) -> T: def get_first_parent_of_type(self, typ: type[T]) -> T:
parent = self parent = self
while not isinstance(parent, typ): while not isinstance(parent, typ):
assert parent.parent, f"{self} doesn't have a parent of {typ}" assert parent.parent, f"{self} doesn't have a parent of {typ}"
parent = parent.parent parent = parent.parent
return parent return parent
def updateMarkup(self) -> None: def update_markup(self) -> None:
self.bar.refresh.set() self.bar.refresh.set()
# TODO OPTI See if worth caching the output # TODO OPTI See if worth caching the output
def generateMarkup(self) -> str: def generate_markup(self) -> str:
raise NotImplementedError(f"{self} cannot generate markup") msg = f"{self} cannot generate markup"
raise NotImplementedError(msg)
def getMarkup(self) -> str: def get_markup(self) -> str:
return self.generateMarkup() return self.generate_markup()
class Button(enum.Enum): class Button(enum.Enum):
@ -114,18 +116,19 @@ class Button(enum.Enum):
SCROLL_DOWN = "5" SCROLL_DOWN = "5"
RICH_DEFAULT_COLOR = rich.color.Color.default()
class Section(ComposableText): class Section(ComposableText):
""" """Colorable block separated by chevrons."""
Colorable block separated by chevrons
"""
def __init__( def __init__(
self, self,
parent: "Module", parent: "Module",
sortKey: Sortable = 0, sort_key: Sortable = 0,
color: rich.color.Color = rich.color.Color.default(), color: rich.color.Color = RICH_DEFAULT_COLOR,
) -> None: ) -> None:
super().__init__(parent=parent, sortKey=sortKey) super().__init__(parent=parent, sort_key=sort_key)
self.parent: Module self.parent: Module
self.color = color self.color = color
@ -134,9 +137,9 @@ class Section(ComposableText):
self.targetSize = -1 self.targetSize = -1
self.size = -1 self.size = -1
self.animationTask: asyncio.Task | None = None self.animationTask: asyncio.Task | None = None
self.actions: dict[Button, str] = dict() self.actions: dict[Button, str] = {}
def isHidden(self) -> bool: def is_hidden(self) -> bool:
return self.size < 0 return self.size < 0
# Geometric series, with a cap # Geometric series, with a cap
@ -147,29 +150,29 @@ class Section(ComposableText):
async def animate(self) -> None: async def animate(self) -> None:
increment = 1 if self.size < self.targetSize else -1 increment = 1 if self.size < self.targetSize else -1
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
frameTime = loop.time() frame_time = loop.time()
animTime = self.ANIM_A anim_time = self.ANIM_A
skipped = 0 skipped = 0
while self.size != self.targetSize: while self.size != self.targetSize:
self.size += increment self.size += increment
self.updateMarkup() self.update_markup()
animTime *= self.ANIM_R anim_time *= self.ANIM_R
animTime = max(self.ANIM_MIN, animTime) anim_time = max(self.ANIM_MIN, anim_time)
frameTime += animTime frame_time += anim_time
sleepTime = frameTime - loop.time() sleep_time = frame_time - loop.time()
# In case of stress, skip refreshing by not awaiting # In case of stress, skip refreshing by not awaiting
if sleepTime > 0: if sleep_time > 0:
if skipped > 0: if skipped > 0:
log.warning(f"Skipped {skipped} animation frame(s)") log.warning("Skipped %d animation frame(s)", skipped)
skipped = 0 skipped = 0
await asyncio.sleep(sleepTime) await asyncio.sleep(sleep_time)
else: else:
skipped += 1 skipped += 1
def setText(self, text: str | None) -> None: def set_text(self, text: str | None) -> None:
# OPTI Don't redraw nor reset animation if setting the same text # OPTI Don't redraw nor reset animation if setting the same text
if self.desiredText == text: if self.desiredText == text:
return return
@ -184,23 +187,23 @@ class Section(ComposableText):
self.animationTask.cancel() self.animationTask.cancel()
# OPTI Skip the whole animation task if not required # OPTI Skip the whole animation task if not required
if self.size == self.targetSize: if self.size == self.targetSize:
self.updateMarkup() self.update_markup()
else: else:
self.animationTask = self.bar.taskGroup.create_task(self.animate()) self.animationTask = self.bar.taskGroup.create_task(self.animate())
def setAction( def set_action(
self, button: Button, callback: typing.Callable | None self, button: Button, callback: typing.Callable | None
) -> None: ) -> None:
if button in self.actions: if button in self.actions:
command = self.actions[button] command = self.actions[button]
self.bar.removeAction(command) self.bar.remove_action(command)
del self.actions[button] del self.actions[button]
if callback: if callback:
command = self.bar.addAction(callback) command = self.bar.add_action(callback)
self.actions[button] = command self.actions[button] = command
def generateMarkup(self) -> str: def generate_markup(self) -> str:
assert not self.isHidden() assert not self.is_hidden()
pad = max(0, self.size - len(self.text)) pad = max(0, self.size - len(self.text))
text = self.text[: self.size] + " " * pad text = self.text[: self.size] + " " * pad
for button, command in self.actions.items(): for button, command in self.actions.items():
@ -209,9 +212,7 @@ class Section(ComposableText):
class Module(ComposableText): class Module(ComposableText):
""" """Sections handled by a same updater."""
Sections handled by a same updater
"""
def __init__(self, parent: "Side") -> None: def __init__(self, parent: "Side") -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
@ -219,21 +220,21 @@ class Module(ComposableText):
self.children: typing.MutableSequence[Section] self.children: typing.MutableSequence[Section]
self.mirroring: Module | None = None self.mirroring: Module | None = None
self.mirrors: list[Module] = list() self.mirrors: list[Module] = []
def mirror(self, module: "Module") -> None: def mirror(self, module: "Module") -> None:
self.mirroring = module self.mirroring = module
module.mirrors.append(self) module.mirrors.append(self)
def getSections(self) -> typing.Sequence[Section]: def get_sections(self) -> typing.Sequence[Section]:
if self.mirroring: if self.mirroring:
return self.mirroring.children return self.mirroring.children
return self.children return self.children
def updateMarkup(self) -> None: def update_markup(self) -> None:
super().updateMarkup() super().update_markup()
for mirror in self.mirrors: for mirror in self.mirrors:
mirror.updateMarkup() mirror.update_markup()
class Alignment(enum.Enum): class Alignment(enum.Enum):
@ -249,39 +250,41 @@ class Side(ComposableText):
self.children: typing.MutableSequence[Module] = [] self.children: typing.MutableSequence[Module] = []
self.alignment = alignment self.alignment = alignment
self.bar = parent.getFirstParentOfType(Bar) self.bar = parent.get_first_parent_of_type(Bar)
def generateMarkup(self) -> str: def generate_markup(self) -> str:
if not self.children: if not self.children:
return "" return ""
text = "%{" + self.alignment.value + "}" text = "%{" + self.alignment.value + "}"
lastSection: Section | None = None last_section: Section | None = None
for module in self.children: for module in self.children:
for section in module.getSections(): for section in module.get_sections():
if section.isHidden(): if section.is_hidden():
continue continue
hexa = section.color.get_truecolor(theme=self.bar.theme).hex hexa = section.color.get_truecolor(theme=self.bar.theme).hex
if lastSection is None: if last_section is None:
if self.alignment == Alignment.LEFT: text += (
text += "%{B" + hexa + "}%{F-}" "%{B" + hexa + "}%{F-}"
else: if self.alignment == Alignment.LEFT
text += "%{B-}%{F" + hexa + "}%{R}%{F-}" else "%{B-}%{F" + hexa + "}%{R}%{F-}"
elif isinstance(lastSection, SpacerSection): )
elif isinstance(last_section, SpacerSection):
text += "%{B-}%{F" + hexa + "}%{R}%{F-}" text += "%{B-}%{F" + hexa + "}%{R}%{F-}"
else: else:
if self.alignment == Alignment.RIGHT: if self.alignment == Alignment.RIGHT:
if lastSection.color == section.color: text += (
text += "" ""
else: if last_section.color == section.color
text += "%{F" + hexa + "}%{R}" else "%{F" + hexa + "}%{R}"
elif lastSection.color == section.color: )
elif last_section.color == section.color:
text += "" text += ""
else: else:
text += "%{R}%{B" + hexa + "}" text += "%{R}%{B" + hexa + "}"
text += "%{F-}" text += "%{F-}"
text += section.getMarkup() text += section.get_markup()
lastSection = section last_section = section
if self.alignment != Alignment.RIGHT and lastSection: if self.alignment != Alignment.RIGHT and last_section:
text += "%{R}%{B-}" text += "%{R}%{B-}"
return text return text
@ -297,32 +300,33 @@ class Screen(ComposableText):
for alignment in Alignment: for alignment in Alignment:
Side(parent=self, alignment=alignment) Side(parent=self, alignment=alignment)
def generateMarkup(self) -> str: def generate_markup(self) -> str:
return ("%{Sn" + self.output + "}") + "".join( return ("%{Sn" + self.output + "}") + "".join(
side.getMarkup() for side in self.children side.get_markup() for side in self.children
) )
RICH_DEFAULT_THEME = rich.terminal_theme.DEFAULT_TERMINAL_THEME
class Bar(ComposableText): class Bar(ComposableText):
""" """Top-level."""
Top-level
"""
def __init__( def __init__(
self, self,
theme: rich.terminal_theme.TerminalTheme = rich.terminal_theme.DEFAULT_TERMINAL_THEME, theme: rich.terminal_theme.TerminalTheme = RICH_DEFAULT_THEME,
) -> None: ) -> None:
super().__init__() super().__init__()
self.parent: None self.parent: None
self.children: typing.MutableSequence[Screen] self.children: typing.MutableSequence[Screen]
self.longRunningTasks: list[asyncio.Task] = list() self.longRunningTasks: list[asyncio.Task] = []
self.theme = theme self.theme = theme
self.refresh = asyncio.Event() self.refresh = asyncio.Event()
self.taskGroup = asyncio.TaskGroup() self.taskGroup = asyncio.TaskGroup()
self.providers: list[Provider] = list() self.providers: list[Provider] = []
self.actionIndex = 0 self.actionIndex = 0
self.actions: dict[str, typing.Callable] = dict() self.actions: dict[str, typing.Callable] = {}
self.periodicProviderTask: typing.Coroutine | None = None self.periodicProviderTask: typing.Coroutine | None = None
@ -334,7 +338,7 @@ class Bar(ComposableText):
continue continue
Screen(parent=self, output=output.name) Screen(parent=self, output=output.name)
def addLongRunningTask(self, coro: typing.Coroutine) -> None: def add_long_running_task(self, coro: typing.Coroutine) -> None:
task = self.taskGroup.create_task(coro) task = self.taskGroup.create_task(coro)
self.longRunningTasks.append(task) self.longRunningTasks.append(task)
@ -360,10 +364,10 @@ class Bar(ComposableText):
while True: while True:
await self.refresh.wait() await self.refresh.wait()
self.refresh.clear() self.refresh.clear()
markup = self.getMarkup() markup = self.get_markup()
proc.stdin.write(markup.encode()) proc.stdin.write(markup.encode())
async def actionHandler() -> None: async def action_handler() -> None:
assert proc.stdout assert proc.stdout
while True: while True:
line = await proc.stdout.readline() line = await proc.stdout.readline()
@ -371,39 +375,36 @@ class Bar(ComposableText):
callback = self.actions.get(command) callback = self.actions.get(command)
if callback is None: if callback is None:
# In some conditions on start it's empty # In some conditions on start it's empty
log.error(f"Unknown command: {command}") log.error("Unknown command: %s", command)
return return
callback() callback()
async with self.taskGroup: async with self.taskGroup:
self.addLongRunningTask(refresher()) self.add_long_running_task(refresher())
self.addLongRunningTask(actionHandler()) self.add_long_running_task(action_handler())
for provider in self.providers: for provider in self.providers:
self.addLongRunningTask(provider.run()) self.add_long_running_task(provider.run())
def exit() -> None: def finish() -> None:
log.info("Terminating") log.info("Terminating")
for task in self.longRunningTasks: for task in self.longRunningTasks:
task.cancel() task.cancel()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, exit) loop.add_signal_handler(signal.SIGINT, finish)
def generateMarkup(self) -> str: def generate_markup(self) -> str:
return "".join(screen.getMarkup() for screen in self.children) + "\n" return "".join(screen.get_markup() for screen in self.children) + "\n"
def addProvider( def add_provider(
self, self,
provider: "Provider", provider: "Provider",
alignment: Alignment = Alignment.LEFT, alignment: Alignment = Alignment.LEFT,
screenNum: int | None = None, screen_num: int | None = None, # None = all screens
) -> None: ) -> None:
""" modules = []
screenNum: the provider will be added on this screen if set, all otherwise
"""
modules = list()
for s, screen in enumerate(self.children): for s, screen in enumerate(self.children):
if screenNum is None or s == screenNum: if screen_num is None or s == screen_num:
side = next( side = next(
filter(lambda s: s.alignment == alignment, screen.children) filter(lambda s: s.alignment == alignment, screen.children)
) )
@ -413,13 +414,13 @@ class Bar(ComposableText):
if modules: if modules:
self.providers.append(provider) self.providers.append(provider)
def addAction(self, callback: typing.Callable) -> str: def add_action(self, callback: typing.Callable) -> str:
command = f"{self.actionIndex:x}" command = f"{self.actionIndex:x}"
self.actions[command] = callback self.actions[command] = callback
self.actionIndex += 1 self.actionIndex += 1
return command return command
def removeAction(self, command: str) -> None: def remove_action(self, command: str) -> None:
del self.actions[command] del self.actions[command]
def launch(self) -> None: def launch(self) -> None:
@ -431,12 +432,10 @@ class Bar(ComposableText):
class Provider: class Provider:
sectionType: type[Section] = Section section_type: type[Section] = Section
def __init__( def __init__(self, color: rich.color.Color = RICH_DEFAULT_COLOR) -> None:
self, color: rich.color.Color = rich.color.Color.default() self.modules: list[Module] = []
) -> None:
self.modules: list[Module] = list()
self.color = color self.color = color
async def run(self) -> None: async def run(self) -> None:
@ -445,9 +444,7 @@ class Provider:
class MirrorProvider(Provider): class MirrorProvider(Provider):
def __init__( def __init__(self, color: rich.color.Color = RICH_DEFAULT_COLOR) -> None:
self, color: rich.color.Color = rich.color.Color.default()
) -> None:
super().__init__(color=color) super().__init__(color=color)
self.module: Module self.module: Module
@ -461,19 +458,19 @@ class MirrorProvider(Provider):
class SingleSectionProvider(MirrorProvider): class SingleSectionProvider(MirrorProvider):
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
self.section = self.sectionType(parent=self.module, color=self.color) self.section = self.section_type(parent=self.module, color=self.color)
class StaticProvider(SingleSectionProvider): class StaticProvider(SingleSectionProvider):
def __init__( def __init__(
self, text: str, color: rich.color.Color = rich.color.Color.default() self, text: str, color: rich.color.Color = RICH_DEFAULT_COLOR
) -> None: ) -> None:
super().__init__(color=color) super().__init__(color=color)
self.text = text self.text = text
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
self.section.setText(self.text) self.section.set_text(self.text)
class SpacerSection(Section): class SpacerSection(Section):
@ -481,7 +478,7 @@ class SpacerSection(Section):
class SpacerProvider(SingleSectionProvider): class SpacerProvider(SingleSectionProvider):
sectionType = SpacerSection section_type = SpacerSection
def __init__(self, length: int = 5) -> None: def __init__(self, length: int = 5) -> None:
super().__init__(color=rich.color.Color.default()) super().__init__(color=rich.color.Color.default())
@ -490,41 +487,41 @@ class SpacerProvider(SingleSectionProvider):
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
assert isinstance(self.section, SpacerSection) assert isinstance(self.section, SpacerSection)
self.section.setText(" " * self.length) self.section.set_text(" " * self.length)
class StatefulSection(Section): class StatefulSection(Section):
def __init__( def __init__(
self, self,
parent: Module, parent: Module,
sortKey: Sortable = 0, sort_key: Sortable = 0,
color: rich.color.Color = rich.color.Color.default(), color: rich.color.Color = RICH_DEFAULT_COLOR,
) -> None: ) -> None:
super().__init__(parent=parent, sortKey=sortKey, color=color) super().__init__(parent=parent, sort_key=sort_key, color=color)
self.state = 0 self.state = 0
self.numberStates: int self.numberStates: int
self.setAction(Button.CLICK_LEFT, self.incrementState) self.set_action(Button.CLICK_LEFT, self.increment_state)
self.setAction(Button.CLICK_RIGHT, self.decrementState) self.set_action(Button.CLICK_RIGHT, self.decrement_state)
def incrementState(self) -> None: def increment_state(self) -> None:
self.state += 1 self.state += 1
self.changeState() self.change_state()
def decrementState(self) -> None: def decrement_state(self) -> None:
self.state -= 1 self.state -= 1
self.changeState() self.change_state()
def setChangedState(self, callback: typing.Callable) -> None: def set_changed_state(self, callback: typing.Callable) -> None:
self.callback = callback self.callback = callback
def changeState(self) -> None: def change_state(self) -> None:
self.state %= self.numberStates self.state %= self.numberStates
self.bar.taskGroup.create_task(self.callback()) self.bar.taskGroup.create_task(self.callback())
class StatefulSectionProvider(Provider): class StatefulSectionProvider(Provider):
sectionType = StatefulSection section_type = StatefulSection
class SingleStatefulSectionProvider( class SingleStatefulSectionProvider(
@ -534,46 +531,44 @@ class SingleStatefulSectionProvider(
class MultiSectionsProvider(Provider): class MultiSectionsProvider(Provider):
def __init__( def __init__(self, color: rich.color.Color = RICH_DEFAULT_COLOR) -> None:
self, color: rich.color.Color = rich.color.Color.default()
) -> None:
super().__init__(color=color) super().__init__(color=color)
self.sectionKeys: dict[Module, dict[Sortable, Section]] = ( self.sectionKeys: dict[Module, dict[Sortable, Section]] = (
collections.defaultdict(dict) collections.defaultdict(dict)
) )
self.updaters: dict[Section, typing.Callable] = dict() self.updaters: dict[Section, typing.Callable] = {}
async def getSectionUpdater(self, section: Section) -> typing.Callable: async def get_section_updater(self, section: Section) -> typing.Callable:
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
async def doNothing() -> None: async def do_nothing() -> None:
pass pass
async def updateSections( async def update_sections(
self, sections: set[Sortable], module: Module self, sections: set[Sortable], module: Module
) -> None: ) -> None:
moduleSections = self.sectionKeys[module] module_sections = self.sectionKeys[module]
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
for sortKey in sections: for sort_key in sections:
section = moduleSections.get(sortKey) section = module_sections.get(sort_key)
if not section: if not section:
section = self.sectionType( section = self.section_type(
parent=module, sortKey=sortKey, color=self.color parent=module, sort_key=sort_key, color=self.color
) )
self.updaters[section] = await self.getSectionUpdater( self.updaters[section] = await self.get_section_updater(
section section
) )
moduleSections[sortKey] = section module_sections[sort_key] = section
updater = self.updaters[section] updater = self.updaters[section]
tg.create_task(updater()) tg.create_task(updater())
missingKeys = set(moduleSections.keys()) - sections missing_keys = set(module_sections.keys()) - sections
for missingKey in missingKeys: for missing_key in missing_keys:
section = moduleSections.get(missingKey) section = module_sections.get(missing_key)
assert section assert section
section.setText(None) section.set_text(None)
class PeriodicProvider(Provider): class PeriodicProvider(Provider):
@ -585,7 +580,7 @@ class PeriodicProvider(Provider):
@classmethod @classmethod
async def task(cls, bar: Bar) -> None: async def task(cls, bar: Bar) -> None:
providers = list() providers = []
for provider in bar.providers: for provider in bar.providers:
if isinstance(provider, PeriodicProvider): if isinstance(provider, PeriodicProvider):
providers.append(provider) providers.append(provider)
@ -596,7 +591,7 @@ class PeriodicProvider(Provider):
loops = [provider.loop() for provider in providers] loops = [provider.loop() for provider in providers]
asyncio.gather(*loops) asyncio.gather(*loops)
now = datetime.datetime.now() now = datetime.datetime.now(datetime.UTC)
# Hardcoded to 1 second... not sure if we want some more than that, # Hardcoded to 1 second... not sure if we want some more than that,
# and if the logic to check if a task should run would be a win # and if the logic to check if a task should run would be a win
# compared to the task itself # compared to the task itself
@ -606,11 +601,11 @@ class PeriodicProvider(Provider):
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
for module in self.modules: for module in self.modules:
bar = module.getFirstParentOfType(Bar) bar = module.get_first_parent_of_type(Bar)
assert bar assert bar
if not bar.periodicProviderTask: if not bar.periodicProviderTask:
bar.periodicProviderTask = PeriodicProvider.task(bar) bar.periodicProviderTask = PeriodicProvider.task(bar)
bar.addLongRunningTask(bar.periodicProviderTask) bar.add_long_running_task(bar.periodicProviderTask)
class PeriodicStatefulProvider( class PeriodicStatefulProvider(
@ -618,7 +613,7 @@ class PeriodicStatefulProvider(
): ):
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
self.section.setChangedState(self.loop) self.section.set_changed_state(self.loop)
class AlertingProvider(Provider): class AlertingProvider(Provider):
@ -626,16 +621,16 @@ class AlertingProvider(Provider):
COLOR_WARNING = rich.color.Color.parse("yellow") COLOR_WARNING = rich.color.Color.parse("yellow")
COLOR_DANGER = rich.color.Color.parse("red") COLOR_DANGER = rich.color.Color.parse("red")
warningThreshold: float warning_threshold: float
dangerThreshold: float danger_threshold: float
def updateLevel(self, level: float) -> None: def update_level(self, level: float) -> None:
if level > self.dangerThreshold: if level > self.danger_threshold:
color = self.COLOR_DANGER color = self.COLOR_DANGER
elif level > self.warningThreshold: elif level > self.warning_threshold:
color = self.COLOR_WARNING color = self.COLOR_WARNING
else: else:
color = self.COLOR_NORMAL color = self.COLOR_NORMAL
for module in self.modules: for module in self.modules:
for section in module.getSections(): for section in module.get_sections():
section.color = color section.color = color

View file

@ -16,6 +16,7 @@ import pulsectl_asyncio
import rich.color import rich.color
from frobar.common import ( from frobar.common import (
RICH_DEFAULT_COLOR,
AlertingProvider, AlertingProvider,
Button, Button,
MirrorProvider, MirrorProvider,
@ -29,18 +30,20 @@ from frobar.common import (
StatefulSection, StatefulSection,
StatefulSectionProvider, StatefulSectionProvider,
clip, clip,
humanSize, human_size,
log, log,
ramp, ramp,
) )
gi.require_version("Playerctl", "2.0") gi.require_version("Playerctl", "2.0")
import gi.repository.Playerctl import gi.repository.Playerctl # noqa: E402
T = typing.TypeVar("T")
class I3ModeProvider(SingleSectionProvider): class I3ModeProvider(SingleSectionProvider):
def on_mode(self, i3: i3ipc.Connection, e: i3ipc.Event) -> None: def on_mode(self, _i3: i3ipc.Connection, e: i3ipc.Event) -> None:
self.section.setText(None if e.change == "default" else e.change) self.section.set_text(None if e.change == "default" else e.change)
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
@ -54,11 +57,11 @@ class I3ModeProvider(SingleSectionProvider):
class I3WindowTitleProvider(SingleSectionProvider): class I3WindowTitleProvider(SingleSectionProvider):
# TODO FEAT To make this available from start, we need to find the # TODO FEAT To make this available from start, we need to find the
# `focused=True` element following the `focus` array # `focused=True` element following the `focus` array
def on_window(self, i3: i3ipc.Connection, e: i3ipc.Event) -> None: def on_window(self, _i3: i3ipc.Connection, e: i3ipc.Event) -> None:
if e.container.name is None: if e.container.name is None:
self.section.setText(None) self.section.set_text(None)
else: else:
self.section.setText(clip(e.container.name, 60)) self.section.set_text(clip(e.container.name, 60))
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
@ -76,15 +79,15 @@ class I3WorkspacesProvider(MultiSectionsProvider):
def __init__( def __init__(
self, self,
custom_names: dict[str, str] = {}, custom_names: dict[str, str] | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.workspaces: dict[int, i3ipc.WorkspaceReply] self.workspaces: dict[int, i3ipc.WorkspaceReply]
self.custom_names = custom_names self.custom_names = custom_names or {}
self.modulesFromOutput: dict[str, Module] = dict() self.modulesFromOutput: dict[str, Module] = {}
async def getSectionUpdater(self, section: Section) -> typing.Callable: async def get_section_updater(self, section: Section) -> typing.Callable:
assert isinstance(section.sortKey, int) assert isinstance(section.sortKey, int)
num = section.sortKey num = section.sortKey
@ -93,13 +96,13 @@ class I3WorkspacesProvider(MultiSectionsProvider):
self.i3.command(f"workspace number {num}") self.i3.command(f"workspace number {num}")
) )
section.setAction(Button.CLICK_LEFT, switch_to_workspace) section.set_action(Button.CLICK_LEFT, switch_to_workspace)
async def update() -> None: async def update() -> None:
workspace = self.workspaces.get(num) workspace = self.workspaces.get(num)
if workspace is None: if workspace is None:
log.warning(f"Can't find workspace {num}") log.warning(f"Can't find workspace {num}")
section.setText("X") section.set_text("X")
return return
name = workspace.name name = workspace.name
@ -113,20 +116,18 @@ class I3WorkspacesProvider(MultiSectionsProvider):
section.color = self.COLOR_DEFAULT section.color = self.COLOR_DEFAULT
if workspace.focused: if workspace.focused:
name = self.custom_names.get(name, name) name = self.custom_names.get(name, name)
section.setText(name) section.set_text(name)
return update return update
async def updateWorkspaces(self) -> None: async def update_workspaces(self) -> None:
""" # Since the i3 IPC interface cannot really tell you by events
Since the i3 IPC interface cannot really tell you by events # when workspaces get invisible or not urgent anymore.
when workspaces get invisible or not urgent anymore. # Relying on those exclusively would need reimplementing the i3 logic.
Relying on those exclusively would require reimplementing some of i3 logic. # Fetching all the workspaces on event is ugly but maintainable.
Fetching all the workspaces on event looks ugly but is the most maintainable. # Times I tried to challenge this and failed: 2.
Times I tried to challenge this and failed: 2.
"""
workspaces = await self.i3.get_workspaces() workspaces = await self.i3.get_workspaces()
self.workspaces = dict() self.workspaces = {}
modules = collections.defaultdict(set) modules = collections.defaultdict(set)
for workspace in workspaces: for workspace in workspaces:
self.workspaces[workspace.num] = workspace self.workspaces[workspace.num] = workspace
@ -135,38 +136,40 @@ class I3WorkspacesProvider(MultiSectionsProvider):
await asyncio.gather( await asyncio.gather(
*[ *[
self.updateSections(nums, module) self.update_sections(nums, module)
for module, nums in modules.items() for module, nums in modules.items()
] ]
) )
def onWorkspaceChange( def on_workspace_change(
self, i3: i3ipc.Connection, e: i3ipc.Event | None = None self,
_i3: i3ipc.Connection,
_event: i3ipc.Event | None = None,
) -> None: ) -> None:
# Cancelling the task doesn't seem to prevent performance double-events # Cancelling the task doesn't seem to prevent performance double-events
self.bar.taskGroup.create_task(self.updateWorkspaces()) self.bar.taskGroup.create_task(self.update_workspaces())
async def run(self) -> None: async def run(self) -> None:
for module in self.modules: for module in self.modules:
screen = module.getFirstParentOfType(Screen) screen = module.get_first_parent_of_type(Screen)
output = screen.output output = screen.output
self.modulesFromOutput[output] = module self.modulesFromOutput[output] = module
self.bar = module.bar self.bar = module.bar
self.i3 = await i3ipc.aio.Connection(auto_reconnect=True).connect() self.i3 = await i3ipc.aio.Connection(auto_reconnect=True).connect()
self.i3.on(i3ipc.Event.WORKSPACE, self.onWorkspaceChange) self.i3.on(i3ipc.Event.WORKSPACE, self.on_workspace_change)
self.onWorkspaceChange(self.i3) self.on_workspace_change(self.i3)
await self.i3.main() await self.i3.main()
class MprisProvider(MirrorProvider): class MprisProvider(MirrorProvider):
STATUSES = { STATUSES: typing.ClassVar = {
gi.repository.Playerctl.PlaybackStatus.PLAYING: "", gi.repository.Playerctl.PlaybackStatus.PLAYING: "",
gi.repository.Playerctl.PlaybackStatus.PAUSED: "", gi.repository.Playerctl.PlaybackStatus.PAUSED: "",
gi.repository.Playerctl.PlaybackStatus.STOPPED: "", gi.repository.Playerctl.PlaybackStatus.STOPPED: "",
} }
PROVIDERS = { PROVIDERS: typing.ClassVar = {
"mpd": "", "mpd": "",
"firefox": "", "firefox": "",
"chromium": "", "chromium": "",
@ -175,10 +178,10 @@ class MprisProvider(MirrorProvider):
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
self.status = self.sectionType(parent=self.module, color=self.color) self.status = self.section_type(parent=self.module, color=self.color)
self.album = self.sectionType(parent=self.module, color=self.color) self.album = self.section_type(parent=self.module, color=self.color)
self.artist = self.sectionType(parent=self.module, color=self.color) self.artist = self.section_type(parent=self.module, color=self.color)
self.title = self.sectionType(parent=self.module, color=self.color) self.title = self.section_type(parent=self.module, color=self.color)
self.manager = gi.repository.Playerctl.PlayerManager() self.manager = gi.repository.Playerctl.PlayerManager()
self.manager.connect("name-appeared", self.on_name_appeared) self.manager.connect("name-appeared", self.on_name_appeared)
@ -196,13 +199,13 @@ class MprisProvider(MirrorProvider):
for name in self.manager.props.player_names: for name in self.manager.props.player_names:
self.init_player(name) self.init_player(name)
self.updateSections() self.update_sections()
while True: while True:
# Occasionally it will skip a second # Occasionally it will skip a second
# but haven't managed to reproduce with debug info # but haven't managed to reproduce with debug info
await self.playing.wait() await self.playing.wait()
self.updateTitle() self.update_title()
if self.player: if self.player:
pos = self.player.props.position pos = self.player.props.position
rem = 1 - (pos % 1000000) / 1000000 rem = 1 - (pos % 1000000) / 1000000
@ -214,20 +217,20 @@ class MprisProvider(MirrorProvider):
def get( def get(
something: gi.overrides.GLib.Variant, something: gi.overrides.GLib.Variant,
key: str, key: str,
default: typing.Any = None, default: T | None = None,
) -> typing.Any: ) -> T | None:
if key in something.keys(): if key in something.keys(): # noqa: SIM118
return something[key] return something[key]
return default return default
@staticmethod @staticmethod
def formatUs(ms: int) -> str: def format_us(ms: int) -> str:
if ms < 60 * 60 * 1000000: if ms < 60 * 60 * 1000000:
return time.strftime("%M:%S", time.gmtime(ms // 1000000)) return time.strftime("%M:%S", time.gmtime(ms // 1000000))
return str(datetime.timedelta(microseconds=ms)) return str(datetime.timedelta(microseconds=ms))
def findCurrentPlayer(self) -> None: def find_current_player(self) -> None:
for name in [self.playerctldName] + self.manager.props.player_names: for name in [self.playerctldName, *self.manager.props.player_names]:
try: try:
self.player = gi.repository.Playerctl.Player.new_from_name( self.player = gi.repository.Playerctl.Player.new_from_name(
name name
@ -241,21 +244,21 @@ class MprisProvider(MirrorProvider):
else: else:
self.player = None self.player = None
def updateSections(self) -> None: def update_sections(self) -> None:
self.findCurrentPlayer() self.find_current_player()
if self.player is None: if self.player is None:
self.status.setText(None) self.status.set_text(None)
self.album.setText(None) self.album.set_text(None)
self.artist.setText(None) self.artist.set_text(None)
self.title.setText(None) self.title.set_text(None)
self.playing.clear() self.playing.clear()
return return
player = self.player.props.player_name player = self.player.props.player_name
player = self.PROVIDERS.get(player, player) player = self.PROVIDERS.get(player, player)
status = self.STATUSES.get(self.player.props.playback_status, "?") status = self.STATUSES.get(self.player.props.playback_status, "?")
self.status.setText(f"{player} {status}") self.status.set_text(f"{player} {status}")
if ( if (
self.player.props.playback_status self.player.props.playback_status
@ -269,48 +272,48 @@ class MprisProvider(MirrorProvider):
album = self.get(metadata, "xesam:album") album = self.get(metadata, "xesam:album")
if album: if album:
self.album.setText(f"{clip(album)}") self.album.set_text(f"{clip(album)}")
else: else:
self.album.setText(None) self.album.set_text(None)
artists = self.get(metadata, "xesam:artist") artists = self.get(metadata, "xesam:artist")
if artists: if artists:
artist = ", ".join(artists) artist = ", ".join(artists)
self.artist.setText(f"{clip(artist)}") self.artist.set_text(f"{clip(artist)}")
else: else:
self.artist.setText(None) self.artist.set_text(None)
self.updateTitle() self.update_title()
def updateTitle(self) -> None: def update_title(self) -> None:
if self.player is None: if self.player is None:
return return
metadata = self.player.props.metadata metadata = self.player.props.metadata
pos = self.player.props.position # In µs pos = self.player.props.position # In µs
text = f"{self.formatUs(pos)}" text = f"{self.format_us(pos)}"
dur = self.get(metadata, "mpris:length") dur = self.get(metadata, "mpris:length")
if dur: if dur:
text += f"/{self.formatUs(dur)}" text += f"/{self.format_us(dur)}"
title = self.get(metadata, "xesam:title") title = self.get(metadata, "xesam:title")
if title: if title:
text += f" {clip(title)}" text += f" {clip(title)}"
self.title.setText(text) self.title.set_text(text)
def on_player_vanished( def on_player_vanished(
self, self,
manager: gi.repository.Playerctl.PlayerManager, _manager: gi.repository.Playerctl.PlayerManager,
player: gi.repository.Playerctl.Player, _player: gi.repository.Playerctl.Player,
) -> None: ) -> None:
self.updateSections() self.update_sections()
def on_event( def on_event(
self, self,
player: gi.repository.Playerctl.Player, _player: gi.repository.Playerctl.Player,
_: typing.Any, _event_data: gi.overrides.GLib.Variant,
manager: gi.repository.Playerctl.PlayerManager, _manager: gi.repository.Playerctl.PlayerManager,
) -> None: ) -> None:
self.updateSections() self.update_sections()
def init_player(self, name: gi.repository.Playerctl.PlayerName) -> None: def init_player(self, name: gi.repository.Playerctl.PlayerName) -> None:
player = gi.repository.Playerctl.Player.new_from_name(name) player = gi.repository.Playerctl.Player.new_from_name(name)
@ -325,136 +328,165 @@ class MprisProvider(MirrorProvider):
self.manager.manage_player(player) self.manager.manage_player(player)
def on_name_appeared( def on_name_appeared(
self, manager: gi.repository.Playerctl.PlayerManager, name: str self,
_manager: gi.repository.Playerctl.PlayerManager,
name: str,
) -> None: ) -> None:
self.init_player(name) self.init_player(name)
self.updateSections() self.update_sections()
class CpuProvider(AlertingProvider, PeriodicStatefulProvider): class CpuProvider(AlertingProvider, PeriodicStatefulProvider):
STATE_MINIMIZED = 0
STATE_AGGREGATED = 1
STATE_FULL = 2
async def init(self) -> None: async def init(self) -> None:
self.section.numberStates = 3 self.section.numberStates = 3
self.warningThreshold = 75 self.warning_threshold = 75
self.dangerThreshold = 95 self.danger_threshold = 95
async def loop(self) -> None: async def loop(self) -> None:
percent = psutil.cpu_percent(percpu=False) percent = psutil.cpu_percent(percpu=False)
self.updateLevel(percent) self.update_level(percent)
text = "" text = ""
if self.section.state >= 2: if self.section.state >= self.STATE_FULL:
percents = psutil.cpu_percent(percpu=True) percents = psutil.cpu_percent(percpu=True)
text += " " + "".join([ramp(p / 100) for p in percents]) text += " " + "".join([ramp(p / 100) for p in percents])
elif self.section.state >= 1: elif self.section.state >= self.STATE_AGGREGATED:
text += " " + ramp(percent / 100) text += " " + ramp(percent / 100)
self.section.setText(text) self.section.set_text(text)
class LoadProvider(AlertingProvider, PeriodicStatefulProvider): class LoadProvider(AlertingProvider, PeriodicStatefulProvider):
STATE_MINIMIZED = 0
STATE_AGGREGATED = 1
STATE_FULL = 2
async def init(self) -> None: async def init(self) -> None:
self.section.numberStates = 3 self.section.numberStates = 3
self.warningThreshold = 5 self.warning_threshold = 5
self.dangerThreshold = 10 self.danger_threshold = 10
async def loop(self) -> None: async def loop(self) -> None:
load = os.getloadavg() load = os.getloadavg()
self.updateLevel(load[0]) self.update_level(load[0])
text = "" text = ""
loads = 3 if self.section.state >= 2 else self.section.state loads = (
3 if self.section.state >= self.STATE_FULL else self.section.state
)
for load_index in range(loads): for load_index in range(loads):
text += f" {load[load_index]:.2f}" text += f" {load[load_index]:.2f}"
self.section.setText(text) self.section.set_text(text)
class RamProvider(AlertingProvider, PeriodicStatefulProvider): class RamProvider(AlertingProvider, PeriodicStatefulProvider):
STATE_MINIMIZED = 0
STATE_ICON = 1
STATE_NUMBER = 2
STATE_FULL = 3
async def init(self) -> None: async def init(self) -> None:
self.section.numberStates = 4 self.section.numberStates = 4
self.warningThreshold = 75 self.warning_threshold = 75
self.dangerThreshold = 95 self.danger_threshold = 95
async def loop(self) -> None: async def loop(self) -> None:
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
self.updateLevel(mem.percent) self.update_level(mem.percent)
text = "" text = ""
if self.section.state >= 1: if self.section.state >= self.STATE_ICON:
text += " " + ramp(mem.percent / 100) text += " " + ramp(mem.percent / 100)
if self.section.state >= 2: if self.section.state >= self.STATE_NUMBER:
text += humanSize(mem.total - mem.available) text += human_size(mem.total - mem.available)
if self.section.state >= 3: if self.section.state >= self.STATE_FULL:
text += "/" + humanSize(mem.total) text += "/" + human_size(mem.total)
self.section.setText(text) self.section.set_text(text)
class TemperatureProvider(AlertingProvider, PeriodicStatefulProvider): class TemperatureProvider(AlertingProvider, PeriodicStatefulProvider):
RAMP = "" RAMP = ""
MAIN_TEMPS = ["coretemp", "amdgpu", "cpu_thermal"] MAIN_TEMPS = ("coretemp", "amdgpu", "cpu_thermal")
# For Intel, AMD and ARM respectively. # For Intel, AMD and ARM respectively.
STATE_MINIMIZED = 0
STATE_FULL = 1
main: str main: str
async def init(self) -> None: async def init(self) -> None:
self.section.numberStates = 2 self.section.numberStates = 2
allTemp = psutil.sensors_temperatures() all_temps = psutil.sensors_temperatures()
for main in self.MAIN_TEMPS: for main in self.MAIN_TEMPS:
if main in allTemp: if main in all_temps:
self.main = main self.main = main
break break
else: else:
raise IndexError("Could not find suitable temperature sensor") msg = "Could not find suitable temperature sensor"
raise IndexError(msg)
temp = allTemp[self.main][0] temp = all_temps[self.main][0]
self.warningThreshold = temp.high or 90.0 self.warning_threshold = temp.high or 90.0
self.dangerThreshold = temp.critical or 100.0 self.danger_threshold = temp.critical or 100.0
async def loop(self) -> None: async def loop(self) -> None:
allTemp = psutil.sensors_temperatures() all_temps = psutil.sensors_temperatures()
temp = allTemp[self.main][0] temp = all_temps[self.main][0]
self.updateLevel(temp.current) self.update_level(temp.current)
text = ramp(temp.current / self.warningThreshold, self.RAMP) text = ramp(temp.current / self.warning_threshold, self.RAMP)
if self.section.state >= 1: if self.section.state >= self.STATE_FULL:
text += f" {temp.current:.0f}°C" text += f" {temp.current:.0f}°C"
self.section.setText(text) self.section.set_text(text)
class BatteryProvider(AlertingProvider, PeriodicStatefulProvider): class BatteryProvider(AlertingProvider, PeriodicStatefulProvider):
# TODO Support ACPID for events # TODO Support ACPID for events
RAMP = "" RAMP = ""
STATE_MINIMIZED = 0
STATE_PERCENTAGE = 1
STATE_ESTIMATE = 2
async def init(self) -> None: async def init(self) -> None:
self.section.numberStates = 3 self.section.numberStates = 3
# TODO 1 refresh rate is too quick # TODO 1 refresh rate is too quick
self.warningThreshold = 75 self.warning_threshold = 75
self.dangerThreshold = 95 self.danger_threshold = 95
async def loop(self) -> None: async def loop(self) -> None:
bat = psutil.sensors_battery() bat = psutil.sensors_battery()
if not bat: if not bat:
self.section.setText(None) self.section.set_text(None)
self.updateLevel(100 - bat.percent) self.update_level(100 - bat.percent)
text = "" if bat.power_plugged else "" text = "" if bat.power_plugged else ""
text += ramp(bat.percent / 100, self.RAMP) text += ramp(bat.percent / 100, self.RAMP)
if self.section.state >= 1: if self.section.state >= self.STATE_PERCENTAGE:
text += f" {bat.percent:.0f}%" text += f" {bat.percent:.0f}%"
if self.section.state >= 2: if self.section.state >= self.STATE_ESTIMATE:
h = int(bat.secsleft / 3600) h = int(bat.secsleft / 3600)
m = int((bat.secsleft - h * 3600) / 60) m = int((bat.secsleft - h * 3600) / 60)
text += f" ({h:d}:{m:02d})" text += f" ({h:d}:{m:02d})"
self.section.setText(text) self.section.set_text(text)
class PulseaudioProvider( class PulseaudioProvider(
MirrorProvider, StatefulSectionProvider, MultiSectionsProvider MirrorProvider, StatefulSectionProvider, MultiSectionsProvider
): ):
async def getSectionUpdater(self, section: Section) -> typing.Callable: STATE_MINIMIZED = 0
STATE_BAR = 1
STATE_PERCENTAGE = 2
async def get_section_updater(self, section: Section) -> typing.Callable:
assert isinstance(section, StatefulSection) assert isinstance(section, StatefulSection)
assert isinstance(section.sortKey, str) assert isinstance(section.sortKey, str)
@ -478,7 +510,7 @@ class PulseaudioProvider(
icon = "" icon = ""
section.numberStates = 3 section.numberStates = 3
section.state = 1 section.state = self.STATE_BAR
# TODO Change volume with wheel # TODO Change volume with wheel
@ -491,23 +523,21 @@ class PulseaudioProvider(
"frobar-get-volume" "frobar-get-volume"
) as pulse: ) as pulse:
vol = await pulse.volume_get_all_chans(sink) vol = await pulse.volume_get_all_chans(sink)
if section.state == 1: if section.state == self.STATE_BAR:
text += f" {ramp(vol)}" text += f" {ramp(vol)}"
elif section.state == 2: elif section.state == self.STATE_PERCENTAGE:
text += f" {vol:.0%}" text += f" {vol:.0%}"
# TODO Show which is default # TODO Show which is default
section.setText(text) section.set_text(text)
section.setChangedState(updater) section.set_changed_state(updater)
return updater return updater
async def update(self) -> None: async def update(self) -> None:
async with pulsectl_asyncio.PulseAsync("frobar-list-sinks") as pulse: async with pulsectl_asyncio.PulseAsync("frobar-list-sinks") as pulse:
self.sinks = dict( self.sinks = {sink.name: sink for sink in await pulse.sink_list()}
(sink.name, sink) for sink in await pulse.sink_list() await self.update_sections(set(self.sinks.keys()), self.module)
)
await self.updateSections(set(self.sinks.keys()), self.module)
async def run(self) -> None: async def run(self) -> None:
await super().run() await super().run()
@ -515,7 +545,7 @@ class PulseaudioProvider(
async with pulsectl_asyncio.PulseAsync( async with pulsectl_asyncio.PulseAsync(
"frobar-events-listener" "frobar-events-listener"
) as pulse: ) as pulse:
async for event in pulse.subscribe_events( async for _ in pulse.subscribe_events(
pulsectl.PulseEventMaskEnum.sink pulsectl.PulseEventMaskEnum.sink
): ):
await self.update() await self.update()
@ -527,9 +557,15 @@ class NetworkProvider(
StatefulSectionProvider, StatefulSectionProvider,
MultiSectionsProvider, MultiSectionsProvider,
): ):
STATE_MINIMIZED = 0
STATE_SSID = 1
STATE_IP = 2
STATE_SPEED = 3
STATE_TOTALS = 4
def __init__( def __init__(
self, self,
color: rich.color.Color = rich.color.Color.default(), color: rich.color.Color = RICH_DEFAULT_COLOR,
) -> None: ) -> None:
super().__init__(color=color) super().__init__(color=color)
@ -539,27 +575,19 @@ class NetworkProvider(
self.io_counters = psutil.net_io_counters(pernic=True) self.io_counters = psutil.net_io_counters(pernic=True)
@staticmethod @staticmethod
def getIfaceAttributes(iface: str) -> tuple[bool, str, bool]: def get_iface_attribute(iface: str) -> tuple[bool, str, bool]:
relevant = True relevant = True
icon = "?" icon = "?"
wifi = False wifi = False
if iface == "lo": if iface == "lo":
relevant = False relevant = False
elif iface.startswith("eth") or iface.startswith("enp"): elif iface.startswith(("eth", "enp")):
if "u" in iface: icon = "" if "u" in iface else ""
icon = "" elif iface.startswith(("wlan", "wl")):
else:
icon = ""
elif iface.startswith("wlan") or iface.startswith("wl"):
icon = "" icon = ""
wifi = True wifi = True
elif ( elif iface.startswith(("tun", "tap", "wg")):
iface.startswith("tun")
or iface.startswith("tap")
or iface.startswith("wg")
):
icon = "" icon = ""
elif iface.startswith("docker"): elif iface.startswith("docker"):
icon = "" icon = ""
elif iface.startswith("veth"): elif iface.startswith("veth"):
@ -569,30 +597,30 @@ class NetworkProvider(
return relevant, icon, wifi return relevant, icon, wifi
async def getSectionUpdater(self, section: Section) -> typing.Callable: async def get_section_updater(self, section: Section) -> typing.Callable:
assert isinstance(section, StatefulSection) assert isinstance(section, StatefulSection)
assert isinstance(section.sortKey, str) assert isinstance(section.sortKey, str)
iface = section.sortKey iface = section.sortKey
relevant, icon, wifi = self.getIfaceAttributes(iface) relevant, icon, wifi = self.get_iface_attribute(iface)
if not relevant: if not relevant:
return self.doNothing return self.do_nothing
section.numberStates = 5 if wifi else 4 section.numberStates = 5 if wifi else 4
section.state = 1 if wifi else 0 section.state = self.STATE_SSID if wifi else self.STATE_MINIMIZED
async def update() -> None: async def update() -> None:
assert isinstance(section, StatefulSection) assert isinstance(section, StatefulSection)
if not self.if_stats[iface].isup: if not self.if_stats[iface].isup:
section.setText(None) section.set_text(None)
return return
text = icon text = icon
state = section.state + (0 if wifi else 1) state = section.state + (0 if wifi else 1)
if wifi and state >= 1: # SSID if wifi and state >= self.STATE_SSID:
cmd = ["iwgetid", iface, "--raw"] cmd = ["iwgetid", iface, "--raw"]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE *cmd, stdout=asyncio.subprocess.PIPE
@ -600,7 +628,7 @@ class NetworkProvider(
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
text += f" {stdout.decode().strip()}" text += f" {stdout.decode().strip()}"
if state >= 2: # Address if state >= self.STATE_IP:
for address in self.if_addrs[iface]: for address in self.if_addrs[iface]:
if address.family == socket.AF_INET: if address.family == socket.AF_INET:
net = ipaddress.IPv4Network( net = ipaddress.IPv4Network(
@ -609,23 +637,23 @@ class NetworkProvider(
text += f" {address.address}/{net.prefixlen}" text += f" {address.address}/{net.prefixlen}"
break break
if state >= 3: # Speed if state >= self.STATE_SPEED:
prevRecv = self.prev_io_counters[iface].bytes_recv prev_recv = self.prev_io_counters[iface].bytes_recv
recv = self.io_counters[iface].bytes_recv recv = self.io_counters[iface].bytes_recv
prevSent = self.prev_io_counters[iface].bytes_sent prev_sent = self.prev_io_counters[iface].bytes_sent
sent = self.io_counters[iface].bytes_sent sent = self.io_counters[iface].bytes_sent
dt = self.time - self.prev_time dt = self.time - self.prev_time
recvDiff = (recv - prevRecv) / dt recv_diff = (recv - prev_recv) / dt
sentDiff = (sent - prevSent) / dt sent_diff = (sent - prev_sent) / dt
text += f"{humanSize(recvDiff)}{humanSize(sentDiff)}" text += f"{human_size(recv_diff)}{human_size(sent_diff)}"
if state >= 4: # Counter if state >= self.STATE_TOTALS:
text += f"{humanSize(recv)}{humanSize(sent)}" text += f"{human_size(recv)}{human_size(sent)}"
section.setText(text) section.set_text(text)
section.setChangedState(update) section.set_changed_state(update)
return update return update
@ -643,17 +671,17 @@ class NetworkProvider(
self.if_addrs = psutil.net_if_addrs() self.if_addrs = psutil.net_if_addrs()
self.io_counters = psutil.net_io_counters(pernic=True) self.io_counters = psutil.net_io_counters(pernic=True)
await self.updateSections(set(self.if_stats.keys()), self.module) await self.update_sections(set(self.if_stats.keys()), self.module)
class TimeProvider(PeriodicStatefulProvider): class TimeProvider(PeriodicStatefulProvider):
FORMATS = ["%H:%M", "%m-%d %H:%M:%S", "%a %y-%m-%d %H:%M:%S"] FORMATS = ("%H:%M", "%m-%d %H:%M:%S", "%a %y-%m-%d %H:%M:%S")
async def init(self) -> None: async def init(self) -> None:
self.section.state = 1 self.section.state = 1
self.section.numberStates = len(self.FORMATS) self.section.numberStates = len(self.FORMATS)
async def loop(self) -> None: async def loop(self) -> None:
now = datetime.datetime.now() now = datetime.datetime.now(datetime.UTC).astimezone()
format = self.FORMATS[self.section.state] format_ = self.FORMATS[self.section.state]
self.section.setText(now.strftime(format)) self.section.set_text(now.strftime(format_))

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python3 import sys # noqa: I001
import contextlib
import os import pathlib
import sys
# From https://github.com/python/cpython/blob/v3.7.0b5/Lib/site.py#L436 # From https://github.com/python/cpython/blob/v3.7.0b5/Lib/site.py#L436
@ -11,7 +10,7 @@ def register_readline() -> None:
try: try:
import readline import readline
import rlcompleter import rlcompleter # noqa: F401
except ImportError: except ImportError:
return return
@ -23,14 +22,13 @@ def register_readline() -> None:
else: else:
readline.parse_and_bind("tab: complete") readline.parse_and_bind("tab: complete")
try: # An OSError here could have many causes, but the most likely one
# is that there's no .inputrc file (or .editrc file in the case of
# Mac OS X + libedit) in the expected location. In that case, we
# want to ignore the exception.
cm = contextlib.suppress(OSError)
with cm:
readline.read_init_file() readline.read_init_file()
except OSError:
# An OSError here could have many causes, but the most likely one
# is that there's no .inputrc file (or .editrc file in the case of
# Mac OS X + libedit) in the expected location. In that case, we
# want to ignore the exception.
pass
if readline.get_current_history_length() == 0: if readline.get_current_history_length() == 0:
# If no history was loaded, default to .python_history. # If no history was loaded, default to .python_history.
@ -38,14 +36,11 @@ def register_readline() -> None:
# each interpreter exit when readline was already configured # each interpreter exit when readline was already configured
# through a PYTHONSTARTUP hook, see: # through a PYTHONSTARTUP hook, see:
# http://bugs.python.org/issue5845#msg198636 # http://bugs.python.org/issue5845#msg198636
history = os.path.join( history = pathlib.Path("~/.cache/python_history").expanduser()
os.path.expanduser("~"), ".cache/python_history" cm = contextlib.suppress(OSError)
) with cm:
try:
readline.read_history_file(history) readline.read_history_file(history)
except OSError:
pass
atexit.register(readline.write_history_file, history) atexit.register(readline.write_history_file, history)
sys.__interactivehook__ = register_readline sys.__interactivehook__ = register_readline # noqa: attr-defined

View file

@ -1,14 +1,15 @@
""" """
Add the networks saved in wireless_networks to wpa_supplicant, Add the networks saved in wireless_networks to wpa_supplicant.
without restarting it or touching its config file.
Does not restart it or touch its config file.
""" """
import json import json
import os import pathlib
import re import re
import subprocess import subprocess
NETWORKS_FILE = "/etc/keys/wireless_networks.json" NETWORKS_FILE = pathlib.Path("/etc/keys/wireless_networks.json")
def wpa_cli(command: list[str]) -> list[bytes]: def wpa_cli(command: list[str]) -> list[bytes]:
@ -20,7 +21,7 @@ def wpa_cli(command: list[str]) -> list[bytes]:
return lines return lines
network_numbers: dict[str, int] = dict() network_numbers: dict[str, int] = {}
networks_tsv = wpa_cli(["list_networks"]) networks_tsv = wpa_cli(["list_networks"])
networks_tsv.pop(0) networks_tsv.pop(0)
for network_line in networks_tsv: for network_line in networks_tsv:
@ -34,8 +35,8 @@ for network_line in networks_tsv:
).decode() ).decode()
network_numbers[ssid] = number network_numbers[ssid] = number
if os.path.isfile(NETWORKS_FILE): if NETWORKS_FILE.is_file():
with open(NETWORKS_FILE) as fd: with NETWORKS_FILE.open() as fd:
networks = json.load(fd) networks = json.load(fd)
for network in networks: for network in networks:
@ -54,4 +55,5 @@ if os.path.isfile(NETWORKS_FILE):
value_str = str(value) value_str = str(value)
ret = wpa_cli(["set_network", number_str, key, value_str]) ret = wpa_cli(["set_network", number_str, key, value_str])
if ret[0] != b"OK": if ret[0] != b"OK":
raise RuntimeError(f"Couldn't set {key} for {ssid}, got {ret}") msg = f"Couldn't set {key} for {ssid}, got {ret}"
raise RuntimeError(msg)

39
os/wireless/import.py Executable file → Normal file
View file

@ -1,7 +1,4 @@
""" """Exports Wi-Fi networks from password store."""
Exports Wi-Fi networks configuration stored in pass
into a format readable by Nix.
"""
# TODO EAP ca_cert=/etc/ssl/... probably won't work. Example fix: # TODO EAP ca_cert=/etc/ssl/... probably won't work. Example fix:
# builtins.fetchurl { # builtins.fetchurl {
@ -11,52 +8,60 @@ into a format readable by Nix.
import json import json
import os import os
import pathlib
import subprocess import subprocess
import yaml import yaml
# passpy doesn't handle encoding properly, so doing this with calls # passpy doesn't handle encoding properly, so doing this with calls
PASSWORD_STORE = os.environ["PASSWORD_STORE_DIR"] PASSWORD_STORE = pathlib.Path(os.environ["PASSWORD_STORE_DIR"])
SUBFOLDER = "wifi" SUBFOLDER = "wifi"
def list_networks() -> list[str]: def list_networks() -> list[pathlib.Path]:
paths = [] paths = []
pass_folder = os.path.join(PASSWORD_STORE, SUBFOLDER) pass_folder = PASSWORD_STORE / SUBFOLDER
for filename in os.listdir(pass_folder): for filename in os.listdir(pass_folder):
if not filename.endswith(".gpg"): if not filename.endswith(".gpg"):
continue continue
filepath = os.path.join(pass_folder, filename) filepath = pass_folder / filename
if not os.path.isfile(filepath): if not filepath.is_file():
continue continue
file = filename[:-4] file = filename[:-4]
path = os.path.join(SUBFOLDER, file) path = pathlib.Path(SUBFOLDER, file)
paths.append(path) paths.append(path)
paths.sort() paths.sort()
return paths return paths
networks: list[dict[str, str | list[str] | int]] = list() networks: list[dict[str, str | list[str] | int]] = []
for path in list_networks(): for path in list_networks():
proc = subprocess.run(["pass", path], stdout=subprocess.PIPE, check=True) proc = subprocess.run(
["pass", path], # noqa: S607
stdout=subprocess.PIPE,
check=True,
)
raw = proc.stdout.decode() raw = proc.stdout.decode()
split = raw.split("\n") split = raw.split("\n")
password = split[0] password = split[0]
data = yaml.safe_load("\n".join(split[1:])) or dict() data = yaml.safe_load("\n".join(split[1:])) or {}
# Helpers to prevent repetition # Helpers to prevent repetition
suffixes = data.pop("suffixes", [""]) suffixes = data.pop("suffixes", [""])
data.setdefault("key_mgmt", ["WPA-PSK"] if password else ["NONE"]) data.setdefault("key_mgmt", ["WPA-PSK"] if password else ["NONE"])
if password: if password:
if any(map(lambda m: "PSK" in m.split("-"), data["key_mgmt"])): if any("PSK" in m.split("-") for m in data["key_mgmt"]):
data["psk"] = password data["psk"] = password
if any(map(lambda m: "EAP" in m.split("-"), data["key_mgmt"])): if any("EAP" in m.split("-") for m in data["key_mgmt"]):
data["password"] = password data["password"] = password
assert "ssid" in data, f"{path}: Missing SSID"
if "ssid" in data:
msg = f"{path}: Missing SSID"
raise KeyError(msg)
data.setdefault("disabled", 0) data.setdefault("disabled", 0)
for suffix in suffixes: for suffix in suffixes:
@ -64,5 +69,5 @@ for path in list_networks():
network["ssid"] += suffix network["ssid"] += suffix
networks.append(network) networks.append(network)
with open("wireless_networks.json", "w") as fd: with pathlib.Path("wireless_networks.json").open("w") as fd:
json.dump(networks, fd, indent=4) json.dump(networks, fd, indent=4)

View file

@ -1,13 +1,32 @@
#! /usr/bin/env python2 import subprocess
from subprocess import check_output
def get_pass(account): def get_pass(account: str) -> str:
return check_output("pass " + account, shell=True).splitlines()[0] proc = subprocess.run(
["pass", account], # noqa: S607
stdout=subprocess.PIPE,
def beep(): check=True,
check_output(
"play -n synth sine E4 sine A5 remix 1-2 fade 0.5 1.2 0.5 2",
shell=True,
) )
raw = proc.stdout.decode()
return raw.splitlines()[0]
def beep() -> None:
cmd = [
"play",
"-n",
"synth",
"sine",
"E4",
"sine",
"A5",
"remix",
"1-2",
"fade",
"0.5",
"1.2",
"0.5",
"2",
]
subprocess.run(cmd, check=False)