momoのアイコン画像
momo 2026年02月19日作成 (2026年02月22日更新) © Apache-2.0
製作品 製作品 閲覧数 62
momo 2026年02月19日作成 (2026年02月22日更新) © Apache-2.0 製作品 製作品 閲覧数 62

簡易USBディスプレイ / Cheap USB display

簡易USBディスプレイ / Cheap USB display

はじめに

USBで接続する簡単なディスプレイを作りました。ディスプレイといっても128x64ピクセルの小さなOLED(SSD1306)です。制御マイコンも数十円のCH32V003J4M6です(物価高で値上がりしましたがそれでも50円くらい:2026/02/01現在秋月価格)。
ホストPC側ではドライバは不要で簡単にアプリが作れるので、ちょっとしたインフォメーション表示など応用範囲は広いかと思います。

システム概要

構成

ホストPCとマイコンはUSB1.1で接続しHIDクラスを使ってコマンド+表示データを送ります。マイコンは、そのコマンドを解釈してしOLED用の表示コマンドに変換します。変換されたコマンドはOLEDにI2Cで送信されます。

表示する画像はホストPC側で生成して、デバイス側では表示制御だけに特化するシステムにしています。それにより、貧弱なマイコン&少ない部品でいろいろな画像が素早く表示できるデバイスになっています。ホスト側ががんばればアニメーション表現も可能です。

コマンドリファレンス

4つのコマンドを実装しました。基本構成は、コマンドID(1byte) + パラメータ(3byte) + データ(max 128byte) です。
このデータ列をUSB HIDクラスを使って送信します。

座標とサイズの扱いは8ピクセル単位のみとしました。基本的な使い方としては、ホスト側で1フレーム分のデータを生成して、それを丸ごと送信するだけの使い方を想定しているからです。

このシステムの思想としては、ホスト側で画像データ(フレームバッファの内容)を作成して、デバイス側では、それをOLEDに転送するだけに徹する事にしました。但し、フォントデータの扱いは面倒なのでデバイス側で処理して欲しい。なので、テキスト描画のコマンドも使えるようにしています。アスキー文字だけですが。

  • 基本フォーマット
Byte 0 1 2 3 4 5 6 ... 132
Descript CMD_ID PRM1 PRM2 PRM3 DAT0 DAT2 DATA3 ... DATA127

  • CMD_CLEAR: フレームバッファの内容を消去。表示画面には反映しない。
    • CMD_ID: 0xA0
    • PRM1~3: 0x00 (fixed value)
    • DAT0~127: n/a

  • CMD_BITBLIT: 指定サイズの矩形領域を指定位置のフレームバッファに書き込む。表示画面には反映しない。
    • CMD_ID: 0xA1
    • PRM1: 表示位置 bit[3:0]:水平位置 bit[7:4]:垂直位置(8pixel単位の設定:0:0pix, 1:8pix, 2:16pix... )
    • PRM2: 表示サイズ bit[3:0]:水平サイズ bit[7:4]:垂直サイズ(8pixel単位の設定:0:8pix, 1:16pix, 2:24pix... )
    • PRM3: 0x00 (fixed value)
    • DAT0~127: 表示したいBitMapデータ(1ppb、LSB-First、Byte Alignment)
      • 1コマンドでは最大で128x8ピクセル画像を送信可能。
      • 1フレーム分の画像(1024Byte)を送る場合は水平位置を変えながら8回送信する。

  • CMD_TEXT: 指定した位置に指定したアスキーコードの文字列をフレームバッファに書き込む。表示画面には反映しない。
    • CMD_ID: 0xA2
    • PRM1: 表示位置 bit[3:0]:水平位置 bit[7:4]:垂直位置(8ピクセル単位の設定:0:0, 1:8, 2:16... )
    • PRM2: フォントサイズ(0:8x8, 1:16x16, 2:32x32, 3:64x64 )
    • PRM3: 文字列の長さ(Fontsize:8の場合に最大16文字、改行はされない)
    • DAT0~15: 表示したいアスキーコード
    • DAT16~127: n/a

  • CMD_REFRESH: フレームバッファの内容を表示。
    • CMD_ID: 0xAF
    • PRM1~3: 0x00 (fixed value)
    • DAT0~127: n/a

ハードウェア

回路構成

