Sunrise and sunset in Waybar with astral and uv

I wanted the next sunrise and sunset times in my waybar -- partly out of curiosity, partly because I cycle a lot and want to know how much daylight I still have. The astral Python library does the math; uv runs it without me having to manage a virtualenv.

waybar fragment showing the sun module with sunset 20:46 and sunrise 05:52

The script

The interesting part is that the Python script is a PEP 723 single-file script. Its dependencies live in a comment block at the top, and the shebang tells uv to handle the rest:

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["astral"]
# ///

When you chmod +x it and run it, uv reads the metadata, builds (or reuses) a cached environment under ~/.cache/uv/environments-v2/, and executes the script in it. Nothing to install globally, no requirements.txt, no project venv to ship.

Astral has a built-in geocoder, but only one of the German cities I cared about (Berlin) is in its database. So I hardcoded a small lat/lon table for the cities I look up:

CITIES: dict[str, tuple[float, float]] = {
    "Stuttgart": (48.7758, 9.1829),
    "Karlsruhe": (49.0069, 8.4037),
    "Berlin": (52.5200, 13.4050),
    "Munich": (48.1351, 11.5820),
}

The render function picks the next upcoming sunrise and the next upcoming sunset (today's if still ahead, tomorrow's otherwise) and orders them so that whichever happens sooner is shown first:

def render(city: str, now: datetime) -> str:
    lat, lon = CITIES[city]
    loc = LocationInfo(city, "Germany", "Europe/Berlin", lat, lon)

    today = sun(loc.observer, date=now.date(), tzinfo=TZ)
    tomorrow = sun(loc.observer, date=now.date() + timedelta(days=1), tzinfo=TZ)
    rise = today["sunrise"] if now < today["sunrise"] else tomorrow["sunrise"]
    set_ = today["sunset"]  if now < today["sunset"]  else tomorrow["sunset"]

    if rise < set_:
        return f"\U0001F305 {rise:%H:%M} \U0001F307 {set_:%H:%M}"
    return f"\U0001F307 {set_:%H:%M} \U0001F305 {rise:%H:%M}"

So during daylight you see 🌇 20:48 🌅 05:50 (sunset is next, then tomorrow's sunrise), and at night or in the early morning hours you see 🌅 05:50 🌇 20:49.

Wiring it into waybar

A tiny shell wrapper makes the city configurable from the waybar config:

#!/bin/bash
exec ~/bin/sun/sun_status.py "${1:-Stuttgart}"

And in ~/.config/waybar/config:

"custom/sun": {
   "exec": "~/bin/sun/sun_status.sh",
   "interval": 3600
}

The other use case

In waybar I only ever show Stuttgart -- but in the shell I look up other cities:

$ ./sun_status.sh Munich
🌇 20:42 🌅 05:39

$ ./sun_status.sh Berlin
🌇 20:46 🌅 05:24

Useful before a cycling tour ("how late can I be on the road and still get back before dark?") or before an early start to catch a sunrise somewhere ("when do I actually have to get up?").

Tests

Because the rendering logic is a single pure function -- render(city, now) -> str -- the tests just pass a constructed datetime directly.

@pytest.mark.parametrize("city, now, expected", [
    ("Stuttgart", datetime(2026, 5, 8, 3, 0, tzinfo=TZ), "🌅 05:52 🌇 20:48"),
    ("Stuttgart", datetime(2026, 5, 8, 14, 0, tzinfo=TZ), "🌇 20:48 🌅 05:50"),
    ("Stuttgart", datetime(2026, 5, 8, 22, 0, tzinfo=TZ), "🌅 05:50 🌇 20:49"),
    # ...
])
def test_render(city, now, expected):
    assert render(city, now) == expected

A pyproject.toml next to the script declares the dev dependencies, so uv run pytest works:

[project]
name = "sun"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["astral"]

[dependency-groups]
dev = ["pytest"]

The script keeps its PEP 723 header, so waybar still calls it directly via the uv run --script shebang. The project venv at ./.venv/ is only used for the tests; the waybar invocation uses the cached script env under ~/.cache/uv/. Two independent environments for the same file -- both managed by uv, neither requiring me to type pip install.

Watch Arch Linux package updates with a Forgejo Action

I wanted to know when specific Arch Linux packages got bumped -- for example when emacs moves from 30.2-2 to 30.2-3. I don't want to run pacman -Syu just to find out, and the Arch website has a JSON API that makes this easy to poll.

The API call for a single package looks like this:

curl -s 'https://archlinux.org/packages/search/json/?name=emacs&repo=Extra&arch=x86_64' \
    | jq -r '.results[0] | (.pkgver + "-" + (.pkgrel|tostring))'

This prints something like 30.2-2.

I wanted a generic solution, so the list of packages to watch lives in a packages.yaml file:

packages:
  - name: emacs
    repo: Extra
    arch: x86_64

Adding another package means appending three more lines.

The Forgejo Action runs once a day, queries each entry, and compares the result to a file in state/ that stores the last seen pkgver-pkgrel. If it differs, the Action sends a ntfy message (see previous post) and commits the new state file back to the repo. That way the git log of state/ becomes the bump history.

name: Check Arch package updates

on:
  schedule:
    - cron: '0 8 * * *'  # Daily at 8:00 AM UTC
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install tools
        run: |
          command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; }
          if ! command -v yq >/dev/null; then
            case "$(uname -m)" in
              x86_64)         arch=amd64 ;;
              aarch64|arm64)  arch=arm64 ;;
              armv7l)         arch=arm ;;
              *) echo "unsupported arch: $(uname -m)"; exit 1 ;;
            esac
            curl -fsSL -o /usr/local/bin/yq \
              "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}"
            chmod +x /usr/local/bin/yq
          fi

      - name: Check packages
        env:
          NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
        run: |
          set -euo pipefail
          mkdir -p state

          yq -o=json '.packages' packages.yaml | jq -c '.[]' | while read -r pkg; do
            name=$(printf '%s' "$pkg" | jq -r '.name')
            repo=$(printf '%s' "$pkg" | jq -r '.repo')
            arch=$(printf '%s' "$pkg" | jq -r '.arch')

            url="https://archlinux.org/packages/search/json/?name=${name}&repo=${repo}&arch=${arch}"
            current=$(curl -sf "$url" \
              | jq -r '.results[0] | if . == null then empty else (.pkgver + "-" + (.pkgrel|tostring)) end')

            if [ -z "${current:-}" ]; then
              echo "::warning::no result for ${name} (${repo}/${arch})"
              continue
            fi

            state_file="state/${repo}_${arch}_${name}.txt"
            previous=$(cat "$state_file" 2>/dev/null || true)

            if [ "$previous" != "$current" ]; then
              printf '%s\n' "$current" > "$state_file"
              if [ -n "$previous" ]; then
                curl -fsS \
                     -H "Authorization: Bearer ${NTFY_TOKEN}" \
                     -H "Title: Arch package updated: ${name}" \
                     -H "Priority: default" \
                     -H "Tags: package" \
                     -d "${repo}/${name} (${arch}): ${previous} -> ${current}" \
                     https://ntfy.madflex.de/forgejo
              else
                echo "initial state for ${name}, not notifying"
              fi
            fi
          done

      - name: Commit and push state changes
        run: |
          git config user.name "Automated"
          git config user.email "actions@forgejo.local"
          git remote set-url origin https://oauth2:${{ secrets.GITHUB_TOKEN }}@forgejo.tail07efb.ts.net/${{ github.repository }}
          git add state/
          git diff --staged --quiet || git commit -m "Update package state - $(date +'%Y-%m-%d')"
          git push

