In praise of grobi for auto-configuring X11 monitors

Michael Stapelberg

X11モニターを自動設定してくれるgrobiを称える

最近、Alexander Neumannが開発したgrobiを再び使い始めました。扱いにくい(しかし素晴らしい)Dell 32インチ8Kモニター(UP3218K)がずっと使いやすくなっていることに気づき、うれしくなりました。以前使っていたスリープベースの方法よりも、早く信号を受信できます。

以前は、PCがSuspend-to-RAMから復帰すると、次の2つのケースがありました。

  1. モニターが接続されている。私のスリープ用プログラムがモニターの電源を(必要に応じて)入れ、少し待ってからxrandr(1)を実行し、モニターを正しく設定できることを期待する。
  2. モニターが接続されていない。たとえば、まだ仕事用PCに接続されたままになっている場合です。

ケース②の場合、あるいはケース①で1回だけ行う設定が失敗した場合は、別のコンピューターからSSHで接続し、モニターに信号を表示させるためにxrandrを手動で実行する必要がありました。

% DISPLAY=:0 xrandr \
  --output DP-4 --mode 3840x4320 --panning 0x0+0+0 \
  --output DP-2 --right-of DP-4 --mode 3840x4320 --panning 0x3840+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が起動します。grobixrandrを実行し、それによってモニターが設定されます。いい感じです!

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のスマートプラグと連携し、モニターへの電力を完全に切ります。モニターはスタンバイ中でも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 により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。