【近未来的】ラズパイで透明OLEDディスプレイを使いこなそう

【PR】この記事には広告が含まれています。

【近未来的】ラズパイで透明OLEDディスプレイを使いこなそう

数あるラズパイ用ディスプレイの中でも、トップクラスの近未来感を放つ透明ディスプレイ。コンパクトながらも「つい見とれてしまう」魅力的なアイテムです。

本記事ではRaspberry Piを使い、透明ディスプレイにさまざまな情報を表示するためのテクニックを紹介します。

透明OLEDディスプレイを6枚同時に使用した作例

今回使用する1.51インチ透明 青色OLEDディスプレイ(128×64 SPI/I2C)は、スイッチサイエンスせんごくネット通販で購入できます。メーカーは円形ディスプレイ電子ペーパーなどを製造しているWaveshareです。

透明ディスプレイを使う準備

透明ディスプレイとコントローラーをケーブルで接続します。表裏の向きに注意しましょう。QCのシールが目印になります。

ラズパイ側の準備

使用するRaspberry Piの準備をします。Raspberry Pi OS Trixieのデスクトップ版を使用しました。

OSをインストールする方法は以下の記事で詳しく解説しています。
≫【2026年最新版】OSインストールから初期設定まで|セットアップ手順のすべて

ラズパイと透明ディスプレイの接続

ラズパイと透明ディスプレイは以下のように配線します。配線作業時はラズパイ本体の電源を切りましょう。付属のワイヤは色分けされているため、色に従って簡単に配線ができます。

出典:WaveShareの公式サイト

接続が完了したら、Raspberry Piを起動します。

SPIを有効にする

Raspberry Piと透明ディスプレイは、SPIを通じて通信します。SPI(Serial Peripheral Interface)はデバイス間で、データを通信するためのインターフェースです。初期設定ではSPIが使える状態になっていないため、以下の手順で設定します。

スタートメニューから「設定」→「Control Centre」の順に展開します。

左側の「インターフェイス」タブをクリック後、SPIを有効にします。

設定を有効にしたら、Raspberry Piを再起動します。

フォントのインストール

透明ディスプレイに日本語を表示させるため、Takaoフォントをインストールします。

以下のコマンドを入力して、Enterキーを押します。このとき、安全確認としてパスワード入力が求められます。ラズベリーパイにログインするときのパスワードを入力後、Enterキーを押してください。

sudo apt install -y fonts-takao

表示テストをしてみよう

透明ディスプレイに3Dアニメーションを表示するためのプログラムを実行してみましょう。

スタートメニューの「プログラミング」から「Thonny」を開きます。ThonnyはPythonの統合開発環境(IDE)で、コードの記述、実行、保存などを行うことができます。

以下がThonnyの画面です。後述のコードをコピーペーストして「Run」ボタンを押すと、プログラムが実行されます。

3Dアニメーションを表示するコード

import spidev
import lgpio
import time
from PIL import Image, ImageDraw
from math import sin, cos, radians

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# 拡大率
scale_factor=0.8

# X方向の位置調整
x_offset=65

# Y方向中心座標
Y_CENTER=32

# 三角錐の頂点座標
pyramid=((0,-20,0),(-20,20,-20),(20,20,-20),(0,20,20))

while True:

    # 0〜360度回転
    for angle in range(0,361,10):

        # 座標保存用
        f=[[0.0 for _ in range(3)] for _ in range(4)]

        # ラジアン変換
        r=radians(angle)

        # 各頂点を回転計算
        for i in range(4):

            x1=pyramid[i][2]*sin(r)+pyramid[i][0]*cos(r)

            ya=pyramid[i][1]

            z1=pyramid[i][2]*cos(r)-pyramid[i][0]*sin(r)

            y2=ya*cos(r)-z1*sin(r)

            x3=(x1*cos(r)-y2*sin(r))*scale_factor+x_offset

            y3=(x1*sin(r)+y2*cos(r))*scale_factor+Y_CENTER

            f[i][0]=x3
            f[i][1]=y3

        # 白黒画像作成
        image=Image.new("1",(128,64),0)

        # 描画オブジェクト作成
        draw=ImageDraw.Draw(image)

        # 線を描画
        for i in range(3):

            draw.line(
                (f[0][0],f[0][1],f[i+1][0],f[i+1][1]),
                fill=1
            )

            draw.line(
                (f[i+1][0],f[i+1][1],
                f[(i+1)%3+1][0],f[(i+1)%3+1][1]),
                fill=1
            )

        # SSD1306転送用バッファ
        buffer=[]

        # 8ページ分変換
        for page in range(8):

            for x in range(128):

                byte=0

                for bit in range(8):

                    # ピクセル確認
                    if image.getpixel((x,page*8+bit)):
                        byte|=(1<<bit)

                buffer.append(byte)

        # ディスプレイへ転送
        send(1,buffer)

        time.sleep(0.1)

このプログラムは透明ディスプレイに三角錐の回転アニメーションを表示するものです。まず、SPI接続を設定してディスプレイと通信します。

三角錐の頂点を計算し、それをスケーリングしてディスプレイの特定の位置に合わせます。三角錐は連続して異なる角度から描画され、それによって立体的に回転しているように見えるアニメーションが生成されます。

各頂点の座標は三角関数を用いて計算され、その結果に基づいてディスプレイ上で線が描かれます。この処理は無限ループ内で実行され、ユーザーがキーボード割り込み(Ctrl+C)を行うまで続けられます。割り込みが発生すると、GPIOピンの設定がクリーンアップされ、プログラムが終了します。

プログラムを実行しても透明ディスプレイに何も表示されない場合、配線の接続ミスが考えられます。もう一度、ワイヤーが正しいピンに接続されているかを確認してみましょう。

図形を表示してみよう

PIL(Pillowライブラリ)には、さまざまな図形を描画するための関数が多数用意されています。これらの関数を使えば、シンプルなコードで図形を表示できます。

以下のコードはディスプレイに6種類の図形を描画するためのものです。

import spidev
import lgpio
import time
from PIL import Image, ImageDraw

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

# 表示用画像作成
img = Image.new("1", (128, 64), 0)

# 描画オブジェクト作成
draw = ImageDraw.Draw(img)

# 1. 左下に四角形(塗りつぶし)
draw.rectangle([(10, 48), (30, 64)], fill=1)

# 2. 右上に円(塗りつぶし)
draw.ellipse([(98, 2), (118, 22)], fill=1)

# 3. 左上から右下へ線
draw.line([(0, 0), (127, 63)], fill=1)

# 4. 中央に三角形(枠線のみ)
draw.polygon([(60, 25), (70, 5), (80, 25)], outline=1)

# 5. 中央右に四角形(枠線のみ)
draw.rectangle([(50, 40), (70, 60)], outline=1)

# 6. 左上に円(枠線のみ)
draw.ellipse([(5, 15), (25, 35)], outline=1)

# SSD1306へ転送
send(1, image_to_buffer(img))

図形を描画する際の座標指定について説明します。座標は図形の位置を指定するために使用されます。具体的には、ディスプレイの左上を(0, 0)として、右方向へのX座標と下方向へのY座標で位置を定めます。

各図形の座標の指定方法は次の通りです。

  • 線・・・始点と終点の座標を指定
  • 円・・・外接する四角形の左上と右下の座標を指定
  • 三角形・・・三点の座標をリストとして指定
  • 四角形・・・左上の角と右下の角の座標を指定

文字を表示してみよう

次は文字の表示方法を紹介します。

文字を1行ずつ表示

以下のコードは透明ディスプレイに、日本語のテキストを表示するためのものです。

