KDE Plasma でスクリーンセーバを制御する

こんにちは、masm11 です。 今回は、KDE Plasma のスクリーンセーバを自動で制御できないか、 という記事になります。

動機

Linux PC に外部モニタを接続し、そちらで作業をすることがよくあります。 Linux は Arch Linux で、KDE Plasma を Wayland で使っています。

で… しばらく暇すると、もちろんスクリーンセーバが起動するのですが、 フリーズすることが頻繁にあるのです… ぐぐったところ、ヒットはするのですが、解決策はなく… KDE の bugtracker にも あるのですが、未解決でした。

さすが多機能な KDE、スクリーンセーバにもモードがあります。 何を表示するかを設定できます。 経験的に、どうも頻繁に画面に動きがあるものだとフリーズしやすいようです。

あと、外部モニタ接続時に限るようです。

困りました。

まぁ、そこまで判っているなら、動きのない固定画像を表示してればいいわけですが、 …つまんなくないですか?

仕方ないので、方針を考えました。

  • 外部モニタ接続時は、仕方ないので、動きのないスクリーンセーバにする
  • 接続していない時は、slideshow で大丈夫なので、slideshow にする

よし、こうしましょう!

具体的に情報を集める

まず、外部モニタを接続・切断したかどうか or 接続してあるかどうか、が 必要になります。あと、スクリーンセーバのモードを切り替える方法が必要になります。

外部モニタを接続・切断したイベントって、DBus で取得できそうだな、と考えました。 それなら dbus-monitor で確認します。

dbus-monitor --session

と実行しておいて、外部モニタを接続してみました。

なんと… ないんですね… T_T

接続してあるかどうかを定期的に確認する方法にしてみましょうか。 DBus で確認できるかもしれません。

そういう場合は qdbusviewer-qt5 というプログラムで調べます。

起動して、適当に探してみましたが、見当たりません…

せっかく qdbusviewer-qt5 を起動したので、ついでに、 スクリーンセーバの設定を変更する方法も探してみます。 あるなら DBus くらいしか思いつきません。

が、これも見当たらず T_T

方針転換

さっそく方針転換をする必要がありそうです。

まず、外部モニタを接続してあるかどうかは、別の方法で代案とすることができます。 具体的には、この外部モニタは有線 LAN コネクタ搭載です。 Linux から見て、USB の向こうにこの有線 LAN インタフェースが見えれば、 モニタが繋がっていると言えます。 shell script なら、ip a コマンドで MAC アドレスが出力されますので、 特定の MAC アドレスが存在すれば、外部モニタが接続されていることになります。 shell script なら ip a で、C/C++ ならシステムコールで取得できるでしょうし、 C/C++ でも最悪 ip a コマンドを実行してしまえばなんとかなります。

次に、設定変更についてですが、設定の変更は諦め、スクリーンセーバの抑制を することにしましょう。inhibit という機能です。 実は先程 qdbusviewer-qt5 で探して見つけてました。

ただし、DBus で inhibit/uninhibit で抑制/回復をすることができますが、 それだけだと、inhibit した後、プログラムが異常終了した場合に inhibit したまま になってしまいます。それを避けるため、おそらく DBus の接続が切れた場合に 自動で uninhibit する作りになっていると思われます。

shell script で DBus アクセスしようと思ったら dbus-send を使えばできますが、 dbus-send で inhibit しても、その dbus-send が終了してしまうと uninhibit されてしまうわけですね。

C++ で Qt プログラミングするしかなさそうです。

そして、Qt を使うなら、つながってるモニタ一覧は取得できそうですね。 /usr/include/qt/QtGui/qguiapplication.h を見ると、 screenAddedscreenRemoved といった関数があり、 ポーリングする必要もなさそうです。

プログラムを書く

「書く」と言っても、Qt プログラムは書いたことがないですね… まずはチュートリアルから。

https://doc.qt.io/qt-6/qt-intro.html

↑ここにありそうですが、qt6 とか言ってますね。大丈夫でしょうか… 前途多難…

https://doc.qt.io/qt-6/qapplication.html

↑サンプルの main 関数があります。いただきましょう。

