あんパン

こしあん派

USB Gadget API for Linux入門: BluetoothキーボードのイベントをUSB HIDのReportに変換する

はじめに

普段は各種開発用途でLinux、Windows、macOSを利用しており、これらはUSB切替器で同一のキーボード、トラックボールに接続されている。そのため機器切替が簡単にできないBluetooth接続のキーボードやマウスを導入することができなかった。そんなある日、以下の記事を読んで moguno/event2usbhid を利用すればBluetooth接続の入力機器をUSB切替器に接続できることを知った。この記事を読むまではUSB Gadget APIの存在も知らなかった。

moguno.hatenablog.jp

ということで最初の数日はこれを便利に使っていたのだけれど、ある日macOSで使った際に修飾キー(control, shift, alt, meta)が一切効かないことに気付いた。また、これは個人的な環境の話題だが、普段利用しているトラックボールの4つ目のボタンが反応しないことにも不便していた。これらを解消すべくUSB HIDの仕様やUSB Gadget APIについて調べて回ったところ、興が乗って同等のツールをフルスクラッチで書いてしまった。この記事ではツールを自分で実装するにあたって調べた内容についてメモがてら簡単に書き残す。

ただ単にツールを使いたい人は以下のrepoからREADMEに沿ってツールをインストールすることで利用することができる。現状で動作を確認しているのはRaspberry Pi Zero WHのみだが、おそらくRaspberry Pi 4上でも動く。

github.com

元となったevent2usbhidと比べて、以下の機能が足されている。

  • macOSのサポート
    • 修飾キーを利用できる
  • 4ボタンマウスへの対応
    • 主にKensingtonのトラックボールを意識している
  • bt2usbhidを起動中はBluetooth接続機器の入力をLinux本体に反映させない
    • こちらはevent2usbhidにPull Requestを送っている状態
  • その他細かな機能
    • USB HIDの仕様上定義されているキーのいくつかに余分に対応している(はず)

なおこのツールを公開するにあたって id:moguno さんには状況を事前に事情をお伝えしており、公開を快諾していただきました。ありがとうございます!

USB Gadget APIとlibcomposite

bt2usbhidではUSB Gadget APIを利用している。このAPIはLinuxカーネルに組み込まれたもので通常のPCでは利用することができず、対応したハードウェアのみで利用することができる。Raspberry Pi ZeroとRaspberry Pi 4はこのUSB Gadget APIに対応している。 libcomposite モジュールを有効にすると、適切なディレクトリに設定ファイルを書き出すだけで端末をUSBデバイスとして振る舞わせることができる。

Raspberry Piでこれらを利用するためには、事前にOTGモードを有効にした上で libcomposite モジュールをロードしておく必要がある。

  • /boot/config.txt の末尾に dtoverlay=dwc2 を追記する
  • modprobedwc2libcomposite を読み込んでおく
    • /etc/modules に足すでも可
    • bt2usbhidではUSB Gadgetの初期化スクリプトの中で modprobe を実行している

Gadgetを初期化する

概ね以下のインストラクションに沿えば良い。

設定はconfigfsに書き出す。Raspberry Piでは自動で /sys/kernel/config 以下にマウントされる。この領域は揮発するので起動する度に設定ファイルを書き出す必要がある。

gadgetを作成する

/sys/kernel/config/usb_gadget/ 以下にgadgetを定義するディレクトリを任意の名前で作成して、その中にidVendor, idProductファイルを書き出す。

$ export GADGET_DIR=/sys/kernel/config/usb_gadget/g1
$ mkdir -p ${GADGET_DIR}
$ echo <idVendor>  > ${GADGET_DIR}/idVendor
$ echo <idProduct> > ${GADGET_DIR}/idProduct

idVendorはUSB-IFによって払い出されるので、自由に指定することはできない。bt2usbhidではidVendorに 0x1d6b (Linux Foundation)、idProductに 0x0104 (Multifunction Composite Gadget)を指定している。

gadgetを定義するディレクトリにserialnumber, manufacturer, productを書き出す。この値は任意で良い。

$ mkdir -p ${GADGET_DIR}/strings/0x409/
$ echo <serialnumber> > ${GADGET_DIR}/strings/0x409/serialnumber
$ echo <manufacturer> > ${GADGET_DIR}/strings/0x409/manufacturer
$ echo <product>      > ${GADGET_DIR}/strings/0x409/product