import spidev
import lgpio
import time
from PIL import Image, ImageDraw, ImageFont
from math import sin, cos, radians

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

# フォントのパスとサイズ設定
font_size=11
font=ImageFont.truetype("/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf",font_size)

# 白黒画像作成
image=Image.new("1",(128,64),0)

# 描画オブジェクト
draw=ImageDraw.Draw(image)

# テキスト描画
draw.text((0,0),"1.51インチ透明",font=font,fill=1)

draw.text((5,30),"青色OLEDディスプレイ",font=font,fill=1)

# 表示
send(1,image_to_buffer(image))

Takaoフォントを使用するために、フォントファイルのパスを指定し、文字のサイズとして11ピクセルを選択します。文字サイズの数値(font_size = 11)は自由に変更できます。

新しい画像オブジェクトを作成した後、この画像上でDrawオブジェクトを使ってテキストを描画します。テキストの位置はxとyの座標を使って指定します。座標の指定方法は、ディスプレイの左上を0として考えます。

テキスト「1.51インチ透明」はx=0、y=0の位置に配置され、これは画像の左上からスタートすることを意味します。次のテキスト「青色OLEDディスプレイ」はx=5、y=30の位置に表示されます。

長文をスクロールして表示

先ほどのコードでは、1行の表示範囲を超える長さの文を表示することができません。そこで、文字を自動的に改行やスクロールしながら表示する方法を紹介します。

import spidev
import lgpio
import time
from PIL import Image, ImageDraw, ImageFont
from math import sin, cos, radians

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

# フォントのパスとサイズ設定
font_size=11
font=ImageFont.truetype("/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf",font_size)

# 行高さ
line_height=font_size+2

# テキストスクロール表示
def draw_text_with_scroll(text):

    lines=[]

    for char in text:

        current_text=(lines[-1] if lines else "")+char

        bbox=font.getbbox(current_text)

        text_width=bbox[2]-bbox[0]

        # 横幅超過で改行
        if text_width>128:
            lines.append(char)

        else:

            if not lines:
                lines.append(current_text)

            else:
                lines[-1]=current_text

        # 縦幅超過でスクロール
        if len(lines)*line_height>64:
            lines.pop(0)

        # 白黒画像作成
        image=Image.new("1",(128,64),0)

        draw=ImageDraw.Draw(image)

        # 行描画
        y=0

        for line in lines:

            draw.text((0,y),line,font=font,fill=1)

            y+=line_height

        # 表示
        send(1,image_to_buffer(image))

        time.sleep(0.04)
        
# 表示テキスト
text="こんにちは、Raspberry Piと透明OLEDディスプレイを使っています。文字がディスプレイの幅を超えた場合は自動的に改行やスクロールを行い、長いテキストも適切に扱えるようにしました。"

draw_text_with_scroll(text)

draw_text_with_scroll関数では、与えられたテキストを一文字ずつ処理し、現在の行のテキストと新しい文字を組み合わせて幅を計算します。テキストの幅がディスプレイの幅を超えると、新しい行を開始します。また、テキストの行がディスプレイの高さを超えた場合には、最上行を削除してスクロールさせる処理を行います。このスクロール処理により、ディスプレイ上では常に最新のテキストが表示され続けます。

関数内の最終行である「time.sleep(0.04)」の数値を変更することにより、文字の表示スピードを調整可能です。

時計を作ろう

これまでに紹介したテクニックを応用して、時計を作ってみましょう。

フォントファイルのダウンロード

時計の雰囲気を盛り上げるために、Seven Segmentフォントという7セグメント風のフォントを利用します。7セグメント(通称:7セグ)は、デジタル時計などでよく使われる表示装置のことで、数字などの文字を表示するために7つの発光部分(セグメント)から構成されています。

通常のフォントから7セグ風のフォントに変更することで、より時計らしい表示の作成が可能です。

フォントの比較

Seven Segmentフォントは個人利用に関しては無料で使用できますが、商用利用の場合は5ドルのライセンス料が必要です。詳細はこちらを確認してください。

出典:dafont.com

フォントファイルは上記のサイトからダウンロードできます。Raspberry Piのブラウザで「ダウンロードボタン」を押すと、ダウンロードフォルダに保存されます。

ターミナルを開き、以下のコマンドを実行して、ZIPファイルを展開し「/home/pi」に移動します。

unzip /home/pi/Downloads/seven_segment.zip -d /home/pi/

手動で移動する場合も、「/home/pi」に以下のように保存します。

/home/piに保存されたフォントファイル

「/home/pi」に正しく保存しないと、後述するコードを実行した際にフォントファイルが読み込めず、エラーが出ます。

デジタル時計のコード

以下のプログラムは、透明ディスプレイにアニメーションと時刻を表示するためのものです。

import spidev
import lgpio
import time
import datetime
from PIL import Image, ImageDraw, ImageFont
from math import sin, cos, radians

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

# フォント
font_path = "/home/pi/Seven Segment.ttf"

font1 = ImageFont.truetype(font_path, 14)
font2 = ImageFont.truetype(font_path, 15)
font3 = ImageFont.truetype(font_path, 25)
font4 = ImageFont.truetype(font_path, 38)

# スケーリング係数
scale_factor = 0.7
x_offset = 25

# 画面中心
Y_CENTER = 32

# 三角錐
pyramid = (
    (0, -20, 0),
    (-20, 20, -20),
    (20, 20, -20),
    (0, 20, 20)
)

scroll_x = 0

try:

    while True:

        for angle in range(0, 361, 10):

            # 描画用画像を作成
            img = Image.new("1", (128, 64), 0)
            draw = ImageDraw.Draw(img)

            # 現在日時を取得
            dt_now = datetime.datetime.now()

            date1 = dt_now.strftime("%m/%d")
            weekday = dt_now.strftime("%a").upper()
            hour = dt_now.strftime("%H")
            minute = dt_now.strftime("%M")
            second = dt_now.strftime("%S")
            second = dt_now.strftime("%S")
            
            scroll_text = dt_now.strftime("%Y/%m/%d %a").upper()

            # 頂点座標格納用
            f = [[0, 0] for _ in range(4)]

            r = radians(angle)

            # 三角錐を回転
            for i in range(4):

                x1 = pyramid[i][2] * sin(r) + pyramid[i][0] * cos(r)
                ya = pyramid[i][1]
                z1 = pyramid[i][2] * cos(r) - pyramid[i][0] * sin(r)

                y2 = ya * cos(r) - z1 * sin(r)

                x3 = (x1 * cos(r) - y2 * sin(r)) * scale_factor + x_offset
                y3 = (x1 * sin(r) + y2 * cos(r)) * scale_factor + Y_CENTER

                f[i][0] = x3
                f[i][1] = y3

            # 三角錐を描画
            for i in range(3):

                draw.line((f[0][0], f[0][1], f[i + 1][0], f[i + 1][1]), fill=1)

                draw.line(
                    (
                        f[i + 1][0],
                        f[i + 1][1],
                        f[(i + 1) % 3 + 1][0],
                        f[(i + 1) % 3 + 1][1]
                    ),
                    fill=1
                )

            # 日時を描画
            scroll_img = Image.new("1", (300, 18), 0)
            scroll_draw = ImageDraw.Draw(scroll_img)

            scroll_draw.text((0, 0), scroll_text, font=font2, fill=1)

            text_width = scroll_draw.textlength(scroll_text, font=font2)

            scroll_draw.text((text_width + 20, 0), scroll_text, font=font2, fill=1)

            crop = scroll_img.crop((scroll_x, 0, scroll_x + 85, 18))

            img.paste(crop, (43, 5))            
            
            draw.text((50, 28), hour, font=font3, fill=1)
            draw.text((71, 12), ":", font=font4, fill=1)
            draw.text((78, 28), minute, font=font3, fill=1)
            draw.text((108, 37), second, font=font2, fill=1)
            
            scroll_x += 2

            if scroll_x >= text_width + 20:
                scroll_x = 0            
            

            # SSD1306へ転送
            send(1, image_to_buffer(img))

            time.sleep(0.1)

