Compare commits

...

5 commits

Author SHA1 Message Date
97295856a3
Don't try to show GPU on framework btop 2025-06-09 00:11:18 -04:00
8bee11d5fb
Massively rework wallpaper script
Now runs automatically every 15 minutes, always updates to new backgrounds, and tries to reduce the blue light level at night
2025-06-09 00:07:31 -04:00
c1c8a83ec5
Optimize sunset.py 2025-06-09 00:05:13 -04:00
310b879416
Remove trailing newlines from scripts 2025-06-09 00:03:27 -04:00
ff9b012217
Add cottage setup for framework 2025-06-09 00:02:12 -04:00
27 changed files with 410 additions and 176 deletions

View file

@ -19,15 +19,17 @@ discord = { source = "config/electron-flags.conf", target = ".config/discord-fla
1password = { source = "autostart/1password.desktop", target = ".config/autostart/1password.desktop", direct = true }
megasync = { source = "autostart/megasync.desktop", target = ".config/autostart/megasync.desktop", direct = true }
# Also needs a one-time `systemctl --user enable --now eww-daemon.service eww-bars.service ip-geolocation.service sunset.timer swww-daemon.service'
# Also needs a one-time `systemctl --user enable --now eww-daemon.service eww-bars.service ip-geolocation.service sunset.timer swww-daemon.service wallpaper.timer'
eww-daemon = { source = "systemd/eww-daemon.service", target = ".config/systemd/user/eww-daemon.service", direct = true }
eww-bars = { source = "systemd/eww-bars.service", target = ".config/systemd/user/eww-bars.service" }
ip-geolocation = { source = "systemd/ip-geolocation.service", target = ".config/systemd/user/ip-geolocation.service", direct = true }
sunset-service = { source = "systemd/sunset.service", target = ".config/systemd/user/sunset.service", direct = true }
sunset-timer = { source = "systemd/sunset.timer", target = ".config/systemd/user/sunset.timer", direct = true }
swww-daemon = { source = "systemd/swww-daemon.service", target = ".config/systemd/user/swww-daemon.service", direct = true }
wallpaper-service = { source = "systemd/wallpaper.service", target = ".config/systemd/user/wallpaper.service", direct = true }
wallpaper-timer = { source = "systemd/wallpaper.timer", target = ".config/systemd/user/wallpaper.timer", direct = true }
btop = { source = "btop", target = ".config/btop", direct = true }
btop = { source = "btop", target = ".config/btop" }
dunst = { source = "dunst", target = ".config/dunst", direct = true }
eww = { source = "eww", target = ".config/eww" }
fastfetch = { source = "fastfetch", target = ".config/fastfetch", direct = true }

View file

@ -50,7 +50,11 @@ graph_symbol_net = "default"
graph_symbol_proc = "default"
#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace.
{% if host.name == "chonk" %}
shown_boxes = "proc cpu mem net gpu0"
{% else %}
shown_boxes = "cpu mem net proc"
{% endif %}
#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs.
update_ms = 1500

View file

@ -120,9 +120,9 @@
;(defwidget colour_selector []
;(clicker :text "" :command "uwsm-app -- hyprpicker -a"))
(defwidget wallpaper []
(clicker :text "󰸉" :command "~/scripts/swww_change.py"))
(clicker :text "󰸉" :command "~/.config/eww/scripts/wallpaper-shell"))
(defwidget sunset []
(clicker :text "" :command "~/scripts/sunset.py"))
(clicker :text "" :command "python -OO ~/scripts/sunset.py"))
(defpoll brightness :interval 60 "~/.config/eww/scripts/backlight get")
@ -234,4 +234,25 @@
:exclusive true
:focusable false
(laptop_bar))
(defwindow laptop_left
:monitor 1
:geometry (geometry :x "0px"
:y "4px"
:width "2552px"
:height "24px"
:anchor "top center")
:stacking "fg"
:exclusive true
:focusable false
(laptop_bar))
(defwindow laptop_right
:monitor 2
:geometry (geometry :x "0px"
:y "4px"
:width "1912px"
:height "24px"
:anchor "top center")
:stacking "fg"
:exclusive true
:focusable false
(laptop_bar))

View file

