IBM Developer Day 2018에서 배포한 IoT 뱃지는 ESP32 Devkit을 기반으로 구성되어 있습니다. 배포 당시 설치된 행사용 소프트웨어 대신 마이크로 파이썬이 포팅된 펌웨어를 설치하면 IoT 뱃지를 개발 보드로 활용할 수 있습니다. 이 튜토리얼에서는 마이크로 파이선의 SPI 모듈을 이용하여 SPI 통신 방법에 대해 알아보며, IoT 뱃지에 탑재된 LCD에 SPI로 명령을 전달하여 단순 화면 제어에 대해 학습합니다.

학습 목표

이 튜토리얼을 마치게 되면 다음과 같은 것을 할 수 있습니다:

  • REPL을 이용하여 IoT Badge에 마이크로 파이썬 코드 실행
  • 마이크로 파이썬 코드로 SPI 통신 및 LCD 제어

사전 준비 사항

  1. Developer Day 2018 IoT Badge 펌웨어 설치하기
  2. Developer Day 2018 IoT Badge에 나만의 파이썬 코드 실행하기
  3. IBM Developer Day 2018 IoT Badge
  4. 데이터 통신용 USB 2.0 Micro B Type 케이블 (마이크로 5핀)

소요 시간

이 튜토리얼을 완료하기까지 대략 30분 정도가 소요됩니다.

단계

펌웨어 버젼 확인

IoT Badge는 리셋 후 뱃지가 시작하면서 자동으로 uGFX 모듈을 초기화하여 VSPI 라인을 점유하게 됩니다. 따라서, SPI를 사용하려면 ugfx.deinit()를 실행하여 uGFX 모듈을 해제해야 합니다. 그러나, release-20190330 V3.0 버젼까지는 ugfx.deinit() 를 실행해도 SPI bus를 계속 점유하는 버그가 있기에 마이크로파이썬 펌웨어 버젼 release-20190411 이후 버젼을 설치해야 합니다.

IoT 뱃지에 설치된 Firmware 정보는 뱃지 부팅 후 REPL 환경에서 uos.uname() 명령으로 현재 설치된 firmware의 build 정보도 같이 확인 할 수 있습니다.

>>> uos.uname()
(sysname='esp32', nodename='esp32', release='1.9.4', version='v1.9.4-690-gc9da0f0c5-dirty on 2019-04-11', machine='ESP32 module with ESP32')
>>> 

펌웨어 업데이트가 완료 되었다면 아래 SPI 초기화하기로 이동하여 계속 진행할 수 있습니다.

SPI 초기화하기

마이크로파이썬에서 제공하는 SPI 모듈은 machine.SPI입니다. 앞서 잠깐 이야기한바와 같이 uGFX에서 먼저 SPI HOST를 선점하고 있으므로 이를 해제해야 합니다. REPL에서 ugfx.deinit() 명령을 실행하면 다음과 같이 나타납니다.

>>> ugfx.deinit()
I (577623) gpio: GPIO[5]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 
I (577623) gpio: GPIO[23]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 
I (577633) gpio: GPIO[19]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 
I (577643) gpio: GPIO[18]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 
>>> 

그리고, 다음과 같은 코드로 machine.SPI 모듈을 이용해 SPI 모듈을 초기화 합니다.

from machine import SPI, Pin
import time

TFT_MOSI = Pin(23) # SPI MOSI
TFT_CLK  = Pin(18) # SPI Clock
TFT_MISO = Pin(19) # SPI MISO  (optional)
vspi = SPI(2, baudrate=80000000, sck=TFT_CLK, miso=TFT_MISO, mosi=TFT_MOSI)

참고로, ugfx.deinit()을 하지 않고 실행하는 경우 다음과 같이 HOST가 사용 중 이라는 메시지를 얻게 됩니다.

>>> vspi = SPI(2, baudrate=80000000, sck=TFT_CLK, miso=TFT_MISO, mosi=TFT_MOSI)
E (5833) spi_master: spi_bus_initialize(146): host already in use
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: SPI device already in use
>>>