except KeyboardInterrupt:
    pass

finally:
    lgpio.gpiochip_close(h)
    spi.close()

無限ループ内で現在の時刻を取得し、三角錐のアニメーションとともに時刻を表示しています。アニメーションはcanvasコンテキストを使用してディスプレイに描画され、時刻や曜日、秒数も同時に表示されます。


画像を表示

透明ディスプレイに画像を表示する方法を紹介します。ここでは、僕が作成した「tenki.png」という画像を表示してみます。

以下のボタンを押すと、画像をダウンロードできます。

tenki.png

tenki.pngはCanvaを使って作成しました。自身で作成する場合は、64×64ピクセル程度のサイズで作成すると良いでしょう。複雑な画像は、つぶれて表示されてしまいます。シンプルで塗りつぶしの少ない、アイコンのような画像が表示に向いています。

ダウンロードした「tenki.png」をラズパイの「/home/pi」に保存します。

以下のコードは透明ディスプレイに画像とテキストを表示するためのものです。

import spidev
import lgpio
import time
from PIL import Image, ImageDraw, ImageFont, ImageOps

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

# 画像読み込み
image = Image.open("tenki.png")

# 色反転
image = ImageOps.invert(image.convert("RGB"))

# 表示用画像作成
img = Image.new("1", (128, 64), 0)

# 画像貼り付け
img.paste(image.convert("1"), (0, 0))

# フォント読み込み
font = ImageFont.truetype(
    "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf",
    11
)

# テキスト描画
draw = ImageDraw.Draw(img)
draw.text((60, 10), "Hello,OLED!", font=font, fill=1)

# SSD1306へ転送
send(1, image_to_buffer(img))

最初に指定されたパスから画像ファイル「tenki.png」を読み込み、その色をRGB形式に変換した後、反転させます。次にディスプレイの解像度に合わせた新しい黒背景の画像を作成し、その背景に読み込んだ画像を左上の座標に配置します。

また、背景画像上に「Hello, OLED!」というテキストを描画します。最終的に、この背景画像をディスプレイに表示することで、画像とテキストが組み合わされた内容を表示します。

ビットコインのチャートを表示

ビットコイン価格をグラフ表示

以下のコードは、ビットコインの価格変動を透明ディスプレイにグラフとして表示するものです。

import spidev
import lgpio
import time
import json
import requests
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont

# SPI初期化
spi=spidev.SpiDev()
spi.open(0,0)
spi.max_speed_hz=8000000

h=lgpio.gpiochip_open(0)
lgpio.gpio_claim_output(h,25)
lgpio.gpio_claim_output(h,27)

# コマンド/データ送信関数
send=lambda dc,d:(lgpio.gpio_write(h,25,dc),spi.writebytes(d))

# ディスプレイをリセット
lgpio.gpio_write(h,27,0);time.sleep(0.05)
lgpio.gpio_write(h,27,1);time.sleep(0.05)

# SSD1306初期化
send(0,[0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,
0x20,0x00,0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,
0xDB,0x40,0xA4,0xA6,0xAF,0x21,0,127,0x22,0,7])

# SSD1306用バッファ変換
def image_to_buffer(image):

    buffer=[]

    for page in range(8):
        for x in range(128):
            byte=0
            for bit in range(8):
                if image.getpixel((x,page*8+bit)):
                    byte|=(1<<bit)
            buffer.append(byte)

    return buffer

def draw_dotted_line(draw, start, end, dash=2):

    x1, y1 = start
    x2, y2 = end

    if y1 == y2:

        for x in range(int(x1), int(x2), dash * 2):
            draw.line((x, y1, min(x + dash, x2), y1), fill=1)

    elif x1 == x2:

        for y in range(int(y1), int(y2), dash * 2):
            draw.line((x1, y, x1, min(y + dash, y2)), fill=1)

# CoinGecko API
url="https://api.coingecko.com/api/v3/coins/bitcoin/market_chart"

params={
    "vs_currency":"jpy",
    "days":80
}

# フォント
font_path="/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf"

font=ImageFont.truetype(font_path,11)
font_s=ImageFont.truetype(font_path,8)