0x409 はLanguage Identifierであり、en-US を表している。

To get the latest LANGID definitions go to https://docs.microsoft.com/en-us/windows/desktop/intl/language-identifier-constants-and-strings. This page will change as new LANGIDs are added.

https://www.usb.org/hid

とのことで、Microsoftのページを辿っていくとLANGIDが記載されたPDFをダウンロードすることができ、この中に 0x0409en-US として記載されている。

configを作成する

configのディレクトリを作成して、中にconfigurationファイルを書き出す。configのディクトリ名は <任意の文字列>.<数値> である必要がある。configurationファイルの中身は任意の値で良い。

$ mkdir -p ${GADGET_DIR}/configs/c.1/strings/0x409
$ echo <configuration> > ${GADGET_DIR}/configs/c.1/strings/0x409/configuration

functionを作成する

functionのディレクトリを作成して、デバイスがどのように振る舞うのかを定義する。キーボードやマウスなどひとつの機能ごとに作成する。ここがUSB Gadget APIのキモといえる。

$ mkdir -p ${GADGET_DIR}/functions/hid.usb0
$ echo <protocol>               > ${GADGET_DIR}/functions/hid.usb0/protocol
$ echo <subclass>               > ${GADGET_DIR}/functions/hid.usb0/subclass
$ echo <report_length>          > ${GADGET_DIR}/functions/hid.usb0/report_length
$ echo -ne <report_description> > ${GADGET_DIR}/functions/hid.usb0/report_desc

protocolはこのデバイスが何を表現しているのかを通知する。 1 はキーボードで 2 はマウスを表す。subclassは、USB HIDデバイスがブートプロトコルに対応している(=BIOSで利用できる)かどうかを通知する。している場合は 1 、していない場合は 0

USBデバイスはReportという単位で情報をやりとりする。このReportの構造を定義するのがReport Descriptorで、report_descとして書き出す。バイナリファイルとして書き出したいので、このファイルだけ echo -ne オプションをつけている。Report Descriptorについては後述する。report_descはReport Descriptorのbyte長を表現している。

functionとconfigを紐付ける

これは単純にconfig内にfunctionへのsymlinkを置けば良い。

$ ln -s ${GADGET_DIR}/functions/hid.usb0 ${GADGET_DIR}/configs/c.1/hid.usb0

gadgetを有効化する

gadgetを有効化するには、USB Device Controllerの名前をUDCファイルに書き出せば良い。

$ ls /sys/class/udc > ${GADGET_DIR}/UDC

これで、USBデバイスを接続した端末から lsusb するとデバイスが表示される。

$ lsusb
...
Bus 001 Device 005: ID 1d6b:0104 Linux Foundation Multifunction Composite Gadget
...

また、USBデバイスとなった端末上では、キーボードとマウスの両方を定義していれば /dev/hidg0 /dev/hidg1 ファイルが生成されていることを確認できる。

$ ls -l /dev/hidg*
crw------- 1 root root 239, 0 Feb  6 14:15 /dev/hidg0
crw------- 1 root root 239, 1 Feb  6 14:15 /dev/hidg1

これらのファイルに適切にReportを書き込むことで、キーボードやマウスとして接続先の端末に通信を行うことができる。

Report Descriptorを定義する

前述の通り、USBの通信において実際にやりとりするデータをReport、Reportの構造の定義をReport Descriptorと呼ぶ。ここではふんわりとReport Descriptorの記法について書き記す。多くのことを省いているので、より詳細な点については直接仕様を当たると良い。

例: マウスのReport Descriptor

Report Descriptorでは、Reportのbit列のどの範囲がどの用途に使われているのかを定義する。例えば、3ボタンマウスのReport Descriptorは以下のようになる*1

Usage Page (Generic Desktop), ;Use the Generic Desktop Usage Page
Usage (Mouse),
Collection (Application), ;Start Mouse collection
  Usage (Pointer),
  Collection (Physical), ;Start Pointer collection
    Usage Page (Buttons)
    Usage Minimum (1),
    Usage Maximum (3),
    Logical Minimum (0),
    Logical Maximum (1), ;Fields return values from 0 to 1
    Report Count (3),
    Report Size (1), ;Create three 1 bit fields (button 1, 2, & 3)
    Input (Data, Variable, Absolute), ;Add fields to the input report.
    Report Count (1),
    Report Size (5), ;Create 5 bit constant field
    Input (Constant), ;Add field to the input report
    Usage Page (Generic Desktop),
    Usage (X),
    Usage (Y),
    Logical Minimum (-127),
    Logical Maximum (127), ;Fields return values from -127 to 127
    Report Size (8),
    Report Count (2), ;Create two 8 bit fields (X & Y position)
    Input (Data, Variable, Relative), ;Add fields to the input report
  End Collection, ;Close Pointer collection