主要な部品は激安マイコンのCH32V003J4M6SSD1306くらいです。
3.3Vに降圧するレギュレータ(AMS1117)は、もっと入手しやすいドロップが小さいモノに変更した方がいいかもしれません、もしくは、CH32V003とSSD1306は5Vでも動作するので、そのまま直結しても動作すると思われます。ただし、その場合はI2Cライン(SCL/SDA)は分圧などして3.3Vにする必要があります。あと、パスコンのコンデンサ容量はあまり詳しくないので適当です...。無くても動くかも。
I2Cラインは、SSD1306モジュール側でプルアップされているようなので別途プルアップ抵抗は必要ないようです。
CH32V003のpin:8 (SWIO)はでライタ(WCH-LinkE)接続用に開けています。pin:7はPowerOn→Bootloader起動→FW起動した時にUSBバスを再接続(D-ラインをリセット)する為に使っています。Bootloaderを使わない場合はプルアップ固定してしておけばpin:7を他の用途でも使えると思います。
Type-C USBコネクタ基板はC1/C2端子にプルダウン抵抗(5.1kΩ)が既に付いているモノもあるようです。Type-BコネクタやUSBケーブルを直付けする場合はをプルダウン抵抗は不要です。

回路

ユニバーサル基板&チップ抵抗を使って手半田で実装したので裏は結構ごちゃごちゃ。SSD1306は取り外しできるようにピンソケットを介して接続。CH32V003J4M6は失敗しても安いので気楽に直付け。(でもマイコンチップよりブレイクアウト基板の方が高価だったりする)
キャプションを入力できます
裏側。
キャプションを入力できます

動いたのでとりあえずヨシ!
キャプションを入力できます

ケースの作成

3Dプリンタでケースを作成して基板を収めました。実はこれが一番時間がかかった...。

3D-CAD

正面。
キャプションを入力できます

裏側、
キャプションを入力できます

試行錯誤の残骸。
キャプションを入力できます

デバイス側のファームウェア

開発環境

ビルド環境はch32fun を使うので、とりあえず /examples/blink とかで makeできるようにセットアップしてください。ちなみに、私は Windows11 + WSL2 で環境構築しました。
CH32V003は激安すぎてUSBコントローラ回路は搭載されていないので全てソフトウェアでUSB制御を実装します。ライブラリは rv003usb を利用します。Bootloaderもあるので一度書き込んでしまえば以降はライタ不要でこの回路のUSB経由でFirmwareアップデートできるので便利です。その際は、BootloaderのソースコードのUSB端子(D+、D-)設定を、この回路構成と同じに変更しておく必要があります。

ソースコード

新たに作る必要があるファイルは下記の4つだけです。

  • Makefile
  • usb2oled.c
  • funconfig.h
  • usb_config.h

Makefile内のパス設定を環境に合わせて変更すれば、どんな構成でもよいですが、参考としてフォルダ構成を載せておきます。/tools/ch32funrv003usb を git cloneしています。

開発フォルダの構成(参考)

./ ├── host/ ├── src/ │ └─ usb2oled/ │ ├── Makefile │ ├── usb2oled.c │ ├── funconfig.h │ └── usb_config.h └── tools ├── ch32fun/ └── rv003usb/

/src/usb2oled/ で、makeすればビルドできます。make flashで書き込み。make debugでデバッグ実行(printfの表示など) できるようにしています。PATH_CH32FUNRV003USB は環境に合わせて変更してください。

Makefile

TARGET:=usb2oled TARGET_MCU?=CH32V003 PATH_TOOLS:=../../tools PATH_CH32FUN:=$(PATH_TOOLS)/ch32fun/ch32fun DEBUGER:=$(PATH_TOOLS)/ch32fun/minichlink/minichlink RV003USB:=$(PATH_TOOLS)/rv003usb ADDITIONAL_C_FILES+=$(RV003USB)/rv003usb/rv003usb.S $(RV003USB)/rv003usb/rv003usb.c EXTRA_CFLAGS:=-I$(RV003USB)/lib -I$(RV003USB)/rv003usb include $(PATH_CH32FUN)/ch32fun.mk all : $(TARGET).bin flash : cv_flash clean : cv_clean debug : $(DEBUGER) -b -T

起動時のロゴ表示などはファームウェアで行っていますが、それ以降の描画制御はUSB経由で受信したデータを ch32funのSSD1306用の関数に変換して渡しているだけです。ch32fun::SSD1306のAPIには線や円の描画、円や矩形の塗りつぶしなどの関数がありますが、この貧弱なマイコンではパフォーマンス的に許容できないと考え実装していません。

USB接続時のシーケンスなどは rv003usb がやってくれるみたいなので特に何もしていません。主に HID Feature Report の受信処理だけを実装しています。(本来は Output Report を使う方がよいともいますが何故かうまくいかなかったので…。)

usb2oled.c