try:

    while True:

        # 価格取得
        response=requests.get(url,params=params)
        response.raise_for_status()

        data=json.loads(response.text)

        prices=[x[1] for x in data["prices"]]

        min_price=min(prices)
        max_price=max(prices)

        sorted_prices=sorted(prices)
        prices_len=len(sorted_prices)

        if prices_len%2==1:
            median_price=sorted_prices[prices_len//2]
        else:
            middle1=sorted_prices[(prices_len//2)-1]
            middle2=sorted_prices[prices_len//2]
            median_price=(middle1+middle2)/2

        formatted_min_price=f"{min_price/1000000:.1f}M"
        formatted_median_price=f"{median_price/1000000:.1f}M"
        formatted_max_price=f"{max_price/1000000:.1f}M"

        last_price=prices[-1]
        formatted_price="{:,}".format(int(last_price))

        # 描画用画像
        img=Image.new("1",(128,64),0)
        draw=ImageDraw.Draw(img)

        # グラフ設定
        width=128
        height=64

        top_margin=13
        bottom_margin=0
        left_margin=22
        right_margin=0

        inner_margin=3

        graph_width=width-left_margin-right_margin
        graph_height=height-top_margin-bottom_margin

        # 現在価格
        draw.text((28,0),f"BTC:{formatted_price}円",font=font,fill=1)

        # 目盛
        draw.text((0,10),formatted_max_price,font=font_s,fill=1)
        draw.text((0,33),formatted_median_price,font=font_s,fill=1)
        draw.text((0,55),formatted_min_price,font=font_s,fill=1)

        # グラフ
        for i in range(1,len(prices)):

            x1=left_margin+inner_margin+(i-1)*(
                (graph_width-inner_margin*2)/(len(prices)-1)
            )

            y1=top_margin+inner_margin+(
                graph_height-inner_margin*2
            )-(
                (prices[i-1]-min_price)
                /(max_price-min_price)
                *(graph_height-inner_margin*2)
            )

            x2=left_margin+inner_margin+i*(
                (graph_width-inner_margin*2)/(len(prices)-1)
            )

            y2=top_margin+inner_margin+(
                graph_height-inner_margin*2
            )-(
                (prices[i]-min_price)
                /(max_price-min_price)
                *(graph_height-inner_margin*2)
            )

            draw.line((x1,y1,x2,y2),fill=1)

        # 枠線
        draw.rectangle(
            [
                left_margin,
                top_margin,
                left_margin+graph_width-1,
                top_margin+graph_height-1
            ],
            outline=1
        )

        # 横中央点線
        middle_y=top_margin+graph_height/2

        draw_dotted_line(
            draw,
            (left_margin,middle_y),
            (width-right_margin-1,middle_y)
        )

        # 縦点線
        third_x1=left_margin+graph_width/3
        third_x2=left_margin+graph_width*2/3

        draw_dotted_line(
            draw,
            (third_x1,top_margin),
            (third_x1,height-bottom_margin-1)
        )

        draw_dotted_line(
            draw,
            (third_x2,top_margin),
            (third_x2,height-bottom_margin-1)
        )

        # OLEDへ転送
        send(1,image_to_buffer(img))

        # 取得時刻表示
        now=datetime.now()
        print(f"取得時刻: {now.strftime('%Y-%m-%d %H:%M:%S')}")

        # 10分待機
        time.sleep(600)

except Exception as e:
    print(e)

except KeyboardInterrupt:
    pass

finally:
    lgpio.gpiochip_close(h)
    spi.close()

まず、CoinGeckoのAPIから過去80日間のビットコイン価格データを日本円単位で取得します。データを受け取った後、最小価格、最大価格、中央値を計算し、軸の目盛の数値として表示する準備をします。さらに、最新の価格を整形してグラフ上部に表示します。

グラフの表示エリアは、ディスプレイのサイズに基づいて計算され、適切なマージンを設定して内部にグラフを描画します。価格データは線グラフとして描かれ、各点は連続して接続されます。グラフの外側には枠が描かれ、縦横の中間点に点線が引かれます。

time.sleep(600)は、プログラムがデータを取得してから次のデータ取得まで10分間待つように設定しています。この待機時間を極端に短くすると、CoinGeckoのAPIサーバーへのアクセスが頻繁になりすぎてしまいます。自動で情報収集する場合は、相手側のサーバーに過度な負荷をかけないよう注意が必要です。

6枚の透明ディスプレイを同時に制御

6枚のディスプレイに気象情報を表示

魅力的な透明ディスプレイですが、サイズが小さいのが難点です。一度に表示できる情報が限られ、見た目の迫力も今ひとつ。現時点では、大型の透明ディスプレイは手に入りません。

そぞら
そぞら

そこで考えたのが、複数の透明ディスプレイを組み合わせて使うというアイデアです。

今回はこの仕組みを使って、気温や湿度、気圧、降水量などの気象情報をリアルタイムに表示する装置を作成しました。

スタンドの作成

3D CADで設計したスタンド

まずは、パソコンのマルチディスプレイのように6枚の透明ディスプレイを並べて設置するためのスタンドを、3Dプリンターで作成しました。


このデザインは、NEKOPLAさんが公開されている作品「株トレーダーみたいなミニチュア机」から影響を受けています。圧倒的な完成度と遊び心に惹かれ、その世界観を取り入れたいと思いました。

作品紹介ページ(NEKOPLAさん):https://note.com/nekopla/n/n00a80ff21389

試作品(4枚バージョンのスタンド)

配線

6枚の透明ディスプレイはすべてSPI接続です。SPI通信は共通のクロックやデータライン(SCLK, MOSI)を共有できますが、CSピンを分けることで1枚ずつ選択して制御できます。

SCLKとMOSIは共通にして、CS(チップセレクト)、DC(データ/コマンド)、RST(リセット)はディスプレイごとに個別のGPIOピンに割り当てました。

CSピンはGPIO 21, 7, 22, 18, 12, 16、DCピンはGPIO 25, 24, 5, 13, 20, 2、RSTピンはGPIO 27, 23, 6, 19, 26, 3を使用しています。電源(3V3)とGNDは全ディスプレイで共通です。

チェックポイント

当初、ディスプレイ1のCSピンにはGPIO 8を割り当てていました。1枚だけを接続している状態では問題なく動作しましたが、ディスプレイを追加したところ、他のディスプレイを更新するたびに、GPIO 8を使っているディスプレイの表示が上書きされる現象が発生しました。

GPIO 8はのSPI0のCS0ピンとしてハードウェア的に制御されており、その影響で意図しないタイミングで選択状態になることが原因と思われます。そこで、ディスプレイ1のCSピンをGPIO 21に変更したところ、不具合が解消されました。

表示テスト

次のコードは6枚のOLEDディスプレイをRaspberry PiにSPI接続し、それぞれに1〜6の番号を表示するものです。

import RPi.GPIO as GPIO
from luma.oled.device import ssd1306
from luma.core.interface.serial import spi
from PIL import Image, ImageDraw, ImageFont

# ====== GPIO設定(6枚分)======
CS_PINS  = [21, 7, 22, 18, 12, 16]
DC_PINS  = [25, 24, 5, 13, 20, 2]
RST_PINS = [27, 23, 6, 19, 26, 3]

GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)

for cs in CS_PINS:
    GPIO.setup(cs, GPIO.OUT)
    GPIO.output(cs, GPIO.HIGH)

# ====== OLEDインスタンスの初期化 ======
devices = []
for i in range(6):
    GPIO.output(CS_PINS[i], GPIO.LOW)
    serial = spi(port=0, device=0, gpio_DC=DC_PINS[i], gpio_RST=RST_PINS[i])
    device = ssd1306(serial)
    devices.append(device)
    GPIO.output(CS_PINS[i], GPIO.HIGH)

# ====== フォント設定 ======
font_path = "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf"
font = ImageFont.truetype(font_path, 68)

# ====== 表示(白背景に黒文字)======
for i in range(6):
    for cs in CS_PINS:
        GPIO.output(cs, GPIO.HIGH)
    GPIO.output(CS_PINS[i], GPIO.LOW)
    image = Image.new('1', (128, 64), color=0)
    draw = ImageDraw.Draw(image)
    draw.text((50, 0), str(i+1), font=font, fill=255)
    devices[i].display(image)
    GPIO.output(CS_PINS[i], GPIO.HIGH)

# 全て非選択に戻す
for cs in CS_PINS:
    GPIO.output(cs, GPIO.HIGH)

まず、CS(チップセレクト)、DC(データ/コマンド切り替え)、RST(リセット)用のGPIOピンをそれぞれ6枚分用意して設定します。

次に、各ディスプレイについて、個別にCSピンをアクティブにして初期化を行い、devices リストにインスタンスとして保存します。続いて、各ディスプレイを順番に選択し、1〜6の番号を表示します。すべての表示が終わったら、CSピンをすべて非アクティブに戻して終了します。

6枚のOLEDそれぞれに異なる番号が表示できることを確認できました。

複数ディスプレイを使った動きのある表示の制御

文字のスクロールやアニメーションなど、動きのある表示をディスプレイごとに個別に制御する必要があります。先ほどの表示確認用のコードでは、1〜6の数字を表示するだけでしたが、ここでは動きのある表示が可能かどうかをテストします。

次のコードではそれぞれのディスプレイに異なる種類の文字を割り当て、スレッドを使って非同期で表示内容を切り替えていきます

そぞら
そぞら

スレッドとは、1つのプログラムの中で複数の処理を並行して実行できる仕組みのことです。

import RPi.GPIO as GPIO
from luma.oled.device import ssd1306
from luma.core.interface.serial import spi
from luma.core.render import canvas
from PIL import ImageFont
import time
import threading

# GPIO定義
CS_PINS  = [21, 7, 22, 18, 12, 16]
DC_PINS  = [25, 24, 5, 13, 20, 2]
RST_PINS = [27, 23, 6, 19, 26, 3]

# 初期化
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
for pin in CS_PINS:
    GPIO.setup(pin, GPIO.OUT)
    GPIO.output(pin, GPIO.HIGH)

devices = []
for i in range(6):
    GPIO.output(CS_PINS[i], GPIO.LOW)
    serial = spi(port=0, device=0, gpio_DC=DC_PINS[i], gpio_RST=RST_PINS[i])
    devices.append(ssd1306(serial))
    GPIO.output(CS_PINS[i], GPIO.HIGH)

# フォント
font_path = "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf"
font = ImageFont.truetype(font_path, 60)

# SPIロック
lock = threading.Lock()

# 共通処理
def draw_centered_text(draw, device_index, char):
    bbox = draw.textbbox((0, 0), char, font=font)
    w = bbox[2] - bbox[0]
    h = bbox[3] - bbox[1]
    draw.text(((128 - w)//2, (64 - h)//2), char, font=font, fill=255)

# 各ディスプレイごとのスレッド処理
def display0_thread():
    chars = list("0123456789")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[0], GPIO.LOW)
            with canvas(devices[0]) as draw:
                draw_centered_text(draw, 0, char)
            GPIO.output(CS_PINS[0], GPIO.HIGH)
        pos += 1
        time.sleep(1)

def display1_thread():
    chars = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[1], GPIO.LOW)
            with canvas(devices[1]) as draw:
                draw_centered_text(draw, 1, char)
            GPIO.output(CS_PINS[1], GPIO.HIGH)
        pos += 1
        time.sleep(0.5)

def display2_thread():
    chars = list("あいうえおかきくけこ")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[2], GPIO.LOW)
            with canvas(devices[2]) as draw:
                draw_centered_text(draw, 2, char)
            GPIO.output(CS_PINS[2], GPIO.HIGH)
        pos += 1
        time.sleep(0.3)

def display3_thread():
    chars = list("漢字東京大阪明日元気")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[3], GPIO.LOW)
            with canvas(devices[3]) as draw:
                draw_centered_text(draw, 3, char)
            GPIO.output(CS_PINS[3], GPIO.HIGH)
        pos += 1
        time.sleep(0.1)

def display4_thread():
    chars = list("♠♥♦♣★☆♪☀☁☂")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[4], GPIO.LOW)
            with canvas(devices[4]) as draw:
                draw_centered_text(draw, 4, char)
            GPIO.output(CS_PINS[4], GPIO.HIGH)
        pos += 1
        time.sleep(0.7)