@ -10,4 +10,3 @@ case "$1" in
*) exit 1
;;
esac

View file

@ -12,4 +12,3 @@ case "$1" in
*) echo "Unrecognized command"; exit 1
;;
esac

View file

@ -70,4 +70,3 @@ pactl subscribe | while read -r LINE; do
get_ids
fi
done

View file

@ -22,4 +22,3 @@ case "$1" in
*) echo "Device name '$1' not recognized"; exit 1
;;
esac

View file

@ -17,4 +17,3 @@ charging() {
}
echo "{\"charge\":$CHARGE,\"icon\":\"$CHARGE_ICON\",\"charging\":$(charging)}"

View file

@ -115,4 +115,3 @@ if __name__ == "__main__":
if len(argv) != 2:
exit("Must provide 1 arg: Polling interval")
main(float(argv[1]))

View file

@ -2,4 +2,3 @@
# EWW doesn't seem to like listening to Python scripts directly, but this wrapper seems to work fine
"$(dirname "$0")"/network-statistics $@

View file

@ -8,4 +8,3 @@ if [ "$(pactl get-default-sink)" = "${SPEAKERS}" ] ; then
else
pactl set-default-sink "${SPEAKERS}"
fi

View file

@ -8,4 +8,3 @@ if [ "$(pactl get-default-source)" = "${BLUE_MIC}" ] ; then
else
pactl set-default-source "${BLUE_MIC}"
fi

4
eww/scripts/wallpaper-shell Executable file
View file

@ -0,0 +1,4 @@
#!/bin/env sh
# EWW doesn't seem to like listening to Python scripts directly, but this wrapper seems to work fine
python -OO ~/scripts/wallpaper.py

View file