#include <stdio.h> #include <string.h> #include "ch32fun.h" #include "rv003usb.h" #include "ch32v003_GPIO_branchless.h" #define SSD1306_128X64 #include "ssd1306_i2c.h" #include "ssd1306.h" #define min(a, b) ((a) < (b) ? (a) : (b)) #define VER "0.0.5" #define ICON_W 112 #define ICON_H 32 const unsigned char IconBmp[] = { 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x80, 0xE8, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x20, 0xFE, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFC, 0x3F, 0x00, 0x00, 0xF0, 0xFF, 0xE7, 0x07, 0x8F, 0x0F, 0x08, 0xF8, 0x00, 0x00, 0xF0, 0x1F, 0x00, 0x00, 0xF0, 0xFF, 0xE7, 0x03, 0x8E, 0x0F, 0x08, 0xF0, 0xF8, 0xFF, 0xE0, 0x8F, 0xFF, 0xFF, 0x03, 0xE0, 0xE7, 0x03, 0x8E, 0x0F, 0x88, 0xE1, 0xFC, 0xFF, 0xE3, 0xC7, 0x00, 0x00, 0xF2, 0xEF, 0xE7, 0x71, 0x8C, 0x8F, 0x8F, 0xE3, 0xFC, 0xFF, 0xE0, 0x63, 0x00, 0x00, 0xF2, 0xEF, 0xE7, 0xF9, 0x8C, 0x8F, 0x8F, 0xE3, 0xFC, 0x03, 0xF0, 0x23, 0x00, 0x00, 0x82, 0xEF, 0xE7, 0xF9, 0x8C, 0x0F, 0x8C, 0xE3, 0xFC, 0x00, 0xF8, 0x31, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0xF9, 0x8C, 0x0F, 0x8C, 0xE3, 0x7C, 0xF0, 0xFF, 0x11, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0xF9, 0x8C, 0x8F, 0x8F, 0xE3, 0x7C, 0xF8, 0xFF, 0x19, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0x71, 0x8C, 0x8F, 0x8F, 0xE3, 0x7C, 0x00, 0x00, 0x18, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0x01, 0x0E, 0x08, 0x88, 0xE1, 0xFC, 0x00, 0x00, 0x18, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0x03, 0x0E, 0x08, 0x08, 0xF0, 0xFC, 0x01, 0x00, 0x18, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0x07, 0x0F, 0x08, 0x08, 0xFC, 0xFC, 0xFF, 0xFF, 0x11, 0x00, 0x00, 0xB2, 0xEF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0x33, 0x00, 0x00, 0x82, 0xEF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0x63, 0x00, 0x00, 0xF2, 0xEF, 0xE7, 0xFF, 0xFF, 0xFF, 0x3F, 0x84, 0xFC, 0xFF, 0xFF, 0xC7, 0x00, 0x00, 0xF2, 0xEF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0x8F, 0xFF, 0xFF, 0x03, 0xE0, 0xE7, 0xFF, 0xFF, 0xFF, 0x17, 0x81, 0xFC, 0xFF, 0xFF, 0x1F, 0x00, 0x00, 0xF0, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0x3F, 0x00, 0x00, 0xF0, 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x3F, 0x00, 0x00, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x3F, 0xF9, 0xFF, 0xE4, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x3F, 0x00, 0x00, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x00, 0x00, 0xF0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, }; uint8_t CmdBuf[128 + 4]; volatile uint8_t FlgRcv = 0; void usb_setup_upd() { #define PIN_USB_DPU GPIOv_from_PORT_PIN(GPIO_port_C, 4) // PC4 GPIO_port_enable(GPIO_port_C); GPIO_pinMode(PIN_USB_DPU, GPIO_pinMode_O_pushPull, GPIO_Speed_10MHz); GPIO_digitalWrite_hi(PIN_USB_DPU); } void oled_setup(void) { Delay_Ms(100); // Wait for OLED setup time if(ssd1306_i2c_init()) { while(1); // I2C initialization failed. } ssd1306_init(); ssd1306_setbuf(0); ssd1306_drawFastHLine(0, 0, SSD1306_W, 1); ssd1306_drawFastHLine(0, SSD1306_H - 1, SSD1306_W, 1); ssd1306_drawFastVLine(0, 0, SSD1306_H, 1); ssd1306_drawFastVLine(SSD1306_W - 1, 0, SSD1306_H, 1); ssd1306_drawstr_sz(0, 4, "USB2OLED", 1, fontsize_16x16); // 16 ssd1306_drawstr_sz(22,20, "ver. " VER, 1, fontsize_8x8); // 36 ssd1306_drawImage(8, 30, IconBmp, ICON_W, ICON_H, 1); ssd1306_refresh(); } void oled_main(uint8_t dat[]) { uint32_t x, y, w, h; switch (dat[0]) { case 0xa0: // clear ssd1306_setbuf(0); break; case 0xa1: // bitblt x = min(((dat[1] >> 0) & 0xf) * 8, SSD1306_W - 1); y = min(((dat[1] >> 4) & 0xf) * 8, SSD1306_H - 1); w = min((((dat[2] >> 0) & 0xf) + 1) * 8, SSD1306_W); // 0:8pix, 1:16pixel, ... 15:128pix h = min((((dat[2] >> 4) & 0xf) + 1) * 8, SSD1306_H); // 0:8pix, 1:16pixel, ... 7:64pix ssd1306_drawImage(x, y, &dat[4], w, h, 0); break; case 0xa2: // text char str[16 + 1] = {"\0"}; x = min(((dat[1] >> 0) & 0xf) * 8, SSD1306_W - 1); y = min(((dat[1] >> 4) & 0xf) * 8, SSD1306_H - 1); uint32_t font = (1 << (dat[2] & 0x3)); // 0:8x8(1), 1:16x16(2), 2:32x32(4), 3:64x64(8) uint32_t len = min(dat[3], sizeof(str) - 1); memcpy(str, &dat[4], len); ssd1306_drawstr_sz(x, y, str, 1, font); break; case 0xaf: // refresh ssd1306_refresh(); break; default: break; } dat[0] = 0x00; } int main() { SystemInit(); // Initialize ch32fun library Delay_Ms(1); // Ensures USB re-enumeration after bootloader or reset; Spec demand >2.5µs ( TDDIS ) usb_setup(); // Initialize rv003usb library usb_setup_upd(); // Reset USB Port oled_setup(); // Initialize OLED while(1) { if(0 != FlgRcv) { oled_main(CmdBuf); // Update OLED FlgRcv = 0; } } } void usb_handle_user_data(struct usb_endpoint * e, int current_endpoint, uint8_t * data, int len, struct rv003usb_internal * ist ) { int offset = e->count; int torx = e->max_len - offset; // Remaining bytes that can be received (len = packet size) torx = min(torx, len); memcpy(&CmdBuf[offset], data, torx); e->count += torx; if (e->count >= e->max_len) { FlgRcv = 1; } } void usb_handle_hid_get_report_start(struct usb_endpoint * e, int reqLen, uint32_t lValueLSBIndexMSB ) { e->opaque = CmdBuf; e->max_len = min(reqLen, sizeof(CmdBuf)); e->count = 0; } void usb_handle_hid_set_report_start(struct usb_endpoint * e, int reqLen, uint32_t lValueLSBIndexMSB ) { e->max_len = min(reqLen, sizeof(CmdBuf)); e->count = 0; }