def display5_thread():
    chars = list("←→↑↓◎◉◇◆■□")
    pos = 0
    while True:
        char = chars[pos % len(chars)]
        with lock:
            GPIO.output(CS_PINS[5], GPIO.LOW)
            with canvas(devices[5]) as draw:
                draw_centered_text(draw, 5, char)
            GPIO.output(CS_PINS[5], GPIO.HIGH)
        pos += 1
        time.sleep(0.5)

# スレッド起動
threads = [
    threading.Thread(target=display0_thread),
    threading.Thread(target=display1_thread),
    threading.Thread(target=display2_thread),
    threading.Thread(target=display3_thread),
    threading.Thread(target=display4_thread),
    threading.Thread(target=display5_thread),
]
for t in threads:
    t.start()

try:
    for t in threads:
        t.join()
except KeyboardInterrupt:
    GPIO.cleanup()

今回は6枚のディスプレイのSCLKとMOSIを共通にして接続しているため、同時に複数のディスプレイに命令を送ると信号が混ざり、表示が乱れる可能性があります。そこで「ロック処理」と呼ばれる仕組みを使い、他の処理が終わるまで一時的に待機させることで、1つのディスプレイに対して順番に安全に通信を行えるようにしています。ロックを使うことで、複数の処理が同時にSPI信号を使おうとしても衝突しないように制御しています。

コードを実行すると、6枚のOLEDディスプレイにそれぞれ異なる種類の文字(数字、英字、ひらがな、漢字、記号、矢印など)が表示され、各ディスプレイごとに設定された異なる間隔で次々に切り替わっていきます。

ロック処理による待機は体感的には気にならないレベルであり、6枚のディスプレイそれぞれに異なる動きのある表示を行えることを確認できました。

OpenWeatherMapのAPIキーを取得

OpenWeatherMap

気象情報はOpenWeatherMapからAPIを使って取得します。OpenWeatherMapは、世界中の天気・気象データを提供しているサービスで、「Free Access(無料アクセス)」という形で無料でも利用できます。無料利用枠では1日あたり1,000件、1分あたり60件までのリクエストが可能です。

APIを使うには、公式サイトで無料アカウントを作成します。画面右上の「Sign in」から「Create an Account」を選び、必要事項を入力して登録を進めます。登録後、入力したメールアドレスに確認メールが届くので、そこから認証を完了させてください。

認証完了後、右上のアカウント名をクリックして「My API keys」セクションに進むと、デフォルトのAPIキーが表示されます。取得したAPIキーは、APIリクエストのパラメータとして使用します。

6枚のOLEDディスプレイに気象データを表示

次のコードは6枚のOLEDディスプレイを使って、天気に関するさまざまな情報を表示するプログラムです。

import RPi.GPIO as GPIO
from luma.oled.device import ssd1306
from luma.core.interface.serial import spi
from luma.core.render import canvas
from PIL import Image, ImageDraw, ImageFont
import time
import threading
from math import sin, cos, radians
import requests
from datetime import datetime, timedelta
from bs4 import BeautifulSoup

# 気象データ共有クラス
class WeatherDataStore:
    def __init__(self):
        self.lock = threading.Lock()
        self.temps = []
        self.pressures = []
        self.humidities = []
        self.rains = []
        self.icon_code = ""
        self.summary = ""

    def update(self, temps, pressures, humidities, rains, summary=""):
        with self.lock:
            self.temps = temps
            self.pressures = pressures
            self.humidities = humidities
            self.rains = rains
            self.summary = summary            

    def get_data(self):
        with self.lock:
            return (
                self.temps[:],
                self.pressures[:],
                self.humidities[:],
                self.rains[:],
                self.summary
            )            


# インスタンス作成(全スレッドで共有)
weather_store = WeatherDataStore()

# データ取得スレッド
def weather_fetch_thread():
    api_key = 'OPENWEATHERMAP_API_KEY'# OpenWeatherMap の APIキー ※ 自分のAPIキーに置き換えてください。
    city = 'Shizuoka,JP' # 天気予報を取得したい都市("都市名,国コード" の形式)
    url = 'https://api.openweathermap.org/data/2.5/forecast'
    params = {'q': city, 'appid': api_key, 'units': 'metric', 'lang': 'en'}
    
    pref_name = "静岡"  # 気象庁の天気概況(日本語の天気概要)取得に使用する都道府県名

    while True:
        try:
            res = requests.get(url, params=params)
            data = res.json()
            temps = [e['main']['temp'] for e in data['list'][:16]]
            pressures = [e['main']['pressure'] for e in data['list'][:16]]
            humidities = [e['main']['humidity'] for e in data['list'][:16]]
            rains = [e.get('rain', {}).get('3h', 0.0) for e in data['list'][:16]]
            
            summary_text = ""
            try:
                feed_url = "https://www.data.jma.go.jp/developer/xml/feed/regular.xml"
                feed_res = requests.get(feed_url)
                soup = BeautifulSoup(feed_res.content, "xml")

                for entry in soup.find_all("entry"):
                    if entry.find("title").text.strip() != "府県天気概況":
                        continue

                    xml_url = entry.find("link")["href"]
                    xml_res = requests.get(xml_url)
                    xml_soup = BeautifulSoup(xml_res.content, "xml")

                    target_area = xml_soup.find("TargetArea")
                    if target_area and pref_name in target_area.text:
                        text_tag = xml_soup.find("Text", {"type": "本文"})
                        if text_tag:
                            full_text = text_tag.text.strip()
                            if "【" in full_text:
                                summary_text = full_text.split("【")[0].strip()
                            else:
                                summary_text = full_text
                        break            

            except Exception as e:
                print("天気概況取得エラー:", e)

            weather_store.update(
                temps=temps,
                pressures=pressures,
                humidities=humidities,
                rains=rains,
                summary=summary_text
            )
            
            print("データ取得完了")
        except Exception as e:
            print("取得エラー:", e)
            
        time.sleep(3600)  # 1時間ごと
        
