From 26f928a8730ac7d9d73f58c155d895fdee5dd2ad Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 30 Jun 2026 20:30:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20STM32=20Renode=20SSD1306=20OLED=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mocks-stm32.ts: _RN_SSD1306_MOCK_H 추가 - 5×7 소프트웨어 폰트 포함 (96 ASCII) - 내부 framebuffer + display() → L:oD: 텔레메트리 - Wire stub, GFX base class (_RN_GFX_DEFINED 가드) - routes-arduino.ts: SSD1306 감지 → mock 주입 - arduino-emulator.html: L:O: (초기화) + L:oD: (프레임) 핸들러 Co-Authored-By: Claude Sonnet 4.6 --- src/gateway/mocks-stm32.ts | 214 ++++++++++++++++++++++++++++++++++ src/gateway/routes-arduino.ts | 28 ++++- web-ui/arduino-emulator.html | 58 +++++++-- 3 files changed, 292 insertions(+), 8 deletions(-) diff --git a/src/gateway/mocks-stm32.ts b/src/gateway/mocks-stm32.ts index bbf52dc..aec88ea 100644 --- a/src/gateway/mocks-stm32.ts +++ b/src/gateway/mocks-stm32.ts @@ -60,6 +60,7 @@ static inline void _rn_lt5(char op, int16_t a, int16_t b, int16_t c, int16_t d, /* ══════════════════════════════════════════════════════ Adafruit_GFX — complete replacement, no SPI ══════════════════════════════════════════════════════ */ +#define _RN_GFX_DEFINED class Adafruit_GFX : public Print { protected: int16_t _width, _height; @@ -184,3 +185,216 @@ public: #endif /* _RN_ILI9341_MOCK_H */ `; + +export const _RN_SSD1306_MOCK_H = `#pragma once +#ifndef _RN_SSD1306_MOCK_H +#define _RN_SSD1306_MOCK_H +#include +#include + +#define SSD1306_SWITCHCAPVCC 0x02 +#define SSD1306_EXTERNALVCC 0x01 +#define SSD1306_BLACK 0 +#define SSD1306_WHITE 1 +#define SSD1306_INVERSE 2 +#ifndef BLACK +#define BLACK SSD1306_BLACK +#define WHITE SSD1306_WHITE +#define INVERSE SSD1306_INVERSE +#endif + +/* ── Wire stub (SSD1306 mock이 실제로 I2C를 사용하지 않음) ── */ +#ifndef _RN_WIRE_STUB +#define _RN_WIRE_STUB +class TwoWire { +public: + void begin() {} void begin(uint8_t,uint8_t) {} void setClock(uint32_t) {} + void beginTransmission(uint8_t) {} uint8_t endTransmission(bool=true){return 0;} + uint8_t requestFrom(uint8_t,uint8_t){return 0;} + size_t write(uint8_t){return 1;} size_t write(const uint8_t*,size_t n){return n;} + int available(){return 0;} int read(){return -1;} int peek(){return -1;} +}; +static TwoWire Wire; +#endif + +/* ── Minimal GFX base (ILI9341 mock가 없을 때만 정의) ── */ +#ifndef _RN_GFX_DEFINED +#define _RN_GFX_DEFINED +struct GFXfont { const uint8_t* bitmap; const void* glyph; uint8_t first,last,yAdvance; }; + +static const uint8_t _SSD_FONT5X7[96][5] = { + {0x00,0x00,0x00,0x00,0x00},{0x00,0x00,0x5F,0x00,0x00},{0x00,0x07,0x00,0x07,0x00}, + {0x14,0x7F,0x14,0x7F,0x14},{0x24,0x2A,0x7F,0x2A,0x12},{0x23,0x13,0x08,0x64,0x62}, + {0x36,0x49,0x55,0x22,0x50},{0x00,0x05,0x03,0x00,0x00},{0x00,0x1C,0x22,0x41,0x00}, + {0x00,0x41,0x22,0x1C,0x00},{0x14,0x08,0x3E,0x08,0x14},{0x08,0x08,0x3E,0x08,0x08}, + {0x00,0x50,0x30,0x00,0x00},{0x08,0x08,0x08,0x08,0x08},{0x00,0x60,0x60,0x00,0x00}, + {0x20,0x10,0x08,0x04,0x02},{0x3E,0x51,0x49,0x45,0x3E},{0x00,0x42,0x7F,0x40,0x00}, + {0x42,0x61,0x51,0x49,0x46},{0x21,0x41,0x45,0x4B,0x31},{0x18,0x14,0x12,0x7F,0x10}, + {0x27,0x45,0x45,0x45,0x39},{0x3C,0x4A,0x49,0x49,0x30},{0x01,0x71,0x09,0x05,0x03}, + {0x36,0x49,0x49,0x49,0x36},{0x06,0x49,0x49,0x29,0x1E},{0x00,0x36,0x36,0x00,0x00}, + {0x00,0x56,0x36,0x00,0x00},{0x08,0x14,0x22,0x41,0x00},{0x14,0x14,0x14,0x14,0x14}, + {0x00,0x41,0x22,0x14,0x08},{0x02,0x01,0x51,0x09,0x06},{0x32,0x49,0x79,0x41,0x3E}, + {0x7E,0x11,0x11,0x11,0x7E},{0x7F,0x49,0x49,0x49,0x36},{0x3E,0x41,0x41,0x41,0x22}, + {0x7F,0x41,0x41,0x22,0x1C},{0x7F,0x49,0x49,0x49,0x41},{0x7F,0x09,0x09,0x09,0x01}, + {0x3E,0x41,0x49,0x49,0x7A},{0x7F,0x08,0x08,0x08,0x7F},{0x00,0x41,0x7F,0x41,0x00}, + {0x20,0x40,0x41,0x3F,0x01},{0x7F,0x08,0x14,0x22,0x41},{0x7F,0x40,0x40,0x40,0x40}, + {0x7F,0x02,0x0C,0x02,0x7F},{0x7F,0x04,0x08,0x10,0x7F},{0x3E,0x41,0x41,0x41,0x3E}, + {0x7F,0x09,0x09,0x09,0x06},{0x3E,0x41,0x51,0x21,0x5E},{0x7F,0x09,0x19,0x29,0x46}, + {0x46,0x49,0x49,0x49,0x31},{0x01,0x01,0x7F,0x01,0x01},{0x3F,0x40,0x40,0x40,0x3F}, + {0x1F,0x20,0x40,0x20,0x1F},{0x3F,0x40,0x38,0x40,0x3F},{0x63,0x14,0x08,0x14,0x63}, + {0x07,0x08,0x70,0x08,0x07},{0x61,0x51,0x49,0x45,0x43},{0x00,0x7F,0x41,0x41,0x00}, + {0x02,0x04,0x08,0x10,0x20},{0x00,0x41,0x41,0x7F,0x00},{0x04,0x02,0x01,0x02,0x04}, + {0x40,0x40,0x40,0x40,0x40},{0x00,0x01,0x02,0x04,0x00},{0x20,0x54,0x54,0x54,0x78}, + {0x7F,0x48,0x44,0x44,0x38},{0x38,0x44,0x44,0x44,0x20},{0x38,0x44,0x44,0x48,0x7F}, + {0x38,0x54,0x54,0x54,0x18},{0x08,0x7E,0x09,0x01,0x02},{0x0C,0x52,0x52,0x52,0x3E}, + {0x7F,0x08,0x04,0x04,0x78},{0x00,0x44,0x7D,0x40,0x00},{0x20,0x40,0x44,0x3D,0x00}, + {0x7F,0x10,0x28,0x44,0x00},{0x00,0x41,0x7F,0x40,0x00},{0x7C,0x04,0x18,0x04,0x78}, + {0x7C,0x08,0x04,0x04,0x78},{0x38,0x44,0x44,0x44,0x38},{0x7C,0x14,0x14,0x14,0x08}, + {0x08,0x14,0x14,0x18,0x7C},{0x7C,0x08,0x04,0x04,0x08},{0x48,0x54,0x54,0x54,0x20}, + {0x04,0x3F,0x44,0x40,0x20},{0x3C,0x40,0x40,0x40,0x3C},{0x1C,0x20,0x40,0x20,0x1C}, + {0x3C,0x40,0x30,0x40,0x3C},{0x44,0x28,0x10,0x28,0x44},{0x0C,0x50,0x50,0x50,0x3C}, + {0x44,0x64,0x54,0x4C,0x44},{0x00,0x08,0x36,0x41,0x00},{0x00,0x00,0x7F,0x00,0x00}, + {0x00,0x41,0x36,0x08,0x00},{0x10,0x08,0x08,0x10,0x08},{0x3C,0x26,0x23,0x26,0x3C}, +}; + +class Print { +public: + virtual size_t write(uint8_t) = 0; + size_t print(const char* s){ size_t n=0; if(s) while(*s) n+=write((uint8_t)*s++); return n; } + size_t println(const char* s){ return print(s)+write('\\n'); } + size_t print(int v, int=10){ char b[16]; snprintf(b,sizeof(b),"%d",v); return print(b); } + size_t println(int v, int=10){ return print(v)+write('\\n'); } + size_t print(long v, int=10){ char b[24]; snprintf(b,sizeof(b),"%ld",v); return print(b); } + size_t println(long v, int=10){ return print(v)+write('\\n'); } + size_t print(unsigned v, int=10){ char b[16]; snprintf(b,sizeof(b),"%u",v); return print(b); } + size_t print(float v, int d=2){ char b[24]; char fmt[8]; snprintf(fmt,sizeof(fmt),"%%.%df",d); snprintf(b,sizeof(b),fmt,v); return print(b); } + size_t println(float v, int d=2){ return print(v,d)+write('\\n'); } + size_t println(){ return write('\\n'); } +}; + +class Adafruit_GFX : public Print { +protected: + int16_t _width, _height, _cx=0, _cy=0; + uint16_t _fg=1, _bg=0; + uint8_t _ts=1; +public: + Adafruit_GFX(int16_t w,int16_t h):_width(w),_height(h){} + virtual void drawPixel(int16_t x,int16_t y,uint16_t c)=0; + int16_t width()const{return _width;} int16_t height()const{return _height;} + void setTextColor(uint16_t c){_fg=c;} void setTextColor(uint16_t f,uint16_t b){_fg=f;_bg=b;} + void setTextSize(uint8_t s){_ts=s?s:1;} void setCursor(int16_t x,int16_t y){_cx=x;_cy=y;} + void setFont(const GFXfont*){} void setTextWrap(bool){} + int16_t getCursorX()const{return _cx;} int16_t getCursorY()const{return _cy;} + + void fillScreen(uint16_t c){fillRect(0,0,_width,_height,c);} + void fillRect(int16_t x,int16_t y,int16_t w,int16_t h,uint16_t c){ + for(int j=y;j=dy){e+=dy;x0+=sx;}if(e2<=dx){e+=dx;y0+=sy;}} + } + void drawCircle(int16_t cx,int16_t cy,int16_t r,uint16_t c){ + int x=0,y=r,d=3-2*r; + while(y>=x){ + drawPixel(cx+x,cy+y,c);drawPixel(cx-x,cy+y,c);drawPixel(cx+x,cy-y,c);drawPixel(cx-x,cy-y,c); + drawPixel(cx+y,cy+x,c);drawPixel(cx-y,cy+x,c);drawPixel(cx+y,cy-x,c);drawPixel(cx-y,cy-x,c); + d<0?d+=4*x+6:(d+=4*(x-y)+10,y--);x++; + } + } + void fillCircle(int16_t cx,int16_t cy,int16_t r,uint16_t c){ + for(int y=-r;y<=r;y++) for(int x=-r;x<=r;x++) if(x*x+y*y<=r*r) drawPixel(cx+x,cy+y,c); + } + void drawRoundRect(int16_t x,int16_t y,int16_t w,int16_t h,int16_t,uint16_t c){drawRect(x,y,w,h,c);} + void fillRoundRect(int16_t x,int16_t y,int16_t w,int16_t h,int16_t,uint16_t c){fillRect(x,y,w,h,c);} + void drawTriangle(int16_t x0,int16_t y0,int16_t x1,int16_t y1,int16_t x2,int16_t y2,uint16_t c){ + drawLine(x0,y0,x1,y1,c);drawLine(x1,y1,x2,y2,c);drawLine(x2,y2,x0,y0,c); + } + void fillTriangle(int16_t x0,int16_t y0,int16_t x1,int16_t y1,int16_t x2,int16_t y2,uint16_t c){ + drawTriangle(x0,y0,x1,y1,x2,y2,c); + } + static uint16_t color565(uint8_t r,uint8_t g,uint8_t b){ + return ((uint16_t)(r&0xF8)<<8)|((uint16_t)(g&0xFC)<<3)|(b>>3); + } + size_t write(uint8_t ch) override { + if(ch=='\\n'){_cy+=8*_ts;_cx=0;return 1;} + if(ch=='\\r')return 1; + if(ch<0x20||ch>0x7F)ch='?'; + const uint8_t* col=_SSD_FONT5X7[ch-0x20]; + for(int c=0;c<5;c++){ + uint8_t line=col[c]; + for(int r=0;r<8;r++,line>>=1) + drawPixel(_cx+c*_ts,_cy+r*_ts,(line&1)?_fg:_bg); + } + _cx+=6*_ts; + return 1; + } +}; +#endif /* _RN_GFX_DEFINED */ + +/* ══════════════════════════════════════════════════════ + Adafruit_SSD1306 — framebuffer + UART telemetry + ══════════════════════════════════════════════════════ */ +class Adafruit_SSD1306 : public Adafruit_GFX { + uint8_t _buf[1024]; // 128×64 / 8 pages + bool _inited = false; + bool _inv = false; +public: + // I2C constructor + Adafruit_SSD1306(uint8_t w,uint8_t h,TwoWire* =nullptr,int8_t=-1) + : Adafruit_GFX(w,h){ memset(_buf,0,sizeof(_buf)); } + // SPI constructor + Adafruit_SSD1306(uint8_t w,uint8_t h,int8_t,int8_t,int8_t,int8_t,int8_t=-1) + : Adafruit_GFX(w,h){ memset(_buf,0,sizeof(_buf)); } + + bool begin(uint8_t=SSD1306_SWITCHCAPVCC,uint8_t=0x3C,bool=true,bool=true){ + _inited=true; + char b[16]; snprintf(b,sizeof(b),"L:O:%d,%d",(int)_width,(int)_height); + Serial.write((uint8_t)2); Serial.print(b); Serial.write((uint8_t)3); + return true; + } + + void clearDisplay(){ memset(_buf,0,sizeof(_buf)); } + + void drawPixel(int16_t x,int16_t y,uint16_t color) override { + if(x<0||x>=_width||y<0||y>=_height) return; + const int page=y/8, bit=y&7; + if(color) _buf[x+page*_width]|= (1<>4]); + Serial.write((uint8_t)HEX[_buf[i]&0xF]); + } + Serial.write((uint8_t)3); + } + + void invertDisplay(bool i){ _inv=i; } + void dim(bool){} + void startScrollRight(uint8_t,uint8_t){} + void startScrollLeft(uint8_t,uint8_t){} + void startScrollDiagRight(uint8_t,uint8_t){} + void startScrollDiagLeft(uint8_t,uint8_t){} + void stopScroll(){} + void ssd1306_command(uint8_t){} + bool getPixel(int16_t x,int16_t y)const{ + if(x<0||x>=_width||y<0||y>=_height) return false; + return (_buf[x+(y/8)*_width]>>(y&7))&1; + } +}; + +#endif /* _RN_SSD1306_MOCK_H */ +`; diff --git a/src/gateway/routes-arduino.ts b/src/gateway/routes-arduino.ts index 14b24d9..0e64cb2 100644 --- a/src/gateway/routes-arduino.ts +++ b/src/gateway/routes-arduino.ts @@ -12,7 +12,7 @@ import { _WIRE_MOCK_H,_MPU6050_MOCK_H,_BME280_MOCK_H,_ONEWIRE_MOCK_H,_DALLASTEMP_MOCK_H, _RELAY_MOCK_H,_L298N_MOCK_H,_SPI_MOCK_H,_TFT_MOCK_H, } from './mocks-esp32'; -import { _RN_ILI9341_MOCK_H } from './mocks-stm32'; +import { _RN_ILI9341_MOCK_H, _RN_SSD1306_MOCK_H } from './mocks-stm32'; export function registerArduinoRoutes(app: express.Application, getSessionUser: (req: express.Request) => any): void { @@ -2013,6 +2013,32 @@ app.post('/api/arduino/renode/start', async (req, res) => { .replace(/&(SPI\d+)\b/g, 'nullptr'); } + // SSD1306 OLED 사용 시: 실제 라이브러리를 로컬 mock으로 교체 + const _hasSSD1306 = /Adafruit_SSD1306\.h/.test(patchedCode); + if (_hasSSD1306) { + const fs3 = require('fs'); + fs3.writeFileSync(sketchDir + '/Adafruit_SSD1306.h', _RN_SSD1306_MOCK_H); + if (!_hasILI9341) { + // ILI9341 mock가 없으면 GFX 헤더도 SSD1306 mock에서 제공 + fs3.writeFileSync(sketchDir + '/Adafruit_GFX.h', '#pragma once\n#include "Adafruit_SSD1306.h"\n'); + patchedCode = patchedCode.replace(/#include\s*/g, '#include "Adafruit_GFX.h"'); + } + // Wire.h stub (SSD1306 I2C init 컴파일 통과용) + if (!fs3.existsSync(sketchDir + '/Wire.h')) { + fs3.writeFileSync(sketchDir + '/Wire.h', + '#pragma once\nclass TwoWire{\npublic:\n void begin(){} void begin(uint8_t,uint8_t){} void setClock(uint32_t){}\n' + + ' void beginTransmission(uint8_t){} uint8_t endTransmission(bool=true){return 0;}\n' + + ' uint8_t requestFrom(uint8_t,uint8_t){return 0;}\n' + + ' size_t write(uint8_t){return 1;} size_t write(const uint8_t*,size_t n){return n;}\n' + + ' int available(){return 0;} int read(){return -1;} int peek(){return -1;}\n};\n' + + 'static TwoWire Wire;\n' + ); + patchedCode = patchedCode.replace(/#include\s*/g, '#include "Wire.h"'); + } + patchedCode = patchedCode + .replace(/#include\s*/g, '#include "Adafruit_SSD1306.h"'); + } + require('fs').writeFileSync(sketchDir + '/sketch.ino', patchedCode); const execQ = (cmd: string, ms = 60000): Promise<{ stdout: string; stderr: string }> => diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html index be335b2..bd87210 100644 --- a/web-ui/arduino-emulator.html +++ b/web-ui/arduino-emulator.html @@ -3125,7 +3125,51 @@ function _stm32ParseGpio(raw) { return out; } +let _stm32OledInited = false; +let _stm32OledW = 128, _stm32OledH = 64; + +function _stm32OledFlush(hexStr, inv) { + const c = document.getElementById('oled-canvas'); + if (!c) return; + const ctx = c.getContext('2d'); + const pages = _stm32OledH / 8; + const img = ctx.createImageData(c.width, c.height); + const sx = c.width / _stm32OledW, sy = c.height / _stm32OledH; + for (let page = 0; page < pages; page++) { + for (let col = 0; col < _stm32OledW; col++) { + const idx = (page * _stm32OledW + col) * 2; + const byte = parseInt(hexStr.slice(idx, idx + 2), 16) || 0; + for (let bit = 0; bit < 8; bit++) { + const on = !!((byte >> bit) & 1) !== inv; + const row = page * 8 + bit; + const r = on ? 0x60 : 0, g = on ? 0xa5 : 0, b = on ? 0xfa : 0; + for (let dy = 0; dy < sy; dy++) for (let dx = 0; dx < sx; dx++) { + const pi = ((Math.floor(row * sy) + dy) * c.width + Math.floor(col * sx) + dx) * 4; + img.data[pi] = r; img.data[pi+1] = g; img.data[pi+2] = b; img.data[pi+3] = 255; + } + } + } + } + ctx.putImageData(img, 0, 0); + oledSetActive(true); +} + function _stm32LcdDraw(op, data) { + // SSD1306 OLED 초기화 + if (op === 'O') { + const [w, h] = data.split(',').map(Number); + _stm32OledW = w || 128; _stm32OledH = h || 64; + _stm32OledInited = true; + const c = document.getElementById('oled-canvas'); + if (c) { c.width = _stm32OledW * 2; c.height = _stm32OledH * 2; } + oledSetActive(true); + return; + } + // SSD1306 프레임 데이터 (대문자=정상, 소문자=반전) + if (op === 'oD' || op === 'od') { + _stm32OledFlush(data, op === 'od'); + return; + } if (op === 'I') { const [w, h] = data.split(',').map(Number); _esp32TftW = w || 240; _esp32TftH = h || 320; _esp32TftCtx2d = null; @@ -3141,13 +3185,13 @@ function _stm32LcdDraw(op, data) { } const n = data.split(',').map(Number); switch (op) { - case 'r': _esp32TftDraw('B:' + n.join(',')); break; // fillRect x,y,w,h,c - case 'h': _esp32TftDraw('B:' + [n[0],n[1],n[2],1,n[3]].join(',')); break; // hline → fillRect h=1 - case 'v': _esp32TftDraw('B:' + [n[0],n[1],1,n[2],n[3]].join(',')); break; // vline → fillRect w=1 - case 'p': _esp32TftDraw('P:' + n.join(',')); break; // pixel x,y,c - case 'l': _esp32TftDraw('L:' + n.join(',')); break; // line x0,y0,x1,y1,c - case 'c': _esp32TftDraw('C:' + [n[0],n[1],n[2],n[4],n[3]].join(',')); break; // circle cx,cy,r,fill,c → C:cx,cy,r,c,fill - case 't': _esp32TftDraw('T:' + n.join(',')); break; // char x,y,fg,bg,size,charcode + case 'r': _esp32TftDraw('B:' + n.join(',')); break; + case 'h': _esp32TftDraw('B:' + [n[0],n[1],n[2],1,n[3]].join(',')); break; + case 'v': _esp32TftDraw('B:' + [n[0],n[1],1,n[2],n[3]].join(',')); break; + case 'p': _esp32TftDraw('P:' + n.join(',')); break; + case 'l': _esp32TftDraw('L:' + n.join(',')); break; + case 'c': _esp32TftDraw('C:' + [n[0],n[1],n[2],n[4],n[3]].join(',')); break; + case 't': _esp32TftDraw('T:' + n.join(',')); break; } }