rv003usbのサンプルコードをベースにしています。デバッグが必要なければFUNCONF_USE_DEBUGPRINTF は不要かも。

funconfig.h

#ifndef _FUNCONFIG_H #define _FUNCONFIG_H #define FUNCONF_USE_DEBUGPRINTF 1 #define CH32V003 1 #define FUNCONF_SYSTICK_USE_HCLK 1 #endif

rv003usbのサンプルコードをベースにしています。HID_FEATURE構造を使ってホストPC⇔デバイス間のコマンドフォーマットを規定しています。(USBは初めてだったので、このディスクリプタの構造を理解するのが難しかった...。)

usb_config.h

#ifndef _USB_CONFIG_H #define _USB_CONFIG_H //Defines the number of endpoints for this device. (Always add one for EP0). For two EPs, this should be 3. #define ENDPOINTS 2 #define USB_PORT A // [A,C,D] GPIO Port to use with D+, D- and DPU #define USB_PIN_DP 2 // [0-4] GPIO Number for USB D+ Pin #define USB_PIN_DM 1 // [0-4] GPIO Number for USB D- Pin #define RV003USB_DEBUG_TIMING 0 #define RV003USB_OPTIMIZE_FLASH 1 #define RV003USB_EVENT_DEBUGGING 0 #define RV003USB_HANDLE_IN_REQUEST 0 #define RV003USB_OTHER_CONTROL 0 #define RV003USB_HANDLE_USER_DATA 1 #define RV003USB_HID_FEATURES 1 #ifndef __ASSEMBLER__ #include <tinyusb_hid.h> #ifdef INSTANCE_DESCRIPTORS //Taken from http://www.usbmadesimple.co.uk/ums_ms_desc_dev.htm static const uint8_t device_descriptor[] = { 18, //Length 1, //Type (Device) 0x10, 0x01, //Spec 0x0, //Device Class 0x0, //Device Subclass 0x0, //Device Protocol (000 = use config descriptor) 0x08, //Max packet size for EP0 (This has to be 8 because of the USB Low-Speed Standard) 0x09, 0x12, //ID Vendor 0x11, 0x11, //ID Product 0x02, 0x00, //ID Rev 1, //Manufacturer string 2, //Product string 3, //Serial string 1, //Max number of configurations }; static const uint8_t special_hid_desc[] = { HID_USAGE_PAGE_N( HID_USAGE_PAGE_VENDOR, 2 ), // ベンダー固有 HID_USAGE ( 0x00 ), HID_REPORT_SIZE ( 8 ), HID_COLLECTION ( HID_COLLECTION_LOGICAL ), // CMD: Clear screen HID_REPORT_COUNT ( 4 ), HID_REPORT_ID ( 0xa0 ) HID_USAGE ( 0x01 ), HID_FEATURE ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , // CMD: Bitblt image HID_REPORT_COUNT ( 4 + 128), HID_REPORT_ID ( 0xa1 ) HID_USAGE ( 0x01 ), HID_FEATURE ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , // CMD: Write text HID_REPORT_COUNT ( 4 + 16), HID_REPORT_ID ( 0xa2 ) HID_USAGE ( 0x01 ), HID_FEATURE ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , // CMD: Refresh screen HID_REPORT_COUNT ( 4 ), HID_REPORT_ID ( 0xaf ) HID_USAGE ( 0x01 ), HID_FEATURE ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , HID_COLLECTION_END, }; static const uint8_t config_descriptor[] = { // configuration descriptor 9, // bLength; 2, // bDescriptorType; 34, 0x00, // wTotalLength (9+9+9+7) 0x01, // bNumInterfaces (Normally 1) 0x01, // bConfigurationValue 0x00, // iConfiguration 0x80, // bmAttributes (was 0xa0) 0x64, // bMaxPower (200mA) //Joystick (It is unusual that this would be here) 9, // bLength 4, // bDescriptorType 0, // bInterfaceNumber = 1 instead of 0 -- well make it second. 0, // bAlternateSetting 1, // bNumEndpoints 0x03, // bInterfaceClass (0x03 = HID) 0x00, // bInterfaceSubClass 0xff, // bInterfaceProtocol 0, // iInterface 9, // bLength 0x21, // bDescriptorType (HID) 0x10,0x01, // bcd 1.1 0x00, //country code 0x01, // Num descriptors 0x22, // DescriptorType[0] (HID) sizeof(special_hid_desc), 0x00, 7, // endpoint descriptor (For endpoint 1) 0x05, // Endpoint Descriptor (Must be 5) 0x81, // Endpoint Address 0x03, // Attributes 0x08, 0x00, // wMaxPacketSize (8 bytes) 100, // Interval (We don't use it.) }; #define STR_MANUFACTURER u"serebent lab." #define STR_PRODUCT u"USB2OLED(powered by rv003usb)" #define STR_SERIAL u"20260201" struct usb_string_descriptor_struct { uint8_t bLength; uint8_t bDescriptorType; uint16_t wString[]; }; const static struct usb_string_descriptor_struct string0 __attribute__((section(".rodata"))) = { 4, 3, {0x0409} }; const static struct usb_string_descriptor_struct string1 __attribute__((section(".rodata"))) = { sizeof(STR_MANUFACTURER), 3, STR_MANUFACTURER }; const static struct usb_string_descriptor_struct string2 __attribute__((section(".rodata"))) = { sizeof(STR_PRODUCT), 3, STR_PRODUCT }; const static struct usb_string_descriptor_struct string3 __attribute__((section(".rodata"))) = { sizeof(STR_SERIAL), 3, STR_SERIAL }; // This table defines which descriptor data is sent for each specific // request from the host (in wValue and wIndex). const static struct descriptor_list_struct { uint32_t lIndexValue; const uint8_t *addr; uint8_t length; } descriptor_list[] = { {0x00000100, device_descriptor, sizeof(device_descriptor)}, {0x00000200, config_descriptor, sizeof(config_descriptor)}, {0x00002200, special_hid_desc, sizeof(special_hid_desc)}, {0x00002100, config_descriptor + 18, 9 }, // Not sure why, this seems to be useful for Windows + Android. {0x00000300, (const uint8_t *)&string0, 4}, {0x04090301, (const uint8_t *)&string1, sizeof(STR_MANUFACTURER)}, {0x04090302, (const uint8_t *)&string2, sizeof(STR_PRODUCT)}, {0x04090303, (const uint8_t *)&string3, sizeof(STR_SERIAL)} }; #define DESCRIPTOR_LIST_ENTRIES ((sizeof(descriptor_list))/(sizeof(struct descriptor_list_struct)) ) #endif // INSTANCE_DESCRIPTORS #endif #endif

