2025-11-07 , ,

PCから電子ペーパーに予定表を表示する(Waveshare13.3インチ6色)

前回、PCからUSB-SPI変換基板を通じて3.5インチ(4色)の電子ペーパーモジュールに画像を表示できました。

しかしやはり3.5インチでは小さくて物足りません。ここはもっと大きなパネルに表示させてみたいところ。

もっと大きなパネルを購入

しかし大きな電子ペーパーモジュールはどこで買えるのでしょうか。検索してもなかなか良いものが見つかりません。国内の電子工作部品を扱っているショップには10インチを超える製品の取扱が少ないようです。そんな中で見つけたのがAmazonで売っていた13.3インチ(6色)の製品。

13.3インチ SPI カラー 1600×1200 Full-Color 電子ペーパー モジュール E-ink Epaper ディスプレイ スクリーン HAT スターターキット for Arduino RPI Raspberry Pi Zero 2 W 3 4B 4 Model B 5 ボード 互換性 8GB 16GB RAM ラズベリーパイ 基板 ラズパイゼロ 2W 電子工作 部品 アクセサリー

出荷元/販売元はSTEMDIYという住所が中国の業者。おそらく海外から発送されてくるパターンでしょう。実際に表示できたという写真付きのレビューもありました。なのでいったんはここで注文したのですが、少々勘違いがあってすぐにキャンセルしてしまいました。またすぐに勘違いに気がつきましたが、キャンセルのキャンセルは出来ないので普通にキャンセル確定。更にすぐにもう一度注文するのも気が引けたので、落ち着いてネットで再度探してみるとAliExpressでもう少し安い値段で売られているのを見つけました。

13.3インチE Ink Spectra 6 (E6)フルカラー電子ペーパーディスプレイ 1600x1200ピクセル 価格タグや棚ラベルに最適 - AliExpress 44

もうこれでいいやと思い「with driver HAT」と書かれている方を注文。支払いはコンビニ払いで。ファミリーマートに行ってレジでバーコードを見せて38757円を支払いました。AliExpressは初めてです。大丈夫なのかなこれ。

翌日の15時くらいに発送の連絡が来て、中国での集荷、空港着、日本の空港着、通関完了、配送業者着と逐一連絡が届き、最終的には佐川急便の手によって自宅に届いたのが発送から8日後でした。

開封の儀

到着した箱はビニールテープでグルグル巻きにされていました。テープの接着剤の臭いがします。

到着した箱
図1: 到着した箱

何この綺麗な箱!

Waveshare 13.3インチ e-Paper(E6) 外箱
図2: Waveshare 13.3インチ e-Paper(E6) 外箱
Waveshare 13.3インチ e-Paper(E6) 内容
図3: Waveshare 13.3インチ e-Paper(E6) 内容

パネルは、薄くて軽い金属板のようです。しなやかさはありません。

Waveshare 13.3インチ e-Paper(E6) E-Inkパネル
図4: Waveshare 13.3インチ e-Paper(E6) E-Inkパネル

付属品。「width Driver HAT」を選んでいたのでちゃんと駆動用のボード(HAT+)が付いてきました。間違えてたらどうしようと心配だったんです。よかった。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図5: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ケーブル接続

マイコンボードに接続するのに最適なピンコネクタ付きケーブルは今回も付属しています。

Waveshare 13.3インチ e-Paper(E6) Driver HAT+
図6: Waveshare 13.3インチ e-Paper(E6) Driver HAT+

ただしピンの本数は10本。3.5インチモジュールは9本だったので1本増えています。CSがCS_MとCS_Sの二つになっています(MASTERとSLAVE)。

一方パネルと接続する側は、フィルム基板と接続するためのコネクタが付いています。

バックフリップ型FPCコネクタ
図7: バックフリップ型FPCコネクタ

これは後ろ側の黒い部分を持ち上げるとコネクタが緩むようになっており、フィルムを差し込んでから下ろすとロックされます。

FPCコネクタ接続後
図8: FPCコネクタ接続後

さて、これらをPCと接続するのには前回と同様にUSB-SPI変換基板を使用します。

USB-SPI変換基板 — スイッチサイエンス

HAT+とUSB-SPI変換基板との接続はCS信号が2本になっているためPmodコネクタだけではピンの数が足りません。USB-SPI変換基板にピンヘッダーを半田付けして、そこのIO7にCS_Sを接続することにしました。今回唯一の半田付け箇所です。

接続表:

e-Paper Pmod 2A MCP2210 Dir
#1(赤)VCC #6(VCC) VCC  
#2(黒)GND #5(GND) GND  
#3(青)DIN #2(MOSI) MOSI Out
#4(黄)SCLK #4(SCLK) SCLK Out
#5(橙)CS_M #1(CS) IO1 Out
#6(白)DC #9(CS2) IO2 Out
#7(紫)RST #8(RESET) IO0 Out
#8(茶)BUSY #7(INT) IO6 In
#9(灰)PWR #10(CS3) IO4 Out
#10(緑)CS_S - IO7 Out

画像の表示