#include <QtWidgets>
#include <QtGui>

int main(int argc, char* argv[])
{
    QScopedPointer<QApplication> app(new QApplication(argc, argv));

    return app->exec();
}

最初の Qt プログラムです。Makefile は↓こんな感じです。

inhibit: inhibit.cc
    c++ -o $@ `pkg-config --cflags --libs Qt5Gui Qt5Widgets` $<

本当は cmake を学んだ方が良いのでしょうが、それはまた次の機会に。 pkg-config は便利ですね。

luna:~ % make
c++ -o inhibit `pkg-config --cflags --libs Qt5Gui Qt5Widgets` inhibit.cc
luna:~ % ./inhibit 

↑ビルド、実行の様子です。

モニタの追加・削除イベントの取得

では次に、モニタの追加削除を取得してみます。

#include <QtWidgets>
#include <QtGui>

static void update(void)
{
    printf("added/removed\n");
}

int main(int argc, char* argv[])
{
    QGuiApplication app(argc, argv);

    QObject::connect(&app, &QGuiApplication::screenAdded, update);
    QObject::connect(&app, &QGuiApplication::screenRemoved, update);

    return app.exec();
}

QObject::connect は QObject クラスの static メンバ関数です。 この関数に、どのオブジェクトの、どのメンバ関数かを指定し、 あとは呼んで欲しい関数も指定します。 そうすると、モニタが追加・削除されたタイミングで、update が呼ばれるようです。

こんな感じに signal connect できるんですねーー。

luna:~ % ./inhibit
added/removed
added/removed

う… 動きました…w 自分で書いておいてナンですけど。

モニタの接続状態の取得

先程のコードでは、追加時も削除時も update を呼ぶように設定しました。 add や remove は安定して toggle してくれるとは限らず、 常に現在の接続状態を取得した方が挙動が安定します。

static int dell_monitor_connected(void)
{
    static QString dell("Dell Inc.");
    QList<QScreen *> scrs = app->screens();
    int len = scrs.size();
    for (int i = 0; i < len; i++) {
        QScreen *scr = scrs[i];
        if (scr->manufacturer() == dell)
            return 1;
    }
    return 0;
}

最初に、dell の文字列を初期化しておきます。 初期化は一度で良いので、static にしてあります。 QString ならまぁ大丈夫でしょうが、なるべく Qt の初期化 (new QGuiApplication()) より後の方がいいかな、と思ったので、関数の中で宣言しています。 関数の外で宣言すると、プログラム起動時で Qt 初期化より前になってしまいますので。

で、次にモニタのリストを取得し、その個数だけループします。 イテレータもありそうですが、使い方がよくわからなかったので、 シンプルに個数を取得してループすることにしました。

scrs[i] でポインタが取得でき、scr->manufacturer() で製造元が 取得できます。これが dell なら DELL モニタが接続されている、 というわけです。

ただ、dell の文字列内容の確認には苦労しました。 QString なのでそのまま printf できるわけもなく、 かといって scr->manufacturer().data() しても単純な char * が 得られるわけでもなし。 結局、

printf("%s\n", qPrintable(scr->manufacturer()));

で確認することができました。わかんねー

で、この関数の返り値を update 関数で処理します。

static void update(void)
{
    int new_state = dell_monitor_connected();
    if (new_state != state) {
        state = new_state;
        printf("state: %d\n", state);
    }
}

こんな感じですね。この update 関数は、main からも呼びます。

int main(int argc, char* argv[])
{
    app = new QGuiApplication(argc, argv);

    QObject::connect(app, &QGuiApplication::screenAdded, update);
    QObject::connect(app, &QGuiApplication::screenRemoved, update);

    update();

    return app->exec();
}

起動時に最初の状態を取得するためですね。 既に dell モニタがつながっているなら、すぐに inhibit しないといけませんので。

inhibit する

ここまでは、まぁ前座です。ここからが難しい…

「Qt DBus」等でぐぐりながら書いていきます。

    static QDBusInterface *iface = new QDBusInterface(
            "org.freedesktop.ScreenSaver",
            "/ScreenSaver",
            "org.freedesktop.ScreenSaver",
            QDBusConnection::sessionBus());