One note about the state:

First-seen packages are recorded silently: when the state file does not exist yet, the current version is written but no ntfy is sent. That avoids a flood of notifications after adding a batch of new packages to packages.yaml.

Now a few days after I wrote the post, the Emacs package was updated and the notification looks like this:

img1

I am monitoring several other packages as well, but the problems with Emacs and Tree-sitter were the starting point.

tmux sessions

I started to use tmux sessions a lot in the last months. Some of my learnings are in this post.

Creating a new session without opening a new terminal

From inside an existing tmux session, there is no need to open another terminal. Just create a new session directly:

# from inside tmux (prefix + :)
:new-session -s work

Or use the shortcut:

# from the shell inside a tmux pane
tmux new-session -d -s work

The -d flag creates the session in the background (detached), so the current session stays in focus. Switch to it afterwards (see below).

Listing sessions

# from the shell
tmux ls

From inside tmux, Ctrl-b s opens an interactive session picker that also shows the windows inside each session.

Switching between sessions

Ctrl-b s

Opens the session list. Use arrow keys to pick one, Enter to switch.

Ctrl-b (

Switch to the previous session.

Ctrl-b )

Switch to the next session.

From the shell:

tmux switch-client -t work

Tree view of sessions and windows

Ctrl-b w

Opens an interactive tree view of every session, with each session expanded to show its windows (and panes). Navigate with the arrow keys, press Enter to jump straight to that window, or x to kill the highlighted item.

Renaming a session

Ctrl-b $

Prompts to rename the current session.

From the shell:

tmux rename-session -t old-name new-name

Showing the session name in the status bar

By default the tmux status bar does not make it obvious which session you are in. Adding #S to status-right in ~/.tmux.conf displays the current session name. Note that status-right is a single string and setting it replaces the previous value entirely -- so include everything you want shown:

set-option -g status-right '#[fg=cyan]#S #[fg=green]| #[fg=blue]#H #[fg=green]|#[fg=yellow]#(uptime | rev | cut -d":" -f1 | rev | sed s/,//g ) #[fg=green]| #[fg=blue]%Y-%m-%d %H:%M'

The #(...) form runs a shell command -- here a small uptime | rev | cut | rev | sed pipeline grabs just the three load averages from the end of uptime's output and strips the commas. Rendered, this looks like:

blogging | dunkelsturmtaucher | 0.58 1.01 1.24 | 2026-05-03 14:36

This puts the session name in front of the hostname, current load and date/time, separated by |. Reload the config with Ctrl-b :source-file ~/.tmux.conf to see the change immediately.

See also

Claude Code tmux window names -- an earlier post on auto-renaming tmux windows (rather than sessions) when running long-lived commands like Claude Code.