はじめに
なかなか記事を書けずに申し訳ないなと思うhermit4です。まぁ、底辺フリーランスプログラマですので、仕事の方であっぷあっぷな生活を過ごしておりまして・・・ごめんなさい。
先日、数年振りにQt勉強会に参加してきました。体調も悪くなく、通院日でもなくという状況なのでおやつを持って参戦、久しぶりに懐かしい顔ぶれぬ会うのは良いものですね。帰り際はちょっと人混みで偏頭痛が出てフラフラしていてすいません。
というわけで、今日の記事は久しぶりのQt勉強会でごにょごにょやっていた内容を取りまとめたいと思います。
勉強会でのお題
諸事情によりyocto + meta-qt6、QPAはlinuxfb(DRM)環境という条件で、USBメディアの抜き差しを検出してアプリを動作させる必要が生じまして、勉強会の時間にちょっと検討・調査していました。
対応策の検討
まぁ、これはいくつかの対処案があるかと思います。パッと思いつく馴染みのある機能ですとQFileSystemWatcherでしょうか。
QFileSystemWatcherは指定のファイルやディレクトリを監視して状態に変化があった時に通知してくれるという機能です。ただ、この方法は思いのほかシステムリソースの消費が激しく、組込み用途としてみるといまいちです。
通常デバイスの検出はカーネルが行いますが、そこから先はnetlink経由でudevに通知が届き実際のマウントはユーザー空間で行われます。udevから先の処理はディストリビューションにより異なりますが、今時のLinuxではsystemdで管理されているため、マウントユニットが生成されています。実際のマウント周りはKUbuntu22.04ではudisk2のルールが記述されていましたのでudisk2が処理しているものと思います。yoctoのような環境では軽量さを優先してsystemd-mountを利用することの方が多いかと思います。
このsystemd-mountを使うかudisk2を使うかでマウント周りは色々事情が異なりますが、手元のKUbuntuもyocto環境も、どちらもsystemdにマウントユニットが登録されたのでD-Bus経由でこの通知を受けるのが無難だろうということで処理を実装してみました。
D-Busとは
D-Busは、アプリケーション間でやり取りを行うためのプロセス間通信の一種です。同一マシン上で並行実行されているプロセス間での汎用的な通信を可能にするメッセージ指向ミドルウェアメカニズムです。
もともとKDEはDCOPという独自のIPCを、GNOMEではCORBAベースのBonoboというIPCを使っていました。同じデスクトップ環境で競合関係にあったわけですが、GNOME開発者でもあるHavoc Pennington氏をはじめとするfreedesktop.orgメンバーが、DCOPに配慮しつつデスクトップ環境から切り離す形で共通に利用できる汎用的なIPCとしてD-Busを開発しました。
BonboはCORBAに基づいた複雑なオブジェクト指向機構で、D-BusはDCOPのシンプルなメッセージ指向により近い形で実装されました。このためD-BusはDCOPに影響を受けたとも言われていますが、競合プロジェクト相手だろうとその良さを受け入れ、標準化し、共通利用を呼びかけ成功させる、そのあり方にこそ賞賛を送りたい話です。オープンソースの文化というものはかくありたいものです。
このような経緯からうまれたD-Busは、汎用的で柔軟でありながら軽量さを維持するIPCとして、Linuxにおけるアプリケーション間の相互運用性を向上させています。特に現在多くのディストリビューションで採用されているsystemdもD-Busを採用していることから重要な技術になってきています。
ライブラリとしても古くはKDE4(2008年)から採用されており、すでに十分実績のあるIPCではあるのですが、同一マシン間という制約、汎用性を持たせるためのやや複雑なAPI、kdbusの件でLinusさんからD-Busが酷評されたこともあってか、日本語の情報や利用記事もあまり見かけないのが現状です。
とはいえ、せっかくマウント・アンマウントの情報が通知される機構があるのだから利用しない手はありません。
実装
さて、それでは本題です。長々歴史などを説明しましたが・・・実際のコード量は多少複雑ではあるもののそこまで多くありません。
#include <QCoreApplication>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusVariant>
#include <QTimer>
#include <QDebug>
class MountWatcher : public QObject
{
Q_OBJECT
public:
MountWatcher(QObject* parent=nullptr);
public slots:
void unitNew(const QString& name, const QDBusObjectPath& path);
void unitRemoved(const QString& name, const QDBusObjectPath& path);
void refresh();
void sync();
signals:
void mounted(QString mountPoint);
void unmounted(QString mountPoint);
private:
bool isUsbMemory(const QDBusInterface& infc) const;
QDBusInterface *systemdMgr_;
QDBusConnection bus_;
QTimer *timer_;
QMap<QString, QDBusObjectPath> cache_;
QMap<QDBusObjectPath, QString> mount_;
};
MountWatcher::MountWatcher(QObject *parent)
: QObject{parent}
, bus_{QDBusConnection::systemBus()} // (1)
, systemdMgr_{new QDBusInterface{
"org.freedesktop.systemd1", // (2)
"/org/freedesktop/systemd1", // (3)
"org.freedesktop.systemd1.Manager", // (4)
bus_,
this
}}
, timer_{new QTimer{this}}
{
timer_->setInterval(100);
timer_->setSingleShot(true);
connect(timer_, &QTimer::timeout, this, &MountWatcher::refresh);
if (systemdMgr_->isValid()) {
connect(systemdMgr_, SIGNAL(UnitNew(QString,QDBusObjectPath)),
this, SLOT(unitNew(QString,QDBusObjectPath))); // (5)
connect(systemdMgr_, SIGNAL(UnitRemoved(QString,QDBusObjectPath)),
this, SLOT(unitRemoved(QString,QDBusObjectPath))); // (5)
}
}
void MountWatcher::unitNew(const QString &name, const QDBusObjectPath &path)
{
if (!name.endsWith(".mount")) //(6)
return;
cache_[name] = path;
timer_->start(); // (7)
}
void MountWatcher::unitRemoved(const QString &name, const QDBusObjectPath &path)
{
if (!name.endsWith(".mount")) //(6)
return;
if (mount_.contains(path)) {
emit unmounted(mount_[path]);
mount_.remove(path);
}
cache_.remove(name);
timer_->start(); // (7)
}
void MountWatcher::refresh() // (7)
{
for (const auto& i : cache_) {
QDBusInterface infc{
"org.freedesktop.systemd1",
i.path(),
"org.freedesktop.systemd1.Mount",
bus_
};
if (isUsbMemory(infc)) {
mount_[i] = infc.property("Where").toString(); // (8)
emit mounted(mount_[i]);
}
}
}
void MountWatcher::sync()
{
if (systemdMgr_->isValid()) {
QDBusMessage msg = systemdMgr_->call("ListUnits");
if (!msg.arguments().isEmpty()) {
const QDBusArgument& args = msg.arguments().at(0).value<QDBusArgument>();
if (args.currentType() == QDBusArgument::ArrayType) {
args.beginArray();
while(!args.atEnd()) {
struct {
QString name, description, load_state, active_state, sub_state, following, job_type, unit_file, unit_file_status;
QDBusObjectPath unit_path, job_path;
quint32 job_id;
} unit;
args.beginStructure();
args >> unit.name
>> unit.description
>> unit.load_state
>> unit.active_state
>> unit.sub_state
>> unit.following
>> unit.unit_path
>> unit.job_id
>> unit.job_type
>> unit.job_path;
args.endStructure();
if (!unit.name.endsWith(".mount")) {
continue;
}
QDBusInterface infc{
"org.freedesktop.systemd1",
unit.unit_path.path(),
"org.freedesktop.systemd1.Mount",
bus_
};
if (isUsbMemory(infc)) {
mount_[unit.unit_path] = infc.property("Where").toString();
emit mounted(mount_[unit.unit_path]);
}
}
args.endArray();
}
}
}
}
bool MountWatcher::isUsbMemory(const QDBusInterface &infc) const
{
if (infc.isValid() && infc.property("What").toString().startsWith("/dev/sd")) {
return true;
}
return false;
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
MountWatcher watcher;
QObject::connect(&watcher, &MountWatcher::mounted, &watcher, [](const QString& path){
qDebug() << "mounted :" << path;
});
QObject::connect(&watcher, &MountWatcher::unmounted, &watcher, [](const QString& path){
qDebug() << "unmounted :" << path;
});
QMetaObject::invokeMethod(&watcher, &MountWatcher::sync, Qt::QueuedConnection);
return a.exec();
}
#include "main.moc"
$ qmake -project QT+=dbus
$ qmake
$ make
お手軽解説
バスとQDBusConnection
D-Busでは1対1の接続も対応しているのですが、多くの場合、特定のメッセージバスデーモンが管理するバスが利用されます。一般的な環境では、システム全体のメッセージに利用されるシステムバスと、ログインセッション(つまり特定のユーザー利用が想定される)のセッションバスとなります。
今回はマウント・アンマウントの情報はシステム全体に対する通知になるためSystemBusを利用します(1)。
バスに接続したアプリケーションは':'から始まる一意の接続名を割り当てられますが、別のアプリケーションからの利用が難しくなるため追加の名前(バス名)が登録されることが多いようです。QtDBusではこのバス名をサービスと呼称しています。今回はsystemdサービスが通信相手ですので”org.freedesktop.systemd1" となります(2)。
次に、そのサービスが提供するオブジェクトを提供するパスが必要になります。カタカナ表記だと分かりにくいですが、service=bus nameで、こちらはpathです。え、言われなくてもわかる・・・そうですか、若いっていいですね・・・。オブジェクトを特定するためのもので、/ 始まり / 区切りで / で終わらないものとされています。まずは"/org/freedesktop/systemd1" のパスで特定されるオブジェクトを利用します(3)。
各オブジェクトは1つ以上のインターフェイスをサポートすることとされています。ここでは、"org.freedesktop.systemd1.Manager"インターフェースを利用します(4)。
このオブジェクトのインターフェースには、新しいユニットの追加と削除の通知(シグナル)が用意されています。
その通知をスロットで拾えるようconnectしています。シグナルの情報はクラスになっていないため、connect文は旧来のSIGNAL/SLOTマクロを利用する必要があります(5)。
この通知は色々な種類のユニット追加・削除の通知が来るのですが、ファイルシステムのマウントに関するオブジェクトのパスは".mount"で終わることになっています。そこで、mount以外を無視しています(6)。
単純にシグナルをスロット受けた時点で処理が完結できると楽なのですが、USBのマウント時にはチャタリングが発生します。そんなわけで情報を一時キャッシュしてQTimerで一定時間まった後にrefresh()を呼ぶように実装しています(7)。
これだけだと、マウントユニットオブジェクトのパスしかわかりません。そこで、”org.freedesktop.systemd1.Mount"インターフェースを使い"Where"プロパティ取得をしています(8)。
まぁ、ここまではあっさりたどり着いたのですが、この方法だとこのアプリケーションの起動前に接続された情報が取得できません。つまり既に接続済みのUSBメモリのマウントパスも欲しいわけです。そこでsync()を用意して起動直後にListUnitsを呼び出し、ユニットの一覧からUSBデバイスのマウント情報を収集しています。QMetaObject::invokeMethodをQueuedConnectionにするとイベントループを開始した直後に1回だけ呼び出すことができます。
問題は、このListUnitsの値です。
ListUnits(out a(ssssssouso) units);
そう、結構複雑な内容になっています。このような場合、QDBusMessageで受信し、内容をQDBusArgumentを使ってパースしていく必要があります。
ちょっと複雑な記述ですが、XMLのパースの時と同じようにArrayのパース開始、Structのパース開始というように指示を重ねて内容を読み出していきます。
Unitのnameとうユニットパスさえ読み出せれば、後のコードはほぼ同じです。
なお、幸いhermit4の環境は/dev/sd* で始まるのがUSBメモリだけな環境だったのでUSBか否かの判定は簡略化していますが、最悪の場合、.deviceなユニットを読み取りつつ、USBに関連するものなのかを同じようにD-Bus経由で拾い上げていくことになりそうです。
まとめ
どうもD-Busに関する日本語の情報は少ないですが、Qtのクラスを使うと面倒な通信などの処理はおおむね端折ってプロパティやメソッドの呼び出しを行うことができます。
QtDbusに関する情報となると、多くの場合自分のアプリケーション(サービス)に、D-Bus APIを付けるにはという視点の情報が多いのですが、既存のサービスとの通信のためにも役立ちます。
手を抜こうと思えば、systemd-mount --listを読んで文字列をパースとか、そういう手段もあるわけなのですが、どうせならD-Busを使えるようになっていると、多少速度面で有利な場合がでてきます。ぜひお試しください。
また、私自身もQtDBusをそれほど使いこなしているわけではありません。もっとこうするとシンプルになる、こうした方が良いというご意見はどしどし募集しております。
[蛇足] QtDBusについての参考記事
私の記事は、あくまでD-Busからマウント状況を拾いたいという読み出すだけの処理の解説です。真面目にサービスを実装するのなら以下の記事を参考にしたほうが良いでしょう