SPI로 LCD 드라이버 구성하기

Iot Badge에 연결된 LCD는 Adafruit ILI9341라는 구동칩을 사용하며 SPI로 제어합니다. IoT Badge와 LCD가 물리적으로 결선된 정보는 iot badge 회로도에 나와 있으며 표로 정리하면 다음과 같습니다.

ESP32 LCD I/F ILI9341 Description
GPIO23 VSPI_MOSI SDI/SDA Serial Data Input or Input/Output (Master Out to Slave In)
GPIO19 VSPI_MISO SDO Serial Data Output
GPIO18 VSPI_SCK SCL Serial Clock Input
GPIO5 VSPI_CS CSX Chip Enable (Chip Select or Slave Select)

IoT Badge에 사용된 LCD는 ILI9341을 4-wire 8-bit data serial interface II 방식을 사용하므로, 사용하는 Pin은 SCL,SDI,D/CX,SDO, CS입니다.

ESP32 LCD I/F ILI9341 Description
GPIO21 LCD_RST RESX Reset
GPIO22 LCD_DC D/CX Data or Command Selection Input

추가로 사용하는 Pin은 다음과 같이 초기화 합니다. Control 사용해야 하므로 OUTPUT 모드로 초기화 합니다.

TFT_CS   = Pin(5, Pin.OUT, value=1)  # Chip Select
TFT_RST  = Pin(21, Pin.OUT, value=1) # SPI Reset (optional)
TFT_DC   = Pin(22, Pin.OUT, value=1) # Data/Command 

ILI9341은 LCD 제어를 위한 가로, 세로 변환이나 Display에 대응하는 메모리 맵 등의 다양한 기능을 제공합니다. 이를 위해 명령 및 데이터를 전송하여 설정을 해 주어야 합니다.

SPI 데이터 전송

ILI9341의 SPI 방식은 Command 전송하고 그 Command에서 필요로 하는 Data를 이어서 전달하는 방식입니다.

다음과 같은 순서로 Command와 Data를 전달합니다.

  1. Chip Select를 Low로 설정
  2. Data/Command를 Low로 설정하여 Command 인식
  3. 명령 코드 전송
  4. Data/Command를 High로 설정하여 Data 인식
  5. 명령 코드에 따라 필요한 데이터 전송
  6. Chip Select를 High로 설정

위의 방법으로 필요한 명령과 데이터를 이용하여 ILI9341를 설정합니다. 이를 마이크로 파이썬 코드로 구성하면 다음과 같습니다.

def writeCommand(cmd, data=None):
    TFT_CS(0) # startWrite
    TFT_DC(0) # Command
    vspi.write(bytearray([cmd]))
    TFT_DC(1) # Data or Ready

    if data is not None:
        vspi.write(data)
    else:
        time.sleep_ms(120)
  
    TFT_CS(1) # endWrite

Command는 정수, data는 bytearray 형식으로 전달이 되어야 하며 command에 따라 data의 길이는 달라질 수 있습니다. 예를 들어 Display ON(29h) 명령의 경우는 데이터가 없지만, Display Function Control (B6h)는 4개의 parameter가 필요합니다. 심지어 Memory Write (2Ch)와 같은 경우는 데이터 크기가 가변입니다.

다음은 앞서 만든 writeCommand()를 호출하는 예제입니다.

writeCommand(0x36, b'\x28')
writeCommand(0x36, bytearray([0x28]))

SPI 데이터 수신

ILI9341의 SPI로 데이터를 수신하는 것도 데이터 전송과 마찬가지로 Command와 Data로 조합됩니다.

다음과 같은 순서로 Command를 보내고 Data를 수신합니다.

  1. Chip Select를 Low로 설정
  2. Data/Command를 Low로 설정하여 Command 인식
  3. 명령 코드 전송
  4. Data/Command를 High로 설정하여 Data 인식
  5. 명령 코드에 따라 필요한 데이터 수신
  6. Chip Select를 High로 설정