ch32funの不具合
ch32fun/ch32fun/extralibs/ssd1306.hに不具合があるようです。issue:249参照。
ssd1306_drawImage()を下記の様に変更しないと正常に表示できません。

ssd1306.h

- 301: x_absolute = x + 8 * (bytes_to_draw - byte) + pixel; + 301: x_absolute = x + 8 * byte + pixel;

ホスト側のソフトウェア

開発環境

マウスやキーボードの接続で使われているHIDクラスを利用するので基本的にドライバなどは不要です。アプリ層だけ作ればよいのでホスト側の実装は比較的簡単です。
プログラム環境としては、USB HIDクラスでデータ通信ができれば何でもよいですが、Python でhidapiライブラリを使うのが一番お手軽かと思いましたので今回はこれで作りました。但し、import する時は hid だけど install は hidapiなので注意。pip install hid とすると別のライブラリがインストールされてしまいます。

pip-install

pip install hidapi

USBデバイスIDはVID:0x1209 PID:0x1111にしていますので、ホスト側では、このIDのデバイスを探してアクセスします。コマンドの仕様は、前述のコマンドリファレンスを参照。詳細はソースコードを見てください。

表示している様子

ホストPCと接続すると先ずBootloaderに接続されます(Bootloaderを書き込んでいる場合)。Firmwareを更新する際はこのタイミングで書き込みます。数秒後、自動的に再接続されUSB2OLEDに切り替わり初期画面が表示されます。その後に usb2oled.py を実行した様子です。動確用に"Hello World !"とチェックカーパターンが表示するようにしています。
基本的な制御を使いまわし易いようにUsb2Oled Classを実装しましたので、これをImportして使うと簡単に扱えると思います。