def draw_dotted_line(draw, start, end, interval=3, dot_length=1):
    x1, y1 = start
    x2, y2 = end
    total_length = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
    num_dots = int(total_length / interval)

    for i in range(num_dots):
        dot_start = i * interval + (interval - dot_length) / 2
        dot_end = dot_start + dot_length
        dot_start_x = x1 + (x2 - x1) * (dot_start / total_length)
        dot_start_y = y1 + (y2 - y1) * (dot_start / total_length)
        dot_end_x = x1 + (x2 - x1) * (dot_end / total_length)
        dot_end_y = y1 + (y2 - y1) * (dot_end / total_length)
        draw.line([(dot_start_x, dot_start_y), (dot_end_x, dot_end_y)], fill="white")        

# ====== GPIO設定(6枚分)======
CS_PINS  = [21, 7, 22, 18, 12, 16]
DC_PINS  = [25, 24, 5, 13, 20, 2]
RST_PINS = [27, 23, 6, 19, 26, 3]

GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
for pin in CS_PINS:
    GPIO.setup(pin, GPIO.OUT)
    GPIO.output(pin, GPIO.HIGH)

# デバイス初期化
devices = []
for i in range(6):
    GPIO.output(CS_PINS[i], GPIO.LOW)
    serial = spi(port=0, device=0, gpio_DC=DC_PINS[i], gpio_RST=RST_PINS[i])
    devices.append(ssd1306(serial))
    GPIO.output(CS_PINS[i], GPIO.HIGH)

font_path = "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf"
font = ImageFont.truetype(font_path, 10)
font_s = ImageFont.truetype(font_path, 8)
        
lock = threading.Lock()

# Display 1: アニメーション
def display1_thread():
    font1 = ImageFont.truetype(font_path, 13)
    font2 = ImageFont.truetype(font_path, 15)
    font3 = ImageFont.truetype(font_path, 25)

    scale_factor = 0.7
    x_offset = 25
    Y_CENTER = 32

    pyramid = ((0, -20, 0), (-20, 20, -20), (20, 20, -20), (0, 20, 20))

    while True:
        for angle in range(0, 361, 10):
            now = datetime.now()
            date1 = now.strftime('%b %-d')
            weekday = now.strftime('%a').upper()
            time1 = now.strftime('%H:%M')
            second = now.strftime('%S')
            f = [[0.0, 0.0] for _ in range(4)]
            r = radians(angle)

            for i in range(4):
                x1 = pyramid[i][2] * sin(r) + pyramid[i][0] * cos(r)
                ya = pyramid[i][1]
                z1 = pyramid[i][2] * cos(r) - pyramid[i][0] * sin(r)
                y2 = ya * cos(r) - z1 * sin(r)
                x3 = (x1 * cos(r) - y2 * sin(r)) * scale_factor + x_offset
                y3 = (x1 * sin(r) + y2 * cos(r)) * scale_factor + Y_CENTER
                f[i] = [x3, y3]

            with lock:
                GPIO.output(CS_PINS[0], GPIO.LOW)
                with canvas(devices[0]) as draw:
                    for i in range(3):
                        draw.line((f[0][0], f[0][1], f[i+1][0], f[i+1][1]), fill="white")
                        draw.line((f[i+1][0], f[i+1][1], f[(i+1)%3+1][0], f[(i+1)%3+1][1]), fill="white")

                    draw.text((46, 5), date1, font=font2, fill="white")
                    draw.text((100, 5), weekday, font=font2, fill="white")
                    draw.text((46, 29), time1, font=font3, fill="white")
                    draw.text((114, 38), second, font=font1, fill="white")
                GPIO.output(CS_PINS[0], GPIO.HIGH)

            time.sleep(0.1)

# Display 2: 
def display2_thread():
    while True:
        try:
            _, _, humidities, _, _ = weather_store.get_data()
            if not humidities:
                time.sleep(1)
                continue

            values = humidities
            title = "Humidity (%)"

            # 湿度は0〜100に固定
            min_v = 0
            max_v = 100

            width = devices[1].width
            height = devices[1].height
            margin = 5
            left_label_width = 16
            bottom_label_height = 10
            top_padding = 10

            graph_width = width - margin - left_label_width
            graph_height = height - margin - bottom_label_height - top_padding
            step_x = graph_width / (len(values) - 1)
            fixed_lines = 5

            with lock:
                GPIO.output(CS_PINS[1], GPIO.LOW)
                with canvas(devices[1]) as draw:
                    draw.text((margin + 20, 0), title, font=font, fill=255)

                    # 補助線とラベル(0〜100を等間隔に)
                    for i in range(fixed_lines):
                        ratio = i / (fixed_lines - 1)
                        label_value = max_v - ratio * (max_v - min_v)
                        y = int(top_padding + ratio * graph_height)
                        draw_dotted_line(draw, (left_label_width, y), (width - margin, y))
                        draw.text((0, y - 4), f"{int(label_value)}", font=font_s, fill=255)

                    # 縦の点線(1/4、1/2、3/4)
                    for x in [
                        left_label_width + graph_width // 4,
                        left_label_width + graph_width // 2,
                        left_label_width + (graph_width * 3) // 4
                    ]:
                        draw_dotted_line(draw, (x, top_padding), (x, top_padding + graph_height))

                    # 折れ線グラフ(四捨五入しない)
                    for i in range(len(values) - 1):
                        x1 = int(left_label_width + i * step_x)
                        x2 = int(left_label_width + (i + 1) * step_x)
                        y1 = int(top_padding + (max_v - values[i]) / (max_v - min_v) * graph_height)
                        y2 = int(top_padding + (max_v - values[i + 1]) / (max_v - min_v) * graph_height)
                        draw.line((x1, y1, x2, y2), fill=255)

                    # グラフ枠線
                    draw.rectangle(
                        (left_label_width, top_padding, left_label_width + graph_width, top_padding + graph_height),
                        outline=255
                    )

                    # 横軸ラベル(仮で0d, 1d)
                    for i in range(2):
                        x = int(left_label_width + (i * 8 * step_x)) - 5
                        draw.text((x, height - bottom_label_height), f"{i}d", font=font_s, fill=255)

                GPIO.output(CS_PINS[1], GPIO.HIGH)
            time.sleep(60)

        except Exception as e:
            print(f"エラー in display1: {e}")
            time.sleep(60)    

