2021年9月22日水曜日

タイムコーダーを作る【2】RaspberryPi4+I2C+OLEDで表示高速化

前回、液晶で作成したタイムコーダーを今回はOLEDで作り直します。

やはり液晶では書き換え速度が遅く電波時計にしかならず、ビデオカメラに写し込んでそのビデオの撮影時刻を正確に掴むところまでには至りませんでした。

前回の話:

タイムコーダーを作る[1] RaspberryPi4+I2Cで液晶LCD1602を使う時は 3.3V版を使う

のつづき

■前提とする環境

RaspberryPi4
RaspberryPi W/WH
Python3
SOC1602A OLED3.3V WS0010/HD44780互換 
I2CアダプターPCF1574(I2C/HD44780変換インターフェース)




■LCDからOLEDに変更した

前回LCDでは液晶の特性上どうしても書き換え速度が上がらず、3fpsぐらいの速度しかでませんでした。そこで、それよりも遥かに速い書き換え速度が期待できるOLEDなキャラクターディスプレイを使うことにしました。
店頭やネット販売でOLEDディスプレイが購入できますが、販売実績が良いものはだいたいSDD1322を使用したビットマップディスプレイです。文字サイズを自由に変更したり、図形を書いたり、写真のようなものを表示したりもできます。しかし、全画面のビットマップデータを毎回送信することになるので高速な書き換えを必要とする用途には向きません。実は、最初にSDD1322をつかってはみたのですが、ビットマップデータの送信に時間がかかることが判明し利用を中止しました。
やはり速いのはキャラクターディスプレイです。文字を表すアスキーコードを送るだけなので、一文字を送るのに8bit送信するだけです。
OLEDでキャラクターディスプレイを探したところ次のものを見つけました。


これに、I2CアダプターPCF1574をはんだ付けして使用します。

■OLEDキャラクターデバイスでミリ秒表示の時計を作ってみた

冒頭の画像が実際に動作しているところです。
1行目は日時を表示しています。2行目はmSecを表す3桁を連続して表示するようにしています。

画面上に表示されている2行目を読むと

728

739

751

692

716

この映像から現在時刻は20:11:02.751以降というのがわかります。

692から順に差分を読むと

24

12

11

12

最悪値を除くと書き換え間隔は12mSec辺りというのがわかります。なので現在時刻は

20:11:02.751~763

辺りにあると思われます。

前回のタイムレコーダーの書き換え間隔が約300mSecだったので、それよりも遥かに精度が良くなりました。

実用上では問題無いレベルにはなりました。しかし、それでも精度にもう少し余裕がほしいところです。ちなみに、動画のフレームレートが60fpsなら、1フレーム当たり16.6mSecとなります。せめて4mSec程度の精度は欲しくなります。


■ソースコード

今回SOC1602Aを利用するに当たり、Pythonのライブラリi2clcdを使用しました。しかしながら、そのままでは動作しなかったので、いくらか手を加えました。
公開していただいたSiYu Wuさん ありがとう!
謝謝 SiYu Wu!
[i2coledclockp4.py]
# -*- coding: utf-8 -*-
# !/usr/bin/python
import os
import time
from time import gmtime, strftime
import datetime
#import i2coled
import i2coled

lcd = i2coled.i2coled()
lcd.init()
lcd.print(os.path.basename(__file__))
print(os.path.basename(__file__))
time.sleep(1)


lcd.clear()
now = datetime.datetime.now()
strnow = now.strftime("%y%m%d %H:%M:%S.")
print(strnow)
lcd.return_home()
time.sleep(0.01)
lcd.print(strnow)

digit_f = 0
second_prev = -1
while(True):

    #### current time
    now = datetime.datetime.now()
    if now.second != second_prev:
        second_prev = now.second
        lcd.clear()
        strnow = now.strftime("%y%m%d %H:%M:%S.")
        lcd.return_home()
        time.sleep(0.01)
        lcd.print(strnow)

    str_f = now.strftime("%f")
    str_f4 = str_f[:3]
    lcd.move_cursor(1, digit_f)
    digit_f = (digit_f + 3) % 15
    #lcd.return_home()
    time.sleep(0)
    lcd.print(str_f4)

    time.sleep(0)