usb2oled.py

import hid import time class Usb2Oled: WIDTH = 128 HEIGHT = 64 PAGE_SIZE = WIDTH * 8 // 8 def __init__(self, vid=0x1209, pid=0x1111): self.dev = None self.vid = vid self.pid = pid def open(self): try: self.dev = hid.device() self.dev.open(self.vid, self.pid) except Exception as e: raise RuntimeError(f"Failed to open HID device: {e}") def close(self): if self.dev is not None: self.dev.close() self.dev = None @staticmethod def hid_list(): for d in hid.enumerate(): print(f"0x{d['vendor_id']:04x} - 0x{d['product_id']:04x} : {d['product_string']} : {d['serial_number']} : {d['manufacturer_string']}") CMD_CLEAR = 0xa0 CMD_IMAGE_ROW = 0xa1 CMD_TEXT = 0xa2 CMD_REFRESH = 0xaf def send_cmd(self, cmd, param=[0, 0, 0], img=None): if self.dev is None: raise RuntimeError("Device not opened") data = [cmd] + param if img is not None: data += img #print("send_cmd:", "".join([f"{d:02x} " for d in data[:16]])) self.dev.send_feature_report(data) def clear(self): self.send_cmd(self.CMD_CLEAR) self.send_cmd(self.CMD_REFRESH) time.sleep(0.03) def refresh(self): self.send_cmd(self.CMD_REFRESH) FONT_8x8 = 0 FONT_16x16 = 1 FONT_32x32 = 2 FONT_64x64 = 3 def draw_txt(self, txt, col, row, font=FONT_8x8, refresh=False): if len(txt) > 16: print("cmd_txt: text length over 16") txt = txt[:16] txt = txt.ljust(16, '\0') data = txt.encode('ascii') self.send_cmd(self.CMD_TEXT, [(col & 0xf) | (row & 0xf) << 4, font, len(txt)], data) time.sleep(0.1) if refresh: self.refresh() def draw_image_row(self, row, img): if len(img) != self.PAGE_SIZE: raise ValueError(f"Image row size over, got {len(img)}") self.send_cmd(self.CMD_IMAGE_ROW, [(row & 0xf) << 4, 0x0f, 0x00], img) def draw_image(self, img, refresh=False): for row in range(8): st = row * self.PAGE_SIZE ed = st + self.PAGE_SIZE #print(f"{row=} {st=} {ed=}") self.draw_image_row(row, img[st:ed]) if refresh: self.refresh() time.sleep(0.01) def __enter__(self): self.open() return self def __exit__(self, exc_type, exc_value, traceback): self.close() if __name__ == "__main__": # Listup connected devices Usb2Oled.hid_list() with Usb2Oled(vid=0x1209, pid=0x1111) as u2o: # Draw text example u2o.clear() u2o.draw_txt("1:Hello World!", 0, 0) u2o.draw_txt("2:Hello World!", 0, 1) u2o.draw_txt("3:Hello World!", 0, 2) u2o.draw_txt("Hello!", 0, 3, font=Usb2Oled.FONT_16x16) u2o.draw_txt("World!", 0, 5, font=Usb2Oled.FONT_16x16) u2o.refresh() time.sleep(3) # Draw image example row_a = bytes([0xFF, 0x00] * 8) row_b = bytes([0x00, 0xFF] * 8) img = bytearray().join(row_a if ((y // 8) & 1) == 0 else row_b for y in range(64)) u2o.clear() u2o.draw_image(img, refresh=True) time.sleep(3) u2o.clear()