# Display 3: 
def display3_thread():
    while True:
        try:
            _, pressures, _, _, _= weather_store.get_data()
            if not pressures:
                time.sleep(1)
                continue

            values = pressures
            title = "Pressure (hPa)"
            min_v = min(values)
            max_v = max(values)

            # 値の差に応じて余白(padding)を計算。ただし差が小さい場合は、最低5を確保する
            padding = max((max_v - min_v) * 0.1, 5)  
            min_v -= padding
            max_v += padding

            width = devices[2].width
            height = devices[2].height
            margin = 5
            left_label_width = 16
            bottom_label_height = 10
            top_padding = 10

            graph_width = width - margin - left_label_width
            graph_height = height - margin - bottom_label_height - top_padding
            step_x = graph_width / (len(values) - 1)
            fixed_lines = 5

            with lock:
                GPIO.output(CS_PINS[2], GPIO.LOW)
                with canvas(devices[2]) as draw:
                    draw.text((margin + 20, 0), title, font=font, fill=255)

                    for i in range(fixed_lines):
                        ratio = i / (fixed_lines - 1)
                        label_value = max_v - ratio * (max_v - min_v)
                        y = int(top_padding + ratio * graph_height)
                        draw_dotted_line(draw, (left_label_width, y), (width - margin, y))
                        draw.text((0, y - 4), f"{int(label_value)}", font=font_s, fill=255)

                    quarter_x1 = left_label_width + graph_width // 4
                    center_x = left_label_width + graph_width // 2
                    quarter_x3 = left_label_width + (graph_width * 3) // 4

                    draw_dotted_line(draw, (quarter_x1, top_padding), (quarter_x1, top_padding + graph_height))
                    draw_dotted_line(draw, (center_x, top_padding), (center_x, top_padding + graph_height))
                    draw_dotted_line(draw, (quarter_x3, top_padding), (quarter_x3, top_padding + graph_height))

                    for i in range(len(values) - 1):
                        x1 = int(left_label_width + i * step_x)
                        x2 = int(left_label_width + (i + 1) * step_x)
                        y1 = int(top_padding + (max_v - values[i]) / (max_v - min_v) * graph_height)
                        y2 = int(top_padding + (max_v - values[i + 1]) / (max_v - min_v) * graph_height)
                        draw.line((x1, y1, x2, y2), fill=255)

                    draw.rectangle(
                        (left_label_width, top_padding, left_label_width + graph_width, top_padding + graph_height),
                        outline=255
                    )

                    for i in range(2):
                        x = int(left_label_width + (i * 8 * step_x)) - 5
                        draw.text((x, height - bottom_label_height), f"{i}d", font=font_s, fill=255)

                GPIO.output(CS_PINS[2], GPIO.HIGH)

            time.sleep(60)

        except Exception as e:
            print(f"エラー: {e}")
            time.sleep(60)

# Display 4:
def display4_thread():
    while True:
        try:
            temps, _, _, _, _= weather_store.get_data()
            if not temps:
                time.sleep(1)
                continue

            values = temps
            title = "Temperature (°C)"
            min_v = min(values)
            max_v = max(values)
            padding = (max_v - min_v) * 0.1
            min_v -= padding
            max_v += padding

            width = devices[3].width
            height = devices[3].height
            margin = 5
            left_label_width = 16
            bottom_label_height = 10
            top_padding = 10

            graph_width = width - margin - left_label_width
            graph_height = height - margin - bottom_label_height - top_padding
            step_x = graph_width / (len(values) - 1)
            fixed_lines = 5

            with lock:
                GPIO.output(CS_PINS[3], GPIO.LOW)
                with canvas(devices[3]) as draw:
                    draw.text((margin + 20, 0), title, font=font, fill=255)

                    for i in range(fixed_lines):
                        ratio = i / (fixed_lines - 1)
                        label_value = max_v - ratio * (max_v - min_v)
                        y = int(top_padding + ratio * graph_height)
                        draw_dotted_line(draw, (left_label_width, y), (width - margin, y))
                        draw.text((0, y - 4), f"{round(label_value)}", font=font_s, fill=255)

                    quarter_x1 = left_label_width + graph_width // 4
                    center_x = left_label_width + graph_width // 2
                    quarter_x3 = left_label_width + (graph_width * 3) // 4

                    draw_dotted_line(draw, (quarter_x1, top_padding), (quarter_x1, top_padding + graph_height))
                    draw_dotted_line(draw, (center_x, top_padding), (center_x, top_padding + graph_height))
                    draw_dotted_line(draw, (quarter_x3, top_padding), (quarter_x3, top_padding + graph_height))

                    for i in range(len(values) - 1):
                        y1 = round(top_padding + (max_v - values[i]) / (max_v - min_v) * graph_height)
                        y2 = round(top_padding + (max_v - values[i + 1]) / (max_v - min_v) * graph_height)
                        x1 = int(left_label_width + i * step_x)
                        x2 = int(left_label_width + (i + 1) * step_x)
                        draw.line((x1, y1, x2, y2), fill=255)

                    draw.rectangle(
                        (left_label_width, top_padding, left_label_width + graph_width, top_padding + graph_height),
                        outline=255
                    )

                    for i in range(2):
                        x = int(left_label_width + (i * 8 * step_x)) - 5
                        draw.text((x, height - bottom_label_height), f"{i}d", font=font_s, fill=255)

                GPIO.output(CS_PINS[3], GPIO.HIGH)

            time.sleep(60)  # 毎分更新で十分

        except Exception as e:
            print(f"エラー in display4: {e}")
            time.sleep(60)
            
# Display 5:
def display5_thread():
    while True:
        try:
            _, _, _, rains, _ = weather_store.get_data()
            if not rains:
                time.sleep(1)
                continue

            values = rains
            title = "Rainfall (mm)"
            min_v = 0  # 降水量は 0 からスタート
            max_v = round(max(values + [0.0]) + 1.0)  # 最大値 + 1.0 mm

            width = devices[4].width
            height = devices[4].height
            margin = 5
            left_label_width = 16
            bottom_label_height = 10
            top_padding = 10

            graph_width = width - margin - left_label_width
            graph_height = height - margin - bottom_label_height - top_padding
            bar_width = graph_width / len(values)
            fixed_lines = 5

            with lock:
                GPIO.output(CS_PINS[4], GPIO.LOW)
                with canvas(devices[4]) as draw:
                    draw.text((margin + 20, 0), title, font=font, fill=255)

                    # 補助線とラベル
                    for i in range(fixed_lines):
                        ratio = i / (fixed_lines - 1)
                        label_value = max_v - ratio * (max_v - min_v)
                        y = int(top_padding + ratio * graph_height)
                        draw_dotted_line(draw, (left_label_width, y), (width - margin, y))
                        draw.text((0, y - 4), f"{label_value:.1f}", font=font_s, fill=255)

                    # 縦の点線(1/4, 1/2, 3/4)
                    for x in [
                        left_label_width + graph_width // 4,
                        left_label_width + graph_width // 2,
                        left_label_width + (graph_width * 3) // 4
                    ]:
                        draw_dotted_line(draw, (x, top_padding), (x, top_padding + graph_height))

                    # 棒グラフ
                    for i, value in enumerate(values):
                        x = int(left_label_width + i * bar_width)
                        h = int((value - min_v) / (max_v - min_v) * graph_height)
                        y_top = top_padding + graph_height - h
                        y_bottom = top_padding + graph_height
                        draw.rectangle((x, y_top, x + int(bar_width) - 1, y_bottom), fill=255)

                    # 枠線
                    draw.rectangle(
                        (left_label_width, top_padding, left_label_width + graph_width, top_padding + graph_height),
                        outline=255
                    )

                    # 横軸ラベル(例:0d, 1d)
                    for i in range(2):
                        x = int(left_label_width + (i * 8 * bar_width)) - 5
                        draw.text((x, height - bottom_label_height), f"{i}d", font=font_s, fill=255)

                GPIO.output(CS_PINS[4], GPIO.HIGH)

            time.sleep(60)

        except Exception as e:
            print(f"エラー in display5: {e}")
            time.sleep(60)


# Display 6:
def display6_thread():
    font_news = ImageFont.truetype(font_path, 12)
    font_size_s = 12

    device = devices[5]

    top_margin = 5
    offset_x = 5
    line_height = font_size_s + 7
    text_area_width = device.width - offset_x + 14

    last_summary = ""

    while True:
        _, _, _, _, summary = weather_store.get_data()
        if not summary:
            time.sleep(1)
            continue
        last_summary = summary

        sentences = [s.strip() + "。" for s in summary.split("。") if s.strip()]

        for sentence in sentences:
            lines = []
            current_line = ""

            for char in sentence:
                test_line = current_line + char
                bbox = font_news.getbbox(test_line)
                line_width = bbox[2] - bbox[0]

                if line_width > (text_area_width-12):
                    lines.append(current_line)
                    current_line = char
                else:
                    current_line = test_line

                # 最大2行+current_lineまで
                display_lines = lines[-2:]

                image = Image.new("1", (device.width, device.height))
                draw = ImageDraw.Draw(image)

                text_y = top_margin
                for line in display_lines:
                    draw.text((offset_x, text_y), line, font=font_news, fill="white")
                    text_y += line_height
                draw.text((offset_x, text_y), current_line, font=font_news, fill="white")

                with lock:
                    GPIO.output(CS_PINS[5], GPIO.LOW)
                    device.display(image)
                    GPIO.output(CS_PINS[5], GPIO.HIGH)

                time.sleep(0.1)
            time.sleep(1)    

        time.sleep(0.1)