[i2coled.py]

# -*- coding: utf-8 -*-
"""
Custumized for OLED SOC1602A
    https://engineering.purdue.edu/477grp3/Files/refs/SOC1602A.pdf

LCD1602/2002/2004 I2C adapter driver for Raspberry Pi or other devices

Copyright (C) 2019 SiYu Wu <wu.siyu@hotmail.com>. All Rights Reserved.

"""
__author__ = 'Nomura Yukiaki <>'
__author__ = 'SiYu Wu <wu.siyu@hotmail.com>'

import smbus
import time

name = 'i2coled'

# Note for developers
#
# I2C byte:   [H ------------------------ L]
#             [    data    ]  [  ctrl_bits ]
# PCA8574:    P7  P6  P5  P4  P3  P2  P1  P0
# LCD1602:    D7  D6  D5  D4  BT  E   R/W RS

# Define some device constants
LCD_DAT = 0x01  # Mode - Sending data
LCD_CMD = 0x00  # Mode - Sending command

# LINE_1 = 0x80   # LCD RAM address for the 1st line
# LINE_2 = 0xC0   # LCD RAM address for the 2nd line
# LINE_3 = 0x94   # LCD RAM address for the 3rd line
# LINE_4 = 0xD4   # LCD RAM address for the 4th line
LCD_LINES = (0x80, 0xC0, 0x94, 0xD4)

# Character code for custom characters in CGRAM
CGRAM_CHR = (b'\x00', b'\x01', b'\x02', b'\x03', b'\x04', b'\x05', b'\x06', b'\x07')


