In praise of grobi for auto-configuring X11 monitors

Michael Stapelberg

X11 모니터 자동 설정을 위한 grobi 예찬

최근에 grobi 프로그램 by Alexander Neumann을 다시 사용하기 시작했는데, 다루기 까다롭지만 훌륭한 Dell 32인치 8K 모니터(UP3218K)를 훨씬 편하게 쓸 수 있게 해 준다는 걸 알게 되어 무척 반가웠습니다 — 예전의 sleep 기반 방식보다 신호가 훨씬 빨리 잡힙니다.

예전에는 PC가 절전(suspend-to-RAM)에서 깨어날 때 두 가지 경우가 있었습니다:

  1. 모니터가 연결된 경우입니다. sleep 프로그램이 필요하면 모니터 전원을 켜고, 잠시 기다린 뒤 xrandr(1)을 실행해 (잘 되면) 모니터를 올바르게 설정했습니다.
  2. 모니터가 연결되지 않은 경우입니다. 예를 들어 모니터가 아직 업무용 PC에 연결되어 있는 경우입니다.

시나리오 ②인 경우나 시나리오 ①에서 한 번 시도한 설정이 실패하면, 다른 컴퓨터에서 SSH로 접속해 xrandr를 수동으로 실행해야 모니터에 신호가 들어왔습니다:

% DISPLAY=:0 xrandr \
  --output DP-4 --mode 3840x4320 --panning 0x0+0+0 \
  --output DP-2 --right-of DP-4 --mode 3840x4320 --panning 0x0+3840+0

grobi로 모니터 자동 설정하기

이제 다음 ~/.config/grobi.conf 파일을 만들어 이 문제를 완전히 해결했습니다:

rules:
  - name: UP3218K

    outputs_connected: [DP-2, DP-4]

	# DP-4 is left, DP-2 is right
    configure_row:
        - DP-4@3840x4320
        - DP-2@3840x4320

    # atomic instructs grobi to only call xrandr once and configure all the
    # outputs. This does not always work with all graphic cards, but is
	# needed to successfully configure the UP3218K monitor.
    atomic: true

…그리고 (Arch Linux에서) 다음 명령으로 grobi를 설치하고 활성화했습니다:

% sudo pacman -S grobi
% systemctl --user enable --now grobi

grobi가 모니터가 연결된 것을 감지하면(X11 RandR 출력 변경 이벤트를 수신합니다) xrandr(1)을 실행해 모니터 해상도와 배치를 설정합니다.

grobi가 무엇을 감지하고 어떤 작업을 하는지 확인하려면 다음 명령을 사용할 수 있습니다:

% systemctl --user status grobi
% journalctl --user -u grob

예를 들어 제 시스템에서는 다음과 같이 표시됩니다:

grobi: 18:31:48.823765 outputs: [HDMI-0 (primary) DP-0 DP-1 DP-2 (connected) 3840x2160+ [DEL-16711-808727372-DELL UP3218K-D2HP805I043L] DP-3 DP-4 (connected) 3840x21>
grobi: 18:31:48.823783 new rule found: UP3218K
grobi: 18:31:48.823785 enable outputs: [DP-4@3840x4320 DP-2@3840x4320]
grobi: 18:31:48.823789 using one atomic call to xrandr
grobi: 18:31:48.823806 running command /usr/bin/xrandr xrandr --output DP-4 --mode 3840x4320 --output DP-2 --mode 3840x4320 --right-of DP-4
grobi: 18:31:49.285944 new RANDR change event received

눈에 띄는 점은, 이제 신호 없음 같은 잘못된 상태에서 빠져나오는 방법이 모니터 전원을 껐다가 다시 켜는 것이 되었다는 점입니다. 그러면 RandR 출력 변경 이벤트가 발생하고, 이를 grobi가 감지해 xrandr를 실행하여 모니터를 설정합니다. 정말 깔끔합니다!

왜 autorandr는 아닌가요?

특별한 이유는 없습니다. grobi를 알고 있었을 뿐입니다.

굳이 하나 꼽자면 grobi는 Go로 작성되어 있어서 앞으로도 오랫동안 안정적으로 동작할 가능성이 높습니다.

grobi는 Wayland에서 동작하나요?

아마 아닐 겁니다. grobi 저장소에는 Wayland에 대한 언급이 없습니다.

보너스: 저의 절전(Suspend-to-RAM) 설정

보너스로, 이 섹션에서는 모니터 관련 자동화의 나머지 절반을 설명합니다.

PC를 RAM 절전 모드로 전환할 때, 나중에 키보드의 키를 누르거나 Wake-on-LAN 패킷을 보내 수동으로 깨우거나, 아니면 매일 아침 6시 50분에 자동으로 깨어나길 원합니다 — 이렇게 하면 컴퓨터를 사용하기 전에 일일 cron 작업이 실행될 시간을 확보할 수 있습니다.

이를 위해 rtcwake(8)systemctl suspend를 감싸는 래퍼 프로그램인 zleep을 사용합니다. 이 프로그램은 myStrom Switch 스마트 플러그와 연동되어 모니터 전원을 완전히 차단합니다. 모니터가 대기 상태에서도 30W나 소모하기 때문에 충분히 할 만한 가치가 있습니다!

package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"time"
)