# スレッド起動
threads = [
    threading.Thread(target=weather_fetch_thread),
    threading.Thread(target=display1_thread),
    threading.Thread(target=display2_thread),
    threading.Thread(target=display3_thread),
    threading.Thread(target=display4_thread),
    threading.Thread(target=display5_thread),
    threading.Thread(target=display6_thread),
]    

for t in threads:
    t.start()

try:
    for t in threads:
        t.join()
except KeyboardInterrupt:
    GPIO.cleanup()

OpenWeatherMapのAPIから静岡市の3時間ごとの天気予報を取得し、その中から16件(48時間分)の温度・湿度・気圧・降水量を抽出して、それぞれグラフで表示します。折れ線グラフでは、各データ点の数値を縦方向の位置に変換し、隣り合う点を線でつなぐことで変化の流れを表現しています。

また、気象庁が提供するXML形式のデータから静岡県の天気概況のテキストを取得し、スクロール表示します。これらのデータは1時間ごとに自動で更新されます。

各ディスプレイは個別のスレッドで制御されており、時刻表示には3Dピラミッド風のアニメーションも含まれています。

プログラムを応用すれば、表示する内容を自由に変更できます。たとえば、ニュースの見出しやビットコインの価格、CPUの状態などを表示することも可能です。

6枚のディスプレイでスクロール表示

透明ディスプレイを6枚リング状に並べて、株価がぐるぐる回る「東証チッカー風ディスプレイ」を作ってみました。透明ディスプレイは裏側からも表示が透けて見えるため、表と裏に画面があるような視覚効果も楽しめます。

東証チッカーのイメージ

Raspberry Piとディスプレイの接続方法は先ほどと同じです。

ベース部分は3Dプリンターで作成しました。

次のコードは、透明ディスプレイを6枚使って株価情報をスクロール表示する仕組みを実装したものです。

import yfinance as yf
import time
import RPi.GPIO as GPIO
from luma.oled.device import ssd1306
from luma.core.interface.serial import spi
from PIL import Image, ImageDraw, ImageFont
import unicodedata

# ====== GPIOピンの設定 ======
CS_PINS  = [21, 7, 22, 18, 12, 16]
DC_PINS  = [25, 24, 5, 13, 20, 2]
RST_PINS = [27, 23, 6, 19, 26, 3]

GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
for cs in CS_PINS:
    GPIO.setup(cs, GPIO.OUT)
    GPIO.output(cs, GPIO.HIGH)

# ====== フォント(24px) ======
font_path = "/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf"
font = ImageFont.truetype(font_path, 24)

devices = []

for i in range(6):
    GPIO.output(CS_PINS[i], GPIO.LOW)
    serial = spi(port=0, device=0, gpio_DC=DC_PINS[i], gpio_RST=RST_PINS[i])
    device = ssd1306(serial)
    devices.append(device)
    GPIO.output(CS_PINS[i], GPIO.HIGH)

companies = {
    "9432.T": "NTT",
    "8035.T": "東京エレクトロン",    
    "6902.T": "デンソー",
    "4063.T": "信越化学",
    "6367.T": "ダイキン",
    "7203.T": "トヨタ",
    "6758.T": "ソニー",
    "9984.T": "ソフトバンク",
    "8306.T": "三菱UFJ",
    "6861.T": "キーエンス",
    "6098.T": "リクルート",
    "^N225": "日経平均"
}

# ====== 表示サイズと余白 ======
name_width = 70
price_width = 70
diff_width = 70
gap = 0
block_width = name_width + price_width + diff_width + gap

display_width = 128 * len(devices)
display_height = 64

# ====== 描画準備 ======
dummy_img = Image.new("1", (1, 1))
dummy_draw = ImageDraw.Draw(dummy_img)

# データ取得と描画領域の計算
stock_data = []
for ticker, name in companies.items():
    stock = yf.Ticker(ticker)
    price = int(stock.fast_info["lastPrice"])
    prev_close = int(stock.fast_info["previousClose"])
    diff = price - prev_close
    sign = "+" if diff >= 0 else "-"
    diff_text = f"{sign}{abs(diff)}"
    stock_data.append((name, str(price), diff_text))

canvas_width = len(stock_data) * block_width
image = Image.new("1", (canvas_width, display_height))
draw = ImageDraw.Draw(image)

# 各行を右揃えで描画
x = 0
for name, price, diff in stock_data:
    name_x = x + name_width - dummy_draw.textbbox((0, 0), name, font=font)[2]
    price_x = x + price_width - dummy_draw.textbbox((0, 0), price, font=font)[2]
    diff_x = x + diff_width - dummy_draw.textbbox((0, 0), diff, font=font)[2]

    draw.text((name_x, 0), name, font=font, fill=255)
    draw.text((price_x, 21), price, font=font, fill=255)
    draw.text((diff_x, 42), diff, font=font, fill=255)

    x += block_width

# ====== スクロール範囲 ======
scroll_width = canvas_width
start_offset = -display_width
end_offset = scroll_width

# ====== 無限スクロールループ ======
while True:
    for offset in range(start_offset, end_offset, 2):
        for i in range(len(devices)):
            for cs in CS_PINS:
                GPIO.output(cs, GPIO.HIGH)
            GPIO.output(CS_PINS[i], GPIO.LOW)

            right_index = len(devices) - 1 - i
            x_start = offset + right_index * 128
            x_end = x_start + 128
            frame = image.crop((x_start, 0, x_end, display_height))

            devices[i].display(frame)
            GPIO.output(CS_PINS[i], GPIO.HIGH)

        time.sleep(0.01)

表示する銘柄はNTTやトヨタ、ソフトバンクなど、日本の代表的な企業10社と日経平均をリスト化しています。yfinanceライブラリを使って、それぞれの株価と前日比を取得し、銘柄名・株価・差額を並べた画像を1枚の大きなキャンバスとして描画します。

描画後は、6枚のディスプレイに表示させる部分を左右に少しずつずらしながら切り出して表示することで、全体がリング状にスクロールしているような演出を実現しています。スクロールは高速でループしており、株価が絶えず回転して見えるようになっています。

コメント一覧

吉田純造

文字を表示するためだけでも大変だな

ssd1306のように 簡単なライブラリーがあればいいが

返信する
takaf

素晴らしい記事をありがとうございます!
とても参考になりました!

返信する
吉田純造

本当に素晴らしいプログラムを公開していただきありがとうございます 先生の作品は全て夢があります
ありがとうございます

返信する
poor man

このディスプレイAmazonだと約6000円ですが、6枚ってことは3.5万円くらい払ったってことですか!?
簡単には真似できません(>_<)

返信する
そぞら

コメントありがとうございます!

実は8枚購入して8連ディスプレイにも挑戦したことがあります(笑)

ただ、8枚になると動作が安定せず、最終的には6枚構成に落ち着きました。

出費は大きかったですが、その分、市販品では実現できないような唯一無二の表示システムを作れた感動も大きかったです。試してみたからこそ分かったことも多く、良い経験になりました。

返信する

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です