이를 python으로 작성하면 다음과 같습니다.

def readCommand(cmd, nread=1):
    TFT_CS(0) # startWrite
    TFT_DC(0) # Command
    vspi.write(bytearray([cmd]))
    TFT_DC(1) # Data or Ready
    data = vspi.read(nread)
    TFT_CS(1) # endWrite
    return data

writeCommand와 마찬가지로 Command에 따라서 읽는 데이터 크기가 다르므로 읽어야 하는 Data 크기를 nread 값으로 전달합니다.

다음은 앞서 만든 readCommand()를 호출하는 예제입니다.

display_madctl = readCommand(0x0b, 2)

ILI9341 LCD 초기화 하기

ILI9341에 SPI 명령을 사용할 준비가 되었으므로 ILI9341 LCD를 초기화 합니다. 초기화에 필요한 명령어는 ILI9341 규격문서에 있지만 우리는 IoT Badge firmware의 LCD driver 초기화 코드를 참고 합니다. 각 명령과 데이터가 의미하는 것은 ILI9341 규격 문서를 참고하시기 바랍니다.

위 코드에서 LCD model이 ILI9341일 때 사용하는 초기화 명령 세트는 다음과 같습니다.

DRAM_ATTR static const lcd_init_cmd_t ili_init_cmds[]={
    {0xCF, {0x00, 0x83, 0x30}, 3},
    {0xED, {0x64, 0x03, 0x12, 0x81}, 4},
    {0xE8, {0x85, 0x01, 0x79}, 3},
    {0xCB, {0x39, 0x2C, 0x00, 0x34, 0x02}, 5},
    {0xF7, {0x20}, 1},
    {0xEA, {0x00, 0x00}, 2},
    {0xC0, {0x26}, 1},
    {0xC1, {0x11}, 1},
    {0xC5, {0x35, 0x3E}, 2},
    {0xC7, {0xBE}, 1},
    {0x36, {0x28}, 1},
    {0x3A, {0x55}, 1},
    {0xB1, {0x00, 0x1B}, 2},
    {0xF2, {0x08}, 1},
    {0x26, {0x01}, 1},
    {0xE0, {0x1F, 0x1A, 0x18, 0x0A, 0x0F, 0x06, 0x45, 0X87, 0x32, 0x0A, 0x07, 0x02, 0x07, 0x05, 0x00}, 15},
    {0XE1, {0x00, 0x25, 0x27, 0x05, 0x10, 0x09, 0x3A, 0x78, 0x4D, 0x05, 0x18, 0x0D, 0x38, 0x3A, 0x1F}, 15},
    {0x2A, {0x00, 0x00, 0x00, 0xEF}, 4},
    {0x2B, {0x00, 0x00, 0x01, 0x3f}, 4}, 
    {0x2C, {0}, 0},
    {0xB7, {0x07}, 1},
    {0xB6, {0x0A, 0x82, 0x27, 0x00}, 4},
    {0x11, {0}, 0x80},
    {0x29, {0}, 0x80},
    {0, {0}, 0xff},
};

자료 구조를 확인하면 처음은 cmd, data 그리고 data의 크기로 되어 있는 것을 볼 수 있습니다.

typedef struct {
    uint8_t cmd;
    uint8_t data[16];
    uint8_t databytes; //No of data in data; bit 7 = delay after set; 0xFF = end of cmds.
} lcd_init_cmd_t;

그리고, 이 명령세트를 실행하는 부분은 다음과 같습니다.

//Send all the commands
while (lcd_init_cmds[cmd].databytes!=0xff) {
    lcd_cmd(*spi_wr_dev, lcd_init_cmds[cmd].cmd, dc);
    lcd_data(*spi_wr_dev, lcd_init_cmds[cmd].data, lcd_init_cmds[cmd].databytes&0x1F, dc);
    if (lcd_init_cmds[cmd].databytes&0x80) {
        vTaskDelay(100 / portTICK_RATE_MS);
    }
    cmd++;
}