var (
	resume = flag.Bool("resume",
		false,
		"run resume behavior only (turn on monitor via smart plug)")

	noMonitor = flag.Bool("no_monitor",
		false,
		"disable turning off/on monitor")
)

func monitorPower(ctx context.Context, method, cmnd string) error {
	if *noMonitor {
		log.Printf("[monitor power] skipping because -no_monitor flag is set")
		return nil
	}
	log.Printf("[monitor power] command: %v", cmnd)
	u, err := url.Parse("http://myStrom-Switch-A46FD0/" + cmnd)
	if err != nil {
		return err
	}
	for {
		if err := ctx.Err(); err != nil {
			return err
		}
		req, err := http.NewRequest(method, u.String(), nil)
		if err != nil {
			return err
		}
		ctx, canc := context.WithTimeout(ctx, 5*time.Second)
		defer canc()
		req = req.WithContext(ctx)
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Print(err)
			time.Sleep(1 * time.Second)
			continue
		}
		if resp.StatusCode != http.StatusOK {
			log.Printf("unexpected HTTP status code: got %v, want %v", resp.Status, http.StatusOK)
			time.Sleep(1 * time.Second)
			continue
		}
		log.Printf("[monitor power] request succeeded")
		return nil
	}
}

func nextWakeup(now time.Time) time.Time {
	midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
	if now.Hour() < 6 {
		// wake up today
		return midnight.Add(6*time.Hour + 50*time.Minute)
	}

	// wake up tomorrow
	return midnight.Add(24 * time.Hour).Add(6*time.Hour + 50*time.Minute)
}

func runResume() error {
	// Retry for up to one minute to give the network some time to come up
	ctx, canc := context.WithTimeout(context.Background(), 1*time.Minute)
	defer canc()
	if err := monitorPower(ctx, "GET", "relay?state=1"); err != nil {
		log.Print(err)
	}
	return nil
}

func zleep() error {
	ctx := context.Background()

	now := time.Now().Truncate(1 * time.Second)
	wakeup := nextWakeup(now)
	log.Printf("now   : %v", now)
	log.Printf("wakeup: %v", wakeup)
	log.Printf("wakeup: %v (timestamp)", wakeup.Unix())

	// assumes hwclock is running in UTC (see timedatectl | grep local)

	// Power the monitor off in 15 seconds.
	// mode=on is intentional: https://api.mystrom.ch/#e532f952-36ea-40fb-a180-a57b835f550e
	// - the switch will be turned on (already on, so this is a no-op)
	// - the switch will wait for 15 seconds
	// - the switch will be turned off
	if err := monitorPower(ctx, "POST", "timer?mode=on&time=15"); err != nil {
		log.Print(err)
	}

	sleep := exec.Command("sh", "-c", fmt.Sprintf("sudo rtcwake -m no --verbose --utc -t %v && sudo systemctl suspend", wakeup.Unix()))
	sleep.Stdout = os.Stdout
	sleep.Stderr = os.Stderr
	fmt.Printf("running %v\n", sleep.Args)
	if err := sleep.Run(); err != nil {
		return fmt.Errorf("%v: %v", sleep.Args, err)
	}

	return nil
}

func main() {
	flag.Parse()
	if *resume {
		if err := runResume(); err != nil {
			log.Fatal(err)
		}
	} else {
		if err := zsleep(); err != nil {
			log.Fatal(err)
		}
	}
}

절전에서 복귀한 뒤 모니터 전원을 켜기 위해 다음 셸 스크립트를 /lib/systemd/system-sleep/zleep.sh에 넣어 두었습니다:

#!/bin/sh

case "$1" in
	pre)	exit 0
		;;
	post)	/usr/local/bin/zleep -resume
		exit 0
		;;
 	*)	exit 1
		;;
esac

전원이 들어오면 grobi가 모니터를 감지하고 설정합니다.

프로그램이 실제로 동작하는 모습은 다음과 같습니다:

2025/05/06 21:58:32 now   : 2025-05-06 21:58:32 +0200 CEST
2025/05/06 21:58:32 wakeup: 2025-05-07 06:50:00 +0200 CEST
2025/05/06 21:58:32 wakeup: 1746593400 (timestamp)
2025/05/06 21:58:32 [monitor power] command: timer?mode=on&time=15
2025/05/06 21:58:32 [monitor power] request succeeded
running [sh -c sudo rtcwake -m no --verbose --utc -t 1746593400 && sudo systemctl suspend]
Using UTC time.
	delta   = 0
	tzone   = 0
	tzname  = UTC
	systime = 1746561512, (UTC) Tue May  6 19:58:32 2025
	rtctime = 1746561512, (UTC) Tue May  6 19:58:32 2025
alarm 1746593400, sys_time 1746561512, rtc_time 1746561512, seconds 0
rtcwake: wakeup using /dev/rtc0 at Wed May  7 04:50:00 2025
suspend mode: no; leaving

원문은 Michael Stapelberg님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.