@ -1,5 +1,6 @@
monitor = DP-3, 1920x1080@60, -4480x0, 1
monitor = DP-2, 2560x1440@100, -2560x0, 1
monitor = eDP-1, 2880x1920@120, 0x0, 1.5
monitor = DP-2, 1920x1080@60, -1920x0, 1
decoration {
blur {
@ -31,6 +32,13 @@ gestures {
workspace_swipe_cancel_ratio = 0.1
}
workspace = 1, monitor:eDP-1, default:true
workspace = 2, monitor:eDP-1,
workspace = 3, monitor:eDP-1,
workspace = 1, monitor:DP-3, default:true
workspace = 2, monitor:DP-2, default:true
workspace = 3, monitor:eDP-1, default:true
workspace = 4, monitor:DP-3
workspace = 5, monitor:DP-2
workspace = 6, monitor:eDP-1
workspace = 7, monitor:DP-3
workspace = 8, monitor:DP-2
workspace = 9, monitor:eDP-1
workspace = 10, monitor:DP-3

View file

@ -30,7 +30,7 @@ listener {
}
listener {
timeout = 330
timeout = 600
on-timeout = systemctl suspend
}
{% endif %}

View file

@ -74,6 +74,11 @@ device {
#sensitivity = -0.9157894737 # Rough calc for max DPI - issues exist
}
device {
name = logitech-usb-receiver-mouse
sensitivity = 0.0
}
windowrulev2 = suppressevent maximize, class:.*
windowrulev2 = opacity 1.0 0.9, class:.*

57
scripts/lib/evening.py Normal file
View file

@ -0,0 +1,57 @@
from datetime import datetime
from pathlib import Path
type Range = tuple[float, float]
def lerped_amount(x: float, edges: Range) -> float:
"""How far `x` is between `values`"""
return (x - edges[0]) / (edges[1] - edges[0])
def smoothstep(x: float, edges: Range) -> float:
"""Smoothly interpolates between the `edges` based on `x`"""
# Technically I should bounds check but whatever
return (x * x * (3.0 - 2.0 * x)) * (edges[1] - edges[0]) + edges[0]
def day_elapsed(hours: int = 0, minutes: int = 0, seconds: int = 0) -> float:
"""Converts (H, M, S) into [0, 1] representing how far through the day it is"""
return ((((hours * 60) + minutes) * 60) + seconds) / (20 * 60 * 60)
def now_elapsed() -> float:
"""How far through the day it is"""
now = datetime.now()
return day_elapsed(now.hour, now.minute, now.second)
SUNRISE_HOURS = (5, 7)
SUNSET_HOURS = (21, 23)
SUNRISE_ELAPSED = (day_elapsed(SUNRISE_HOURS[0]), day_elapsed(SUNRISE_HOURS[1]))
SUNSET_ELAPSED = (day_elapsed(SUNSET_HOURS[0]), day_elapsed(SUNSET_HOURS[1]))
def sunup_amount() -> float:
"""What temperature the monitor should be"""
elapsed = now_elapsed()
if elapsed <= SUNRISE_ELAPSED[0]:
return 0
elif elapsed < SUNRISE_ELAPSED[1]:
return lerped_amount(elapsed, SUNRISE_ELAPSED)
elif elapsed <= SUNSET_ELAPSED[0]:
return 1
elif elapsed < SUNSET_ELAPSED[1]:
return 1 - lerped_amount(elapsed, SUNSET_ELAPSED)
else:
return 0
def on_steam() -> bool:
"""Whether the Steam PID is in use"""
pid_file = Path("~/.steampid").expanduser().resolve(strict=True)
pid = pid_file.read_text().strip()
pid_dir = Path("/proc") / pid
return pid_dir.is_dir()

37
scripts/lib/sunset.py Normal file
View file

@ -0,0 +1,37 @@
"""Sets the monitor temperature based on the current time using Hyprsunset"""
from os import environ
from socket import AF_UNIX, SOCK_STREAM, socket
from .evening import on_steam, Range, smoothstep, sunup_amount
def setup_socket() -> socket:
"""Connects to the Hyprsunset socket"""
hyprsunset = socket(AF_UNIX, SOCK_STREAM)
hyprsunset.connect(f"{environ["XDG_RUNTIME_DIR"]}/hypr/{environ["HYPRLAND_INSTANCE_SIGNATURE"]}/.hyprsunset.sock")
return hyprsunset
def get_temperature(hyprsunset: socket) -> int:
"""Retrieves the current screen temperature"""
# In theory the message might not send in one go, but in practice it does so I won't bother handling the error
_ = hyprsunset.send(b"temperature")
# 4 bytes should be enough but why not 8 for comfort
# Just raise an error if it's not a number, nothing special to do here
return int(hyprsunset.recv(8))
def set_temperature(hyprsunset: socket, temperature: float) -> None:
"""Sends a message to hyprsunset to set the temperature"""
# In theory the message might not send in one go, but in practice it does so I won't bother handling the error
_ = hyprsunset.send(f"temperature {temperature:.1f}".encode())
def main(temperature_range: Range, /, check_steam: bool = False) -> None:
"""Adjusts the monitor temperature based on the current time"""
hyprsunset = setup_socket()
temperature = int(smoothstep(sunup_amount(), temperature_range))
if temperature != get_temperature(hyprsunset):
if check_steam and on_steam():
return
set_temperature(hyprsunset, temperature)

View file

@ -1,45 +0,0 @@
"""Basic functions for interacting with swww"""
from json import loads
from pathlib import Path
from random import sample
from shlex import split
from socket import gethostname
from subprocess import run
IS_DESKTOP = gethostname() == "chonk"
MONITORS = ("DP-1", "DP-2") if IS_DESKTOP else ("eDP-1",)
ANGLE_TRANSITION_ARGS = [
"--transition-type", "wipe",
"--transition-angle", "30",
"--transition-step", "45",
"--transition-fps", "165" if IS_DESKTOP else "120",
]
IMMEDIATE_TRANSITION_ARGS = [
"--transition-step", "255",
]
def _run_cmd(arg0: str, *args: str) -> str:
return run([arg0, *args] if args else split(arg0), capture_output=True).stdout.decode("utf-8")
def monitors() -> list[str]:
return [monitor["name"] for monitor in loads(_run_cmd("hyprctl monitors -j"))]
def run_swww(monitor: str, image: Path, extra_args: list[str] | None = None) -> None:
run(["swww", "img", "-o", monitor, image, *(extra_args or [])])
def get_wallpapers() -> list[Path]:
return list(Path("~/Pictures/wallpapers/").expanduser().glob("*"))
def sample_wallpapers(displays: list[str] | None = None) -> "zip[tuple[str, Path]]":
if displays is None:
displays = monitors()
return zip(displays, sample(get_wallpapers(), len(displays)))

216
scripts/lib/wallpaper.py Normal file
View file

@ -0,0 +1,216 @@
"""Script to update the wallpaper"""
from abc import ABC, abstractmethod
from json import dumps, loads
from math import ceil, floor, isnan, nan
from os import environ
from pathlib import Path
from random import sample
from re import Pattern, compile, match
from subprocess import run
from time import sleep
from typing import cast, override
from .evening import on_steam, sunup_amount
# Python sets aren't insertion-ordered, but dict keys are
type OrderedSet[T] = dict[T, None]
class Background(ABC):
_colour: Pattern[str] = compile(r"^[a-fA-F0-9]{6}$")
@abstractmethod
def blue_light(self) -> float: ...
@abstractmethod
@override
def __eq__(self, value: object, /) -> bool: ...
@abstractmethod
@override
def __hash__(self) -> int: ...
@abstractmethod
@override
def __str__(self) -> str: ...
@classmethod
def parse(cls, arg: str) -> "Background":
if match(cls._colour, arg):
return Colour(int(arg, 16))
else:
return Wallpaper(Path(arg))
class Wallpaper(Background):
_cache: Path = Path(environ["XDG_CACHE_HOME"]) / "wallpaper_blue_light.json"
_wallpapers: Path = Path("~/Pictures/wallpapers/").expanduser()
def __init__(self, image: Path) -> None:
self._path: Path = image
self._blue_light: float = nan
def _calculate_blue_light(self) -> float:
from PIL import Image
data = cast(list[int], Image.open(self._path).getdata(2))
return sum(data) / len(data)
@classmethod
def _read_cache(cls) -> dict[str, float]:
if cls._cache.exists():
if (text := cls._cache.read_text().strip()):
if isinstance((obj := loads(text)), dict):
return cast(dict[str, float], obj)
return {}
@classmethod
def _write_cache(cls, cache: dict[str, float]) -> None:
stringified = dumps(cache)
if len(stringified) != cls._cache.write_text(stringified):
raise Exception("Did not fully write blue light cache to file")
@classmethod
def available(cls) -> "OrderedSet[Background]":
cache = cls._read_cache()
modified = False
wallpapers = {str(path): Wallpaper(path) for path in cls._wallpapers.iterdir()}
for wallpaper in wallpapers.values():
cached = cache.get(str(wallpaper._path))
if cached is None or isnan(cached):
print("New file:", wallpaper._path)
cached = wallpaper._calculate_blue_light()
modified = True
cache[str(wallpaper._path)] = cached
wallpaper._blue_light = cached
if modified:
cache = {path: score for (path, score) in sorted(cache.items(), key=lambda pair: pair[1])}
cls._write_cache(cache)
return {wallpapers[path]: None for path in cache.keys()}
@override
def blue_light(self) -> float:
return self._blue_light
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Wallpaper):
return self._path == value._path
return False
@override
def __hash__(self) -> int:
return hash(self._path)
@override
def __str__(self) -> str:
return str(self._path)
@override
def __repr__(self) -> str:
return f"Wallpaper({repr(self._path)})"
class Colour(Background):
def __init__(self, value: int) -> None:
self._value: int = value
@override
def blue_light(self) -> float:
return self._value & 0xFF
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Colour):
return self._value == value._value
return False
@override
def __hash__(self) -> int:
return hash(self._value)
@override
def __str__(self) -> str:
return hex(self._value)
@override
def __repr__(self) -> str:
return f"Colour({hex(self._value)})"
def _convert_bytes(output: bytes) -> str:
return output.decode("utf-8").strip()
def _swww_cmd(*args: str) -> str:
output = run(["swww", *args], capture_output=True)
if output.stderr:
raise Exception(f"swww returned: '{_convert_bytes(output.stderr)}'")
return _convert_bytes(output.stdout)
class Monitor:
_query: Pattern[str] = compile(r"^([^:]+): (\d+)x(\d+), scale: ([\d.]+), currently displaying: (?:image|color): (.+)$")
_transition_args: list[str] = [
"--transition-type=wipe",
"--transition-angle=30",
"--transition-step=45",
"--transition-fps=120",
]
def __init__(self, line: str) -> None:
if not (values := match(self._query, line)):
raise Exception(f"Failed to extract information from: '{line}'")
self._name: str = values[1]
self._resolution: tuple[int, int] = (int(values[2]), int(values[3]))
self._scale: float = float(values[4])
self._background: Background = Background.parse(values[5])
def is_colour(self) -> bool:
return isinstance(self._background, Colour)
def set_wallpaper(self, background: Background) -> None:
self._background = background
_ = _swww_cmd("img", *self._transition_args, f"--outputs={self._name}", str(self._background))
def get_background(self) -> Background:
return self._background
@classmethod
def current(cls) -> "list[Monitor]":
return [Monitor(line) for line in _swww_cmd("query").splitlines()]
@override
def __eq__(self, value: object, /) -> bool:
if isinstance(value, Monitor):
return self._background == value._background
return False
@override
def __repr__(self) -> str:
return f"Monitor('{self._name}: {self._resolution[0]}x{self._resolution[1]}, scale: {self._scale}, currently displaying: {"color" if self.is_colour() else "image"}: {self._background}')"
@override
def __str__(self) -> str:
return f"{self._name}: {self._background}"
def select_wallpapers(available: list[Background]) -> list[Background]:
radius = len(available) / 3
centre = sunup_amount() * len(available)
return available[max(0, floor(centre - radius)): min(len(available) + 1, ceil(centre + radius))]
def main(check_steam: bool = False) -> None:
if check_steam and on_steam():
return
monitors = Monitor.current()
wallpapers = Wallpaper.available()
for monitor in monitors:
wallpapers.pop(monitor.get_background(), None)
for monitor, wallpaper in zip(monitors, sample(select_wallpapers(list(wallpapers.keys())), len(monitors))):
monitor.set_wallpaper(wallpaper)
sleep(1.25)