End Collection ;Close Mouse collection

この各行をhexで表現すると以下のようになる。

\0x05 \0x01 // Usage Page (Generic Desktop), ;Use the Generic Desktop Usage Page
\0x09 \0x02 // Usage (Mouse),
\0xA1 \0x01 // Collection (Application), ;Start Mouse collection
\0x09 \0x01 //   Usage (Pointer),
\0xA1 \0x00 //   Collection (Physical), ;Start Pointer collection
\0x05 \0x09 //     Usage Page (Buttons)
\0x19 \0x01 //     Usage Minimum (1),
\0x29 \0x03 //     Usage Maximum (3),
\0x15 \0x00 //     Logical Minimum (0),
\0x25 \0x01 //     Logical Maximum (1), ;Fields return values from 0 to 1
\0x95 \0x03 //     Report Count (3),
\0x75 \0x01 //     Report Size (1), ;Create three 1 bit fields (button 1, 2, & 3)
\0x81 \0x02 //     Input (Data, Variable, Absolute), ;Add fields to the input report.
\0x95 \0x01 //     Report Count (1),
\0x75 \0x05 //     Report Size (5), ;Create 5 bit constant field
\0x81 \0x01 //     Input (Constant), ;Add field to the input report
\0x05 \0x01 //     Usage Page (Generic Desktop),
\0x09 \0x30 //     Usage (X),
\0x09 \0x31 //     Usage (Y),
\0x15 \0x81 //     Logical Minimum (-127),
\0x25 \0x7F //     Logical Maximum (127), ;Fields return values from -127 to 127
\0x75 \0x08 //     Report Size (8),
\0x95 \0x02 //     Report Count (2), ;Create two 8 bit fields (X & Y position)
\0x81 \0x06 //     Input (Data, Variable, Relative), ;Add fields to the input report
\0xC0       //   End Collection, ;Close Pointer collection
\0xC0       // End Collection ;Close Mouse collection

このReport Descriptorに対応するReportは以下のような構造になる。

(bit)76543210
0bytePaddingButton 3Button 2Button 1
1byteX
2byteY

つまり全長3byteで、0byte目ではボタンの状態、1byte目でX軸の移動、2byte目でY軸の移動を表現する。

さらに分解してボタン部分について見てみる。

\0x05 \0x09 // Usage Page (Buttons)
\0x19 \0x01 // Usage Minimum (1),
\0x29 \0x03 // Usage Maximum (3),
\0x15 \0x00 // Logical Minimum (0),
\0x25 \0x01 // Logical Maximum (1),
\0x95 \0x03 // Report Count (3),
\0x75 \0x01 // Report Size (1),
\0x81 \0x02 // Input (Data, Variable, Absolute),
\0x95 \0x01 // Report Count (1),
\0x75 \0x05 // Report Size (5),
\0x81 \0x01 // Input (Constant),

各行をitemと呼び、それぞれの0byte目はitemの型、データ部のbyte長を含んだprefixになっている。例えば1行目は \0x05 がUsage Pageを表現する。Usage Pageのうち、何を指定するかはその後のdata部に定義する。ここではButton Pageを指定したいので \0x09 にしている*2。prefixがデータ部のbyte長を含んでいるので、データ部は可変長、具体的には0,1,2,4byteのいずれかになっている(さらに長いデータ部を取る方法もある)。

itemにはmain item, global item, local itemが存在している。Report Descriptorのパース時の挙動はこれらのitemの種類に寄る*3。main itemが出てきた場合は、main itemに対してそれまで登場したglobal item, local itemの設定値を適用する。上記ボタンのDescriptorの例でいうと、最初に登場する Input(Data, Variable, Absolute) には、それよりも上に登場した Usage PageLogical Maximum などの値が適用される。main itemを解釈した時点でlocal itemの設定値は揮発して、global itemの設定値は以降のmain itemの値に引き継がれる。