class i2coled():
    def __init__(self, i2c_bus=1, i2c_addr=0x27, lcd_width=16):
        """
        initialize the connection with the LCD

        i2c_bus:    the smbus where the LCD connected to,
                    for Raspberry Pi, it should be 1 or 0 (depending on the model)
        i2c_addr:   I2C address of the adapter, usually 0x27, 0x20 or 0x3f
        lcd_width:  the width of the LCD, e.g. 16 for LCD1602, 20 for LCD2002/2004
        """
        self._bus = smbus.SMBus(i2c_bus)
        time.sleep(0.1)
        self._i2c_addr = i2c_addr
        self._lcd_width = lcd_width

        self._backlight = True
        self._write_data = 0x00
        self._read_data = 0x00
        

    def _i2c_write(self, data):
        """write one byte to I2C bus"""
        self._write_data = data
        self._bus.write_byte(self._i2c_addr, data)

    def _i2c_read(self):
        """read one byte from I2C bus"""
        data = self._bus.read_byte(self._i2c_addr)
        return data

    def _write_en(self):
        """proform a high level pulse to EN"""

        time.sleep(0)
        self._i2c_write(self._write_data | 0b00000100)
        time.sleep(0)
        self._i2c_write(self._write_data & ~0b00000100)
        time.sleep(0)

    def _read_en(self):
        """proform a high level pulse to EN"""

        time.sleep(0)
        self._i2c_write(self._write_data | 0b00000100)
        time.sleep(0.00018) #tACC68@p11
        data_H = self._i2c_read()
        self._i2c_write(self._write_data & ~0b00000100)
        time.sleep(0)

        self._i2c_write(self._write_data | 0b00000100)
        time.sleep(0.00018) #tACC68@p11
        data_L = self._i2c_read()
        self._i2c_write(self._write_data & ~0b00000100)
        time.sleep(0)

        data = (data_H & 0xF0) + (data_L >> 4)
        return data

    def write_byte(self, data, mode):
        """write one byte to LCD"""

        data_H = (data & 0xF0) | self._backlight * 0x08 | mode
        data_L = ((data << 4) & 0xF0) | self._backlight * 0x08 | mode

        self._i2c_write(data_H)
        self._write_en()

        self._i2c_write(data_L)
        self._write_en()

        time.sleep(0.0001)

    def read_byte(self, command):
        self._i2c_write(command)
        data = self._read_en()

        return data

    def wait_busy(self):
        busy = 0x80
        while busy & 0x80:
            busy = self.read_byte(0x02) #BT:0 E:0 Read:1 RS:0  ReadBusyFlag
            time.sleep(0)

    def init(self):
        """
        Initialize the LCD
         SOC1602A.pdf Page 21, 4-bit mode Initialization
        """
        time.sleep(0.001)       #Wait for power stabilization
        # setting LCD data interface to 8 bit
        # Function Set
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0041)
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0001)
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0001)


        # setting LCD data interface to 4 bit
        # Function Set  from instruction of developers manual
        self._i2c_write(0x20)
        self._write_en()
        time.sleep(0.0041)
        self._i2c_write(0x20)
        self._write_en()
        time.sleep(0.0001)
        self._i2c_write(0x80)
        self._write_en()
        time.sleep(0.0001)
        #self.wait_busy()

        # Display Off
        self.write_byte(0x08, LCD_CMD)
            #self._i2c_write(0x00)
            #self._write_en()
            #self._i2c_write(0x80)
            #self._write_en()

        #Display Clear
        self.clear()

        #Entry Mode Set
        self.write_byte(0x06, LCD_CMD)
            #self._i2c_write(0x00)
            #self._write_en()
            #self._i2c_write(0x60)
            #self._write_en()

        #Home Command
        self.return_home()

        #Display ON
        self.write_byte(0x0C, LCD_CMD)


    def init_org(self):
        """
        Initialize the LCD
        """

        # setting LCD data interface to 4 bit
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0041)
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0001)
        self._i2c_write(0x30)
        self._write_en()
        time.sleep(0.0001)
        self._i2c_write(0x20)
        self._write_en()

        self.write_byte(0x28, LCD_CMD)    # 00101000 Function set: interface 4bit, 2 lines, 5x8 font
        self.write_byte(0x0C, LCD_CMD)    # 00001100 Display ON/OFF: display on, cursor off, cursor blink off
        self.write_byte(0x06, LCD_CMD)    # 00000110 Entry Mode set: cursor move right, display not shift
        self.clear()

    def clear(self):
        """
        Clear the display and reset the cursor position
        """
        self.write_byte(0x01, LCD_CMD)
        time.sleep(0.002)

    def set_backlight(self, on_off):
        """
        Set whether the LCD backlight is on or off
        """
        self._backlight = on_off
        i2c_data = (self._write_data & 0xF7) + self._backlight * 0x08
        self._i2c_write(i2c_data)

    def set_cursor(self, cursor_visible, cursor_blink):
        """
        Set whether the cursor is visible and whether it will blink
        """
        cmd = 0x0C + cursor_visible * 0x02 + cursor_blink * 0x01
        self.write_byte(cmd, LCD_CMD)

    def move_cursor(self, line, column):
        """
        Move the cursor to a new posotion

        line:   line number starts from 0
        column: column number starts from 0
        """
        cmd = LCD_LINES[line] + column
        self.write_byte(cmd, LCD_CMD)

    def shift(self, direction='RIGHT', move_display=False):
        """
        Move the cursor and display left or right

        direction:      could be 'RIGHT' (default) or 'LEFT'
        move_display:   move the entire display and cursor, or only move the cursor
        """
        direction = 0x04 if direction == 'RIGHT' else 0x00
        cmd = 0x10 + direction + move_display * 0x08
        self.write_byte(cmd, LCD_CMD)

    def return_home(self):
        """
        Reset cursor and display to the original position.
        """
        self.write_byte(0x02, LCD_CMD)
        time.sleep(0.002)

    def write_CGRAM(self, chr_data, CGRAM_solt=0):
        """
        Write a custom character to CGRAM

        chr_data:     a tuple that stores the character model data
        CGRAM_solt:   int from 0 to 7 to determine where the font data is written

        NOTICE: re-setting the cursor position after calling this method, e.g.

        lcd.write_CGRAM((0x10, 0x06, 0x09, 0x08, 0x08, 0x09, 0x06, 0x00), 2)
        lcd.move_cursor(1, 0)
        lcd.print(b'New char: ' + i2coled.CGRAM_CHR[2])
        """
        cmd = 0x40 + CGRAM_solt * 8
        self.write_byte(cmd, LCD_CMD)

        for dat in chr_data:
            self.write_byte(dat, LCD_DAT)

    def print(self, text):
        """
        Print a string at the current cursor position

        text:   bytes or str object, str object will be encoded with ASCII
        """
        if isinstance(text, str):
            text = text.encode('ascii')

        for b in text:
            self.write_byte(b, LCD_DAT)

    def print_line(self, text, line, align='LEFT'):
        """
        Fill a whole line of the LCD with a string

        text:   bytes or str object, str object will be encoded with ASCII
        line:   line number starts from 0
        align:  could be 'LEFT' (default), 'RIGHT' or 'CENTER'
        """

        if isinstance(text, str):
            text = text.encode('ascii')

        text_length = len(text)
        if text_length < self._lcd_width:
            blank_space = self._lcd_width - text_length
            if align == 'LEFT':
                text = text + b' ' * blank_space
            elif align == 'RIGHT':
                text = b' ' * blank_space + text
            else:
                text = b' ' * (blank_space // 2) + text + b' ' * (blank_space - blank_space // 2)
        else:
            text = text[:self._lcd_width]

        self.write_byte(LCD_LINES[line], LCD_CMD)
        self.print(text)



オリジナルのソースコードでは初期化に失敗することが有りましたので、ELM by ChaNさんの推奨する初期化方式を参考に修正いたしました。
初期化の方針としては、いきなり4bitモードで初期化するのではなく、一度8bitモードに確実に変更したあと、4bitモードへあらためて変更するという方法です。4bitモードの半バイト送信状態で4bitモード初期化をかけようとすると失敗するようです。

■なぜ遅いのか さらなる高速化を図るには

このOLEDの書き換え間隔は12mSec辺りでした。では、限界値はどこにあるのか。

RaspberryPi4のI2Cのクロックは最大で400kbps標準で100kbpsです。デフォルト値の100kbpsの場合、3桁の数字を送るのにかかる時間は、概算で

3桁 x 8ビット  (1 / 100,000bps) = 0.24mSec

 1mSecごとの表示も可能なはずです。

 なぜ遅いのか。HD44780互換インターフェースでは、8ビットと4ビット幅のデータ転送に対応しており、I2Cでも8ビットの送信が出来ます。そのまま接続できそうですが、8ビット全てをデータバスに接続していまうと、HD44780(LCD)のRS,R/W,E信号線のコントロールが出来ません。そのため、HD44780の4ビットバスモードを利用し、データは4ビット幅で送信し、のこり4ビットでRS,R/W,E信号線をコントールしています。


なので、同じ3桁を表示するためには、

RaspberryPi4から送信する8ビットはそれぞれ


一桁目上位4ビット送信
EN High
EN Low
一桁目下位4ビット送信
EN High
EN Low 
二桁目上位4ビット送信
EN High
EN Low
二桁目下位4ビット送信
EN High
EN Low
三桁目上位4ビット送信
EN High
EN Low
三桁目下位4ビット送信
EN High
EN Low

となります。I2Cを使用しI2C-HD44780インターフェースアダプターを使用すると、1バイト送信するためには6バイト必要です。しかも、それぞれ1バイト送信するのに、I2Cのアドレスも送信しないといけないのでさらに1バイト必要となり、なんと12バイト必要となります。
さらに、先の3桁の送信には36バイト。さらに文字の表示位置も毎回指定するのでさらに6バイト合計42バイト必要になります。

    42byte x 8bit x 10uSec = 2880uSec = 3.36mSec

3.36mSec必要になります。
実際にはI2Cのスタートコンディション、ACK受信、ストップコンディション等の処理がかかるのでこれ以上の時間が必要となっています。

ということで、さらなる高速化を図るには、I2C変換モジュールがボトルネックになっているので、OLEDをI2Cで直接接続されているキャラクターモジュールが必要となります。

そして、見つけたのが…

SOC1602F <SHENZHEN SURENOO TECHNOLOGY CO.,LTD.>
SO1602AWWB-UC-WB-U <Sunlike Display Tech. Corp.>

I2Cインターフェースを持つOLEDキャラクターモジュール!HD44780インターフェースへ変換せず、I2Cで直接コマンド操作できるので遥かに早く動作するはずです。

いずれもドライバーICはUS2066。

次回、I2Cネイティブ動作のOLEDを使ってさらなる高速化を図ります。


タイムコーダーを作る【3】RaspberryPi4+I2CネイティブOLEDを使う