View file

@ -1,109 +1,23 @@
#!/bin/env python
"""Sets the monitor temperature based on the current time using Hyprsunset"""
from argparse import ArgumentParser, Namespace
from datetime import datetime
from os import environ
from pathlib import Path
from socket import AF_UNIX, SOCK_STREAM, socket
from typing import cast
type Range = tuple[float, float]
def lerped_amount(x: float, edges: Range) -> float:
"""How far `x` is between `values`"""
return (x - edges[0]) / (edges[1] - edges[0])
def smoothstep(x: float, edges: Range) -> float:
"""Smoothly interpolates between the `edges` based on `x`"""
# Technically I should bounds check but whatever
return (x * x * (3.0 - 2.0 * x)) * (edges[1] - edges[0]) + edges[0]
def day_elapsed(hours: int = 0, minutes: int = 0, seconds: int = 0) -> float:
"""Converts (H, M, S) into [0, 1] representing how far through the day it is"""
return ((((hours * 60) + minutes) * 60) + seconds) / 86400
def now_elapsed() -> float:
"""How far through the day it is"""
now = datetime.now()
return day_elapsed(now.hour, now.minute, now.second)
def calculate_temperature(sunrise: Range, sunset: Range, temperature_range: Range) -> float:
"""What temperature the monitor should be"""
elapsed = now_elapsed()
if elapsed <= sunrise[0]:
return temperature_range[0]
elif elapsed < sunrise[1]:
return smoothstep(lerped_amount(elapsed, sunrise), temperature_range)
elif elapsed <= sunset[0]:
return temperature_range[1]
elif elapsed < sunset[1]:
return smoothstep(lerped_amount(elapsed, sunset), (temperature_range[1], temperature_range[0]))
else:
return temperature_range[0]
def setup_socket() -> socket:
"""Connects to the Hyprsunset socket"""
hyprsunset = socket(AF_UNIX, SOCK_STREAM)
# In theory I should use $XDG_RUNTIME_DIR, but for me it's always `/run/user/1000/`
hyprsunset.connect(f"{environ["XDG_RUNTIME_DIR"]}/hypr/{environ["HYPRLAND_INSTANCE_SIGNATURE"]}/.hyprsunset.sock")
return hyprsunset
def get_temperature(hyprsunset: socket) -> int:
"""Retrieves the current screen temperature"""
# In theory the message might not send in one go, but in practice it does so I won't bother handling the error
_ = hyprsunset.send(b"temperature")
# 4 bytes should be enough but why not 8 for comfort
# Just raise an error if it's not a number, nothing special to do here
return int(hyprsunset.recv(8))
def set_temperature(hyprsunset: socket, temperature: float, /, instant: bool = False) -> None:
# In theory the message might not send in one go, but in practice it does so I won't bother handling the error
_ = hyprsunset.send(f"temperature {temperature:.1f}".encode())
if instant:
# Setting the temperature twice in quick succession sometimes skips the transition period
set_temperature(hyprsunset, temperature, instant=False)
def on_steam() -> bool:
"""Whether the Steam PID is in use"""
pid_file = Path("~/.steampid").expanduser().resolve(strict=True)
pid = pid_file.read_text().strip()
pid_dir = Path("/proc") / pid
return pid_dir.is_dir()
def main(sunrise: Range, sunset: Range, temperature_range: Range, /, check_steam: bool = False, instant: bool = False) -> None:
"""Adjusts the monitor temperature based on the current time"""
hyprsunset = setup_socket()
temperature = int(calculate_temperature(sunrise, sunset, temperature_range))
if temperature != get_temperature(hyprsunset):
if check_steam and on_steam():
return
set_temperature(hyprsunset, temperature, instant=instant)
from lib.sunset import main
class Arguments(Namespace):
check_steam: bool = False
instant: bool = False
if __name__ == "__main__":
def call_from_args() -> None:
parser = ArgumentParser(description="Adjusts the temperature of your screen based on the time of day")
_ = parser.add_argument("-s", "--check-steam", action="store_true", help="Don't adjust temperature if Steam is active")
_ = parser.add_argument("-i", "--instant", action="store_true", help="Try to instantly change temperature")
args = cast(Arguments, parser.parse_args())
main(
(day_elapsed(5), day_elapsed(7)),
(day_elapsed(21), day_elapsed(23)),
(2500.0, 6000.0),
check_steam=args.check_steam,
instant=args.instant
)
if __name__ == "__main__":
call_from_args()