アニメーション表示の様子。上記の usb2oled.py と同じフォルダに下記ファイルをおいて実行します。思ったよりも表示の更新速度が速いので結構ヌルヌル動く感じです。撮影条件の影響でフリッカが見えていますが肉眼ではほとんど気になりません。実物はもっときれいに見えます。

sample_anime.py

from usb2oled import Usb2Oled import time import math import numpy as np CX, CY = Usb2Oled.WIDTH / 2, Usb2Oled.HEIGHT / 2 def set_pix(fb, x, y): if x < 0 or x >= Usb2Oled.WIDTH or y < 0 or y >= Usb2Oled.HEIGHT: return pos = y * Usb2Oled.WIDTH + x byte_index = pos >> 3 if 0 <= byte_index < len(fb): # fb[byte_index] |= 1 << (7 - (pos & 7)) # MSB first fb[byte_index] |= 1 << (pos & 7) # LSB first def set_frame(fb, u2o): u2o.draw_image(fb, refresh=True) time.sleep(0.016) # ≒1/60 ※オーバーヘッドがあるので実際のfpsはもっと落ちる def anime_circle_wave(n=100, step=0.3): for t in np.arange(0.0, n * step, step): fb = bytearray(Usb2Oled.WIDTH * Usb2Oled.HEIGHT // 8) for y in range(Usb2Oled.HEIGHT): for x in range(Usb2Oled.WIDTH): if math.sin(math.hypot(x - CX, y - CY) / 4 - t) > 0: set_pix(fb, x, y) set_frame(fb, u2o) def amine_time_tonnel(n=100, step=0.25): for t in np.arange(0.0, n * step, step): fb = bytearray(Usb2Oled.WIDTH * Usb2Oled.HEIGHT // 8) for y in range(Usb2Oled.HEIGHT): for x in range(Usb2Oled.WIDTH): dx = x - CX dy = y - CY angle = math.atan2(dy, dx) dist = math.hypot(dx, dy) if math.sin(dist*0.15 - t + angle) > 0: set_pix(fb, x, y) set_frame(fb, u2o) def anime_wave_feild(n=100, step=0.2): for t in np.arange(0.0, n * step, step): fb = bytearray(Usb2Oled.WIDTH * Usb2Oled.HEIGHT // 8) for y in range(Usb2Oled.HEIGHT): for x in range(Usb2Oled.WIDTH): if 0 < math.sin(x*0.12 + t) + math.cos(y*0.15 - t): set_pix(fb, x, y) set_frame(fb, u2o) def ball_xy(offset_x, offset_y, angle_x, angle_y): return CX + offset_x * math.cos(angle_x), CY + offset_y * math.sin(angle_y) def anime_meta_ball(n=100, step=0.2, scale=1.0): for t in np.arange(0.0, n * step, step): fb = bytearray(Usb2Oled.WIDTH * Usb2Oled.HEIGHT // 8) x1, y1 = ball_xy(30, 20, t, t * 1.3) x2, y2 = ball_xy(30, 20, t * 0.7, t) for y in range(Usb2Oled.HEIGHT): for x in range(Usb2Oled.WIDTH): d1 = scale / (math.hypot(x - x1, y - y1) + 1) d2 = scale / (math.hypot(x - x2, y - y2) + 1) if (d1 + d2) > 0.06: set_pix(fb, x, y) set_frame(fb, u2o) def anime_meta_ball_x3(n=100, step=0.16, scale=0.7, threshold=0.095): for t in np.arange(0.0, n * step, step): fb = bytearray(Usb2Oled.WIDTH * Usb2Oled.HEIGHT // 8) x1, y1 = ball_xy(42, 26, t * 0.8, t * 1.1) x2, y2 = ball_xy(40, 24, t * 1.3, t * 0.7) x3, y3 = ball_xy(38, 22, t * 1.6, t * 1.4) for y in range(Usb2Oled.HEIGHT): for x in range(Usb2Oled.WIDTH): d1 = scale / (math.hypot(x - x1, y - y1) + 1) d2 = scale / (math.hypot(x - x2, y - y2) + 1) d3 = scale / (math.hypot(x - x3, y - y3) + 1) v = d1 + d2 + d3 if v > threshold: if (v - threshold) < 0.03: # 境界付近だけディザを入れる if ((x + y) & 1) == 0: # 疑似ハイライト(縞) set_pix(fb, x, y) else: set_pix(fb, x, y) # 中心部はベタ白 set_frame(fb, u2o) with Usb2Oled(vid=0x1209, pid=0x1111) as u2o: anime_circle_wave() amine_time_tonnel() anime_wave_feild() anime_meta_ball() anime_meta_ball_x3() u2o.clear()