ひとつめのInputは

  • Usage PageがButton
  • Button Pageで定義されているボタンのうち、Button 1からButton 3までを定義する
  • 値は0 or 1
  • 各ボタンの通信で使うbit数は1(0 or 1なので), 数は3(ボタンが3つなので)

ということになる。後続のInputはReportの穴埋め(padding)として指定している。詰めずにInputごとに1byteずつ出力する方がReport作成処理が簡素になるので穴埋めしているものと思われる(仕様にはpaddingできるとしか記載されていないのであくまで想像)

例えば4ボタンマウスを作りたければ、Button 4を定義することにしてReport Countを3から4に増やせば良いので、Descriptorを以下のように指定すれば良い。

\0x05 \0x09 // Usage Page (Buttons)
\0x19 \0x01 // Usage Minimum (1),
\0x29 \0x04 // Usage Maximum (4),
\0x15 \0x00 // Logical Minimum (0),
\0x25 \0x01 // Logical Maximum (1),
\0x95 \0x04 // Report Count (4),
\0x75 \0x01 // Report Size (1),
\0x81 \0x02 // Input (Data, Variable, Absolute),
\0x95 \0x01 // Report Count (1),
\0x75 \0x04 // Report Size (4),
\0x81 \0x01 // Input (Constant),

macOSにおけるキーボードのReport Descriptorの解釈

冒頭にも書いたとおり、macOSにおいてはmodifierキーが動作しない現象が発生していた。通常のUSBキーボードのReport Descriptorでは0byte目をmodifierキーの領域として定義している。具体的には以下のような調子になっている。

\x05 \x07 // Usage Page      (Key Codes)
\x19 \xE0 // Usage Minimum   (224)
\x29 \xE7 // Usage Maximum   (231)
\x15 \x00 // Logical Minimum (0)
\x25 \x01 // Logical Maximum (1)
\x75 \x01 // Report Size     (1)
\x95 \x08 // Report Count    (8)
\x81 \x02 // Input           (Data, Variable, Absolute)

224-231はmodifierキーを指していて*4、この8つのキーについてはReportの0byte目の各bitを立てることでON/OFFを通知できる。

(bit)76543210
keyRIGHT GUIRIGHT ALTRIGHT SHIFTRIGHT CTRLLEFT GUILEFT ALTLEFT SHIFTLEFT CTRL

Linuxではmodifierキーも通常のキーと同様にキーコードを送れば認識されているが、macOSの場合はmodifierキーの場合はこの0byte目のbit列をON/OFFする必要があるようだった。

Reportを出力するプログラムを書く

ここまで設定すれば、あとはReport Descriptorの定義に沿って /dev/hidg0/dev/hidg1 に出力すれば良い。ここはどんな言語でも良い。bt2usbhidではReportの構造体を定義しておいて、そのままwriteで出力している。

Report DescriptorとReportをデバッグする

実装がうまく動かない場合、Report Descriptorと実際に出力しているReportを確認しながらデバッグすることになる。usbhid-dump というツールと、 hidrd-convert というツールが役に立つ。usbhid-dump はArch Linuxであれば sudo pacman -Sy usbutils でインストールできる。 hidrd-convert はAURからインストールすることができる。

利用するにはまず lsusb して、addressを確認する。以下の例ではBusが001, Deviceが005なので、addressは 001.005 となる。

$ lsusb
...
Bus 001 Device 005: ID 1d6b:0104 Linux Foundation Multifunction Composite Gadget
...

このデバイスのReport Descriptorを知りたい場合は、以下のコマンドで出力できる。

$ sudo usbhid-dump -e descriptor -a 001:005 | grep -v : | xxd -r -p | hidrd-convert -o spec
Usage Page (Desktop),               ; Generic desktop controls (01h)
Usage (Mouse),                      ; Mouse (02h, application collection)
Collection (Application),
    Usage (Pointer),                ; Pointer (01h, physical collection)
...

Reportを全て確認したい場合は、以下のコマンドで待ち受けることができる。この例では、bt2usbhidのレポートを待ち受けており、キーボード上で a を押下して離した後、マウスの主ボタンを押下して離している。