이 코드는 C 언어로 되어 있고 이것을 그대로 파이썬으로 포팅할 수도 있지만 단순 초기화 코드이므로 초기화 명령만 참고하고 파이썬에 맞춰 다시 작성하는 것이 더 좋습니다. 위의 명령 세트를 참고하여 파이썬 형식으로 변환하면 다음과 같이 정의 할 수 있습니다.

_init_cmds = (
    (0xCF, b'\x00\x83\x30'),
    (0xED, b'\x64\x03\x12\x81'),
    (0xE8, b'\x85\x01\x79'),
    (0xCB, b'\x39\x2C\x00\x34\x02'),
    (0xF7, b'\x20'),
    (0xEA, b'\x00\x00'),
    (0xC0, b'\x26'),
    (0xC1, b'\x11'),
    (0xC5, b'\x35\x3E'),
    (0xC7, b'\xBE'),
    (0x36, b'\x28'),
    (0x3A, b'\x55'),
    (0xB1, b'\x00\x1B'),
    (0xF2, b'\x08'),
    (0x26, b'\x01'),
    (0xE0, b'\x1F\x1A\x18\x0A\x0F\x06\x45\x87\x32\x0A\x07\x02\x07\x05\x00'),
    (0xE1, b'\x00\x25\x27\x05\x10\x09\x3A\x78\x4D\x05\x18\x0D\x38\x3A\x1F'),
    (0x2A, b'\x00\x00\x00\xEF'),
    (0x2B, b'\x00\x00\x01\x3F'),
    (0x2C, None),
    (0xB7, b'\x07'),
    (0xB6, b'\x0A\x82\x27\x00'),
    (0x11, None),
    (0x29, None),
  )

# Reset
TFT_RST(1)
time.sleep_ms(100)
TFT_RST(0)
time.sleep_ms(100)
TFT_RST(1)
time.sleep_ms(200)

# Send all the commands
for cmd, data in _init_cmds:
    writeCommand(cmd, data)

초기화가 완료되면 다음과 같은 화면을 볼 수 있습니다.

원하는 영역에 점 그리기

ILI9341에서는 LCD에 점을 그릴 때 RAMWR(2Ch) Command를 이용하여 색상 값을 데이터로 전달합니다. 그런데 이 Command에는 LCD의 어느 위치에 색상을 전달하라는 것이 없습니다. 그 대신 가로(column), 세로(page)의 영역을 지정하는 Command가 따로 준비되어 있습니다. 따라서 다음과 같은 순서로 설정해 줍니다.

  1. CASET(2Ah)으로 가로 영역 지정 : 시작(SC), 종료(EC)
  2. PASET(2Bh)으로 세로 영역 지정 : 시작(SP), 종료(EP)
  3. RAMWR(2Ch)으로 column/page로 지정한 Address Window에 필요한 색상 데이터를 전달

따라서, x값을 SC와 EC에 똑같이 지정하고, y값을 SP와 EP에 지정하면 x, y 위치에 점을 찍을 수 있게 됩니다.

예를 들어 (10, 10) 위치에 대한 것을 입력한다면 SC=0x000A, EC=0x000A, SP=000x0A, EP=0x000A 값이 됩니다. 이를 코드로 구현하면 다음과 같습니다.

writeCommand(0x2A, b'\x00\x0A\x00\x0A')
writeCommand(0x2B, b'\x00\x0A\x00\x0A')
writeCommand(0x2C, b'\xF8\x00')

원하는 영역에 사각형 그리기

좀 더 눈에 띄도록 2×2 크기로 정한다면 SC=0x000A, EC=0x000B, SP=000x0A, EP=0x000B 값이 됩니다. 이를 코드로 구현하면 다음과 같습니다.

writeCommand(0x2A, b'\x00\x0A\x00\x0B')
writeCommand(0x2B, b'\x00\x0A\x00\x0B')
writeCommand(0x2C, b'\xF8\x00\xF8\x00\xF8\x00\xF8\x00')