応用例

python環境だと簡単に試せるので楽しくなってきていろいろ作ってみました。

Raspberry Piの情報表示

ヘッドレスでRasPiを運用しているとIPアドレスが変わった時にリモートでアクセスできなくなります。USB接続した時にUSB2OLED用のスクリプトを実行するようにしておけば別途モニタやキーボード等を接続することなくIPアドレスを確認する事ができます。RasPi基板のヘッダピンにOLEDを直付けしてもできるけどこの方がスマートでしょ。

アナログ時計

時計。この小さい画面だどちょっと見にくいかも。

3DCG ワイヤーフレーム

3次元空間座標を浮動小数計算していますがホストPC側で全て計算しているので、こんな表示も可能。IMU(3次元加速度センサ)からの入力をリアルタイムに表示させてもおもしろいかも。

何かのカウントダウン

発射10前!! 9、8、・・・3、2、1、0

fps測定

ざっくり測定ですが、100フレーム表示するのに 5.07[sec] かかったので 19.7[fps] くらいはでいるようです。表示する際にch32funの中でフレームバッファのデータを毎回SSD1306用の画素配置に変換しているみたいなので、ホスト側で直接SSD1306用の配列で画像データを送り、関数を介さずに直接フレームバッファにコピーするようにすれば、もっと速くなる気がします。

まとめ

CH32系チップでUSB接続するなら数十円しか違わないCH32V203/103を使え という指摘は置いておくとして。
安い部品 数点と数百行程度のコードを書くだけでUSB接続ディスプレイができました。USBを挿すだけで使えるで実用性があり性能としても満足の出来です。
なんだかんだでUSB仕様の調査と3Dプリンタでのケース作成の時間がほとんどでした。

ちなみに、このデバイスの仕様をAIに教えてから「おもしろいアンメーションを表示するpythonコードを作って。」とお願いしたら幾つも作ってくれました。上記のアニメーション表示するコードの一部は自動生成です。



以下は備忘録

  • usbipdのセットアップ(for win)
    Windows+WSLにUSBを認識させる場合usbipd-winが必要

  • libusbのセットアップ(for Linux)
    hidapiを使う場合、環境によっては libusb をインストールしておく必要あり

    sudo apt install libusb-1.0-0-dev libudev-dev pkg-config
  • USBアクセス権限(for Linux)
    Linux では USB HID デバイスはデフォルトで root 権限が必要

    1. /etc/udev/rules.d/99-usb2oled.rules に下記内容を書き込む
      RUN= にスクリプトを設定するとUSBを挿した時に実行される
    SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="1111", MODE="0666"、RUN="/bin/python3 [情報表示スクリプト]"

    2.設定したルールを反映

    sudo udevadm control --reload-rules sudo udevadm trigger

    3 対象デバイスを一度抜き差しする

1
momoのアイコン画像
組み込みSWエンジニア
  • momo さんが 前の木曜日の11:56 に 編集 をしました。 (メッセージ: 初版)
  • momo さんが 前の木曜日の12:10 に 編集 をしました。
  • momo さんが 前の木曜日の14:32 に 編集 をしました。
  • momo さんが 前の金曜日の2:01 に 編集 をしました。
  • momo さんが 前の金曜日の2:03 に 編集 をしました。
  • momo さんが 前の金曜日の2:11 に 編集 をしました。
  • momo さんが 前の土曜日の22:10 に 編集 をしました。
  • momo さんが 前の土曜日の23:14 に 編集 をしました。
  • momo さんが 前の土曜日の23:18 に 編集 をしました。
  • momo さんが 前の日曜日の10:49 に 編集 をしました。
ログインしてコメントを投稿する