$ sudo usbhid-dump -e all -a 001:005
001:005:001:DESCRIPTOR         1612887753.901255
 05 01 09 02 A1 01 09 01 A1 00 05 09 19 01 29 04
 15 00 25 01 75 01 95 04 81 02 75 04 95 01 81 01
 05 01 09 30 09 31 09 38 15 81 25 7F 75 08 95 03
 81 06 C0 C0

001:005:000:DESCRIPTOR         1612887753.901707
 05 01 09 06 A1 01 05 07 19 E0 29 E7 15 00 25 01
 75 01 95 08 81 02 75 08 95 01 81 01 05 08 19 01
 29 05 75 01 95 05 91 02 75 03 95 01 91 01 05 07
 19 00 2A FF 00 15 00 26 FF 00 75 08 95 06 81 00
 C0

Starting dumping interrupt transfer stream
with 1 minute timeout.

001:005:000:STREAM             1612887757.455080
 00 00 04 00 00 00 00 00

001:005:000:STREAM             1612887757.534081
 00 00 00 00 00 00 00 00

001:005:001:STREAM             1612887759.112078
 01 00 00 00

001:005:001:STREAM             1612887759.190067
 00 00 00 00

既製品のUSBデバイスのaddressを指定すれば、Report DescriptorとReportを見ることでどのような実装になっているかを確認することもできる。

主に参考にしたページ等

*1:Device Class Definition for Human Interface Devices (HID) Firmware Specification 5/27/01 Version 1.11 p.25より引用 https://www.usb.org/sites/default/files/hid1_11.pdf

*2:HID Usage Tables FOR Universal Serial Bus p.102 https://usb.org/sites/default/files/hut1_21_0.pdf

*3:Device Class Definition for Human Interface Devices (HID) Firmware Specification 5/27/01 Version 1.11 p.15 https://www.usb.org/sites/default/files/hid1_11.pdf

*4:HID Usage Tables FOR Universal Serial Bus p.87 https://usb.org/sites/default/files/hut1_21_0.pdf

久々にRaspberry Piセットアップして躓いたところメモ

ちまたに情報は満ち溢れているのでいまさらだけど自分用にまとめておく。ちなみに前回Raspberry Piを触ったのは一昨年でRaspberry Pi Zero WHで勤怠ツールを作ったとき。その前は多分5年くらい前で、 Raspberry Pi 2 Model Bで勤怠ツールを作ったとき。

OSのインストール

以前はRaspbianとかArchとか選んで直接書き込んだりファイルをSDカード上に置く形式だったけれど、いまは基本的にRaspberry Pi OSがオススメということになっている。Raspberry Pi OSは Raspbianの名前が変わったやつという認識。SDカードへの焼き込みは専用のRaspberry Pi ImagerというGUIツールがあるので、これを選ぶ。

Arch Linuxの場合はAURにあるので yay --Sy rpi-imager でインストールできる。microSDカードを適当なSDカードリーダーに挿してUSBポートに接続すれば、mountなどはせずにrpi-imagerが認識してくれる。実行する際は管理者権限がないとSDカードに書き込めずエラーになるので、 sudo rpi-imager で起動する必要がある。

f:id:masawada:20210117101322p:plain
Raspberry Pi Imager

Raspberry Pi OSには以下のバージョンがある。

  • Raspberry Pi OS with desktop and recommended software
  • Raspberry Pi OS with desktop
  • Raspberry Pi OS Lite

だいたい書いてあるとおりで、Liteはデスクトップ環境がない。Raspberry Pi Imagerはデフォルトではwith desktopを推してくるけど、デスクトップが不要であればLiteを選択する。with desktop and recommended softwareの方はFull版として表示される。

画面を表示する

通常のRaspberry PiシリーズはHDMIケーブルが利用できる。Raspberry Pi Zeroシリーズはmini HDMIケーブルが必要になる。直接画面に繋いでも良いが、 どうせすぐにSSH経由でしか利用しなくなるので、激安HDMIキャプチャを噛ませて一瞬だけ見るのでも良い。だいたい入力から1秒くらいギャップがある。常用しないしわざわざ画面の裏に手を入れるのが面倒な人向けと言える。激安HDMIキャプチャはAliExpressやAmazonなどで買える。

こういうやつ。だいたい1000円くらいで買えて便利。ただ、表記上のスペックを満たしていないケースが多いのでその点は注意。詳しくはMS2109で検索すると良さそう。

f:id:masawada:20210117102638p:plain
OBSで画面出力をキャプチャする