session bus を取得し、該当サービスに接続します。 session bus は KDE Plasma のセッションが存在する間だけ存在し、 主に KDE Plasma の制御に使われます。 その他の3つの文字列は qdbusviewer-qt5 に表示されているものです。 何に接続するかを示しています。 おそらく↓こんな感じです。

        QDBusReply<uint> reply = iface->call("Inhibit", "Inhibit", "External monitor is connected.");
        if (reply.isValid()) {
            cookie = reply.value();
            printf("inhibited. cookie=%u\n", cookie);
        } else {
            const QDBusError err = reply.error();
            printf("inhibition failed.\n");
            printf("%s\n", qPrintable(err.message()));
        }

inhibit 処理です。最初の "Inhibit" はサービスに存在するメソッド名です。 その後の "Inhibit" は、アプリケーション名です。私が作っているこのアプリケーションの名前です。 その後の "External ..." は inhibit する理由です。 この2つの引数は、まぁ何でもいいと思います。 どこかに表示される場合があるとか、そんな理由で存在するのでしょう。

メソッドを呼び出したら、返り値があります。 このメソッドは uint 型の cookie を返しますので、QDBusReply<uint> で受けます。 メソッド呼び出しが成功したかどうかは isValid() で確認できます。 確認できたら value() で値を取り出します。cookie は後で使うので覚えておきます。

なお、引数や返り値の情報は、qdbusviewer-qt5 で得られます。表示するには↓のように Introspect メソッドを呼び出します。

        if (cookie != 0) {
            QDBusReply<void> reply = iface->call("UnInhibit", cookie);
            if (reply.isValid()) {
                printf("uninhibited.\n");
            } else {
                const QDBusError err = reply.error();
                printf("uninhibition failed.\n");
                printf("%s\n", qPrintable(err.message()));
            }
        }
        cookie = 0;

uninhibit の処理です。"UnInhibit" がメソッド名で、先程の cookie を渡しています。 返り値はないので QDBusReply<void> で受けます。 先程と同じようにメソッド呼び出しが成功したかどうかを確認します。

同じ cookie で二度以上 uninhibit しないように、使い終わったら cookie を 0 にし、 0 の場合は uninhibit しないようにガードしておきました。

以上をかなり試行錯誤しながら書きました。 やっぱりエラーメッセージは重要ですね。 なんでかメソッド呼び出しが失敗している、と思ったら、 期待する返り値の型が間違っている、 とエラーメッセージが教えてくれました。

長い間いろんな人とチームでプログラムを書いていると、 「エラー処理みたいな親切設計の開発は最後でいいよね?」と言っている人を 時々見かけます。そうじゃないんです! エラー処理は開発段階でも重要なんです! と再確認しました。

さて、実行して、外部モニタを接続し、切断したところ、以下のように出力されました。

luna:~ % ./inhibit 
state: 0
state: 1
inhibited. cookie=15224
state: 0
uninhibited.

接続したまま5分以上待ち、スクリーンセーバが起動しないことを確認し、 切断して5分以上待ち、スクリーンセーバが起動することを確認しました (スクリーンセーバの設定は5分にしてあります)。

あとは、設定の「自動起動」の「Add Login Script...」にでも突っ込んでおけば良いでしょう。

完成です!

完成品は以下に置いてあります。

https://github.com/masm11/inhibit

所感

当初やりたいこととは少し変わってしまいましたが、 まぁこれでもいいかな。

C++ は大昔に学習したことがありました。 当時はまだ template も例外もない時代でした。 何年か前に STL (Standard Template Library) も含めて学習しなおしましたが、 STL にはついていけませんでした。 Qt が STL を使ってなくて良かったです (QList とかも自前実装でしたね)。

一方、Qt は今回が初めてでした。 C++ が嫌いじゃなければ使いやすいんじゃないですかね。 私は C++ 嫌いですが… 言語仕様の巨大さが嫌ですね。 だから Qt も避けてました。 少しは使ってみようかな。 C は大好きなんですけどねー (だから Gtk は使ってました)。

ではまた!