In praise of grobi for auto-configuring X11 monitors

Michael Stapelberg

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

원문은 Michael Stapelberg님이 에 게재했습니다. 이 블로그 구독하기

최근에 grobi program 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를 절전(suspend-to-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

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

댓글