만약 화면 전체를 흰색으로 덮어쓴다면 (0,0)에서 (320,240)까지 0xFFFF 값을 출력하면 됩니다. 하지만 그렇게 메모리를 사용하는 경우 320x240x2 = 153,600 바이트의 메모리가 필요한데, 부팅하면서 90Kbytes 정도 사용가능한 IoT 뱃지에서는 Out of memory 오류가 발생하게 됩니다. 참고로 메모리는 REPL에서 gc.mem_free()를 실행해서 확인해 볼 수 있습니다.

띠라서, 일정량 별도 메모리를 할당하고 이를 이용해서 데이터를 전송해야 합니다. 만약, 한 row 씩 전송한다고 가정했을 때 예상되는 320×2=640 바이트 정도는 문제가 없으니 다음과 같은 방법을 이용할 수도 있습니다.

import ustruct

LCD_WIDTH = 320
LCD_HEIGHT = 240
rowbuf = bytearray([0xff]*2*LCD_WIDTH)
writeCommand(0x2A, ustruct.pack('>HH', 0, LCD_WIDTH-1))
for i in range(LCD_HEIGHT):
    writeCommand(0x2B, ustruct.pack('>HH', i, i))
    writeCommand(0x2C, rowbuf)

원하는 영역에 원하는 색깔의 사각형 그리기

앞서 작성한 코드는 무조건 흰색으로 LCD 화면을 채우는 코드입니다. IoT Badge의 LCD는 RGB565로 형식을 따르게 되어 있으므로 흰색은 0xFFFF가 되며, 이 때문에 rowbuf의 크기는 LCD_WIDTH의 두 배의 크기만큼 할당후 0xFF로 초기화 했었습니다. 빨간색인 경우는 0xF800가 되며 row_buf를 초기화 할 때 이를 이 값을 이용하면 빨간색이 화면에 표시됩니다.

RGB565_RED = 0xF800
RGB565_GREEN = 0x07E0
RGB565_BLUE = 0x001F

color = RGB565_RED
color_high = color >> 8
color_low = color & 0xff 

rowbuf = bytearray(2*LCD_WIDTH)
for i in range(0, len(rowbuf), 2):
    rowbuf[i] = color_high
    rowbuf[i+1] = color_low
writeCommand(0x2A, ustruct.pack('>HH', 0, LCD_WIDTH-1))
for i in range(LCD_HEIGHT):
    writeCommand(0x2B, ustruct.pack('>HH', i, i))
    writeCommand(0x2C, rowbuf)

이를 정리해서 fillrect(x, y, width, height, color) 함수를 만들면 아래와 같이 됩니다.

def fillrect(x, y, width, height, color=0xFFFF):
    x1 = 0 if x < 0 else x
    y1 = 0 if y < 0 else y
    x2 = x + width
    x2 = (LCD_WIDTH if x2 >= LCD_WIDTH else x2) - 1
    y2 = y + height
    y2 = (LCD_HEIGHT if y2 >= LCD_HEIGHT else y2) - 1
    
    width = x2 - x1 + 1
    height = y2 - y1 + 1
    
    if width <= 0 or height <= 0 :
        raise Exception('Invalid arguments')
    
    color_high = color >> 8
    color_low = color & 0xff

    rowbuf = bytearray(2*width)

    for i in range(0, len(rowbuf), 2):
        rowbuf[i] = color_high
        rowbuf[i+1] = color_low
    writeCommand(0x2A, ustruct.pack('>HH', x1, x2))
    for i in range(y1, y1+height):
        writeCommand(0x2B, ustruct.pack('>HH', i, i))
        writeCommand(0x2C, rowbuf)

이를 활용하여 다음과 같이 호출하면 50,50 위치 부터 100×50 픽셀 크기의 초록색 직사각형을 볼 수 있습니다.

fillrect(50,50, 100, 50, RGB565_GREEN)

맺음말

이 튜토리얼에서는 IBM Developer Day 2018 IoT 뱃지의 LCD를 활용하여 마이크로 파이썬의 SPI(Serial Peripheral Interface)의 사용방법에 대해 알아보았습니다.