AX206 LCD について

# はじめに

[AIDA64 Extreme](https://www.aida64.com/products/aida64-extreme) や [LCD4linux](http://wiki.lcd4linux.tk/doku.php) を使って、PC の CPU負荷 や 温度他 をモニターするときに使用する LCD（IPS液晶ディスプレイ）についての記事です。

ここでは、`AX206LCD`と呼ぶことにします。
（ _**AX206** とは、このディスプレイが使用しているLCDドライバーICの名称のようです_ ）

- アリエクで入手したAX206LCDは、3.5インチ 480×320 ピクセルのもの

[<img width="300" alt="LCD-3-5-C-CPU-pu-USB-C.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/b9dc24a6-adc9-8985-5d6d-de9f804eb190.png" />](https://ja.aliexpress.com/item/1005005735745433.html)

PC と AX206LCD は USB2.0 で接続し、本来の用途は、DPF（デジタルフォトフレーム）のようです。


- Mac にUSB接続したときの情報

![usb0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/25d191bf-3e37-757d-833d-55959eb93546.png)

```terminal
$ lsusb 
Bus 000 Device 007: ID 1908:0102 1908 USB-Display  Serial: WCH32
```



この手の多くのLCDドライバーICで採用されている RGB565 カラー形式で、6万5千色を表示できます。


USBデバイスであれば、[pyusb](https://pypi.org/project/pyusb/) を使って表示させることができますが、サイズ調整やRGB565変換等の処理が必要なため、AX206LCD クラスを作って 簡単に表示できるようにします。

Python でアクセスできるため、[Pillow](https://pillow.readthedocs.io/en/stable/) を使って好みの画像内容を編集し、それを簡単にAX206LCDに表示させることができるようになります。

<br><br>その前に、

LCD4linux のソースコードと、LCD4linux や AIDA64 と 当LCD が実際に入出力した USBデータのキャプチャー情報から解析した、AX206LCDにアクセスするための情報をまとめておきます。


# USB Mass Storage Class - Bulk Only Transport

このLCDは [`USB Mass Storage Class - Bulk Only Transport`](https://www.usb.org/sites/default/files/usbmassbulk_10.pdf) プロトコルでアクセスします。

## Endpoint

Bulk 転送に使用するエンドポイントは、次の通り。

- PC → LCD ： 0x01
- LCD → PC ： 0x81



## CBW (Command Block Wrapper)

CBW（31バイト）の内容は、次の通り。

|offset|パラメタ |内容 |
|:-:|-|-|
|0-3|<b><i>dCBWSignature</i></b>| 0x55 0x53 0x42 0x43 ("USBC") |
|4-7|<b><i>dCBWTag</i></b>| 0xde 0xad 0xbe 0xef |
|8-11|<b><i>dCBWDataTransferLength</i></b>| データ転送長 (little endian)|
|12|<b><i>bmCBWFlags</i></b>| 0x80 : Data In (LCD → PC)<br> 0x00 : Data Out (PC → LCD)|
|13|<b><i>bCBWLUN</i></b>| 0x00 |
|14|<b><i>bCBWCBLength</i></b>| 0x10 （CBWCB長）|
|15-30|<b><i>CBWCB</i></b>| <b>後述</b> |


## CSW (Command Status Wrapper)

CSW（13バイト）の内容は、次の通り。
（LCD4linux では、ACK と表現している）

|offset|パラメタ |内容 |
|:-:|-|-|
|0-3|<b><i>dCSWSignature</i></b>| 0x55 0x53 0x42 0x53  ("USBS") |
|4-7|<b><i>dCSWTag</i></b>| 0xde 0xad 0xbe 0xef |
|8-11|<b><i>dCSWDataResidue</i></b>| 0x00 0x00 0x00 0x00 0x00 |
|12|<b><i>bCSWStatus</i></b>| 0x00 : Command Passed (good status)<br>0x01 : Command Failed<br> 0x02 : Phase Error|


## CBWCB (Command Block Wrapper - Command Block)

次の3種類を確認した

### 1. LCDサイズ取得

デバイスにLCDのサイズ（width・height）を問い合わせる。

|offset|パラメタ |内容 |
|:-:|-|-|
|0|<b><i> </i></b>| 0xcd |
|1-4|<b><i> </i></b>| 0x00 0x00 0x00 0x00 |
|5|<b><i> </i></b>| 0x02 : get LCD parameters |
|6-15|<b><i> </i></b>| 0x00 --- 0x00 |

上記データをAX206LCDに送信すると、次の5バイトのデータが デバイスから送り返される。

|offset|パラメタ |内容 |
|:-:|:-:|-|
|0-1|<b><i> width </i></b>| 0xe0 0x01（0x01e0：480） |
|2-3|<b><i> height </i></b>| 0x40 0x01（0x0140：320） |
|4|<b><i> </i></b>| 0xff |


### 2. バックライト設定

LCDのバックライトの明るさを設定する

|offset|パラメタ |内容 |
|:-:|:-:|-|
|0|<b><i> </i></b>| 0xcd |
|1-4|<b><i> </i></b>| 0x06 0x01 0x01 0x00 |
|5|<b><i> </i></b>| 0x00 〜 0x07 : set LCD backlight |
|6-15|<b><i> </i></b>| 0x00 --- 0x00 |


### 3. 画像データ転送

表示する画像データを AX206LCDに転送する

|offset|パラメタ |内容 |
|:-:|:-:|-|
|0|<b><i> </i></b>| 0xcd |
|1-4|<b><i> </i></b>| 0x00 0x00 0x00 0x00 |
|5-6|<b><i> </i></b>| 0x06 0x12 |
|7-8|<b><i> x0 </i></b>| 0x00 0x00（0x0000：0） |
|9-10|<b><i> y0 </i></b>| 0x00 0x00（0x0000：0） |
|11-12|<b><i> x1 </i></b>| 0xdf 0x01（0x01df：479） |
|13-14|<b><i> y1 </i></b>| 0x3f 0x01（0x013f：319） |
|15|<b><i> </i></b>| 0x00 |

これに続き、(x0, y0)〜(x1, y1) の RGB565データを転送する。

（ただし、(0, 0)〜(479, 319)以外（一部領域指定）のデータ転送は、当LCDは無応答となる。）



# AX206LCDクラス の 使用例

次に AX206LCDクラス の 使用例を示します。

商品ページにあったサンプル画像を、順に表示させる pythonコードです。

<img width="100" alt="sample.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/766d7fc4-be79-6abf-7fe2-77f273bc599a.jpeg" /><br>

```python
import time
from AX206LCD import AX206LCD
from PIL import Image

lcd = AX206LCD()
lcd.setbacklight(2)

files = ['sample1', 'sample2', 'sample3']
while True:
    for file in files:
        image = Image.open(f'./{file}.png')
        lcd.draw(image)
        time.sleep(5)
```

超シンプルに表示できます。

<img width="300" alt="IMG_sample1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/e2d28a8e-c3a9-6b7a-5d19-bec1a6e73068.png" />
<img width="300" alt="IMG_sample2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/b677620b-f528-57fb-2113-634299f5d75f.png" />
<img width="300" alt="IMG_sample3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/89b26c3d-ca71-6456-aa30-fb52e78f3130.png" />

`draw()`1回あたり、0.6〜0.7秒で画面を書き換えることができました。

:::note
**転送性能の考察**

先の情報によると 最高12Mbps、効率を8割としてバイト換算すると、毎秒1.2MBの転送能力。

![usb1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/f101f064-b746-c331-b8f1-293e4ff3b783.png)

480x320のRGB565のイメージデータは 307,200バイト（300KB）で、単純な割り算だと 0.244秒となる。

しかし、USBプロトコルのやりとりが何往復も発生することと、LCD内の画像処理時間が多くの時間を占めると想定する。

次のスクショの黄色線の箇所での 0.517秒の内訳は、
・262171バイトのデータ転送に 0.208秒
・残りの 0.309秒がLCD内部処理
と想定される。

なお、307,200バイトのイメージデータは、262,171バイトと 45,083バイトの2回に分けて転送されている（含む、USBプロトコルヘッダー<b>：</b>27バイト）。

![wireshake.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/e435a7a3-4cc6-a612-cd98-e52a8ec67d94.png)

:::



約1.5fpsのため、真にリアルタイムな更新には向きませんが、リソースモニターや現在時刻表示（時計）程度なら十分かもしれません。

なお、LCD4Linux で実装されている『画面領域の一部矩形の書き換え』は、当LCDは機能しませんでした（コマンドを送信しても応答が来ない、画面も書き換わらない）。


# AX206LCDクラス の 使い方

- **from AX206LCD import AX206LCD**<br>AX206LCDクラスをインポート<br>　

- **lcd = AX206LCD()**<br>AX206LCDの初期化を行う。その前にAX206LCDを接続しておくこと（複数台の同時接続には未対応）。<br>_初期化でエラーが起きる場合は、USBケーブルの抜き差しを行ってみてください_<br>　

- **lcd.setbacklight(brightness)**<br>LCDのバックライトの明るさを、0〜7 で指定する。LCDの初期値は 7、<br>0 を指定するとバックライトは消灯して画像は見えない<br>　

- **lcd.draw(image)**<br>Imageを当クラス内部で480x320にリサイズして画面に表示する。<br>横縦比が3:2でない画像の場合は、画像を中央に配置し、余白を黒で表示する。基本は呼ぶ側では画像サイズを気にする必要は無い



# AX206LCDクラス

AX206LCDクラス の pythonコードを示す。

```python:AX206LCD.py
import io
import usb.core
import usb.util
from struct import pack, unpack
from PIL import Image, ImageOps

class AX206LCD_Base:
    _AX206_VID = 0x1908
    _AX206_PID = 0x0102
    _black = (0, 0, 0)

class AX206LCD(AX206LCD_Base):
    def __init__(self, debug=False):
        self.width = 0
        self.height = 0
        self.dev = None
        self.DIR_IN = "IN"
        self.DIR_OUT = "OUT"
        self.__debug = debug
        self.__counter = 0 #for debug

        #open device
        dev = usb.core.find(idVendor=AX206LCD_Base._AX206_VID, idProduct=AX206LCD_Base._AX206_PID)
        assert dev is not None, "AX206LCD not found. It may not be connected."
        self.dev = dev

        dev.set_configuration()

        cmd = b'\xcd\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
        buf = bytearray(5)
        assert self.__wrap_scsi(cmd, self.DIR_IN, buf) == 0, "device open error reading LCD dimensions"

        self.width, self.height = unpack('<HH', buf[0:4])
        print(f"AX206LCD: got LCD dimensions: {self.width}x{self.height}")

    def __del__(self):
        if self.dev is None: return
        usb.util.dispose_resources(self.dev)
        self.dev = None

    def setbacklight(self, brightness=1):
        assert 0 <= brightness <= 7, "must be value is 0...7"

        cmd = bytearray(b'\xcd\x00\x00\x00\x00\x06\x01\x01\x00\xff\x00\x00\x00\x00\x00\x00')
        cmd[9] = brightness
        assert self.__wrap_scsi(cmd, self.DIR_OUT, None) == 0, f"setbacklight failer. value: {brightness}"

    def __resize(self, image):
        x_ratio = self.width / image.width
        y_ratio = self.height / image.height
        if x_ratio < y_ratio:
            resize_size = (self.width, round(image.height * x_ratio))
        else:
            resize_size = (round(image.width * y_ratio), self.height)

        image = image.resize(resize_size)
        newImage = Image.new("RGBA", (self.width, self.height), AX206LCD_Base._black)
        #centering
        x, y = (self.width - image.size[0]) // 2, (self.height - image.size[1]) // 2
        newImage.paste(image, (x, y))
        return newImage

    def clear(self, color=AX206LCD_Base._black):
        #RGBA to RGB565
        r, g, b = color
        rgb565 = bytes([(((r & 0xf8)) | ((g & 0xe0) >> 5)), (((g & 0x1c) << 3 ) | ((b & 0xf8) >> 3))])

        out_size = self.width * self.height * 2
        out_img = bytearray(out_size)
        for n in range(0, out_size, 2): out_img[n: n+2] = rgb565

        cmd = bytearray(b'\xcd\x00\x00\x00\x00\x06\x12\xff\xff\xff\xff\xff\xff\xff\xff\x00')
        cmd[7:15] = pack("<HHHH", 0, 0, self.width - 1, self.height - 1)
        assert self.__wrap_scsi(cmd, self.DIR_OUT, out_img) == 0, f"draw failer. size: {len(out_img)}"

    def draw(self, image):
        image = self.__resize(image)
        image = ImageOps.flip(image) # horizontal flip

        img_bytes = io.BytesIO()
        image.save(img_bytes, format="BMP")
        image_bytes = img_bytes.getvalue()[54:] #skip BMP header (54bytes)
        width, height = image.size
        byteperpixel = len(image_bytes) // (width * height)
        out_size = width * height * 2
        out_img = bytearray(out_size)
        for n in range(0, out_size, 2):
            m = n // 2 * byteperpixel
            #RGBA to RGB565
            out_img[n + 0] = (((image_bytes[m + 2] & 0xf8)      ) | ((image_bytes[m + 1] & 0xe0) >> 5))
            out_img[n + 1] = (((image_bytes[m + 1] & 0x1c) << 3 ) | ((image_bytes[m + 0] & 0xf8) >> 3))

        cmd = bytearray(b'\xcd\x00\x00\x00\x00\x06\x12\xff\xff\xff\xff\xff\xff\xff\xff\x00')
        cmd[7:15] = pack("<HHHH", 0, 0, width - 1, height - 1)
        assert self.__wrap_scsi(cmd, self.DIR_OUT, out_img) == 0, f"draw failer. size: {len(out_img)}"

    def __dump(self, title, buf, readlen=0):
        if not self.__debug: return
        
        length = 0 if buf is None else len(buf)
        str = '' if readlen==0 else f', read-len:{readlen}'
        print(f"#{self.__counter} <{title}> buf-len: {length}{str}")
        if buf is None: return
        
        if length > 64: length = 64
        for n in range(length):
            if n > 0 and n % 16 == 0: print()
            d = buf[n]
            if 0x20 <= d <= 0x7f:
                c = f"[{chr(d)}]{'{:02x}'.format(d)}"
            else:
                c = f"{'{:02x}'.format(d)}"
            print(c, end=' ')
        print()

    def __wrap_scsi(self, cmd, dir, buf):
        if self.__debug:
            self.__counter += 1
            print(f"#{self.__counter} wrap_scsi cmd:{cmd} dir:{dir}, buf:{'None' if buf is None else f'size:{len(buf)}'}")

        cbw = bytearray(b'USBC\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x10')
        cbw[14] = len(cmd)
        if buf is not None:
            cbw[8:12] = pack("<L", len(buf))

        out = cbw + cmd
        self.__dump("cmd bulk write", out)
        ret = self.dev.write(0x1, out, 1000)
        assert ret is not None, f"{self.__counter} cmd bulk write failed. cmd:{out}"

        if dir == self.DIR_OUT and buf is not None:
            self.__dump("buf bulk write", buf)
            ret = self.dev.write(0x1, buf, 3000)
            assert ret is not None, f"{self.__counter} cmd bulk write failed. buf len:{len(buf)}"

        elif dir == self.DIR_IN and buf is not None:
            buf_len = len(buf)
            self.__dump("cmd bulk reading", None, buf_len)
            ret = self.dev.read(0x81, buf_len, 4000)
            assert ret is not None, f"{self.__counter} cmd bulk read failed."
            recv = bytearray(ret)
            assert len(recv) == buf_len, f"{self.__counter} cmd bulk read length unmatch. {buf_len}:{len(recv)}"
            buf[0:buf_len] = recv
            self.__dump("cmd bulk readed", buf)

        #get CSW as ACK response
        self.__dump("ack bulk reading", None)
        ret = self.dev.read(0x81, 13, 5000) # CSW size is 13
        assert ret is not None, f"{self.__counter} ack read failed."
        csw = bytearray(ret)
        self.__dump("ack bulk read", csw)
        assert len(csw) == 13, f"{self.__counter} ack read length unmatch. 13:{len(csw)}"
        assert csw[:4] == b'USBS', f"{self.__counter} NO ACK. {csw[:4]}"
        
        return csw[12] # bCSWStatus
```



---
自分にとっては、こういうのも「電子工作」なんです。<br><br><br><br>


**以上**