事前にSDカードにWiFiのcredentialとSSHを起動するフラグを立てるファイルを置いておくだけで画面を見ずに接続する方法もあるけど、なんだかんだ画面を見ながらデバッグしたいということはあるのでこういうことができると良い。

キーボードを繋ぐ

通常のRaspberry PiであればUSB Type-Aが刺さるので特に問題はないがRaspberry Pi Zeroの場合はMicro USBポートしかないので、Micro USB(オス)からUSB Type-A(メス)に変換するUSB OTGケーブル/アダプタが必要になる。

こういうやつ。

piユーザのパスワードを変更しておく

sudo raspi-config から変更できる。普通に passwd コマンドでもOK。

キーボードのレイアウトを変更する

デフォルトだとUK配列になってるぽい? のでUS配列にする。 sudo raspi-config から変更できる。何気にHappy Hacking Keyboardのレイアウトが登録されていた。そのまま登録を進めていくとEnglishだけどUK配列になってしまうので、ここでUS配列にする。

IPを固定してネットワークに繋ぐ

/etc/wpa_supplicant/wpa_supplicant.conf に無線LANのcredentialを置く。無線LANのパスワードは事前に暗号化しておく。sudo su && wpa_passphrase ESSID passphrase >> /etc/wpa_supplicant/wpa_supplicant.conf しておいて、後で整形する。Raspberry Pi Zero Wは2.4GHz帯の規格にしか対応していないので5GHz帯のESSIDで繋ぎにいかないこと。最初忘れてて15分くらい潰した。

$ sudo vi /etc/wpa_supplicant/wpa_supplicant.conf
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=JP

network={
        ssid="ESSID"
        psk=***
}

みたいな形にしておく。次に、IPを固定するためにdhcpcd.confを編集する。最後の以下の4行を追加する。IPとかは環境による。

$ sudo vi /etc/dhcpcd.conf
...
interface wlan0
static ip_address=192.168.0.2/24
static routers=192.168.0.1
static domain_name_servers=192.168.0.1

SSHする

あとはいつもどおり。

  • sudo systemctl enable ssh.service && sudo systemctl start ssh.service
  • ssh-copy-id で接続元の端末のpubkeyを転送しておく
  • sudo vi /etc/ssh/sshd_configして以下の箇所を変更
    • PubkeyAuthentication yes にする
    • PasswordAuthentication no にする
  • sudo systemctl restart ssh.service

だいたいこれでまともにつかえるはず。

X270環境構築時のつまずきメモ

画面がチラつく

lspci -vv すると Intel Corporation HD Graphics 620kernel driveri915 ということが分かる。i915ではPanel Self Refreshにより画面がチラつくことがある。/etc/default/grub を編集して GRUB_CMDLINE_LINUX_DEFAULT の値の末尾に i915.enable_psr=0 を追加すればチラつかなくなる。

参考: https://wiki.archlinux.org/index.php/intel_graphics#Screen_flickering

無線LANが繋がらない

wifi-menu で設定しても CONNECTING FAILED と出てしまう。 これは単純に netctl-auto が有効になっていないからだった。ip a で対象のNICを特定して

$ sudo systemctl enable netctl-auto@wlp3s0.service
$ reboot

すれば wifi-menu で繋げられるようになる

バックライトの照度を変更できない

light コマンドがうまく動かない。 light -v3 -A 10 とかするとエラーメッセージが出てくるのでそれを確認する。みたところPermission Deniedのようだった。ユーザがvideoグループに入っていれば良いとのことなので

$ sudo usermod -aG video $USER

で解決。

参考: https://github.com/haikarainen/light#debianubuntu ここの Note に書いてあった。

引数に取ったPerlのコードを実行して結果を表示しつつクリップボードに投入するシェルスクリプト

#!/bin/bash

EXPR="$@"; perl -e "print $EXPR" | tee >(xsel -ib); echo;

これを p という名前でPATHの通った場所に置いています。使い方は

$ p '1+2'
3
$ xsel -ob
3

みたいな感じ。xsel -ib はLinuxにおける標準入力をクリップボードに投入するコマンド、xsel -ob はクリップボードからstdoutに出力するコマンド。macOSであればそれぞれ pbcopy pbpaste に相当します。