早速PCと接続してプログラムを手直しして実行してみましたが、画像を表示させようとするとリフレッシュが始まって画面が少し書き換わった後に例外が発生して終了してしまいました。画面を白一色で塗りつぶすだけならうまく行くのですが、写真を表示させようとするとダメです。

Waveshareの資料を見ると3.3V/1A以上の電源を推奨と書かれていました。(https://www.waveshare.com/wiki/13.3inch_e-Paper_HAT+_(E)_Manual#Resources )

Question: When the main control board such as STM32, ESP32, and Arduino drives the e-Paper screen, it cannot be driven, and the main control board keeps restarting

Answer: The power supply is insufficient, the current required for the e-Paper screen and the driver board to run is relatively large, it is recommended to use a power supply of 3.3V/1A or more to power it

どうもバスパワーでは足りなそうです。幸いHAT+もUSB-SPI変換基板も5Vに対応しているので、適当なUSB電源アダプタから5Vを取ることにしました(電源の入れっぱなしが気になったので、後にPC連動型のセルフパワーUSBハブを使うようにしました)。こんなこともあろうかと先日スイッチサイエンスでUSB QI Cableを買っておいたのでそれを使います。GNDは共通にして、HAT+のVCCはUSB電源アダプタから取るようにしました。USB-SPI変換基板には5Vと3.5Vを切り替えるスイッチがあるので、それも5V側にしておきます。この辺りは電気に詳しい人なら色々と注意点があるところかもしれません。全ては自己責任でお願いします。

配線
図9: 配線

そうして再度実行すると、無事に画面の書き換えが完了しました。

写真の表示例
図10: 写真の表示例

転送には3~4分 、リフレッシュには20秒くらいかかりますが。

表示に使ったコードは次の通り。色々作りかけです。

# Usage: python epaper_print.py <imagefile>
import argparse
import time
import math
import logging
import mcp2210
import hid
from abc import ABC, abstractmethod
from typing import Sequence, Iterable
from PIL import Image, ImagePalette

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

# Bridge

class EPaperBridge(ABC):
    """ホストコンピュータからEPaperディスプレイへの通信を担うブリッジ"""

    @abstractmethod
    def close(self):
        ...

    @abstractmethod
    def power_on(self):
        ...

    @abstractmethod
    def reset(self):
        ...

    @abstractmethod
    def power_off(self):
        ...

    @abstractmethod
    def wait_until_not_busy(self):
        ...

    @abstractmethod
    def select_driver_chips(self, chip_numbers: Iterable[int]):
        ...

    @abstractmethod
    def send_command_code(self, _command_code: int):
        ...

    @abstractmethod
    def send_data_bytes(self, _data_bytes: bytes):
        ...

    def send_data(self, data: int | Sequence[int]):
        if isinstance(data, int):
            self.send_data_bytes(bytes([data]))
        elif isinstance(data, bytes):
            self.send_data_bytes(data)
        else:
            self.send_data_bytes(bytes(data))

    def send_command(self, command_code: int, *params: int | Sequence[int]):
        self.on_command_start()
        self.send_command_code(command_code)
        for p in params:
            self.send_data(p)
        self.on_command_end()

    def on_command_start(self):
        pass

    def on_command_end(self):
        pass

class EPaperBridgeMCP2210(EPaperBridge):
    """MCP2210を使用したブリッジ"""

    @staticmethod
    def is_mcp2210(device_dict):
        MCP2210_VENDOR_ID = 0x04d8
        MCP2210_PRODUCT_ID = 0x00de
        return (device_dict.get("product_id", -1) == MCP2210_PRODUCT_ID and
                device_dict.get("vendor_id", -1) == MCP2210_VENDOR_ID)

    @staticmethod
    def enumerate_mcp2210_devices():
        return list(filter(EPaperBridgeMCP2210.is_mcp2210, hid.enumerate()))

    @staticmethod
    def find_mcp2210_device():
        device_dicts = EPaperBridgeMCP2210.enumerate_mcp2210_devices()
        if len(device_dicts) == 0:
            raise RuntimeError("No MCP2210 devices found")
        elif len(device_dicts) >= 2:
            raise RuntimeError("Multiple MCP2210 devices found")
        return device_dicts[0]["serial_number"]

    def __init__(self):
        serial_number = EPaperBridgeMCP2210.find_mcp2210_device()
        self._pin_dc = 2
        self._pin_com_led = 3
        self._pin_busy = 6
        self._pin_rst = 0
        self._pin_pwr = 4
        self._cs_pins = [1, 7]
        self._cs_pins_selected = 1 << self._cs_pins[0] # Select chip#0
        #self._reset_wait_times = (0.2, 0.002, 0.2)
        self._reset_wait_times = (0.03, 0.03, 0.03, 0.03, 0.03)
        self._mcp = mcp2210.Mcp2210(serial_number, immediate_gpio_update=False)
        # self._mcp._spi_settings.bit_rate = 12000000
        self.init_mcp2210_for_paper()

    def close(self):
        self._mcp._hid.close()

    def init_mcp2210_for_paper(self):
        self._mcp.configure_spi_timing(chip_select_to_data_delay=0,
                                       last_data_byte_to_cs=0,
                                       delay_between_bytes=0)

        self.clear_mcp2210_gpio_status()
        # 13in3eはMCP2210のチップセレクト機能を使うと動作しない。
        # 明示的にチップセレクト信号を制御する。
        # for pin in self._cs_pins:
        #     self._mcp.set_gpio_designation(
        #         pin, mcp2210.Mcp2210GpioDesignation.CHIP_SELECT)
        for pin in self._cs_pins:
            self._mcp.set_gpio_output_value(pin, True)
        self._mcp.set_gpio_direction(self._pin_busy,
                                     mcp2210.Mcp2210GpioDirection.INPUT)
        self._mcp.set_gpio_output_value(self._pin_rst, True)
        self._mcp.set_gpio_output_value(self._pin_com_led, True)
        self._mcp.gpio_update()

    def clear_mcp2210_gpio_status(self):
        for pin_number in range(9):
            self._mcp.set_gpio_designation(pin_number,
                                           mcp2210.Mcp2210GpioDesignation.GPIO)
            self._mcp.set_gpio_direction(pin_number,
                                         mcp2210.Mcp2210GpioDirection.OUTPUT)
            self._mcp.set_gpio_output_value(pin_number, False)

    def power_on(self):
        self._mcp.set_gpio_output_value(self._pin_pwr, True)
        self._mcp.gpio_update()
        time.sleep(0.2)

    def reset(self):
        reset_value = True
        for wait_time in self._reset_wait_times:
            self._mcp.set_gpio_output_value(self._pin_rst, reset_value)
            self._mcp.gpio_update()
            reset_value = not reset_value
            time.sleep(wait_time)
        self.wait_until_not_busy()

    def power_off(self):
        self._mcp.set_gpio_output_value(self._pin_rst, False)
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.set_gpio_output_value(self._pin_pwr, False)
        self._mcp.gpio_update()
        # self.clear_mcp2210_gpio_status(mcp)
        # self._mcp.gpio_update()

    def wait_until_not_busy(self):
        start_time = time.time()
        logger.debug("Start waiting (busy=%s)"
                     % self._mcp.get_gpio_value(self._pin_busy))
        # time.sleep(0.1)
        while(self._mcp.get_gpio_value(self._pin_busy) == False):
            time.sleep(0.005)
        logger.debug("End waiting (wait time=%s seconds)"
                     % (time.time() - start_time))

    def select_driver_chips(self, chip_numbers: Iterable[int]):
        bits = 0
        for chip in chip_numbers:
            bits |= 1 << self._cs_pins[chip]
        self._cs_pins_selected = bits

    def send_command_code(self, command_code: int):
        self._mcp.set_gpio_output_value(self._pin_dc, False)
        self._mcp.gpio_update()
        self.my_spi_exchange(bytes([command_code]))

    def send_data_bytes(self, data_bytes: bytes):
        self._mcp.set_gpio_output_value(self._pin_dc, True)
        self._mcp.gpio_update()
        self.my_spi_exchange(data_bytes)

    def on_command_start(self):
        for pin in range(9):
            if self._cs_pins_selected & (1 << pin):
                self._mcp.set_gpio_output_value(pin, False)
        self._mcp.gpio_update()

    def on_command_end(self):
        for pin in range(9):
            if self._cs_pins_selected & (1 << pin):
                self._mcp.set_gpio_output_value(pin, True)
        self._mcp.gpio_update()

    # -------------------------------------------------------------------
    # Derived from mcp2210.py
    # mcp2210.pyのspi_exchangeは一つのCSを指定しなければならないので改造する
    # Mcp2210Commands
    TRANSFER_SPI_DATA = 0x42
    # Mcp2210CommandResult
    SUCCESS = 0x00
    SPI_DATA_NOT_ACCEPTED = 0xF7
    TRANSFER_IN_PROGRESS = 0xF8
    # Mcp2210SpiTransferStatus
    SPI_TRANSFER_COMPLETE = 0x10
    SPI_TRANSFER_PENDING_NO_RECEIVED_DATA = 0x20
    SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE = 0x30
    # Derived from spi_exchange()
    def my_spi_exchange(self, payload: bytes) -> bytes:
        # -- CHANGE BEGIN --
        mcp = self._mcp
        # cs_pin_bits = self._cs_pins_selected
        # mcp._spi_settings.active_chip_select_value = 0x01FF ^ cs_pin_bits
        mcp._spi_settings.active_chip_select_value = 0x01FF
        # -- CHANGE END --
        mcp._spi_settings.transfer_size = len(payload)
        mcp._set_spi_configuration()

        chunked_payload = []
        for i in range(math.ceil(len(payload) / 60)):
            start_index = i * 60
            stop_index = (i + 1) * 60
            chunk = bytes(payload[start_index:stop_index])
            chunked_payload.append(chunk)

        chunk_index = 0
        received_data = []
        while 1:
            if chunk_index == len(chunked_payload):
                next_chunk = b''
            else:
                next_chunk = chunked_payload[chunk_index]

            request = [EPaperBridgeMCP2210.TRANSFER_SPI_DATA, len(next_chunk), 0x00, 0x00]
            response = mcp._execute_command(bytes(request) + next_chunk, check_return_code=False)

            if response[1] == EPaperBridgeMCP2210.SPI_DATA_NOT_ACCEPTED:
                raise mcp2210.Mcp2210SpiBusLockedException
            elif response[1] == EPaperBridgeMCP2210.TRANSFER_IN_PROGRESS:
                # TODO: このあたりでC-cによって例外が起きると、続くshutdownが機能しない。
                time.sleep(0.005)
                continue
            elif response[1] == EPaperBridgeMCP2210.SUCCESS:
                # data was accepted, move to next chunk
                chunk_index += 1

                receive_data_size = response[2]
                spi_transfer_status = response[3]

                if spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_NO_RECEIVED_DATA:
                    continue
                elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_PENDING_RECEIVED_DATA_AVAILABLE:
                    received_data.append(response[4:receive_data_size + 4])
                    continue
                elif spi_transfer_status == EPaperBridgeMCP2210.SPI_TRANSFER_COMPLETE:
                    received_data.append(response[4:receive_data_size + 4])
                    break
                else:
                    raise mcp2210.Mcp2210CommandFailedException("Encountered unknown SPI transfer status")
            else:
                raise mcp2210.Mcp2210CommandFailedException("Received return code 0x{:02X} from device".format(response[1]))

        combined_receive_data = b''.join(bytes(x) for x in received_data)
        if len(combined_receive_data) != len(payload):
            raise RuntimeError("Length of receive data does not match transmit data")

        return combined_receive_data
    # -------------------------------------------------------------------

# EPaperDisplay

class EPaperDisplayWaveshare(ABC):
    "Waveshare製EPaperディスプレイモジュール(HAT)の基底クラス"
    def __init__(self,
                 bridge: EPaperBridge,
                 width: int, height: int, bits_per_pixel: int,
                 palette: bytes, default_pixel_value: int,
                 all_driver_chip_numbers: Sequence[int]):
        self._bridge = bridge
        self._width = width
        self._height = height
        self._bits_per_pixel = bits_per_pixel
        self._palette = palette
        self._default_pixel_value = default_pixel_value
        self._all_driver_chip_numbers = all_driver_chip_numbers
        self.init()

    # Initialization

    def init(self):
        try:
            self._bridge.power_on()
            self._bridge.reset()
            self.init_driver_chips()
        except Exception:
            self._bridge.power_off()
            raise

    @abstractmethod
    def init_driver_chips(self):
        ...

    # Shutdown

    def shutdown(self):
        try:
            self.shutdown_driver_chips()
        finally:
            self._bridge.power_off()

    def shutdown_driver_chips(self):
        logger.debug("Shutdown driver chips")
        self._bridge.wait_until_not_busy()
        self.select_all_driver_chips()
        # # 02:Power OFF Command
        # self.power_off_panel()
        # 07:Deep Sleep Command
        self._bridge.send_command(0x07, 0xA5)
        time.sleep(2)

    # Panel Power Control

    def power_on_panel(self):
        # 04:Power ON Command
        self._bridge.send_command(0x04)
        self._bridge.wait_until_not_busy()

    def power_off_panel(self):
        # 02:Power OFF Command
        self._bridge.send_command(0x02, 0x00)
        self._bridge.wait_until_not_busy()

    # Driver Chip Selection

    def select_all_driver_chips(self):
        """コマンドの送信先を全ての駆動チップとします。"""
        self._bridge.select_driver_chips(self._all_driver_chip_numbers)

    def select_driver_chip(self, driver_chip_number):
        """コマンドの送信先を指定されたチップのみとします。"""
        self._bridge.select_driver_chips([driver_chip_number])

    # Frame

    @abstractmethod
    def set_frame_bytes(self, frame_bytes: bytes):
        ...

    @property
    def width(self) -> int:
        """パネル全体の水平方向のピクセル数です。"""
        return self._width

    @property
    def height(self) -> int:
        """パネル全体の垂直方向のピクセル数です。"""
        return self._height

    @property
    def default_pixel_value(self) -> int:
        """デフォルトのピクセル値です。基本的に「白」を意味する値です。"""
        return self._default_pixel_value

    @property
    def frame_bits_per_pixel(self) -> int:
        """ピクセルあたりのビット数です。基本的に8以下の値です。"""
        return self._bits_per_pixel

    @property
    def frame_pixels_per_byte(self) -> int:
        """1バイトあたりのピクセル数です。"""
        return 8 // self._bits_per_pixel

    def make_filled_frame_byte(self, pixel_value: int):
        """1バイトの中を指定されたpixel_valueで満たしたものを返します。"""
        bpp = self.frame_bits_per_pixel
        pixel_value = pixel_value & ((1 << bpp) - 1)
        frame_byte = 0
        bitpos = 8 - bpp
        while bitpos >= 0:
            frame_byte |= pixel_value << bitpos
            bitpos = bitpos - bpp
        return frame_byte

    @property
    def frame_line_nbytes(self) -> int:
        """フレームバッファの1行のバイト数を返します。"""
        return ((self._width + self.frame_pixels_per_byte - 1) //
                self.frame_pixels_per_byte)

    @property
    def frame_nbytes(self) -> int:
        """フレームバッファのバイト数を返します。"""
        return self.frame_line_nbytes * self.height

    def fill_frame_with_byte(self, frame_byte: int):
        """フレームの全バイトをframe_byteで満たします。"""
        self.set_frame_bytes(bytes([frame_byte]) * self.frame_nbytes)

    def fill_frame(self, pixel_value: int):
        """フレームの全ピクセルをpixel_valueで塗りつぶします。"""
        self.fill_frame_with_byte(self.make_filled_frame_byte(pixel_value))

    def clear_frame(self):
        """フレームの全ピクセルを白一色で塗りつぶします。"""
        self.fill_frame(self.default_pixel_value)


    def frame_image_palette(self) -> ImagePalette.ImagePalette:
        return ImagePalette.ImagePalette("RGB", self._palette)

    def convert_image_to_frame_bytes(self, image: Image.Image) -> bytes:
        """imageをselfの仕様に合わせたフレームバイト列へ変換します。"""
        palette_image = Image.new("P", (1, 1))
        palette_image.putpalette(self.frame_image_palette())

        quantized_image = image.convert("RGB").quantize(palette=palette_image)

        src_bytes = quantized_image.getdata()
        src_w, src_h = quantized_image.size

        return bytes(EPaperDisplayWaveshare.convert_pimage_bytes_to_frame_bytes(
            src_bytes, src_w, 0, 0, src_w, src_h,
            self.width, self.height, self.frame_bits_per_pixel))

    @staticmethod
    def convert_pimage_bytes_to_frame_bytes(
            src_bytes,
            src_pitch: int,
            src_x: int, src_y: int, src_w: int, src_h: int,
            dst_w: int, dst_h: int, dst_bits_per_pixel: int) -> bytearray:
        scan_w = min(src_w, dst_w)
        scan_h = min(src_h, dst_h)
        dst_line_nbytes = (dst_w // (8 // dst_bits_per_pixel))
        dst_pitch = dst_line_nbytes
        dst_bytes = bytearray(dst_line_nbytes * dst_h)
        for y in range(scan_h):
            src = src_pitch * (src_y + y) + src_x
            dst = dst_pitch * y
            dst_bitpos = 8
            dst_byte = 0
            dst_bitmask = (1 << dst_bits_per_pixel) - 1
            for x in range(scan_w):
                dst_bitpos -= dst_bits_per_pixel
                dst_byte |= ((int(src_bytes[src + x]) & dst_bitmask)
                             << dst_bitpos)
                if dst_bitpos < dst_bits_per_pixel:
                    dst_bytes[dst] = dst_byte
                    dst = dst + 1
                    dst_bitpos = 8
                    dst_byte = 0
            if dst_bitpos < 8:
                dst_bytes[dst] = dst_byte
        return dst_bytes

    # Panel Update

    def update_panel(self):
        """現在のフレームをディスプレイの表示に反映します。"""
        logger.debug("Refresh Start")
        self.select_all_driver_chips()
        # 04:Power ON Command
        self.power_on_panel()
        # 12:Display Refresh Command
        self._bridge.send_command(0x12, 0x00)
        self._bridge.wait_until_not_busy()
        # 02:Power OFF Command
        self.power_off_panel()
        logger.debug("Refresh End")

    def show_image(self, image: Image.Image):
        """imageを表示します。"""
        self.set_frame_bytes(self.convert_image_to_frame_bytes(image))
        self.update_panel()


class EPaperDisplayWaveshare3in5G(EPaperDisplayWaveshare):
    """Waveshare 3.5インチ(G)"""

    def __init__(self, bridge):
        super().__init__(
            bridge = bridge,
            width = 184,
            height = 384,
            bits_per_pixel = 2,
            palette = bytes((0,0,0)+(255,255,255)+(255,255,0)+(255,0,0)+
                            (0,0,0)*252),
            default_pixel_value = 1,
            all_driver_chip_numbers = [0]
        )

    def init_driver_chips(self):
        # See: epd3in5g.py -> EPD -> init
        # ??
        self._bridge.send_command(0x66, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
        # ??
        self._bridge.send_command(0x4D, 0x78)
        # 00:Panel setting Register
        self._bridge.send_command(0x00, 0x0F, 0x29)
        # 01:Power setting Register
        self._bridge.send_command(0x01, 0x07, 0x00)
        # 03:Power OFF Sequence Setting Register
        self._bridge.send_command(0x03, 0x10, 0x54, 0x44)
        # 06:Booster Soft Start Command
        self._bridge.send_command(0x06,
                                  0x0F, 0x0A, 0x2F, 0x25, 0x22, 0x2E, 0x21)
        # 50:VCOM and DATA Interval setting Register
        self._bridge.send_command(0x50, 0x37)
        # ??
        self._bridge.send_command(0x60, 0x02, 0x02)
        # 61:Resolution setting
        self._bridge.send_command(0x61,
                                  self._width>>8, self._width&255,
                                  self._height>>8, self._height&255)
        # ??
        self._bridge.send_command(0xE7, 0x1C)
        # E3:Power Saving Register
        self._bridge.send_command(0xE3, 0x22)
        # ??
        self._bridge.send_command(0xB6, 0x6F)
        self._bridge.send_command(0xB4, 0xD0)
        self._bridge.send_command(0xE9, 0x01)
        # 30:PLL Control Register
        self._bridge.send_command(0x30, 0x08)
        # # 04:Power ON Command
        # self.power_on_panel()

    # Frame

    def set_frame_bytes(self, frame_bytes: bytes):
        """frame_bytesを電子ペーパーに転送します(表示しない)。"""
        # 10:Data Start Transmission Register
        self._bridge.send_command(0x10, frame_bytes)


class EPaperDisplayWaveshare13in3E(EPaperDisplayWaveshare):
    """Waveshare 13.3インチ(E)"""

    def __init__(self, bridge):
        super().__init__(
            bridge = bridge,
            width = 1200,
            height = 1600,
            bits_per_pixel = 4,
            palette = bytes((0,0,0)+
                            (255,255,255)+
                            (255,255,0)+
                            (255,0,0)+
                            (0,0,0)+
                            (0,0,255)+
                            (0,255,0)+
                            (0,0,0)*249),
            default_pixel_value = 1,
            all_driver_chip_numbers = [0, 1]
        )

    def init_driver_chips(self):
        # See: epd13in3E.py -> EPD -> init
        # (Master)
        self.select_driver_chip(0)
        self._bridge.send_command(0x74, 0xC0, 0x1C, 0x1C, 0xCC,
                                  0xCC, 0xCC, 0x15, 0x15, 0x55)
        # (Master and Slave)
        self.select_all_driver_chips()
        self._bridge.send_command(0xF0, 0x49, 0x55, 0x13, 0x5D, 0x05, 0x10)
        self._bridge.send_command(0x00, 0xDF, 0x69)
        self._bridge.send_command(0x50, 0xF7)
        self._bridge.send_command(0x60, 0x03, 0x03)
        self._bridge.send_command(0x86, 0x10)
        self._bridge.send_command(0xE3, 0x22) # E3:Power Saving Register
        self._bridge.send_command(0xE0, 0x01)
        self._bridge.send_command(0x61, 0x04, 0xB0, 0x03, 0x20) # 61:Resolution setting
        # (Master)
        self.select_driver_chip(0)
        self._bridge.send_command(0x01, 0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38)
        self._bridge.send_command(0xB6, 0x07)
        self._bridge.send_command(0x06, 0xE8, 0x28)
        self._bridge.send_command(0xB7, 0x01)
        self._bridge.send_command(0x05, 0xE8, 0x28)
        self._bridge.send_command(0xB0, 0x01)
        self._bridge.send_command(0xB1, 0x02)

    # Frame

    def set_frame_bytes(self, frame_bytes: bytes):
        """frame_bytesを電子ペーパーに転送します(表示しない)。"""
        line_pitch = self.frame_line_nbytes
        line_half = self.frame_line_nbytes // 2
        height = self.height
        frame_nbytes = self.frame_nbytes
        # 10:Data Start Transmission Register
        self.select_driver_chip(0)
        self._bridge.on_command_start()
        self._bridge.send_command_code(0x10)
        for y in range(height):
            print(f"\r{line_half*y}/{frame_nbytes}", end="", flush=True)
            self._bridge.send_data(
                frame_bytes[y * line_pitch :
                            y * line_pitch + line_half])
        self._bridge.on_command_end()

        self.select_driver_chip(1)
        self._bridge.on_command_start()
        self._bridge.send_command_code(0x10)
        for y in range(height):
            print(f"\r{line_half*(height+y)}/{frame_nbytes}", end="", flush=True)
            self._bridge.send_data(
                frame_bytes[y * line_pitch + line_half :
                            y * line_pitch + line_half + line_half])
        self._bridge.on_command_end()
        print(f"\r{frame_nbytes}/{frame_nbytes}", flush=True)

# Main

def main():
    parser = argparse.ArgumentParser(
        prog="epaper_print",
        usage="python epaper_print.py [options]")
    parser.add_argument("filename", help="image file name")
    # TODO: Add options for specify device types
    # parser.add_argument("--bridge") #--bridge=mcp2210:serial=0002193217:pin_dc=2:pin_pwr=4
    # parser.add_argument("--epaper") #--epaper=waveshare13in3e

    # TODO: Add panel clear option
    # parser.add_argument("--clear")

    # TODO: Add forced power off option
    # parser.add_argument("--poweroff")

    # TODO: Add image rotation option
    # parser.add_argument("--rotate")

    # TODO: Add verbose option
    # parser.add_argument("--verbose")

    args = parser.parse_args()

    logger.debug("Load image")
    im = Image.open(args.filename)
    logger.debug("Rotate image")
    im = im.rotate(-90, expand=True)

    logger.debug("Create bridge")
    bridge = EPaperBridgeMCP2210()
    logger.debug("Create EPaperDisplay")
    # epaper = EPaperDisplayWaveshare3in5G(bridge)
    epaper = EPaperDisplayWaveshare13in3E(bridge)

    try:
        logger.debug("Resize image")
        im = im.resize((epaper.width, epaper.height))
        logger.debug("Show image")
        epaper.show_image(im)

        # epaper.fill_frame_with_byte(0xaf)
        # epaper.update_panel()

        # epaper.clear_frame()
        # epaper.update_panel()
    finally:
        logger.debug("Shutdown")
        try:
            # TODO: 転送中等にC-cで中断するとうまくshutdownできないことがある。パネルの電源が入りっぱなしになると良くない。
            epaper.shutdown()
        finally:
            bridge.close()

    logger.debug("End")

if __name__ == "__main__":
    main()

前回の3.5インチモジュールとの一番の違いは、画面が左右二つに分割されていて制御するチップも二つに分かれていることです。そのためCS信号は二つ(CS_MとCS_S)あります。どちらかを選んでコマンドを送信することもありますし、二つのチップに同時に送信することもあります。詳しくはDouble-IC Programming Analysisを参照してください。

また、6色ディスプレイなので画像データの形式も違います。有効なピクセル値は 0:黒, 1:白, 2:黄, 3:赤, 4:青, 5:緑 の6通りです。1ピクセル4ビットで1バイトに2ピクセル入ります(左詰)。パネル全体の解像度は1200×1600ですが、上述のように左右で分割されているため600×1600を2回に分けて送信する必要があります。全体のバイト数は1200×1600/2=960000バイトになります。

細かい点では、初期化シーケンス、リセットタイミング、CS信号の出し方にも違いがありました。パネルの電源のON/OFFタイミングもサンプルコードレベルでは違いましたが、3.5インチでも13.3インチのやり方で問題なかったので13.3インチのやり方で統一しました(リフレッシュ前後でON/OFFする)。

Waveshareのサンプルコードが参考になります。

e-Paper/E-paper_Separate_Program/13.3inch_e-Paper_E/RaspberryPi/python/examples/epd_13in3E_test.py at master · waveshareteam/e-Paper

ケース作り

無事に表示できたのは良いのですが、パネルや基板がむき出しでは扱いづらくて仕方ありません。何か手頃なケースは無いかとダイソーに探しに行きました。

最初はスチレンボードを切り貼りして作ろうかなと思ったのですが、A4のフォトフレームがちょうど良さそうな大きさでした。

フォトフレーム(A4、クリアファイル対応、白・黒) - 100均 通販 ダイソーネットストア【公式】

試しに買って帰ってはめ込んでみたところほぼピッタリでした。

ただし電子ペーパーパネルはA4よりも若干長辺が短いので窓からフィルム基板部分が見えてしまいます。この辺りは後でカバーでも作りましょう。

それとフィルム基板のコネクタ部分が枠内に収まりません。無理矢理曲げても良いのかもしれませんが、心配だったので枠をカットしました。この素材はMDF(中密度繊維板)というのでしょうか? カッターでサクサク切れました。

ダイソーのフォトレームを削った様子
図11: ダイソーのフォトレームを削った様子
ダイソーのフォトフレームに収めた様子
図12: ダイソーのフォトフレームに収めた様子

こうなると基板がプラプラしているのが大変邪魔です。テープでべたっと覆ってしまっても良いのですが、これもダイソーに手頃なケースがあったのでそれに入れてみました。

プチプラケース L - 100均 通販 ダイソーネットストア【公式】

若干高さが足りなかったのでCS_Sのピンを折り曲げて無理矢理収めました。

ケース内
図13: ケース内

後はポリプロピレンに使える両面テープでケースごと裏面に貼り付け。

フォトフレーム背面
図14: フォトフレーム背面

org-modeのagendaを表示する

で、一番やりたかったのがorg-agendaを表示すること。

色々調整した結果、次のように無事表示できました。

Org Agendaを表示した例
図15: Org Agendaを表示した例

写真だとそれほどでもありませんが、肉眼で見るとコントラスト比の低さは気になりますね。白が結構黒いです。

Org Agendaを表示した例
図16: Org Agendaを表示した例

次のMakefileでagenda HTML生成、作業ディレクトリへコピー、画像化、表示までを行えます。(あらかじめorg-agenda-custom-commandsにhtmlファイル名を指定してorg-batch-store-agenda-viewsでhtmlファイルがエクスポートされるようにしておく必要があります)

EMACS = C:/my-program-dir/emacs-30.2/bin/emacs
CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"
AGENDA_HTML_SRC = ~/my-org-agenda-html/agenda.html
MAGICK = magick
EPAPER_PRINT = python epaper_print.py

.PHONY: all
all: update-agenda upload

.PHONY: update-agenda
update-agenda:
        $(EMACS) -batch -l ~/.emacs.d/init.el -eval '(org-batch-store-agenda-views)'

tmp-agenda.png: tmp-agenda.html
        $(CHROME) --headless --screenshot=$(abspath tmp-agenda.png) --window-size=1600,1300 --force-device-scale-factor=1 --hide-scrollbars $(abspath tmp-agenda.html)
        $(MAGICK) $@ -crop 1600x1200+0+0 +repage $@
# ↑下に空白が空いてしまう(95px)ので大きめに作ってImageMagickでカットする。
#  https://www.reddit.com/r/chrome/comments/1jsa174/chrome_headless_screenshot_omitting_bottom_95/
#  https://issues.chromium.org/issues/405165895

tmp-agenda.html: $(AGENDA_HTML_SRC)
        cp $< $@

.PHONY: upload
upload: tmp-agenda.png
        $(EPAPER_PRINT) tmp-agenda.png

.PHONE: clean
clean:
        rm tmp-agenda.png
        rm tmp-agenda.html

予定表の見た目はEmacsとCSSの双方をうまく調整してやる必要があります。

私のEmacs側の設定は Org Agendaに天気・日の出日の入・月の状態を表示する に書きました。

そこではorg-agenda-custom-commands(org-agenda-export-html-style "<link rel=\"stylesheet\" type=\"text/css\" href=\"agenda.css\">") という指定を入れてあるので、htmlと同じディレクトリにある agenda.css が参照されます。

電子ペーパーに特化した調整をしたかったので、Makefileがあるディレクトリに電子ペーパー用CSSを配置し、そこに一時的にhtmlをコピーしてからChromeで画像化しています。

実際に使ったCSSは次の通りです。

/* agenda.css: org-agenda電子ペーパー用CSS */

html {
    margin: 0;
    padding: 0;
}
body {
    margin: 0;
    padding: 0;
    overflow: hidden;
    line-height: 1.5;
    /* 文字 */
    /*-webkit-font-smoothing: none;*/
    font-family:"MS Gothic", "Noto Sans JP", monospace;
    font-size: 26px;
    color: #000000;
    background-color: #ffffff;
}
body>pre {
    margin: 0;
    padding: 10px;
    font-family: inherit;
    /* 高さ固定2段組 */
    height: 100vh;
    box-sizing: border-box;
    column-width: calc((100vw - 10px - 20px - 10px) / 2);
    column-gap: 20px;
    /* 折り返しの回避 */
    white-space: pre;
    text-overflow: ellipsis;
    overflow: hidden;
}
/* ハイパーリンクの装飾を取消 */
a {
    color: inherit;
    background-color: inherit;
    font: inherit;
    text-decoration: none;
}
/* タイトル */
.org-agenda-structure {
    font-weight: bold;
    color: #ffffff;
    background-color: #102e80;
    padding: 4px 10px;
}
/* アジェンダ行TODOキーワード・タグ・優先度等 */
.org-todo, .org-modern-todo {
    color: #c00000;
    font-size: 75%;
    display: none;
}
.org-done, .org-modern-done {
    color: #20c060;
    font-size: 75%;
}
.org-tag, .org-modern-tag {
    border: 1px solid #555;
    font-size: 60%;
    color: #555;
}
.org-priority, .org-modern-priority {
    color: #ff00ff;
}
.org-hide {
    /* color: #ffffff; font-size: 8px; */
    display: none;
}
/* アジェンダ行内容 */
.org-agenda-done {color: #20c060;}
.org-agenda-diary {color: #c00040;}
.org-agenda-calendar-sexp {color: #506090;}
.org-scheduled {}
.org-scheduled-today {}
.org-scheduled-previously {color: #c00000;}
.org-upcoming-deadline {color: #ff0000;}
.org-imminent-deadline {color: #ff0000; font-weight: bold;}
/* タイムグリッド */
.org-time-grid {color: #506090;}
.org-agenda-current-time {color: #506090; font-weight: bold;}

/* 日付行(日付・曜日・付加情報) */
:root {
    --date-color: #000;
    --date-color-sat: #04f;
    --date-color-sun: #c00;
}
.org-agenda-date, .my-org-agenda-date-saturday, .org-agenda-date-weekend {
    font-size: 125%;
    font-weight: bold;
    border-left: 10px solid currentColor;
    padding-left: 4px;
    /* padding-top: 4px; */
}
.org-agenda-date {color: var(--date-color);}
.my-org-agenda-date-saturday {color: var(--date-color-sat);}
.org-agenda-date-weekend {color: var(--date-color-sun);}
.my-org-agenda-dow, .my-org-agenda-dow-saturday, .my-org-agenda-dow-weekend {
    font-size: 70%;
    font-weight: bold;
    padding-left: 2px;
    vertical-align: 2px;
}
.my-org-agenda-dow {color: var(--date-color);}
.my-org-agenda-dow-saturday {color: var(--date-color-sat);}
.my-org-agenda-dow-weekend {color: var(--date-color-sun);}
.my-org-agenda-date-info { /* 付加情報 */
    font-size: 77%;
    margin-left: 8px;
}
.my-org-agenda-date-info img[src*="/.jma-weather-cache/"] { /* 天気画像 */
    height: 2em;
    vertical-align: -30%;
}
.my-org-agenda-date-info img { /* 月画像 */
    height: 1.3em;
    vertical-align: -8%;
}

フォントはMS Gothicを使うとアンチエイリアシングが無くてシャープだったのでそれを使っています。

エクスポートされるHTMLは一つの巨大なpre要素なので、文書構造を意識したスタイル指定は困難です。見た目を変えるためにEmacs側の挙動を色々変更する必要もありました。

課題

一番の課題は転送速度の遅さでしょうか。3~4分はさすがに長すぎるような気もします。まぁ、1時間に1回更新するくらいならさほど問題にはなりませんが。MCP2210でも使い方によっては改善できるでしょうか。それともFT232Hのようなものを使った方が良いでしょうか。