View file

@ -1,9 +0,0 @@
#!/bin/env python
"""Selects new backgrounds for the monitors"""
from lib.swww import ANGLE_TRANSITION_ARGS, run_swww, sample_wallpapers
for monitor, wallpaper in sample_wallpapers():
run_swww(monitor, wallpaper, ANGLE_TRANSITION_ARGS)

View file

@ -1,9 +0,0 @@
#!/bin/env python
"""Sets a new background for the monitors immediately"""
from lib.swww import IMMEDIATE_TRANSITION_ARGS, run_swww, sample_wallpapers
for monitor, wallpaper in sample_wallpapers():
run_swww(monitor, wallpaper, IMMEDIATE_TRANSITION_ARGS)

20
scripts/wallpaper.py Executable file
View file

@ -0,0 +1,20 @@
#!/bin/env python
from argparse import ArgumentParser, Namespace
from typing import cast
from lib.wallpaper import main
class Arguments(Namespace):
check_steam: bool = False
def call_from_args() -> None:
parser = ArgumentParser(description="Adjusts the wallpaper based on the time of day")
_ = parser.add_argument("-s", "--check-steam", action="store_true", help="Don't adjust the wallpaper if Steam is active")
args = cast(Arguments, parser.parse_args())
main(args.check_steam)
if __name__ == "__main__":
call_from_args()

View file

@ -3,4 +3,4 @@ Description=Sets the monitor temperature based on the time of day
[Service]
Type=oneshot
ExecStart=/bin/env python -O /home/mbradley/scripts/sunset.py --check-steam
ExecStart=/bin/env python -OO /home/mbradley/scripts/sunset.py --check-steam

View file

@ -0,0 +1,6 @@
[Unit]
Description=Sets the wallpaper based on the time of day
[Service]
Type=oneshot
ExecStart=/bin/env python -OO /home/mbradley/scripts/wallpaper.py --check-steam

12
systemd/wallpaper.timer Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=Changes the wallpaper over the course of the day to ease eye strain
After=graphical-session.target
After=swww-daemon.service
[Timer]
OnCalendar=*:0/15
Persistent=true
AccuracySec=5s
[Install]
WantedBy=timers.target