Arduinoにはたくさんの種類のボードがあり、魅力的です。
特にLCD付きのボードがたくさんあるので、それにブラウザから画像表示してみます。
Arduinoには、有志によりたくさんのディスプレイライブラリがあるので、いろんな大きさや解像度のディスプレイを扱うことができているかと思います。
それらをJavascriptから扱えるようにすれば、いろいろ連携できるのではないかと思った次第です。
ですが、最初に断っておきますが、「遅くて使い物にならない」でした。
チューニングすればよいのですが、知識と時間が必要だったので、とりあえずここまでにしています。
例えば、1画面まるまる転送すると、、
解像度:320x240ピクセルのフルカラーLCD(SPI接続)で、
1ピクセル16ビット(RGB565)転送の場合:60秒
1ピクセル8ビット(RGB332)転送の場合:33秒
1ピクセル1ビット(モノクロ)転送の場合:13秒
(SDカードからの24ビットビットマップファイルを使った表示は2秒強なのでaRESTが遅いんでしょうかね。)
今回採用したボードは以下です。
TTGO-TM-ESP32
https://www.aliexpress.com/item/32848882218.html
https://github.com/LilyGO/TTGO-TM-ESP32
今回は、このうち、SPI接続のLCD(ST7789)、SPI接続のSDカード端子 を使います。
(2020/1/31) 補足
肝心なことを書き忘れていました。aRESTを使っていますが、そのままでは使いにくく、この投稿 で示した改造をしている前提です。
Arduino側
今回採用しているLCDは、ST7789です。Adafruitからライブラリが提供されているので、ありがたく使わせていただいています。
・Adafruit FGX Library
・Adafruit ST7735 and ST7789 Library
それから、Webサーバとして稼働させ、GET呼び出しで画像転送を受け付けるようにします。
・aREST
それ以外に、WiFiで接続しますし、内部でSPIを使いますし、SDカードからビットマップファイルを読みだすためのライブラリも利用しています。
#include <Adafruit_GFX.h> // Core graphics library
#include <Adafruit_ST7789.h> // Hardware-specific library for ST7789
#include <SPI.h>
#include <WiFi.h>
#include <WiFiServer.h>
#include <aREST.h>
#include <SD.h>
// 編集はここから
const char* wifi_ssid = "【WiFiアクセスポイントのSSID】";
const char* wifi_password = "【WiFiアクセスポイントのパスワード】";
// GET接続を待ち受けるポート番号
#define REST_PORT 80
// SDカードから読み出す画像ファイル名
const char* bgimage = "/bgimage.bmp";
// LCDの解像度
#define DISP_WIDTH 240
#define DISP_HEIGHT 320
// LCDの接続ポート(SPI接続)
#define TFT_CS 5
#define TFT_RST 17
#define TFT_DC 16
#define TFT_MOSI 23 // Data out
#define TFT_SCLK 18 // Clock out
// SDカードの接続ポート(SPI接続)
#define SD_CS 13
#define SD_SCK 14
#define SD_MOSI 15
#define SD_MISO 2
Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST);
// LCD画面を回転させるかどうか
#define DISP_ROTATE 1
// 編集はここまで
SPIClass spi_sd(VSPI);
#define DISP_LENGTH ((DISP_WIDTH >= DISP_HEIGHT) ? DISP_WIDTH : DISP_HEIGHT)
#define BUFFER_SIZE DISP_LENGTH
uint16_t line_buffer[BUFFER_SIZE];
#define PARTS_SIZE (3 * 10) // must 3 times
WiFiServer server(REST_PORT);
aREST rest = aREST();
// aREST function
// SDカードにある画像ファイルの表示
String drawBackground(String command) {
Serial.println("drawBackground called");
if( drawBmp(command.c_str()) < 0 )
return "NG";
return "OK";
}
// 表示画像の転送(RGB565)
String drawBmp565(String command) {
Serial.println("drawBmp565 called");
uint16_t winsize[4];
int len = parseRGB565(command, winsize, line_buffer, BUFFER_SIZE );
int data_len = winsize[2] * winsize[3];
if( len != data_len )
return "NG";
tft.startWrite();
tft.setAddrWindow(winsize[0], winsize[1], winsize[2], winsize[3]);
tft.writePixels(line_buffer, data_len);
tft.endWrite();
return "OK";
}
// 表示画像の転送(RGB332)
String drawBmp332(String command) {
Serial.println("drawBmp332 called");
uint16_t winsize[4];
int len = parseRGB332(command, winsize, line_buffer, BUFFER_SIZE );
int data_len = winsize[2] * winsize[3];
if( len != data_len )
return "NG";
tft.startWrite();
tft.setAddrWindow(winsize[0], winsize[1], winsize[2], winsize[3]);
tft.writePixels(line_buffer, data_len);
tft.endWrite();
return "OK";
}
// 表示画像の転送(モノクロ)
String drawBmp1(String command) {
Serial.println("drawBmp1 called");
uint16_t winsize[4];
int len = parseRGB1(command, winsize, line_buffer, BUFFER_SIZE );
int data_len = winsize[2] * winsize[3];
if( len != data_len )
return "NG";
tft.startWrite();
tft.setAddrWindow(winsize[0], winsize[1], winsize[2], winsize[3]);
tft.writePixels(line_buffer, data_len);
tft.endWrite();
return "OK";
}
// 解像度情報の取得
String getInfo(String command) {
Serial.println("getInfo called");
return String(DISP_ROTATE) + "," + String(tft.width()) + "," + String(tft.height());
}
// 初期化
void setup(void) {
Serial.begin(9600);
Serial.println(F("Hello! ST77xx TFT Test"));
// SDカードのマウント
spi_sd.end();
spi_sd.begin(SD_SCK, SD_MISO, SD_MOSI);
if(!SD.begin(SD_CS, spi_sd)){
Serial.println("Card Mount Failed");
// return;
}
// LCDの初期化
tft.init(DISP_WIDTH, DISP_HEIGHT); // Init ST7789 320x240
tft.invertDisplay(false);
tft.setRotation(DISP_ROTATE);
Serial.println(F("Initialized"));
// 初期画像の表示(SDカードからの読み出し含む)
if( drawBmp(bgimage) < 0 )
tft.fillScreen(ST77XX_BLACK);
// Init variables and expose them to REST API
// Function to be exposed
// GETエンドポイントの定義
rest.function("drawbg", drawBackground);
rest.function("draw565", drawBmp565);
rest.function("draw332", drawBmp332);
rest.function("draw1", drawBmp1);
rest.function("getInfo", getInfo);
// Give name & ID to the device (ID should be 6 characters long)
rest.set_id("0001");
rest.set_name("esp32");
// WiFiアクセスポイントへの接続
WiFi.begin(wifi_ssid, wifi_password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(WiFi.localIP());
// Webサーバ起動
server.begin();
Serial.println("Server started");
}
// ループ処理
void loop() {
WiFiClient client = server.available();
if (client) {
// GET呼び出しを検知
for( int i = 0 ; i < 10000; i += 10 ){
if(client.available()){
// GET呼び出しのコールバック呼び出し
rest.handle(client);
return;
}
delay(10);
}
// まれにGET呼び出し受付に失敗するようです。
Serial.println("timeout");
}
}
// RGB332からRGB565への変換
uint16_t fromColor332(uint8_t val){
return ((val & 0x00E0) << 8) | ((val & 0x001C) << 6) | ((val & 0x0003) << 3);
}
// 16進数文字列からuint16配列への変換のための関数軍
int char2int(char c){
if( c >= '0' && c <= '9' )
return c - '0';
if( c >= 'a' && c <= 'f' )
return c - 'a' + 10;
if( c >= 'A' && c <= 'F' )
return c- 'A' + 10;
return 0;
}
char int2char(int i){
if( i >= 0 && i <= 9 )
return '0' + i;
if( i >= 10 && i <= 15 )
return 'a' + (i - 10);
return '0';
}
uint16_t get_uint16b_from_str(String str, int offset){
uint16_t value = char2int(str.charAt(offset)) << 12;
value += char2int(str.charAt(offset + 1)) << 8;
value += char2int(str.charAt(offset + 2)) << 4;
value += char2int(str.charAt(offset + 3));
return value;
}
uint8_t get_uint8b_from_str(String str, int offset){
uint8_t value = char2int(str.charAt(offset)) << 4;
value += char2int(str.charAt(offset + 1));
return value;
}
// RGB565転送呼び出しのパラメータ解析
int parseRGB565(String str, uint16_t *win, uint16_t *array, int maxlen){
int len = str.length();
if( ((len - 16) % 4) != 0 )
return -1;
if( len > (16 + maxlen * 4) )
return -1;
for( int i = 0 ; i < 16 ; i += 4 ){
win[i / 4] = get_uint16b_from_str(str, i);
}
for( int i = 0 ; i < len - 16 ; i += 4 ){
array[i / 4] = get_uint16b_from_str(str, 16 + i);
}
return (len - 16) / 4;
}
// RGB332転送呼び出しのパラメータ解析
int parseRGB332(String str, uint16_t *win, uint16_t *array, int maxlen){
int len = str.length();
if( ((len - 16) % 2) != 0 )
return -1;
if( len > (16 + maxlen * 2) )
return -1;
for( int i = 0 ; i < 16 ; i += 4 ){
win[i / 4] = get_uint16b_from_str(str, i);
}
for( int i = 0 ; i < len - 16; i += 2 ){
uint8_t value = get_uint8b_from_str(str, 16 + i);
array[i / 2] = fromColor332(value);
}
return (len - 16) / 2;
}
// RGBモノクロ転送呼び出しのパラメータ解析
int parseRGB1(String str, uint16_t *win, uint16_t *array, int maxlen){
int len = str.length();
if( ((len - 16) % 2) != 0 )
return -1;
if( len > (16 + maxlen / 4) )
return -1;
for( int i = 0 ; i < 16 ; i += 4 ){
win[i / 4] = get_uint16b_from_str(str, i);
}
int col = 0;
for( int i = 0 ; i < len - 16; i += 2 ){
uint8_t value = get_uint8b_from_str(str, 16 + i);
for( int j = 0 ; j < 8 ; j++ )
array[col++] = ((value >> ( 7 - j )) & 0x0001) ? 0xffff : 0x0000;
}
return col;
}
// SDカードからビットマップファイルの取得およびLCD表示
int drawBmp(const char *filename) {
File bmpFile;
int bmpWidth, bmpHeight; // W+H in pixels
uint8_t bmpDepth; // Bit depth (currently must be 24)
uint32_t bmpImageoffset; // Start of image data in file
uint32_t rowSize; // Not always = bmpWidth; may have padding
boolean flip = true; // BMP is stored bottom-to-top
Serial.println("BMP Loading: "); Serial.print(filename);
// Open requested file on SD card
if ((bmpFile = SD.open(filename)) == NULL) {
Serial.println("File not found");
return -1;
}
// Parse BMP header
if(fread_uint16b(bmpFile) != 0x4D42){
bmpFile.close();
Serial.println("not BMP signature");
return -1;
}
Serial.println("File size: "); Serial.println(fread_uint32b(bmpFile));
fread_uint32b(bmpFile); // Read & ignore creator bytes
bmpImageoffset = fread_uint32b(bmpFile); // Start of image data
// Read DIB header
Serial.println("Header size: "); Serial.println(fread_uint32b(bmpFile));
bmpWidth = fread_uint32b(bmpFile);
Serial.println("bmpWidth size: "); Serial.println(bmpWidth);
bmpHeight = fread_uint32b(bmpFile);
Serial.println("bmpHeight size: "); Serial.println(bmpHeight);
if(fread_uint16b(bmpFile) != 1){
bmpFile.close();
Serial.println("Not supported planes");
return -1;
}
bmpDepth = fread_uint16b(bmpFile); // bits per pixel
Serial.println("Bit Depth: "); Serial.println(bmpDepth);
if((bmpDepth != 24) || (fread_uint32b(bmpFile) != 0)) { // 0 = uncompressed
bmpFile.close();
Serial.println("Not supported format");
return -1;
}
Serial.println("Image size: ");
Serial.print(bmpWidth);
Serial.println(bmpHeight);
// BMP rows are padded (if needed) to 4-byte boundary
rowSize = (bmpWidth * 3 + 3) & ~3;
// If bmpHeight is negative, image is in top-down order.
// This is not canon but has been observed in the wild.
if(bmpHeight < 0) {
bmpHeight = -bmpHeight;
flip = false;
}
// Crop area to be loaded
int w = bmpWidth;
int h = bmpHeight;
if(w > tft.width())
w = tft.width();
if(h > tft.height())
h = tft.height();
for (int row = 0; row < h; row++) { // For each scanline...
uint32_t pos;
if(flip) // Bitmap is stored bottom-to-top order (normal BMP)
pos = bmpImageoffset + (bmpHeight - 1 - row) * rowSize;
else // Bitmap is stored top-to-bottom
pos = bmpImageoffset + row * rowSize;
if(bmpFile.position() != pos) // Need seek?
bmpFile.seek(pos);
uint8_t buffer[PARTS_SIZE];
int donesize = 0;
int col = 0;
while( donesize < rowSize ){
int readsize = ((rowSize - donesize) > PARTS_SIZE) ? PARTS_SIZE : (rowSize - donesize);
if( bmpFile.read(buffer, readsize) != readsize ){
bmpFile.close();
Serial.println("read failed");
return -1;
}
for( int i = 0 ; i < readsize && col < w; i += 3 )
line_buffer[col++] = tft.color565(buffer[i + 2], buffer[i + 1], buffer[i]);
donesize += readsize;
}
tft.startWrite();
tft.setAddrWindow(0, row, w, 1);
tft.writePixels(line_buffer, w);
tft.endWrite();
} // end scanline
bmpFile.close();
Serial.println("BMP Loaded in ");
return 0;
}
uint16_t fread_uint16b(File f) {
uint16_t result;
((uint8_t *)&result)[0] = f.read(); // LSB
((uint8_t *)&result)[1] = f.read(); // MSB
return result;
}
uint32_t fread_uint32b(File f) {
uint32_t result;
((uint8_t *)&result)[0] = f.read(); // LSB
((uint8_t *)&result)[1] = f.read();
((uint8_t *)&result)[2] = f.read();
((uint8_t *)&result)[3] = f.read(); // MSB
return result;
}
「編集はここから」から「編集はここまで」の間の部分を、環境に合わせて編集してください。
特に、もし、ST7789以外のディスプレイコントローラの場合は、Adafruitのライブラリの仕様に従って書き換えていただく必要があります。
起動時処理
以下のことをやっています。
・シリアルコンソールの設定
・SDカードのマウント
・LCDの初期化
・SDカードからビットマップファイルの読み出しおよび表示
・GETエンドポイントの設定
・WiFiアクセスポイントへの接続
・Webサーバの起動
SDカードの読み出しでは、SDカードに配置したビットマップファイルを読みだしています。24ビットで圧縮無しのビットマップファイルのみサポートしています。
ファイル名は、「const char* bgimage = "/bgimage.bmp"」で指定しています。
GETエンドポイントの仕様
・エンドポイント名:drawbg
機能:SDカードにあるビットマップファイル名を指定して表示させます。
パラメータ:ビットマップファイル名
・エンドポイント名:draw565
機能:RGB565(2バイト/ピクセル)形式でビットマップデータを受信して表示させます。
パラメータ:[開始X座標(2B)][開始Y座標(2B)][描画幅(2B)][描画高さ(2B)][ビットマップデータ(RGB565)] ← 16進数文字列で指定
・エンドポイント名:draw332
機能:RGB332(1バイト/ピクセル)形式でビットマップデータを受信して表示させます。
パラメータ:[開始X座標(2B)][開始Y座標(2B)][描画幅(2B)][描画高さ(2B)][ビットマップデータ(RGB332)] ← 16進数文字列で指定
・エンドポイント名:draw1
機能:モノクロ(1ビット/ピクセル)形式でビットマップデータを受信して表示させます。
パラメータ:[開始X座標(2B)][開始Y座標(2B)][描画幅(2B)][描画高さ(2B)][ビットマップデータ(モノクロ)] ← 16進数文字列で指定
・エンドポイント名:getInfo
機能:LCDの解像度を取得します。
パラメータ:無し
例)draw565 に対して、(X,Y)=(0,0) (幅,高さ)=(2,2)
http://192.168.1.1:80/draw565?params=0000000000020002ffffffffffffffff
クライアント側
GET呼び出しができれば、なんでもクライアントになることができます。
そこで今回は、Javascriptで実装しました。ブラウザのJavascriptからはobnizのdisplayと似たI/Fで呼び出せるようにしています。
'use strict';
class TTGO{
constructor(target_url){
this.util = {
createCanvasContext: function(width, height){
var canvas = document.createElement('canvas');
canvas.setAttribute("width", width.toString());
canvas.setAttribute("height", height.toString());
return canvas.getContext('2d');
}
};
fetch( target_url + '/getInfo', {
method: 'GET'
})
.then((response) =>{
if( !response.ok )
throw 'status is not 200';
return response.text();
})
.then((text) => {
var winsize = JSON.parse(text).return_value.split(',');
this.display = new TTGO_display(target_url, parseInt(winsize[1]), parseInt(winsize[2]));
this.onconnect();
});
}
async onconnect(){
}
}
class TTGO_display{
constructor(target_url, width, height){
console.log(width, height);
this.target_url = target_url;
this.width = width;
this.height = height;
this.mode = false;
this.buffer = [];
this.clear();
this.mode = true;
}
async clear(){
for( var y = 0 ; y < this.height ; y++ ){
for( var x = 0 ; x < this.width ; x++ ){
this.buffer[ y * this.width + x ] = 0x0000;
}
}
if( this.mode ){
return this.update();
}else{
return Promise.resolve();
}
}
async drawing(mode){
this.mode = mode;
if( this.mode ){
return this.update();
}else{
return Promise.resolve();
}
}
async raw(ary){
for( var y = 0 ; y < this.height ; y++ ){
for( var x = 0 ; x < this.height ; x += 8 ){
var val = ary[y * this.width + this.fl(x / 8)];
for( var i = 0 ; i < 8 ; i++ )
this.put_pixel(x + i, y, (val & (0x01 << i)) ? 0xffff : 0x0000 );
}
}
if( this.mode ){
return this.update();
}else{
return Promise.resolve();
}
}
async draw(ctx){
var img = ctx.getImageData(0, 0, this.width, this.height);
for (var y = 0; y < this.height; y++ ) {
for (var x = 0; x < this.width; x++) {
var val = toRGB565(img.data[(x + y * this.width) * 4], img.data[(x + y * this.width) * 4 + 1], img.data[(x + y * this.width) * 4 + 2], img.data[(x + y * this.width) * 4 + 3]);
this.put_pixel(x, y, val);
}
}
if( this.mode ){
return this.update();
}else{
return Promise.resolve();
}
}
put_pixel(x, y, val){
this.buffer[y * this.width + x] = val;
}
async update(){
for( var y = 0 ; y < this.height ; y++ ){
var winsize_str = uint16arraytohexstr([0, y, this.width, 1], 0, 4);
var image_str = uint16arraytohexstr(this.buffer, y * this.width, this.width);
var url = this.target_url + '/draw565?params=' + winsize_str + image_str;
// var image_str = uint16arraytohexstr8(this.buffer, y * this.width, this.width);
// var url = this.target_url + '/draw332?params=' + winsize_str + image_str;
// var image_str = uint16arraytohexstr1(this.buffer, y * this.width, this.width);
// var url = this.target_url + '/draw1?params=' + winsize_str + image_str;
await fetch( url, {
method: 'GET'
})
.then((response) =>{
if( !response.ok )
throw 'status is not 200';
});
}
}
}
function toRGB332from565(val){
return ((val & 0xe000) >> 8) | ((val & 0x0700) >> 6) | ((val & 0x0018) >> 3);
}
function toRGB565from332(val){
return ((val & 0x00E0) << 8) | ((val & 0x001C) << 6) | ((val & 0x0003) << 3);
}
function toRGB1from565(val){
var r = (val & 0xe000) >> 8;
var g = (val & 0x0700) >> 3;
var b = val & 0x0018;
var grey = r * 0.299 + g * 0.587 + b * 0.114;
if( grey > 127.5)
return 1;
else
return 0;
}
function toRGB332(r, g, b, a){
return (r & 0xe0) | ((g & 0xe0) >> 3) | ((b & 0xc0) >> 6);
}
function toRGB565(r, g, b, a){
return ((r & 0x00f8) << 8) | ((g & 0x00fc) << 3) | ((b & 0x00f8) >> 3);
}
function uint16arraytohexstr(array, offset, length){
var str = '';
for( var i = 0 ; i < length ; i++ )
str += ('000' + array[offset + i].toString(16)).slice(-4);
return str;
}
function uint16arraytohexstr8(array, offset, length){
var str = '';
for( var i = 0 ; i < length ; i++ )
str += ('0' + toRGB332from565(array[offset + i]).toString(16)).slice(-2);
return str;
}
function uint16arraytohexstr1(array, offset, length){
var str = '';
for( var i = 0 ; i < length ; i += 8 ){
var b = 0x00;
for( var j = 0 ; j < 8 ; j++ ){
if( toRGB1from565(array[offset + i + j]) )
b |= (0x01 << (7 - j));
}
str += ('0' + b.toString(16)).slice(-2);
}
return str;
}
以降は、サンプルページです。
すべてCanvasにいったん描いてから、Canvasの内容をArduinoに転送しています。
文字列を表示させたり、画像を転送したりしています。
'use strict';
var ttgo = null;
const target_url = "【ArduinoのURL】";
var vue_options = {
el: "#top",
data: {
progress_title: '',
target_url: target_url,
string: '',
},
computed: {
},
methods: {
connect_ttgo: async function(){
ttgo = new TTGO(this.target_url);
ttgo.onconnect = async () =>{
try{
// await ttgo.display.clear();
console.log('connected');
}catch(error){
console.log(error);
}
};
},
put_image: async function(){
if( ttgo == null ){
alert('ttgoと接続していません。');
return;
}
try{
const ctx = ttgo.util.createCanvasContext(ttgo.display.width, ttgo.display.height);
var image = document.getElementById('image');
ctx.drawImage(image, 0, 0);
await ttgo.display.draw(ctx);
console.log("completed");
}catch( error ){
alert(error);
}
},
print_string: async function(){
if( ttgo == null ){
alert('ttgoと接続していません。');
return;
}
try{
const ctx = ttgo.util.createCanvasContext(ttgo.display.width, ttgo.display.height);
ctx.clearRect(0, 0, ttgo.display.width, ttgo.display.height);
ctx.fillStyle = "white";
ctx.font = "20px Avenir";
ctx.fillText(this.string, 0, 40);
await ttgo.display.draw(ctx);
console.log("completed");
}catch( error ){
alert(error);
}
},
clear_screen: async function(){
if( ttgo == null ){
alert('ttgoと接続していません。');
return;
}
try{
await ttgo.display.clear();
console.log("completed");
}catch( error ){
alert(error);
}
}
},
created: function(){
},
mounted: function(){
this.connect_ttgo();
}
};
var vue = new Vue( vue_options );
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<!-- Optional theme -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
<!-- Latest compiled and minified JavaScript -->
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<title>obniz + TTGO</title>
<script src="js/ttgo.js"></script>
<script src="https://unpkg.com/vue"></script>
</head>
<body>
<div id="top" class="container">
<h1>obniz + TTGO</h1>
<br>
<label>string</label> <input type="text" class="form-control" v-model="string">
<button class="btn btn-primary" v-on:click="print_string()">print_string</button>
<button class="btn btn-primary" v-on:click="clear_screen()">clear_screen</button>
<br><br>
<img id="image" src="./img/background.jpg"><br>
<button class="btn btn-primary" v-on:click="put_image()">put_image</button>
</div>
<script src="js/start.js"></script>
</body>
参考
Display BMP Pictures from SD Card on TFT LCD Shield
https://www.hackster.io/SurtrTech/display-bmp-pictures-from-sd-card-on-tft-lcd-shield-f3074c
Adafruit ST7735 and ST7789 Library
https://github.com/adafruit/Adafruit-ST7735-Library
Adafruit GFX Library
https://github.com/adafruit/Adafruit-GFX-Library
aREST
https://github.com/marcoschwartz/aREST
Arduino:SD
https://github.com/espressif/arduino-esp32/tree/master/libraries/SD
Arduino: SPI
https://github.com/espressif/arduino-esp32/tree/master/libraries/SPI
こちらもどうぞ
ArduinoでPCM5102Aを使って音楽を再生する
以上