普段は上記のように便利計算機として使うことが多いですが、Perlのコードを評価できるのでもうすこし面白い使い方ができそうな気もします。

$ p 'join ", ", (1, 2, 3)'
1, 2, 3

みたいなことができたりとか。まあ、いまのところ良い使い道は思い付かないですが。

蔵書管理を支える技術

これは Spreadsheets/Excel Advent Calendar 2020 4日目の記事です。
昨日は id:yunico_jp さんの スプレッドシートを使った超単純な進捗管理、過去最高に捗る説 - Yunicode でした。

こんにちは、 id:masawada です。突然ですが、みなさんが所属されている組織ではどのように蔵書を管理していますか?

組織において書籍や検証機など何らかの物品を貸し出すケースはごく日常的なものだと思います。貸し出す物品の数や人数が少なければホワイトボードなどに書いて管理することもできそうですが、膨大になってくるとそれでは立ち行かないこともあるでしょう。わざわざ物理的なカンバンを見に行かずにどこにどの物品があるのかを検索したいというニーズもありそうです。

この記事では、このような問題を解決するために自分が作った蔵書管理用のスプレッドシートをご紹介します。実際に自分が所属している会社ではこのスプレッドシートを利用して蔵書を管理しています。

https://docs.google.com/spreadsheets/d/1XshcYZmNPpKHAJjFEY-KdFJkYzjwj3lHtreRqfEvxXk/edit?usp=sharing

ファイル > コピーを作成 から自分のGoogle Driveにコピーを作成してご利用ください。

スプレッドシートは

  • 貸出記録
  • 蔵書一覧
  • 所蔵場所
  • 蔵書分類

の4つに分かれています。具体的にどこでどのようなテクニックを使っているのかを交えながら紹介します。

貸出記録

貸出記録シートには、以下のような機能があります

  • 書籍IDを記入すると書名を自動でフィルインする
  • 貸出日を記入すると返却日(貸出日の2週間後)を自動でフィルインする

f:id:masawada:20201204001639p:plain

書籍IDは、蔵書一覧シート(後述)において書籍ひとつひとつに対して割り振るIDです。蔵書一覧シートのA列が蔵書IDになっています。例えば貸出記録のC2セルは

=IF(ISBLANK(B2),,VLOOKUP(B2, '蔵書一覧'!$A$2:B, 2))

のようになっており、 B2(B列の同じ行)に何か値があったら、蔵書一覧の$A$2:Bの範囲からB2の値に合致するものを探し出してきて、その行の2番目のセルを表示 しています。

この例ではB2は 1 なので、蔵書一覧の $A$2:B から 1 に合致するものを探してきてその行の2番目のセルを表示します。

f:id:masawada:20201204002623p:plain

蔵書一覧の $A$2:B はこの範囲なので、書籍IDが1にマッチする 転生したらスプレッドシートだった件 が表示されます。


返却日のフィルインは

=IF(ISBLANK(E2),,E2+14)

のようになっており、貸出日(ここではE2)が入力されていたら E2+14 を返すとしています。日付の値が入っている場合は、単純に +14 とするだけで2週間後の値を計算できます。

蔵書一覧

蔵書一覧シートには、以下のような機能があります

  • 貸出中かどうかを判定する機能
  • 所蔵場所の選択肢を所蔵場所シートから引っ張ってくる機能
  • 分類の選択肢を蔵書分類シートから引っ張ってくる機能

f:id:masawada:20201204003708p:plain

貸出中かどうかを判定する機能は、以下のように実現しています。

=IF(ISBLANK(B2), , IF(COUNTBLANK(QUERY('貸出記録'!$A$2:G, "select G where B = " & A2, -1)) > 0, "貸出中", "貸出可能"))

なかなか複雑ですね。分解してみましょう。まず一番外の IF は、蔵書名がなかった場合は何も表示しないための条件分岐です。この外側のIFがないと以下のようになります。

f:id:masawada:20201204003844p:plain

書籍がなければ貸し出し可能もなにもないのでこのIF文でガードしています。

