Raspberry Pi に小型 OLED ディスプレイを内蔵し、コンソールにログインできるようにする

# 概要
|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　|
|:-:|
<img width="60%" alt="写真" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962189/f0caf214-b40f-be7f-294f-f80c09bae1a9.jpeg"><br>
[Youtube 動画で見る(Hello World のコンパイルのデモ 他)](https://youtu.be/b10QhRqWvHo)

Raspberry Pi にディスプレイを接続せずに、リモートで使っていると、IP アドレスを確認したいときや、リモートでの操作に応答しなくなってしまったときなど、ほんのちょっとした用途のためにディスプレイが必要になることがあります。

その都度 HDMI ケーブルとディスプレイを持ち出して接続するのは大変なので、 Raspberry Pi に <font color="red"><strong>OLED (有機 EL) ディスプレイ</strong></font>を内蔵して、ソフトウェアでコンソールのテキスト画面を転送表示し、<font color="green"><strong>いつでも USB キーボードを接続してログインできるようにしました。</strong></font>

# 動作環境
### ハードウェア
- Raspberry Pi 1 Model A (初代)
- Raspberry Pi Zero
- Raspberry Pi Zero W
- Raspberry Pi 3 Model A+
- Raspberry Pi 3 Model B

で動作確認しました。
### OS
公式ページにある Raspberry Pi Imager v1.8.5 で SD カードに書き込んだ
- Raspberry Pi OS 64-bit (Bookworm)
- Raspberry Pi OS Legacy 32-bit (Bullseye) 

で動作確認しました (2024年12月)。

# 必要な部品
<font color="red"><strong>SSD1306</strong></font>という IC を使った 128×64 ドットの OLED ディスプレイを使います。秋月や Amazon 等で数百円から1000円程度で購入できます (2024年12月)。<font color="red"><strong>Raspberry Pi と 4本のケーブルで I2C というプロトコルで接続できるものが必要です</strong></font>。 表示色は単色で、白色、青色、青色+黄色などがあります。

:::note warn
7本のケーブルを使って SPI というプロトコルで接続するタイプの OLED もありますが、今回は使えません。
:::

### 組み立て済みパーツ
下記のサイトで、専用基板に OLED ディスプレイとピンソケットがはんだ付けされた、組み立て済みのパーツを販売しています。Raspberry Pi の入出力ピンにさしこむだけで配線不要で使えます。はんだ付けや配線が面倒な方はこちらでご購入ください。

https://minne.com/items/41909739?code=muDzRedDDt

# 作り方
### (1) OLED ディスプレイの配線
|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　|
|:-:|
<img width="100%" alt="配線図" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962189/4b587aa1-e811-4408-e6bf-0090321359eb.png"><br>

OLED ディスプレイの GND, VCC(VDD), SCL(SCK), SDA の 4本の端子を Raspberry Pi の 1,3,5,6 番ピンに図のように接続します。

:::note alert
OLED の製品によっては GND と VCC(VDD) が逆の配置になっている場合があります。電源を逆に接続すると簡単に壊れて、正しく表示されなくなってしまいますので注意してください。
:::

### (2) ソフトウェアのインストール
- [OLED 表示用実行ファイル oled のダウンロード](https://purose.net/fanout/index.php?plugin=attach&pcmd=open&file=oled&refer=OLED%20%E3%82%92%20Raspberry%20Pi%20%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%BD%E3%83%BC%E3%83%AB%E3%81%AB%E3%81%99%E3%82%8B) 
<!-- - [C ソースファイル oled.c のダウンロード](https://purose.net/fanout/index.php?plugin=attach&pcmd=open&file=oled&refer=OLED%20%E3%82%92%20Raspberry%20Pi%20%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%BD%E3%83%BC%E3%83%AB%E3%81%AB%E3%81%99%E3%82%8B) -->
- [インストールスクリプト Install.sh のダウンロード](https://purose.net/fanout/index.php?plugin=attach&pcmd=open&file=Install.sh&refer=OLED%20%E3%82%92%20Raspberry%20Pi%20%E3%81%AE%E3%82%B3%E3%83%B3%E3%82%BD%E3%83%BC%E3%83%AB%E3%81%AB%E3%81%99%E3%82%8B)

<details><summary>OLED 表示プログラムの C ソースファイル oled.c を表示(クリックで展開)</summary>

```c:oled.c
/*
  oled: 128*64 OLED にフレームバッファを連続転送してモニタとして利用する
  2017/9/19 Soft I2C 対応, Brightness オプション追加
  コンパイル: gcc -static -o oled oled.c 
*/

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <linux/fb.h>
#include <sys/mman.h>
#include <syslog.h>

#include <linux/i2c-dev.h>
#include <sys/ioctl.h>

#define OLED_ADDR (0x3C)   // (0x78>>1) = 0x3c
#define byte unsigned char
#define CMD (0x80)
#define DATA (0x40)

char vram[128][64];
byte *buf = NULL;
int bufsize=1024+1;
int pt;
int lcd;
char i2cError = 0;
char initial=1;
unsigned char brightness=0x80;

// i2c セッション開始
void i2c_write_session_begin(byte type) {
  pt=0;
  buf[pt]=type;
  pt++;
}

// i2c データ投入
void i2c_write(byte value) {
  buf[pt]=value;
  pt++;
  if (pt>=bufsize) {
    buf= realloc(buf, bufsize*2);
    bufsize = bufsize*2;
  }
}

// i2c セッション終了
void i2c_write_session_end(void) {
  if (write(lcd, buf, pt) != pt){
    if (i2cError==0) {
      syslog(LOG_INFO, "Error write() to i2c slave\n");
      usleep(1000*1000*60*5);
      exit(1);
    }
    i2cError = 1;
  } else {
    i2cError = 0;
  }
}

// OLED の初期化
void setup_i2c() {
  byte i,j,k;
  i2c_write_session_begin(CMD);
  //i2c_write(0xAE); // AF:ON, AE:スリープモード
  i2c_write(0xAE); // AF:ON, AE:スリープモード

  i2c_write(0xA4); // VRAM の内容を表示

  // 表示開始アドレス
  i2c_write(0x40);
  i2c_write(0xD3);
  i2c_write(0x00);

  // メモリアドレッシングモードの変更
  i2c_write(0x20); // メモリアドレッシングモードの変更
  i2c_write(0x02); // 00H:Hモード, 01H:Vモード, 02H:ページアクセスモード, 03H:無効

  // スクロールしない
  i2c_write(0x2E);

  // Y 開始位置(H/V アドレスモード)
  i2c_write(0x22);
  i2c_write(0x00);
  i2c_write(0x07);

  // X 開始位置(H/V アドレスモード)
  i2c_write(0x21);
  i2c_write(0x00);
  i2c_write(0x7f);

  // コントラスト設定
  i2c_write(0x81); // コントラスト設定
  i2c_write(brightness); // コントラスト

  // 描画方向
  i2c_write(0xA0);
  i2c_write(0xC0);
  
  // A6H: ノーマル, A7H: 白黒反転
  i2c_write(0xA6); 

  // チャージポンプ
  i2c_write(0x8d); // チャージポンプの設定
  i2c_write(0x14); // 14:ON, 10:OFF
  i2c_write(0xAF); // AF:ON, AE:スリープモード
  i2c_write_session_end();
}

// 描画実行
void drawDisplay() {
  static char prev[128][64];
  int x,y;
  char flag;
  for(y=0; y<8; y++){
    // 前回値と同じかチェック
    flag=0;
    for (x=0;x<128;x++) {
      if (vram[x][y] != prev[x][y]) { flag=1; }
      prev[x][y] = vram[x][y];
    }
    // 1ブロック転送
    if (flag || initial) {
      i2c_write_session_begin(CMD);
      // Y 開始アドレスの指定(ページアクセスモード用)
      i2c_write(0xB0 | y);
      // X 開始アドレスの指定(ページアクセスモード用)
      i2c_write(0x00 | 0x00); // X 開始アドレス低ニブル
      i2c_write(0x10 | 0x00); // X 開始アドレス高ニブル
      i2c_write_session_end();
      // 8バイト毎に水平に連続で16個表示
      i2c_write_session_begin(DATA);
      for(x=0; x<128; x++){
			i2c_write(vram[x][y]);
	//i2c_write(0xff);
      }// X ループ  
      i2c_write_session_end();
    } // if (flag || initial) 
  } // Yループ
  initial=0;
}

// main
int main(int argc, char* argv[]) {
  int fbfd = 0;
  // struct fb_var_screeninfo orig_vinfo;
  struct fb_var_screeninfo vinfo;
  struct fb_fix_screeninfo finfo;
  long int screensize = 0;
  char *fbp = 0;

  // ヘルプ
  if (argc>1 && (strcmp(argv[1], "-h")==0 || strcmp(argv[1], "-H")==0)) {
    fprintf(stderr, "OLED Display Driver\n");
    fprintf(stderr, "%s [waitTime(100)] [i2cAddress(0x3c)] [i2cDeviceFile(/dev/i2c-0)] [framebufferDeviceFile(/dev/fb0)][brightness(0-255)]\n", argv[0]);
    fprintf(stderr, "Example: %s 100 0x3c /dev/i2c-0 /dev/fb0 128\n", argv[0]);
    exit(0);
  }
  
  // I2C 通信用バッファ
  buf = realloc(buf, bufsize);
  
  // I2C デバイスのオープン
  if (argc>3) {
    if ((lcd = open(argv[3], O_RDWR)) >= 0) goto i2copen;
  } else {
    if ((lcd = open("/dev/i2c-1", O_RDWR)) >= 0) goto i2copen;
    if ((lcd = open("/dev/i2c-0", O_RDWR)) >= 0) goto i2copen;
  }
  syslog(LOG_INFO,"Faild to open() i2c port\n");
  exit(1);
 i2copen:
    
  // 通信先アドレスの設定
  {
    unsigned long oled_address = OLED_ADDR;
    int res;
    if (argc>2) {
      oled_address = strtol(argv[2], NULL, 0);
    }
    if (res = ioctl(lcd, I2C_SLAVE, oled_address) < 0){
      syslog(LOG_INFO,"Unable to get bus access to talk to slave\n");
      exit(1);
    }
    syslog(LOG_INFO,"To get bus access to talk to slave successfully.\n");
    //syslog(LOG_INFO,"code=%d\n", res);
  }

  // 明度
  if (argc>5) {
    sscanf(argv[5], "%d", &brightness);
  }
  
  
  // OLED setup
  setup_i2c();
  
  // フレームバッファのファイルオープン
  if (argc>4) {
    if (fbfd = open(argv[4], O_RDWR)) goto fbopen;
  } else {
    if (fbfd = open("/dev/fb0", O_RDWR)) goto fbopen;
  }
  syslog(LOG_INFO, "Error: cannot open() framebuffer device.\n");
  exit(1);
 fbopen:
  syslog(LOG_INFO, "The framebuffer device was opened successfully.\n");
  
  // 画面情報(vinfo)の取得
  // Get variable screen information
  if (ioctl(fbfd, FBIOGET_VSCREENINFO, &vinfo)) {
    syslog(LOG_INFO,"Error reading variable information.\n");
    exit(1);
  }
  syslog(LOG_INFO,"vinfo.xres=%d, vinfo.yres=%d, vinfo.bits_per_pixel=%d\n",
	 vinfo.xres, vinfo.yres, vinfo.bits_per_pixel);
  
  // 画面情報(finfo)の取得
  if (ioctl(fbfd, FBIOGET_FSCREENINFO, &finfo)) {
    syslog(LOG_INFO,"Error reading fixed information.\n");
    exit(1);
  }
  syslog(LOG_INFO, "finfo.line_length=%d\n", finfo.line_length);
	
  // フレームバッファをメモリに mmap() で割り付け
  screensize = finfo.smem_len;
  fbp = (char*)mmap(0, screensize, PROT_READ | PROT_WRITE, MAP_SHARED, fbfd, 0);
	if ((void*)fbp == MAP_FAILED) {
    syslog(LOG_INFO, "Failed to mmap().\n");
    exit(1);
  }

  int x, y;
  unsigned int pix_offset;
  unsigned int value;

  while(1) {
    // フレームバッファを VRAM に転送
    for (y = 0; y < 64; y++) {
      for (x = 0; x < 128; x++) {
	// ピクセルのメモリ先頭からのオフセットを算出
        pix_offset = x*(vinfo.bits_per_pixel>>3) + y * finfo.line_length;
        value = *((char*)(fbp + pix_offset));
	if (value) { vram[127-x][7-y/8] |= 1<<(7-y % 8); }
	else       { vram[127-x][7-y/8] &= ~(1<<(7-y % 8)); }
      } // x
    } // y

    // OLED 表示
    drawDisplay();

    // wait
    {
      useconds_t waittime = 100*1000;
      if (argc>1) {
	waittime = strtol(argv[1], NULL, 0) * 1000;
      }
      usleep(waittime);
    }
    
  } // while

  close(fbfd);
  close(lcd);
  return 0;
}
```
</details>

<details><summary>インストールスクリプト Install.sh を表示(クリックで展開)</summary>

```bash:Install.sh
#!/bin/sh

echo
echo "----- OLED Display setup [SSD1306]"

# echo "(Push Enter)"
# read Wait

export LC_ALL=C

cp oled /bin/oled
chmod a+x /bin/oled

export DEBIAN_FRONTEND=noninteractive
apt-get -y install console-data

/bin/grep  "128x64-60" /etc/fb.modes
while [ ${?} = 1 ]
do
cat<<'END1' >> /etc/fb.modes

mode "128x64-60"
    # D: 25.175 MHz, H: 31.469 kHz, V: 59.94 Hz
    geometry 128 64 128 64 8
    timings 39722 48 16 33 10 96 2
endmode
END1
done

cat<<'END2' > /etc/systemd/system/oled.service
[Unit]
Description = oled daemon
After = multi-user.target
#After=display-manager.service
[Service]
ExecStartPre=/bin/bash -c "/usr/bin/sleep 10; /bin/chvt 1; /bin/fbset '128x64-60'; /bin/setfont 'alt-8x8'; export TERM=linux; setterm -blank 0 > /dev/tty1"
ExecStopPost=/bin/bash -c "/bin/fbset '800x600-60'; /bin/setfont 'default8x16'"
ExecStart = /bin/oled
Restart = always
Type = simple
[Install]
WantedBy = multi-user.target
END2

raspi-config nonint do_i2c 0

sed -i -e 's/dtoverlay=vc4-kms-v3d/dtoverlay=vc4-fkms-v3d/' /boot/config.txt
sed -i -e 's/#hdmi_safe=1/hdmi_safe=1/' /boot/config.txt
sed -i -e 's/.*[^6][^0][^D]$/& video=HDMI-A-1:1920x1200@60D/' /boot/firmware/cmdline.txt

systemctl enable oled

# loadkeys jp106

echo --------------------------------------------------------
echo Please execute "/usr/bin/raspi-config" AND Enable I2C!!!
echo --------------------------------------------------------
```
</details>



"oled" と "Install.sh" の二つのファイルを Raspberry Pi にダウンロードしてカレントディレクトリに置き、root ユーザで

```sh
# sh Install.sh
```
を実行します。

:::note info
ここでビットマップフォントなどのパッケージが apt でダウンロードしてインストールされますので、Raspberry Pi がネットワークに接続されている必要があります。
:::

:::note warn
HDMI ディスプレイが接続されていなくても正常に動作するようにするため、Install.sh は OS の設定ファイル (/boot/config.txt, /boot/firmware/cmdline.txt) を変更します。既存のサーバで実行する場合は、念のために実行前に重要なファイルのバックアップを取っておいてください。
:::

### (3) キーボード配列の設定
日本語 USB キーボードを使う場合は、下記の設定が必要です。

https://tksfj17.hatenablog.com/entry/2022/03/19/170942

:::note info
初期状態のまま日本語 USB キーボードを接続すると「|」(パイプ) などの記号が正しく入力できませんので、ここで設定しておきます。
:::

### (4) 再起動
一度 OS を再起動します。

再起動後には OLED ディスプレイにコンソール画面が表示され、Raspberry Pi に接続した USB キーボードからコマンドを実行できるようになります。

<img width="30%" alt="写真" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3962189/0d2aa592-4a49-65c8-7d38-8abd243acbd9.jpeg"><br>

#### OLED を抜き差ししたとき
OS の起動後に<font color="green"><strong>OLED ディスプレイを抜くと表示の更新が止まってしまいますが、差し込みなおして下記のコマンドを実行すると表示が再開します</strong></font> (ウェイトを入れているため10秒ほどかかります)。

```
$ sudo systemctl restart oled
```

# 操作
### コンソール画面の切り替え

 USB キーボードで下記のキー操作をすると画面表示が切り替わります。

|キー操作|画面表示|
|:-|:-|
|[CTRL]+[ALT]+[F1]|コンソール1 (tty1) を表示します。画面の解像度は 128x64 ドットで OLED ディスプレイに表示されます (初期状態)|
|[CTRL]+[ALT]+[F2]|コンソール2 (tty2) を表示します。画面の解像度は本来の高解像度で HDMI ディスプレイに表示されます。HDMI ディスプレイを接続したときはこのモードにします|
|[CTRL]+[ALT]+[F7]|GUI 画面を HDMI ディスプレイに表示します。HDMI ディスプレイを接続したときはこのモードにします|

### サービスの操作
OLED ディスプレイの表示プログラムは Install.sh によって oled という名前でサービスとして登録されます。サービスを停止すると、一時的に OLED の表示が更新されなくなります。また、サービスを無効にすると、OS を起動したときに、恒久的に OLED の表示が開始されなくなります。

サービスの開始/停止/有効化/無効化は下記のコマンドで行います。

- サービスを開始する (ウェイトを入れているため10秒ほどかかります)
```
$ sudo systemctl start oled
```
- サービスを停止する
```
$ sudo systemctl stop oled
```
- サービスを有効にする
```
$ sudo systemctl enable oled
```
- サービスを無効にする
```
$ sudo systemctl disable oled
```

# OLED に何も表示されない場合のチェックポイント
### I2Cの有効化
Install.sh の実行時に I2C は有効になっているはずです。もし I2C が無効になっていると OLED  に何も表示されません。コマンド
```
% raspi-config nonint get_i2c
```
を実行して
```
1
```

と表示される場合は I2C が無効の状態です。

また I2C が有効で OLED が正しく接続されていれば、コマンド

```sh
$ i2cdetect -y 1
```

を実行したときに、下記のように OLED の I2C アドレス 「3c」 が表示されます。

```sh
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
```

I2C が無効になっている場合は、下記のどちらかの手順で有効化してください。

- I2C を raspi-config コマンドで有効化する

https://www.y2c.co.jp/i2c-r/raspberrypi/i2c-enable/?srsltid=AfmBOoqlGJCmGywWs1GLzmz8s4OrnaqFk46YNH85YDjGWO-OqeAiP0Q4

- I2C をコマンドラインで有効化する

https://qiita.com/homelan/items/e8ab8b963e52dfadbc41

:::note warn
I2C アドレスは表示されるのに OLED に何も表示されない場合は、VCC(VDD) 端子と GND 端子の接続が正しいことを確認してください。
::: 

### システムログの確認
最近の Raspberry Pi では、syslog に代わりジャーナルというシステムでシステムログを確認する必要があります。oled 表示サービスの実行結果を確認する場合は、次のコマンドを実行してください。

```
% journalctl | grep oled
```

正常なら最後に下記のログが記録されています

```
Dec 19 08:35:42 pi systemd[1]: Started oled.service - oled daemon.
Dec 19 08:35:42 pi oled[1148]: To get bus access to talk to slave successfully.
Dec 19 08:35:42 pi oled[1148]: The framebuffer device was opened successfully.
Dec 19 08:35:42 pi oled[1148]: vinfo.xres=128, vinfo.yres=64,
vinfo.bits_per_pixel=16
Dec 19 08:35:42 pi oled[1148]: finfo.line_length=2560
```

# 解説
### どのように動作しているのか
Linux のコンソール画面に表示されている内容は、フレームバッファ /dev/fb0 から画像として読むことができます。プログラム oled はフレームバッファの内容を読み取って、定期的に I2C で OLED に出力し続けます。力技ですが、画面のサイズが 128x64 ドットと小さいため、リアルタイムに実行することができます。

### 設定用スクリプト (Install.sh) の動作
apt でパッケージ console-data をインストールします

プログラム /bin/oled を実行するサービス /etc/systemd/system/oled.service を作成して有効にします。このサービスは下記の動作を行います
- コンソールを tty1 に切り替える
- 解像度を 128x64 ドット、リフレッシュレートを 60Hz に変更
- フォントを alt-8x8 に変更
- アイドル時の画面の OFF を無効にする
- I2C を有効にする
- /bin/oled を実行する

Raspberry Pi では、OS の起動時に HDMI 端子に何も接続されていないと、コンソール画面が正しく表示されない現象が発生します。これを解決するために、OS の設定ファイルを修正しています。下記の書籍のページを参考にしました。

https://raspibb1a.blogspot.com/2020/06/raspberry-pi-4.html