次に COUNTBLANK(QUERY('貸出記録'!$A$2:G, "select G where B = " & A2, -1)) > 0 の部分を見てみましょう。まず QUERY で貸出記録シートの $A$2:G の範囲から "select G where B = " & A2 しています。& は単なる文字列結合で、A2はここでは 1 なので select G where B = 1 と等価です。貸出記録シートにおいてG列は返却日、B列は書籍IDなので、 書籍IDが1の行の返却日 を検索しています。これを包んでいるのが COUNTBLANK で、これは文字通り空セルを数えるものです。QUERYで引いた返却日リストに空セルがある場合、つまり貸出記録で書籍IDが埋まっているが返却日が記入されていない場合はそのセルの数を計算できます。これが1以上であれば貸出中という寸法です。

最後にIFで 貸出中 を表示するか 貸出可能 を表示するかを判定しています。


所蔵場所や分類の選択肢をそれぞれのシートから引っ張ってくる機能は、 データの入力規則 から設定できます。条件として リストを範囲で指定 して、シート名!範囲 を指定すれば良いだけです。例えば、所蔵場所シートのA2以降を指定したいときは 所蔵場所!A2:A1000 のような値を指定します。所蔵場所が1000を超えることはないので、適当に1000としています。

その他の機能

Google App Scriptを利用すると、返却日を過ぎていた場合にSlackに通知することもできます。以下のようなスクリプトを ツール > スクリプトエディタ から起動するエディタに貼り付けてSlackのwebhookのURLなどを設定しておき、時間をトリガーに doPost 関数を実行することで、返却日当日や返却日を過ぎていた場合にSlackで通知することができます。…多分動きます(未検証だけどだいたい同じものを運用しています。)

/// <reference path="typings/bundle.d.ts" />
// Configuration
var WEBHOOK_URL = 'SLACK_WEBHOOK_URL';
var SLACK_USERNAME = '蔵書管理';
var SLACK_ICON_EMOJI = ':books:';
var SHEET_NAME = '貸出記録';

// Slackにメッセージを送信する関数
function sendMessage (userId, message) {
  // send message
  var res = UrlFetchApp.fetch(WEBHOOK_URL, {
    method: 'post',
    payload: JSON.stringify({
      username: SLACK_USERNAME,
      icon_emoji: SLACK_ICON_EMOJI,
      link_names: 1,
      text: '@' + userId + ': ' + message,
    }),
  });
};

// 返却日を確認する本丸
function doPost () {
  // 貸出シートからsheetのインスタンスを得て行数を取得しておく
  var sheet = SHEET_NAME ? SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME) : SpreadsheetApp.getActiveSheet();
  var maxRows = sheet.getMaxRows();

  // 貸出シートを1行ずつ舐めていく
  for (var r = 2; r <= maxRows; r++) {
    var row = sheet.getRange(r, 1, r, 7);

    var bookName   = row.getCell(1, 3).getValue();
    var userId     = row.getCell(1, 4).getValue();
    var startDate  = row.getCell(1, 5).getValue();
    var endDate    = row.getCell(1, 6).getValue();
    var returnDate = row.getCell(1, 7).getValue();

    if (startDate === '') {
      break;
    }

    // 返却日をミリ秒で取得する
    var d = new Date(endDate).getTime();

    // 今日の00:00:00の値をミリ秒で取得する
    // `- 32400000` してるのは時差のためだったはず…(不明)
    var n = (function(d){return d - d % 86400000 - 32400000;})(new Date().getTime());

    if (d === n && returnDate === '') {
      sendMessage(userId, '本日が書籍 "' + bookName + '"の返却日です。');
    } else if (d < n && returnDate === '') {
      sendMessage(userId, '書籍 "' + bookName + '"の返却日を過ぎています。お早めにお返しください。');
    }
  }
};

トリガーは Edit > Current project's triggers のあたりで設定できます。これを押すと以下のような画面に遷移して設定できるようになります。

f:id:masawada:20201204010150p:plain

借りていたことを忘れるのはよくあることなので、実際に返却日にお知らせがあると便利です。

f:id:masawada:20201204011330p:plain

まとめ

蔵書管理システムでも、簡単なものであればスプレッドシートで実現できますよというご紹介でした。欠点としては、あまりにも貸出記録の行数が増えるとVLOOKUPが大量に走って重くなることでしょうか。一定期間ごとにrotateしてあげるのが良いと思います。

このシートを作ってみて、スプレッドシートは意外と表現力が柔軟で、かつ複雑な管理UIを作らなくても利用できて便利だなと感じました。あまりに複雑なものを作ってしまうとメンテナンス性が損なわれるのでユースケースにもよりそうですが、もっと活用していきたいですね。