v3.0.5: RP2040 브라우저 에뮬레이터 — rp2040js 기반 전체 구현
- RP2040(Pico) 보드 감지·컴파일·에뮬레이션 지원 추가 - Serial → Serial1(UART0) 자동 리다이렉트 (#define 삽입) - .uf2 파일 우선 추출, bootrom 자동 다운로드·캐싱 - ELF .text 추출: arm-none-eabi-objcopy 대신 Node.js 내장 파서 - HTTP 302 리다이렉트 자동 팔로우 (_httpsGetFollow) - rp2040js 에뮬레이터: fast-path 초기화 루프, 피코 SVG 보드, GPIO25 LED - 예외 처리: HardFault(#3)만 중지, 나머지 IRQ/SysTick 정상 통과 - UART onByte: TextDecoder stream 모드로 한글 UTF-8 정상 출력 - heartbeat 메시지 제거 - WiFi scan mock: WSCN 프로토콜로 C++ ↔ 브라우저 동기화, UI 편집 지원 - 에뮬레이터 팝업 → 코드 에디터 인라인 iframe 패널로 전환 - CDN 의존성 → /vendor/ 로컬 서빙 (xterm, Monaco) - SSE 세션 idle timer (창 닫힘 후 5분 자동 종료) - 업로드: 속도 자동 재시도(921600→460800→230400) + FlashMode fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+32
-2
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "smallclaw",
|
||||
"version": "1.1.0",
|
||||
"version": "2.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "smallclaw",
|
||||
"version": "1.1.0",
|
||||
"version": "2.10.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.6",
|
||||
@@ -17,6 +20,7 @@
|
||||
"express": "^5.2.1",
|
||||
"imapflow": "^1.3.3",
|
||||
"mailparser": "^3.9.8",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"ollama": "^0.6.3",
|
||||
@@ -658,6 +662,27 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
|
||||
@@ -1844,6 +1869,11 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz",
|
||||
"integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.6",
|
||||
@@ -45,6 +48,7 @@
|
||||
"express": "^5.2.1",
|
||||
"imapflow": "^1.3.3",
|
||||
"mailparser": "^3.9.8",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"ollama": "^0.6.3",
|
||||
|
||||
+39
-13
@@ -186,6 +186,7 @@ public:
|
||||
void flush(){fflush(stdout);}
|
||||
void end(){}
|
||||
operator bool(){return true;}
|
||||
void _prepend(const std::string& s){_rx=s+_rx;}
|
||||
};
|
||||
static int _gpio_v[40]={};
|
||||
inline void pinMode(uint8_t pin,uint8_t mode){printf("\\x02M%d:%c\\n",pin,mode==1?'O':'I');fflush(stdout);}
|
||||
@@ -260,9 +261,32 @@ public:
|
||||
}
|
||||
operator bool(){return _sfd>=0;}
|
||||
};
|
||||
typedef enum { WIFI_AUTH_OPEN=0,WIFI_AUTH_WEP,WIFI_AUTH_WPA_PSK,WIFI_AUTH_WPA2_PSK,WIFI_AUTH_WPA_WPA2_PSK,WIFI_AUTH_WPA2_ENTERPRISE,WIFI_AUTH_WPA3_PSK,WIFI_AUTH_MAX } wifi_auth_mode_t;
|
||||
class WiFiClass{
|
||||
String _ssid;int _st=WL_DISCONNECTED;bool _conn=false;IPAddress _ip;
|
||||
void _emit(){printf("\\x02WIFI:{\\\"connected\\\":%s,\\\"ssid\\\":\\\"%s\\\",\\\"ip\\\":\\\"%s\\\",\\\"rssi\\\":-42}\\n",_st==WL_CONNECTED?"true":"false",_ssid.c_str(),_st==WL_CONNECTED?_ip.toString().c_str():"");fflush(stdout);}
|
||||
String _ssid;int _st=WL_DISCONNECTED;bool _conn=false;IPAddress _ip;int _rssi=-42;
|
||||
struct _SN{char s[64];int32_t r,c;};
|
||||
static _SN _scl[20];
|
||||
static int _scn;
|
||||
static void _scpoll(){
|
||||
extern HardwareSerial Serial;
|
||||
for(;;){
|
||||
if(!Serial.available()||Serial.peek()!=0x02)return;
|
||||
Serial.read();
|
||||
std::string cmd;{char ch;while(Serial.available()&&(ch=(char)Serial.read())!='\\n')cmd+=ch;}
|
||||
if(cmd.rfind("WSCN:",0)==0){
|
||||
const char*p=cmd.c_str()+5;char*e;
|
||||
int n=(int)strtol(p,&e,10);p=*e?e+1:e;n=n<0?0:n>20?20:n;
|
||||
int i=0;for(;i<n;i++){
|
||||
const char*q=strchr(p,':');if(!q)break;
|
||||
int l=(int)(q-p);l=l>63?63:l;memcpy(_scl[i].s,p,l);_scl[i].s[l]=0;p=q+1;
|
||||
_scl[i].r=(int32_t)strtol(p,&e,10);if(!*e){i++;break;}p=e+1;
|
||||
_scl[i].c=(int32_t)strtol(p,&e,10);if(!*e){i++;break;}p=e+1;
|
||||
}
|
||||
_scn=i;
|
||||
}else{Serial._prepend("\\x02"+cmd+"\\n");}
|
||||
}
|
||||
}
|
||||
void _emit(){printf("\\x02WIFI:{\\\"connected\\\":%s,\\\"ssid\\\":\\\"%s\\\",\\\"ip\\\":\\\"%s\\\",\\\"rssi\\\":%d}\\n",_st==WL_CONNECTED?"true":"false",_ssid.c_str(),_st==WL_CONNECTED?_ip.toString().c_str():"",_rssi);fflush(stdout);}
|
||||
public:
|
||||
struct _STAProxy {
|
||||
bool _ok=false;
|
||||
@@ -273,26 +297,26 @@ public:
|
||||
} STA;
|
||||
void mode(int){}
|
||||
int begin(const char* s,const char* p=nullptr){
|
||||
_ssid=s?s:"";
|
||||
_st=WL_CONNECTED;_ip=IPAddress(127,0,0,1);_conn=true;
|
||||
_ssid=s?s:"";_st=WL_CONNECTED;_ip=IPAddress(127,0,0,1);_rssi=-42;_conn=true;
|
||||
printf("\\x02WIFI:{\\\"connected\\\":true,\\\"ssid\\\":\\\"%s\\\",\\\"ip\\\":\\\"127.0.0.1\\\",\\\"rssi\\\":-42}\\n",_ssid.c_str());fflush(stdout);
|
||||
return WL_CONNECTED;
|
||||
}
|
||||
int begin(const char* s){return begin(s,"");}
|
||||
int status(){if(!_conn&&_ssid.length()>0){_st=WL_CONNECTED;_ip=IPAddress(127,0,0,1);_conn=true;_emit();}return _st;}
|
||||
int status(){if(!_conn&&_ssid.length()>0){_st=WL_CONNECTED;_ip=IPAddress(127,0,0,1);_rssi=-42;_conn=true;_emit();}return _st;}
|
||||
void disconnect(bool=false){_st=WL_DISCONNECTED;}
|
||||
IPAddress localIP(){return _ip;}
|
||||
IPAddress gatewayIP(){return IPAddress(127,0,0,1);}
|
||||
IPAddress subnetMask(){return IPAddress(255,255,255,0);}
|
||||
String macAddress(){return String("AA:BB:CC:DD:EE:FF");}
|
||||
String SSID(){return _ssid;}
|
||||
String SSID(int i){const char* n[]={"HomeNetwork","Office_5G","Neighbor_WiFi"};return String(i>=0&&i<3?n[i]:"");}
|
||||
int RSSI(){return -42;}
|
||||
int32_t RSSI(int i){return -42-i*10;}
|
||||
int32_t channel(int i){return i%13+1;}
|
||||
String BSSIDstr(int i){char b[18];snprintf(b,18,"AA:BB:CC:DD:EE:%02X",i);return String(b);}
|
||||
int8_t scanNetworks(bool=false,bool=false,uint8_t=0,uint8_t*=nullptr){return 3;}
|
||||
template<typename F> void scanNetworksAsync(F cb,bool=false){cb(3);}
|
||||
String SSID(int i){return(i>=0&&i<_scn)?String(_scl[i].s):String();}
|
||||
int RSSI(){return _rssi;}
|
||||
int32_t RSSI(int i){return(i>=0&&i<_scn)?_scl[i].r:-100;}
|
||||
int32_t channel(int i){return(i>=0&&i<_scn)?_scl[i].c:0;}
|
||||
String BSSIDstr(int i){char b[18];snprintf(b,18,"AA:BB:CC:DD:EE:%02X",i&0xFF);return String(b);}
|
||||
wifi_auth_mode_t encryptionType(int i){return(i>=0&&i<_scn)?WIFI_AUTH_WPA2_PSK:WIFI_AUTH_OPEN;}
|
||||
int8_t scanNetworks(bool=false,bool=false,uint8_t=0,uint8_t* =nullptr){_scpoll();return _scn;}
|
||||
template<typename F> void scanNetworksAsync(F cb,bool=false){_scpoll();cb(_scn);}
|
||||
void scanDelete(){}
|
||||
bool softAP(const char*, const char* = nullptr, int = 1, bool = false){return false;}
|
||||
void softAPdisconnect(bool=false){}
|
||||
@@ -301,6 +325,8 @@ public:
|
||||
void hostname(const char*){}
|
||||
String hostname(){return String("esp32");}
|
||||
};
|
||||
WiFiClass::_SN WiFiClass::_scl[20]={{"HomeNetwork",-45,1},{"Office_5G",-55,6},{"Neighbor_WiFi",-65,11}};
|
||||
int WiFiClass::_scn=3;
|
||||
extern HardwareSerial Serial;
|
||||
extern HardwareSerial Serial1;
|
||||
extern WiFiClass WiFi;
|
||||
@@ -678,7 +704,7 @@ public:
|
||||
int32_t RSSI(int i) { return -42 - i*10; }
|
||||
int32_t channel(int i) { return i % 13 + 1; }
|
||||
String BSSIDstr(int i) { char b[18]; snprintf(b,18,"AA:BB:CC:DD:EE:%02X",i); return String(b); }
|
||||
int8_t scanNetworks(bool=false, bool=false, uint8_t=0, uint8_t*=nullptr) { return 3; }
|
||||
int8_t scanNetworks(bool=false, bool=false, uint8_t=0, uint8_t* =nullptr) { return 3; }
|
||||
template<typename F> void scanNetworksAsync(F cb, bool=false) { cb(3); }
|
||||
void scanDelete() {}
|
||||
bool softAP(const char* s, const char* p = nullptr, int c = 1, bool h = false) { (void)s;(void)p;(void)c;(void)h; return false; }
|
||||
|
||||
+190
-13
@@ -137,6 +137,8 @@ function _detectChip(fqbn: string): string {
|
||||
const vendor = parts[0] || '';
|
||||
const bid = (parts[2] || '').toLowerCase();
|
||||
if (vendor === 'esp8266') return 'ESP8266';
|
||||
if (vendor === 'rp2040') return 'RP2040';
|
||||
if (vendor === 'arduino' && (parts[1] || '').startsWith('mbed_rp2040')) return 'RP2040';
|
||||
if (vendor === 'arduino') return 'AVR';
|
||||
const s2kw = ['esp32s2','magtag','funhouse','atmegazero_esp32s2'];
|
||||
if (s2kw.some(k => bid.includes(k)) || /_s2[_-]/.test(bid) || bid.endsWith('_s2')) return 'S2';
|
||||
@@ -153,6 +155,7 @@ function _qemuForChip(chip: string): string | false {
|
||||
if (chip === 'ESP32') return 'esp32';
|
||||
if (chip === 'S3') return 'esp32s3';
|
||||
if (chip === 'ESP8266') return 'esp8266-native';
|
||||
if (chip === 'RP2040') return 'rp2040-browser';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -513,11 +516,19 @@ app.post('/api/arduino/compile', async (req, res) => {
|
||||
const sketchName = 'sketch';
|
||||
const sketchDir = path.join(tmpDir, sketchName);
|
||||
fs.mkdirSync(sketchDir);
|
||||
fs.writeFileSync(path.join(sketchDir, `${sketchName}.ino`), code);
|
||||
|
||||
// FQBN 기반 보드 계열 판별
|
||||
const fqbnVendor = fqbn.split(':')[0] || '';
|
||||
const fqbnArch = fqbn.split(':')[1] || '';
|
||||
|
||||
// RP2040 브라우저 에뮬레이터: Serial(USB CDC)을 Serial1(UART0)으로 리다이렉트
|
||||
// USB CDC 에뮬레이션은 rp2040js에서 실용적으로 지원되지 않으므로
|
||||
// UART0을 통해 시리얼 출력을 받는다.
|
||||
const isRP2040Arch = fqbnArch === 'rp2040' || fqbnArch === 'mbed_rp2040';
|
||||
const sketchCode = isRP2040Arch
|
||||
? `// [에뮬레이터] Serial → Serial1(UART0) 자동 리다이렉트\n#define Serial Serial1\n\n${code}`
|
||||
: code;
|
||||
fs.writeFileSync(path.join(sketchDir, `${sketchName}.ino`), sketchCode);
|
||||
// AVR 계열만 .hex 생성 (esp8266은 xtensa → .bin)
|
||||
const producesHex = fqbnArch === 'avr';
|
||||
|
||||
@@ -547,7 +558,16 @@ app.post('/api/arduino/compile', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 그 외(STM32, RP2040, SAMD 등): .bin 또는 .uf2
|
||||
// RP2040: .uf2 우선 (.bin도 같이 생성되므로 먼저 체크)
|
||||
if (fqbnArch === 'rp2040' || fqbnArch === 'mbed_rp2040') {
|
||||
const uf2FileRp = files.find((f: string) => f.endsWith('.uf2'));
|
||||
if (uf2FileRp) {
|
||||
const buf = fs.readFileSync(path.join(tmpDir, uf2FileRp));
|
||||
resolve({ bin: buf.toString('base64'), size: buf.length, boardType: 'rp2040' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 그 외(STM32, SAMD 등): .bin 또는 .uf2
|
||||
const binFile = files.find((f: string) => f.endsWith('.bin') && !f.endsWith('.merged.bin'));
|
||||
const uf2File = files.find((f: string) => f.endsWith('.uf2'));
|
||||
const hexFile = files.find((f: string) => f.endsWith('.hex'));
|
||||
@@ -617,12 +637,90 @@ app.post('/api/arduino/compile', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── RP2040 bootrom binary ────────────────────────────────────────────────────
|
||||
// rp2040js가 bootrom function table을 사용하려면 공식 RP2040 bootrom(16KB)이 필요.
|
||||
// 없으면 Raspberry Pi 공식 GitHub 릴리즈에서 자동 다운로드 후 ~/.smallclaw에 캐싱.
|
||||
const RP2040_BOOTROM_URL = 'https://github.com/raspberrypi/pico-bootrom-rp2040/releases/download/b2/b2.elf';
|
||||
const RP2040_BOOTROM_CACHE = path.join(require('os').homedir(), '.smallclaw', 'rp2040-bootrom.bin');
|
||||
|
||||
function _extractElfSection(elf: Buffer, sectionName: string): Buffer | null {
|
||||
if (elf.length < 52) return null;
|
||||
if (elf.readUInt32BE(0) !== 0x7f454c46 || elf[4] !== 1) return null; // ELF32 magic
|
||||
const e_shoff = elf.readUInt32LE(0x20);
|
||||
const e_shentsize = elf.readUInt16LE(0x2e);
|
||||
const e_shnum = elf.readUInt16LE(0x30);
|
||||
const e_shstrndx = elf.readUInt16LE(0x32);
|
||||
const strOff = e_shoff + e_shstrndx * e_shentsize;
|
||||
const strSecOff = elf.readUInt32LE(strOff + 16);
|
||||
for (let i = 0; i < e_shnum; i++) {
|
||||
const sh = e_shoff + i * e_shentsize;
|
||||
const nameIdx = elf.readUInt32LE(sh);
|
||||
const end = elf.indexOf(0, strSecOff + nameIdx);
|
||||
const name = elf.slice(strSecOff + nameIdx, end).toString('utf8');
|
||||
if (name === sectionName) {
|
||||
const secOff = elf.readUInt32LE(sh + 16);
|
||||
const secSize = elf.readUInt32LE(sh + 20);
|
||||
return elf.slice(secOff, secOff + secSize);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _httpsGetFollow(url: string, maxRedirects = 5): Promise<{ ok: boolean; body?: Buffer; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const mod = url.startsWith('https') ? require('https') : require('http');
|
||||
mod.get(url, { timeout: 60000 }, (res: any) => {
|
||||
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location && maxRedirects > 0) {
|
||||
res.resume();
|
||||
resolve(_httpsGetFollow(res.headers.location, maxRedirects - 1));
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); resolve({ ok: false, error: `HTTP ${res.statusCode}` }); return; }
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => resolve({ ok: true, body: Buffer.concat(chunks) }));
|
||||
}).on('error', (e: any) => resolve({ ok: false, error: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
async function _ensureRP2040Bootrom(): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(RP2040_BOOTROM_CACHE)) {
|
||||
const cacheDir = path.dirname(RP2040_BOOTROM_CACHE);
|
||||
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const curl = await _httpsGetFollow(RP2040_BOOTROM_URL);
|
||||
if (!curl.ok || !curl.body) { console.error('[rp2040-bootrom] download failed:', curl.error); return null; }
|
||||
const textSection = _extractElfSection(curl.body, '.text');
|
||||
if (!textSection || textSection.length !== 16 * 1024) {
|
||||
console.error('[rp2040-bootrom] .text section extract failed, got', textSection?.length);
|
||||
return null;
|
||||
}
|
||||
fs.writeFileSync(RP2040_BOOTROM_CACHE, textSection);
|
||||
console.log('[rp2040-bootrom] cached to', RP2040_BOOTROM_CACHE, 'size', textSection.length);
|
||||
}
|
||||
return fs.existsSync(RP2040_BOOTROM_CACHE) ? RP2040_BOOTROM_CACHE : null;
|
||||
} catch (e) {
|
||||
console.error('[rp2040-bootrom] ensure failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/arduino/rp2040-bootrom', async (_req, res) => {
|
||||
const sess = getSessionUser(_req);
|
||||
if (!sess) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const bootromPath = await _ensureRP2040Bootrom();
|
||||
if (!bootromPath) return res.status(503).json({ error: 'bootrom download/extract failed' });
|
||||
const buf = fs.readFileSync(bootromPath);
|
||||
res.json({ bin: buf.toString('base64'), size: buf.length });
|
||||
});
|
||||
|
||||
// ── ESP8266 Native emulator sessions ──────────────────────────────────────────
|
||||
interface Esp8266Session {
|
||||
proc: any | null; tmpDir: string; binPath: string; fqbn: string;
|
||||
listeners: Set<(d: string) => void>;
|
||||
sseConns: Set<any>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
idleTimer?: ReturnType<typeof setTimeout>;
|
||||
ptyBuf: string;
|
||||
gpioBuf: string[];
|
||||
}
|
||||
@@ -662,6 +760,7 @@ interface QemuSession {
|
||||
listeners: Set<(d: string) => void>;
|
||||
sseConns: Set<any>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
idleTimer?: ReturnType<typeof setTimeout>; // 창 닫힘 후 5분 타임아웃
|
||||
ptyBuf: string; // buffer for parsing GPIO bridge protocol
|
||||
gpioBuf: string[]; // buffered GPIO/DHT/TFT events for late SSE connections
|
||||
}
|
||||
@@ -820,6 +919,7 @@ function killQemuSession(id: string) {
|
||||
if (!s) return;
|
||||
qemuSessions.delete(id);
|
||||
clearTimeout(s.timer);
|
||||
clearTimeout(s.idleTimer);
|
||||
if (s.pty) { try { s.pty.kill(); } catch {} try { s.pty.stdin?.end(); } catch {} }
|
||||
try { require('fs').rmSync(s.tmpDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
@@ -1073,7 +1173,16 @@ app.get('/api/arduino/qemu/:id/output', (req, res) => {
|
||||
};
|
||||
s.listeners.add(send);
|
||||
s.sseConns.add(res);
|
||||
req.on('close', () => { s.listeners.delete(send); s.sseConns.delete(res); });
|
||||
clearTimeout(s.idleTimer);
|
||||
s.idleTimer = undefined;
|
||||
req.on('close', () => {
|
||||
s.listeners.delete(send);
|
||||
s.sseConns.delete(res);
|
||||
// 모든 창이 닫히면 5분 후 자동 종료
|
||||
if (s.sseConns.size === 0) {
|
||||
s.idleTimer = setTimeout(() => killQemuSession(req.params.id), 5 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/arduino/qemu/:id/input', (req, res) => {
|
||||
@@ -1159,6 +1268,24 @@ app.post('/api/arduino/qemu/:id/wifi', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/arduino/qemu/:id/scan', (req, res) => {
|
||||
const s = qemuSessions.get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'session not found' });
|
||||
const { networks = [] } = req.body || {};
|
||||
const nets = (networks as any[]).slice(0, 20);
|
||||
const n = nets.length;
|
||||
// WSCN:n:ssid1:rssi1:ch1:ssid2:rssi2:ch2:... (colon-delimited, ssid must not contain ':')
|
||||
const parts: string[] = [String(n)];
|
||||
for (const net of nets) {
|
||||
const ssid = String(net.ssid || 'Network').replace(/:/g, '_');
|
||||
const rssi = Math.round(Number(net.rssi ?? -50));
|
||||
const ch = Math.max(1, Math.min(14, Math.round(Number(net.channel ?? 1))));
|
||||
parts.push(ssid, String(rssi), String(ch));
|
||||
}
|
||||
s.pty.stdin.write(`\x02WSCN:${parts.join(':')}\n`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/arduino/qemu/:id/restart', (req, res) => {
|
||||
const s = qemuSessions.get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'session not found' });
|
||||
@@ -1204,6 +1331,7 @@ function killEsp8266Session(id: string) {
|
||||
if (!s) return;
|
||||
esp8266Sessions.delete(id);
|
||||
clearTimeout(s.timer);
|
||||
clearTimeout(s.idleTimer);
|
||||
if (s.proc) { try { s.proc.kill(); } catch {} try { s.proc.stdin?.end(); } catch {} }
|
||||
try { require('fs').rmSync(s.tmpDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
@@ -1390,7 +1518,15 @@ app.get('/api/arduino/esp8266/:id/output', (req, res) => {
|
||||
};
|
||||
s.listeners.add(send);
|
||||
s.sseConns.add(res);
|
||||
req.on('close', () => { s.listeners.delete(send); s.sseConns.delete(res); });
|
||||
clearTimeout(s.idleTimer);
|
||||
s.idleTimer = undefined;
|
||||
req.on('close', () => {
|
||||
s.listeners.delete(send);
|
||||
s.sseConns.delete(res);
|
||||
if (s.sseConns.size === 0) {
|
||||
s.idleTimer = setTimeout(() => killEsp8266Session(req.params.id), 5 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/arduino/esp8266/:id/input', (req, res) => {
|
||||
@@ -1534,7 +1670,7 @@ app.get('/api/arduino/ports', async (_req, res) => {
|
||||
app.post('/api/arduino/upload', async (req, res) => {
|
||||
const { code, fqbn: rawFqbn, port } = req.body || {};
|
||||
if (!code || !rawFqbn || !port) return res.status(400).json({ error: '코드, FQBN, 포트가 필요합니다' });
|
||||
const fqbn = /^[\w\-:\.]+$/.test(rawFqbn) ? rawFqbn : 'arduino:avr:uno';
|
||||
const fqbn = /^[\w\-:\.=,]+$/.test(rawFqbn) ? rawFqbn : 'arduino:avr:uno';
|
||||
|
||||
const cliPath = await new Promise<string | null>(resolve => {
|
||||
const candidates = [
|
||||
@@ -1569,15 +1705,56 @@ app.post('/api/arduino/upload', async (req, res) => {
|
||||
await execQ(`"${cliPath}" core install "${coreId}"`, 300000);
|
||||
}
|
||||
|
||||
const { stdout, stderr, err } = await execQ(
|
||||
`"${cliPath}" compile --upload --fqbn ${fqbn} -p "${port}" ${sketchDir}`,
|
||||
180000
|
||||
);
|
||||
const combined = (stdout + '\n' + stderr).trim();
|
||||
if (err && !combined.toLowerCase().includes('success') && !combined.includes('done uploading')) {
|
||||
return res.json({ error: combined || '업로드 실패' });
|
||||
const isFlashError = (s: string) =>
|
||||
s.includes('Invalid head of packet') || s.includes('verify flash chip connection') ||
|
||||
s.includes('serial noise') || s.includes('does not respond') ||
|
||||
s.includes('Failed to connect');
|
||||
|
||||
const baseFqbn = fqbn.split(':').slice(0, 3).join(':');
|
||||
const hasSpeedOption = fqbn.includes('UploadSpeed=');
|
||||
const hasFlashMode = fqbn.includes('FlashMode=');
|
||||
|
||||
// 속도 재시도: 921600 → 460800 → 230400 (직접 지정한 경우엔 그것만)
|
||||
const speedCandidates = hasSpeedOption ? [fqbn] : [
|
||||
fqbn,
|
||||
`${baseFqbn}:UploadSpeed=460800`,
|
||||
`${baseFqbn}:UploadSpeed=230400`,
|
||||
];
|
||||
// FlashMode=dio 재시도 (qio 기본값이 일부 칩에서 실패)
|
||||
const flashFallback = hasFlashMode ? [] : ['dio', 'dout'];
|
||||
|
||||
let lastResult = { stdout: '', stderr: '', err: null as any };
|
||||
const attempts: string[] = [];
|
||||
|
||||
for (const ef of speedCandidates) {
|
||||
for (const fm of ['', ...flashFallback]) {
|
||||
const effectiveFqbn = fm
|
||||
? ef.includes(':FlashMode=') ? ef : ef + (ef.includes(':UploadSpeed=') ? ',FlashMode=' + fm : ':FlashMode=' + fm)
|
||||
: ef;
|
||||
const note = [
|
||||
ef.match(/UploadSpeed=(\d+)/)?.[1] ? `속도 ${ef.match(/UploadSpeed=(\d+)/)![1]}` : '',
|
||||
fm ? `FlashMode=${fm}` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
if (note) attempts.push(note);
|
||||
|
||||
const r = await execQ(
|
||||
`"${cliPath}" compile --upload --fqbn ${effectiveFqbn} -p "${port}" ${sketchDir}`,
|
||||
180000
|
||||
);
|
||||
lastResult = r;
|
||||
const combined = (r.stdout + '\n' + r.stderr).trim();
|
||||
if (!r.err || combined.toLowerCase().includes('success') || combined.includes('done uploading')) {
|
||||
const header = note ? `⚡ ${note} 으로 성공\n` : '';
|
||||
return res.json({ success: true, output: header + combined });
|
||||
}
|
||||
if (!isFlashError(r.stderr + r.stdout)) { attempts.length = 0; break; }
|
||||
}
|
||||
if (!isFlashError(lastResult.stderr + lastResult.stdout)) break;
|
||||
}
|
||||
res.json({ success: true, output: combined });
|
||||
|
||||
const combined = (lastResult.stdout + '\n' + lastResult.stderr).trim();
|
||||
const triedNote = attempts.length ? `\n시도한 설정: ${attempts.join(' → ')}` : '';
|
||||
return res.json({ error: combined + triedNote || '업로드 실패' });
|
||||
} finally {
|
||||
require('fs').rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
<button onclick="compileAndRun()" id="btn-compile" title="컴파일 & 실행 (Ctrl+Enter)">▶ 컴파일</button>
|
||||
<button onclick="togglePower()" id="btn-stop" title="정지 / 시작" disabled>▶ 시작</button>
|
||||
<button onclick="resetEmulator()" id="btn-reset" title="에뮬레이션 재시작 (리셋)" disabled>↺ 재시작</button>
|
||||
<span id="rp2040-led" title="GPIO25 (내장 LED)" style="display:none;width:12px;height:12px;border-radius:50%;background:#1a2e1a;border:1px solid #2d4a2d;margin:0 2px;flex-shrink:0"></span>
|
||||
<button onclick="toggleBB()" id="btn-bb" title="브레드보드 & SPICE 시뮬레이터">🔌 회로</button>
|
||||
<div class="theme-picker" title="테마">
|
||||
<button class="tdot active" id="td-light" onclick="setTheme('light')" style="--c:#e5e7eb;border:1px solid #94a3b8" title="라이트"></button>
|
||||
@@ -571,6 +572,15 @@
|
||||
<button class="wifi-btn" id="wifi-connect-btn" onclick="_wifiConnectClicked()">연결</button>
|
||||
<button class="wifi-btn" onclick="_wifiDisconnectClicked()">끊기</button>
|
||||
</div>
|
||||
<!-- Scan network mock config -->
|
||||
<div style="margin-top:8px;border-top:1px solid var(--bdr-in);padding-top:8px">
|
||||
<div style="font-size:10px;color:var(--muted);font-weight:600;letter-spacing:.06em;margin-bottom:5px">SCAN 목록 (scanNetworks 모의)</div>
|
||||
<div id="wifi-scan-list" style="display:flex;flex-direction:column;gap:3px;margin-bottom:5px"></div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:4px">
|
||||
<button class="wifi-btn" onclick="_wifiScanAddNet()" style="flex:none;padding:2px 8px;font-size:10px">+ 추가</button>
|
||||
<button class="wifi-btn" onclick="_wifiScanApply()" style="flex:none;padding:2px 8px;font-size:10px;background:var(--accent);border-color:var(--accent);color:#fff">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -617,6 +627,7 @@
|
||||
</div>
|
||||
<span style="margin-left:auto;display:flex;gap:4px;align-items:center">
|
||||
<button class="sch-file-btn" id="sch-gen-btn" onclick="schGenFromCode()" title="스케치 코드를 분석해서 회로도 자동 생성">⚡ 코드→회로</button>
|
||||
<span id="sch-log" style="font-size:11px;color:#e8c860;white-space:nowrap"></span>
|
||||
<button class="sch-file-btn" id="sch-gen-code-btn" onclick="schGenCodeFromCircuit()" title="회로도를 분석해서 Arduino 스케치 자동 생성">🤖 회로→코드</button>
|
||||
<button class="sch-file-btn" onclick="schSaveProject()" title="circuit.json을 프로젝트 폴더에 저장">💾 저장</button>
|
||||
<button class="sch-file-btn" onclick="schLoadLocal()" title="저장된 회로도 불러오기">📂 불러오기</button>
|
||||
@@ -1592,6 +1603,7 @@ function _wifiShow() {
|
||||
const sec = document.getElementById('wifi-section');
|
||||
if (sec) sec.style.display = '';
|
||||
_wifiUpdateUI();
|
||||
_wifiScanRender();
|
||||
}
|
||||
|
||||
function _wifiHandleEvent(msg) {
|
||||
@@ -1648,6 +1660,47 @@ function _wifiDisconnectClicked() {
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi scan mock management
|
||||
let _wifiScanNets = [
|
||||
{ ssid: 'HomeNetwork', rssi: -45, channel: 1 },
|
||||
{ ssid: 'Office_5G', rssi: -55, channel: 6 },
|
||||
{ ssid: 'Neighbor_WiFi', rssi: -65, channel: 11 }
|
||||
];
|
||||
|
||||
function _wifiScanRender() {
|
||||
const list = document.getElementById('wifi-scan-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
_wifiScanNets.forEach((net, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:3px;font-size:10px';
|
||||
row.innerHTML =
|
||||
`<input value="${net.ssid}" placeholder="SSID" style="flex:2;min-width:0;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].ssid=this.value">` +
|
||||
`<input type="number" value="${net.rssi}" min="-100" max="-1" style="width:42px;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].rssi=+this.value" title="RSSI (dBm)">` +
|
||||
`<input type="number" value="${net.channel}" min="1" max="14" style="width:30px;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].channel=+this.value" title="Ch">` +
|
||||
`<button onclick="_wifiScanRemove(${idx})" style="flex:none;padding:1px 5px;border:1px solid var(--bdr2);background:var(--panel);color:var(--muted);border-radius:3px;cursor:pointer;font-size:10px">✕</button>`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function _wifiScanAddNet() {
|
||||
_wifiScanNets.push({ ssid: 'NewNetwork', rssi: -70, channel: 1 });
|
||||
_wifiScanRender();
|
||||
}
|
||||
|
||||
function _wifiScanRemove(idx) {
|
||||
_wifiScanNets.splice(idx, 1);
|
||||
_wifiScanRender();
|
||||
}
|
||||
|
||||
function _wifiScanApply() {
|
||||
if (!_IS_ESP32 || !_esp32SessionId) return;
|
||||
fetch('/api/arduino/qemu/' + _esp32SessionId + '/scan', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ networks: _wifiScanNets })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function _showSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = '';
|
||||
@@ -2059,6 +2112,12 @@ function _esp32PinState(gpio) {
|
||||
|
||||
// ── ESP8266 state ─────────────────────────────────────────────────────────────
|
||||
let _IS_ESP8266 = false;
|
||||
let _IS_RP2040 = false;
|
||||
let _rp2040Sim = null;
|
||||
let _rp2040Running = false;
|
||||
let _rp2040RafId = null;
|
||||
let _rp2040UartBuf = '';
|
||||
let _avrBoardType = 'board_uno';
|
||||
let _esp8266SessionId = null;
|
||||
let _esp8266EvtSource = null;
|
||||
let _esp8266PinStates = {};
|
||||
@@ -3064,7 +3123,12 @@ window.clearSerial = () => { document.getElementById('serial-output').innerHTML
|
||||
|
||||
window.sendSerial = () => {
|
||||
const inp = document.getElementById('serial-input');
|
||||
if (!inp.value || !uart) return;
|
||||
if (!inp.value) return;
|
||||
if (_IS_RP2040 && _rp2040Sim) {
|
||||
for (const ch of inp.value + '\n') _rp2040Sim.rp2040.uart[0].feedByte(ch.charCodeAt(0));
|
||||
inp.value = ''; return;
|
||||
}
|
||||
if (!uart) return;
|
||||
for (const ch of inp.value + '\n') uart.writeByte(ch.charCodeAt(0));
|
||||
inp.value = '';
|
||||
};
|
||||
@@ -3283,7 +3347,333 @@ window.stopEmulator = () => {
|
||||
_updatePowerSwitch(); // 메시지 자동 출력
|
||||
};
|
||||
|
||||
// ── RP2040 emulation ─────────────────────────────────────────────────────────
|
||||
const _PICO_BOARD_SVG = `<svg id="board-svg" viewBox="0 0 90 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- PCB -->
|
||||
<rect x="1" y="1" width="88" height="198" rx="5" fill="#1a5c2a" stroke="#3a9a4a" stroke-width="1.2"/>
|
||||
<!-- USB Micro-B -->
|
||||
<rect x="31" y="0" width="28" height="10" rx="2" fill="#aaa" stroke="#ccc" stroke-width=".5"/>
|
||||
<rect x="34" y="1.5" width="22" height="6" rx="1" fill="#666"/>
|
||||
<text x="45" y="7.5" text-anchor="middle" fill="#eee" font-size="3.5" font-family="monospace">USB</text>
|
||||
<!-- RP2040 chip -->
|
||||
<rect x="22" y="78" width="46" height="44" rx="3" fill="#111" stroke="#555" stroke-width=".8"/>
|
||||
<circle cx="24" cy="80" r="1.5" fill="#333"/>
|
||||
<text x="45" y="97" text-anchor="middle" fill="#ccc" font-size="7" font-family="monospace" font-weight="bold">RP2040</text>
|
||||
<text x="45" y="107" text-anchor="middle" fill="#888" font-size="3.5" font-family="monospace">Raspberry Pi</text>
|
||||
<!-- Flash chip -->
|
||||
<rect x="28" y="118" width="22" height="12" rx="1.5" fill="#222" stroke="#555" stroke-width=".6"/>
|
||||
<text x="39" y="126" text-anchor="middle" fill="#777" font-size="3" font-family="monospace">FLASH</text>
|
||||
<!-- Built-in LED GPIO25 -->
|
||||
<circle id="pico-led" cx="76" cy="25" r="3.5" fill="#0a1f0a" stroke="#2a6a2a" stroke-width=".8"/>
|
||||
<text x="76" y="20" text-anchor="middle" fill="#6aaa6a" font-size="3" font-family="monospace">LED</text>
|
||||
<!-- BOOTSEL button -->
|
||||
<rect x="58" y="148" width="14" height="7" rx="1.5" fill="#2a2a2a" stroke="#666" stroke-width=".5"/>
|
||||
<text x="65" y="153.5" text-anchor="middle" fill="#aaa" font-size="3" font-family="monospace">BOOT</text>
|
||||
<!-- Left pins (20) -->
|
||||
${Array.from({length:20},(_,i)=>{
|
||||
const y=18+i*9;
|
||||
const labels=['VBUS','VSYS','GND','3V3','3V3_EN','ADC_REF','GP28','AGND','GP27','GP26','RUN','GP22','GND','GP21','GP20','GP19','GP18','GND','GP17','GP16'];
|
||||
const isGnd=labels[i].includes('GND')||labels[i]==='AGND';
|
||||
const is3v=labels[i].includes('3V3')||labels[i]==='VBUS'||labels[i]==='VSYS';
|
||||
const col=isGnd?'#888':is3v?'#f80':'#5cf';
|
||||
return `<rect x="1" y="${y}" width="6" height="5" rx="1" fill="#888" stroke="#aaa" stroke-width=".4"/>
|
||||
<text x="9" y="${y+4}" fill="${col}" font-size="3" font-family="monospace">${labels[i]}</text>`;
|
||||
}).join('')}
|
||||
<!-- Right pins (20) -->
|
||||
${Array.from({length:20},(_,i)=>{
|
||||
const y=18+i*9;
|
||||
const labels=['GP0','GP1','GND','GP2','GP3','GP4','GP5','GND','GP6','GP7','GP8','GP9','GND','GP10','GP11','GP12','GP13','GND','GP14','GP15'];
|
||||
const isGnd=labels[i].includes('GND');
|
||||
const col=isGnd?'#888':'#5cf';
|
||||
return `<rect x="83" y="${y}" width="6" height="5" rx="1" fill="#888" stroke="#aaa" stroke-width=".4"/>
|
||||
<text x="81" y="${y+4}" text-anchor="end" fill="${col}" font-size="3" font-family="monospace">${labels[i]}</text>`;
|
||||
}).join('')}
|
||||
</svg>`;
|
||||
|
||||
function _rp2040Start() {
|
||||
if (_rp2040Running || !_rp2040Sim) return;
|
||||
_rp2040Running = true;
|
||||
const NS = 8; // 125 MHz → 8 ns/cycle
|
||||
const rp2040 = _rp2040Sim.rp2040;
|
||||
const clock = _rp2040Sim.clock;
|
||||
const FLASH_BASE = 0x10000000, SRAM_BASE = 0x20000000;
|
||||
// Uint16 뷰: Thumb 명령어(2바이트 정렬) 패턴 감지용
|
||||
const flashU16 = new Uint16Array(rp2040.flash.buffer);
|
||||
// Uint32 뷰: 4바이트 단위 bulk 복사/제로필 용
|
||||
const flashU32 = new Uint32Array(rp2040.flash.buffer);
|
||||
const sramU32 = new Uint32Array(rp2040.sram.buffer);
|
||||
|
||||
// SRAM 코드 fast-path 감지용 Uint16 뷰
|
||||
const sramU16 = new Uint16Array(rp2040.sram.buffer);
|
||||
|
||||
// 범용 초기화 루프 fast-path 함수
|
||||
// 패턴: STMIA Rn!,{r0} / CMP Rn,Rm / BNE or BCC back
|
||||
function _tryFastLoop(pc, memU16, base, isFlash) {
|
||||
const hOff = (pc - base) >>> 1;
|
||||
const op0 = memU16[hOff], op1 = memU16[hOff+1], op2 = memU16[hOff+2];
|
||||
// Fast-path A: LDMIA r1!,{r0}(c901) / STMIA r2!,{r0}(c201) / CMP r2,r3(429a) / BCC(d3fb)
|
||||
if (isFlash && op0 === 0xc901 && op1 === 0xc201 && memU16[hOff+2] === 0x429a && memU16[hOff+3] === 0xd3fb) {
|
||||
const r1 = rp2040.core.registers[1] >>> 0;
|
||||
const r2 = rp2040.core.registers[2] >>> 0;
|
||||
const r3 = rp2040.core.registers[3] >>> 0;
|
||||
if (r2 < r3 && (r1 & 3) === 0 && (r2 & 3) === 0 &&
|
||||
r1 >= FLASH_BASE && r1 < FLASH_BASE + rp2040.flash.length &&
|
||||
r2 >= SRAM_BASE && r2 < SRAM_BASE + rp2040.sram.length) {
|
||||
const cnt = (r3 - r2) >>> 2;
|
||||
sramU32.set(flashU32.subarray((r1-FLASH_BASE)>>>2, ((r1-FLASH_BASE)>>>2)+cnt), (r2-SRAM_BASE)>>>2);
|
||||
rp2040.core.registers[1] = r1 + cnt * 4;
|
||||
rp2040.core.registers[2] = r3;
|
||||
rp2040.core.PC = pc + 8;
|
||||
clock.tick(NS * 2 * cnt * 4);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fast-path B: STMIA r1!,{r0}(c101) / CMP r1,r2(4291) / BNE(d1fc) — .bss 제로 루프
|
||||
if (op0 === 0xc101 && op1 === 0x4291 && op2 === 0xd1fc) {
|
||||
const r0 = rp2040.core.registers[0] >>> 0;
|
||||
const r1 = rp2040.core.registers[1] >>> 0;
|
||||
const r2 = rp2040.core.registers[2] >>> 0;
|
||||
if (r1 < r2 && (r1 & 3) === 0 && (r2 & 3) === 0 &&
|
||||
r1 >= SRAM_BASE && r2 <= SRAM_BASE + rp2040.sram.length) {
|
||||
const cnt = (r2 - r1) >>> 2;
|
||||
sramU32.fill(r0, (r1-SRAM_BASE)>>>2, ((r1-SRAM_BASE)>>>2)+cnt);
|
||||
rp2040.core.registers[1] = r2;
|
||||
rp2040.core.PC = pc + 6;
|
||||
clock.tick(NS * 2 * cnt * 3);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stuck detection: same PC for too long usually means a fault/loop
|
||||
let _lastPc = rp2040.core.PC, _stuckCycles = 0, _reportedStuck = false;
|
||||
|
||||
function tick() {
|
||||
if (!_rp2040Running) return;
|
||||
const _booted = !!(_rp2040Sim && _rp2040Sim._booted);
|
||||
// 부팅 중: 10ms 버스트 후 즉시 재스케줄 (RAF 16ms 상한 없이 최대 처리량)
|
||||
// 부팅 후: RAF로 전환 (UI 동기화)
|
||||
const BURST = _booted ? 20 : 10; // ms
|
||||
const deadline = performance.now() + BURST;
|
||||
let checkN = 0;
|
||||
try {
|
||||
while (true) {
|
||||
if (rp2040.core.waiting) {
|
||||
const dt = clock.nanosToNextAlarm;
|
||||
clock.tick(dt > 0 ? dt : NS);
|
||||
} else {
|
||||
const pc = rp2040.core.PC & ~1;
|
||||
if (pc === _lastPc) {
|
||||
if (++_stuckCycles > 50000 && !_reportedStuck) {
|
||||
_reportedStuck = true;
|
||||
appendSerial(`\n[경고: PC ${pc.toString(16)}에서 멈춤 — HardFault/idle loop 의심]\n`, 'error');
|
||||
}
|
||||
} else {
|
||||
_lastPc = pc; _stuckCycles = 0; _reportedStuck = false;
|
||||
}
|
||||
// Flash 범위: fast-path 검사
|
||||
if (pc >= FLASH_BASE && pc < FLASH_BASE + rp2040.flash.length - 8) {
|
||||
if (_tryFastLoop(pc, flashU16, FLASH_BASE, true)) {
|
||||
if (++checkN >= 500 && performance.now() >= deadline) break;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// SRAM 범위: fast-path 검사 (.bss 패턴만)
|
||||
else if (pc >= SRAM_BASE && pc < SRAM_BASE + rp2040.sram.length - 6) {
|
||||
if (_tryFastLoop(pc, sramU16, SRAM_BASE, false)) {
|
||||
if (++checkN >= 500 && performance.now() >= deadline) break;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rp2040.step();
|
||||
clock.tick(NS * 2);
|
||||
}
|
||||
if (++checkN >= 500 && performance.now() >= deadline) break;
|
||||
}
|
||||
} catch(e) { _rp2040Running = false; appendSerial('\n[실행 오류: ' + e.message + ']\n', 'error'); return; }
|
||||
// Heartbeat: 부팅 중에는 1초마다, 부팅 후에는 3초마다 PC/SP/LR/waiting 출력
|
||||
if (_rp2040Sim && _rp2040Sim._booted) {
|
||||
_rp2040RafId = requestAnimationFrame(tick);
|
||||
} else {
|
||||
// 부팅 중: setTimeout(0)으로 최대한 빠르게 재스케줄
|
||||
_rp2040RafId = setTimeout(tick, 0);
|
||||
}
|
||||
}
|
||||
_rp2040RafId = setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
function _rp2040Stop() {
|
||||
_rp2040Running = false;
|
||||
if (_rp2040RafId) {
|
||||
cancelAnimationFrame(_rp2040RafId);
|
||||
clearTimeout(_rp2040RafId);
|
||||
_rp2040RafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _loadUF2(data, rp2040) {
|
||||
const FLASH_START = 0x10000000;
|
||||
for (let i = 0; i + 512 <= data.length; i += 512) {
|
||||
const v = new DataView(data.buffer, data.byteOffset + i, 512);
|
||||
if (v.getUint32(0, true) !== 0x0A324655 || v.getUint32(4, true) !== 0x9E5D5157) continue;
|
||||
const addr = v.getUint32(12, true);
|
||||
const size = v.getUint32(16, true);
|
||||
const off = addr - FLASH_START;
|
||||
if (off < 0 || off + size > rp2040.flash.length) continue;
|
||||
rp2040.flash.set(new Uint8Array(data.buffer, data.byteOffset + i + 32, size), off);
|
||||
}
|
||||
}
|
||||
|
||||
async function initRP2040Mode(uf2B64, fname) {
|
||||
_IS_RP2040 = true;
|
||||
document.getElementById('filename').textContent = fname || 'sketch.ino';
|
||||
document.querySelector('.board-title').textContent = 'RASPBERRY PI PICO · RP2040';
|
||||
document.querySelector('.board-svg').innerHTML = _PICO_BOARD_SVG;
|
||||
const footerEl = document.getElementById('footer-info');
|
||||
if (footerEl) footerEl.textContent = 'rp2040js · RP2040 @ 125 MHz';
|
||||
document.getElementById('btn-compile').style.display = 'none';
|
||||
|
||||
let Simulator, USBCDC;
|
||||
try {
|
||||
({ Simulator, USBCDC } = await import('https://esm.sh/rp2040js@1.3.3'));
|
||||
} catch(e) { showOverlayError('rp2040js 로드 실패\n' + e.message); return; }
|
||||
|
||||
const uf2Bytes = Uint8Array.from(atob(uf2B64), c => c.charCodeAt(0));
|
||||
const sim = new Simulator();
|
||||
_rp2040Sim = sim;
|
||||
|
||||
// 1) RP2040 공식 bootrom 로드 (helper 함수 table을 위해 필수)
|
||||
try {
|
||||
const br = await fetch('/api/arduino/rp2040-bootrom', { credentials: 'include' });
|
||||
if (!br.ok) throw new Error('HTTP ' + br.status);
|
||||
const brData = await br.json();
|
||||
const bootrom = Uint8Array.from(atob(brData.bin), c => c.charCodeAt(0));
|
||||
if (bootrom.length !== 16 * 1024) throw new Error('bootrom size mismatch: ' + bootrom.length);
|
||||
const bootromU32 = new Uint32Array(bootrom.buffer, bootrom.byteOffset, 4096);
|
||||
sim.rp2040.loadBootrom(bootromU32);
|
||||
appendSerial(`[RP2040 bootrom 로드 완료: ${bootrom.length} bytes]\n`, 'sys');
|
||||
} catch(e) {
|
||||
appendSerial('[RP2040 bootrom 로드 실패: ' + e.message + ']\n', 'error');
|
||||
// bootrom 없이도 계속 시도 (helper 호출 시 멈출 수 있음)
|
||||
}
|
||||
|
||||
// 2) UF2 → flash 로드
|
||||
_loadUF2(uf2Bytes, sim.rp2040);
|
||||
|
||||
// 3) 앱 벡터 테이블 찾기
|
||||
// Arduino-Pico UF2: boot2(0x10000000) 뒤에 임시 vector table(0x10000100)이 있고,
|
||||
// 실제 .text 섹션 시작인 0x10003000에 진짜 vector table이 위치함.
|
||||
// 보드/링커 설정에 따라 0x10003000 또는 0x10000100 중 하나를 사용.
|
||||
const fv = new DataView(sim.rp2040.flash.buffer);
|
||||
const readVTOR = (base) => ({ sp: fv.getUint32(base, true), pc: fv.getUint32(base + 4, true), base });
|
||||
const candidates = [readVTOR(0x3000), readVTOR(0x100)].filter(v => v.sp && v.pc && (v.pc & ~1) >= 0x10000000);
|
||||
const appVTOR = candidates[0];
|
||||
if (!appVTOR) {
|
||||
showOverlayError('UF2 로드 실패: 앱 벡터 테이블을 찾을 수 없습니다.\nrp2040:rp2040 코어(arduino-pico)를 사용하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 부팅을 위해 VTOR를 찾은 앱 vector table로 설정.
|
||||
// bootrom을 덮어쓰지 않으므로 bootrom helper 함수들은 그대로 사용 가능.
|
||||
// reset()이 실행되면 core.PC = flash[VTOR+4], core.SP = flash[VTOR].
|
||||
sim.rp2040.core.VTOR = 0x10000000 + appVTOR.base;
|
||||
|
||||
// 4) CPU 리셋 (VTOR → flash 앱 vector 테이블에서 SP/PC 로드)
|
||||
sim.rp2040.core.reset();
|
||||
|
||||
// rp2040js 로거 경고 억제: 미구현 명령어·주변장치 warn이 콘솔에 범람하는 것을 방지
|
||||
{
|
||||
const logger = sim.rp2040.logger;
|
||||
if (logger && typeof logger.warn === 'function') {
|
||||
logger.warn = () => {}; // 모든 warn 억제 (error/info는 유지)
|
||||
}
|
||||
// core가 별도 logger를 가질 경우도 처리
|
||||
const clogger = sim.rp2040.core?.logger;
|
||||
if (clogger && clogger !== logger && typeof clogger.warn === 'function') {
|
||||
clogger.warn = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
// rp2040js 패치: exception(HardFault 등) 발생 시 실행을 멈추고 메시지를 출력.
|
||||
// (주의: 이전 wide-Thumb 명령어 PC 패치는 BL/BLX 등 정상 32비트 명령어를 망가뜨려 제거함)
|
||||
{
|
||||
const core = sim.rp2040.core;
|
||||
const _origException = core.exceptionEntry.bind(core);
|
||||
core.exceptionEntry = function(exceptionNumber) {
|
||||
if (exceptionNumber === 3) { // HardFault만 중지 (IRQ/SysTick/PendSV는 정상 실행)
|
||||
_rp2040Running = false;
|
||||
_origException(exceptionNumber);
|
||||
appendSerial(`\n[HardFault (LR=${this.LR.toString(16)}, PC=${this.PC.toString(16)}) — 실행 중지]\n`, 'error');
|
||||
} else {
|
||||
_origException(exceptionNumber);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Serial 출력 발생 시 부팅 완료로 전환 (setTimeout→RAF 스케줄러 전환)
|
||||
function _markBooted() {
|
||||
if (_rp2040Sim && !_rp2040Sim._booted) {
|
||||
_rp2040Sim._booted = true;
|
||||
appendSerial('[부팅 완료]\n', 'sys');
|
||||
}
|
||||
}
|
||||
|
||||
// UART0 → Serial1 (UTF-8 스트리밍 디코딩)
|
||||
const _rp2040UartDec = new TextDecoder('utf-8', { fatal: false });
|
||||
sim.rp2040.uart[0].onByte = (value) => {
|
||||
_markBooted();
|
||||
const decoded = _rp2040UartDec.decode(new Uint8Array([value]), { stream: true });
|
||||
if (decoded) appendSerial(decoded);
|
||||
};
|
||||
|
||||
// USB CDC → Serial (default Arduino-Pico Serial)
|
||||
try {
|
||||
const cdc = new USBCDC(sim.rp2040.usbCtrl);
|
||||
const dec = new TextDecoder();
|
||||
cdc.onSerialData = (buf) => { _markBooted(); appendSerial(dec.decode(buf)); };
|
||||
cdc.onDeviceConnected = () => appendSerial('[USB CDC 연결됨]\n', 'sys');
|
||||
} catch(e) {
|
||||
appendSerial('[USB CDC 초기화 실패: ' + e.message + ']\n', 'error');
|
||||
}
|
||||
|
||||
// GPIO25 = 내장 LED
|
||||
sim.rp2040.gpio[25].addListener((state) => {
|
||||
const on = state === 1;
|
||||
const toolbar = document.getElementById('rp2040-led');
|
||||
if (toolbar) toolbar.style.background = on ? '#4ade80' : '#1a2e1a';
|
||||
const svg = document.getElementById('pico-led');
|
||||
if (svg) svg.setAttribute('fill', on ? '#22c55e' : '#0a1f0a');
|
||||
});
|
||||
|
||||
document.getElementById('overlay').style.display = 'none';
|
||||
const ledEl2 = document.getElementById('rp2040-led');
|
||||
if (ledEl2) ledEl2.style.display = 'inline-block';
|
||||
document.getElementById('btn-stop').disabled = false;
|
||||
setStatus('running', '실행 중');
|
||||
const resetBtn = document.getElementById('btn-reset');
|
||||
if (resetBtn) { resetBtn.disabled = false; }
|
||||
appendSerial(`[RP2040 에뮬레이터 시작]\n[Serial → USB CDC, Serial1 → UART0]\n[VTOR=${sim.rp2040.core.VTOR.toString(16)} SP=${sim.rp2040.core.SP.toString(16)} PC=${sim.rp2040.core.PC.toString(16)}]\n`, 'sys');
|
||||
_rp2040Start();
|
||||
}
|
||||
|
||||
window.resetEmulator = () => {
|
||||
if (_IS_RP2040) {
|
||||
if (!_rp2040Sim) return;
|
||||
_rp2040Stop();
|
||||
_rp2040UartBuf = '';
|
||||
appendSerial('\n--- 리셋 ---\n', 'sys');
|
||||
// 주의: rp2040.reset()은 flash.fill(0xff)를 실행하므로 앱 코드가 날아감.
|
||||
// VTOR를 다시 앱 vector table로 재설정한 뒤 core.reset()만 수행.
|
||||
// (flash offset 0x3000 우선, 없으면 0x100)
|
||||
const fv2 = new DataView(_rp2040Sim.rp2040.flash.buffer);
|
||||
const readVTOR2 = (base) => ({ sp: fv2.getUint32(base, true), pc: fv2.getUint32(base + 4, true), base });
|
||||
const cand2 = [readVTOR2(0x3000), readVTOR2(0x100)].filter(v => v.sp && v.pc && (v.pc & ~1) >= 0x10000000);
|
||||
_rp2040Sim.rp2040.core.VTOR = cand2.length ? (0x10000000 + cand2[0].base) : 0x10003000;
|
||||
_rp2040Sim.rp2040.core.reset();
|
||||
_rp2040Start();
|
||||
return;
|
||||
}
|
||||
if (!hexData) return;
|
||||
// 먼저 실행 정지
|
||||
running = false;
|
||||
@@ -3357,6 +3747,11 @@ window.setTheme = (name) => {
|
||||
};
|
||||
|
||||
window.togglePower = () => {
|
||||
if (_IS_RP2040 && _rp2040Sim) {
|
||||
if (_rp2040Running) { _rp2040Stop(); setStatus('stopped', '정지'); }
|
||||
else { _rp2040Start(); setStatus('running', '실행 중'); }
|
||||
return;
|
||||
}
|
||||
if (running) { stopEmulator(); return; }
|
||||
if (!emulatorReady) return;
|
||||
startEmulator();
|
||||
@@ -3375,6 +3770,8 @@ async function boot() {
|
||||
leonardo: 'ARDUINO LEONARDO · ATmega32U4', micro: 'ARDUINO MICRO · ATmega32U4',
|
||||
esp32: 'ESP32 · Xtensa LX6', esp32s3: 'ESP32-S3 · Xtensa LX7',
|
||||
esp8266: 'ESP8266 · L106',
|
||||
rpipico: 'Raspberry Pi Pico · RP2040', rpipicow: 'Raspberry Pi Pico W · RP2040',
|
||||
pico: 'Arduino Nano RP2040 Connect',
|
||||
};
|
||||
if (_fqbnBoard && _BOARD_NAMES[_fqbnBoard]) {
|
||||
const boardTitleEl = document.querySelector('.board-title');
|
||||
@@ -3408,6 +3805,19 @@ async function boot() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── RP2040 browser mode ──────────────────────────────────────────────────────
|
||||
const uf2B64 = sessionStorage.getItem('arduino_uf2');
|
||||
if (uf2B64) {
|
||||
sessionStorage.removeItem('arduino_uf2');
|
||||
const rp2040Sketch = sessionStorage.getItem('arduino_sketch');
|
||||
sessionStorage.removeItem('arduino_sketch');
|
||||
if (rp2040Sketch) _esp32SketchCode = rp2040Sketch;
|
||||
const rp2040Fname = sessionStorage.getItem('arduino_filename') || 'sketch.ino';
|
||||
sessionStorage.removeItem('arduino_filename');
|
||||
initRP2040Mode(uf2B64, rp2040Fname);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Arduino AVR mode (기존) ──────────────────────────────────────────────────
|
||||
const sketch = sessionStorage.getItem('arduino_sketch');
|
||||
const board = sessionStorage.getItem('arduino_board') || 'uno';
|
||||
@@ -3419,16 +3829,17 @@ async function boot() {
|
||||
sessionStorage.removeItem('arduino_hex');
|
||||
|
||||
// AVR 모드 보드 타이틀 (URL fqbn이 없는 경우 sessionStorage board로 대체)
|
||||
const _avrBoardKey = (_fqbnBoard || (board || 'uno').split(':').pop()).toLowerCase();
|
||||
_avrBoardType = _avrBoardKey.includes('mega') ? 'board_mega' : _avrBoardKey.includes('nano') ? 'board_nano' : 'board_uno';
|
||||
if (!_fqbnBoard) {
|
||||
const _avrBoard = (board || 'uno').toLowerCase();
|
||||
const _avrName = _BOARD_NAMES[_avrBoard] || _BOARD_NAMES.uno;
|
||||
const _avrName = _BOARD_NAMES[_avrBoardKey] || _BOARD_NAMES.uno;
|
||||
const boardTitleEl = document.querySelector('.board-title');
|
||||
if (boardTitleEl && _avrBoard !== 'uno') boardTitleEl.textContent = _avrName;
|
||||
if (boardTitleEl && _avrBoardKey !== 'uno') boardTitleEl.textContent = _avrName;
|
||||
}
|
||||
|
||||
document.getElementById('filename').textContent = fname;
|
||||
|
||||
if (hexDirect) { initEmulator(hexDirect); return; }
|
||||
if (hexDirect) { if (sketch) _esp32SketchCode = sketch; initEmulator(hexDirect); return; }
|
||||
|
||||
if (!sketch) {
|
||||
showOverlayError('스케치가 없습니다.\n코드 에디터에서 .ino 파일을 열고 실행하세요.');
|
||||
@@ -5659,8 +6070,7 @@ ${code.slice(0, 3000)}
|
||||
|
||||
// Auto-layout and apply generated component list to schematic
|
||||
function _schApplyGenerated(boardType, components) {
|
||||
if (!components.length) return;
|
||||
if (SCH.components.length && !confirm(`회로도에 ${components.length}개 부품을 배치합니다. 기존 회로도를 지울까요?`)) return;
|
||||
if (SCH.components.length && !confirm(components.length ? `회로도에 ${components.length}개 부품을 배치합니다. 기존 회로도를 지울까요?` : '회로도를 초기화하고 보드만 표시합니다. 계속할까요?')) return;
|
||||
SCH.components = [];
|
||||
SCH.wires = [];
|
||||
|
||||
@@ -5894,7 +6304,7 @@ function _schApplyGenerated(boardType, components) {
|
||||
window.schGenFromCode = async function schGenFromCode() {
|
||||
const code = _getEditorCode() || (_IS_ESP32 ? _esp32SketchCode : _esp8266SketchCode);
|
||||
if (!code || !code.trim()) { alert('스케치 코드가 없습니다. 먼저 코드를 에디터에 붙여넣거나 컴파일하세요.'); return; }
|
||||
const boardType = _IS_ESP32 ? 'board_esp32' : 'board_esp8266';
|
||||
const boardType = _IS_ESP32 ? 'board_esp32' : _IS_ESP8266 ? 'board_esp8266' : _avrBoardType;
|
||||
const btn = document.getElementById('sch-gen-btn');
|
||||
if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }
|
||||
try {
|
||||
@@ -5908,8 +6318,11 @@ window.schGenFromCode = async function schGenFromCode() {
|
||||
else merged.push(aiComp);
|
||||
}
|
||||
const valid = merged.filter(c => SCH_TERMS[c.type]);
|
||||
if (!valid.length) { alert('인식된 부품이 없습니다. 코드에 라이브러리 include나 pinMode 호출이 있는지 확인하세요.'); return; }
|
||||
_schApplyGenerated(boardType, valid);
|
||||
if (!valid.length) {
|
||||
const log = document.getElementById('sch-log');
|
||||
if (log) { log.textContent = '인식된 부품 없음 — 보드만 표시됩니다.'; log.style.color='#e8c860'; }
|
||||
}
|
||||
} finally {
|
||||
if (btn) { btn.textContent = '⚡ 코드→회로'; btn.disabled = false; }
|
||||
}
|
||||
|
||||
+16
-5
@@ -16,13 +16,13 @@
|
||||
<!-- CodeMirror + xterm (UMD libs loaded before Monaco's AMD loader to avoid anonymous define() clashes) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||
<link rel="stylesheet" href="/vendor/xterm.css">
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/addon-fit.js"></script>
|
||||
<script src="/vendor/addon-web-links.js"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<!-- Monaco loader (AMD). Hide define.amd first so any stray UMD lib does not call Monaco's anonymous define(). -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs/loader.js"></script>
|
||||
<script src="/vendor/vs/loader.js"></script>
|
||||
<script>
|
||||
try {
|
||||
var _t = localStorage.getItem('code-page-theme') || localStorage.getItem('cherryclaw_theme') || 'dark';
|
||||
@@ -510,6 +510,17 @@
|
||||
</div>
|
||||
<iframe id="code-preview-iframe" src="" style="flex:1;border:none;background:#fff;min-height:0" sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups"></iframe>
|
||||
</div>
|
||||
<!-- 🖥️ Emulator panel -->
|
||||
<div id="code-emu-panel" style="display:none;flex-direction:column;flex-shrink:0;height:520px;min-height:120px;border-top:2px solid #1a4a25;background:#0a1410;position:relative">
|
||||
<div id="code-emu-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
|
||||
<div style="display:flex;align-items:center;height:32px;background:#0d1f18;border-bottom:1px solid #1a4a25;flex-shrink:0;padding:0 8px;gap:6px">
|
||||
<span style="font-size:13px;flex-shrink:0">🖥️</span>
|
||||
<span id="emu-panel-title" style="font-size:12px;font-weight:700;color:#4ade80;font-family:'IBM Plex Mono',monospace;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">에뮬레이터</span>
|
||||
<button onclick="emuPanelNewTab()" title="새 탭에서 열기" style="background:none;border:none;color:#4a7a5c;cursor:pointer;font-size:13px;padding:0 5px;flex-shrink:0">⧉</button>
|
||||
<button onclick="toggleEmuPanel()" title="닫기" style="background:none;border:none;color:#3d6b4f;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">×</button>
|
||||
</div>
|
||||
<iframe id="code-emu-iframe" src="" style="flex:1;border:none;min-height:0" allow="serial;usb"></iframe>
|
||||
</div>
|
||||
<!-- ⚡ Upload panel -->
|
||||
<div id="code-upload-panel" style="display:none;flex-direction:column;flex-shrink:0;height:300px;min-height:100px;border-top:2px solid #7a4e20;background:#1e1810;position:relative">
|
||||
<div id="code-upload-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
|
||||
|
||||
+116
-44
@@ -133,7 +133,7 @@ function initCodeView() {
|
||||
setCodeAiTech(codeAiTech);
|
||||
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
|
||||
if (typeof require === 'undefined') return;
|
||||
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min/vs' } });
|
||||
require.config({ paths: { vs: '/vendor/vs' } });
|
||||
require(['vs/editor/editor.main'], function() {
|
||||
monacoReady = true;
|
||||
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||
@@ -909,80 +909,125 @@ function toggleCodePreview() {
|
||||
}
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
}
|
||||
let _emuPanelOpen = false;
|
||||
let _emuPanelUrl = '';
|
||||
|
||||
function toggleEmuPanel() {
|
||||
const panel = document.getElementById('code-emu-panel');
|
||||
const btn = document.getElementById('code-emu-btn');
|
||||
if (!panel) return;
|
||||
_emuPanelOpen = !_emuPanelOpen;
|
||||
panel.style.display = _emuPanelOpen ? 'flex' : 'none';
|
||||
if (btn) btn.classList.toggle('active', _emuPanelOpen);
|
||||
if (!_emuPanelOpen) {
|
||||
const iframe = document.getElementById('code-emu-iframe');
|
||||
if (iframe) iframe.src = '';
|
||||
_emuPanelUrl = '';
|
||||
}
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
}
|
||||
|
||||
function _emuPanelLoad(url, title) {
|
||||
const panel = document.getElementById('code-emu-panel');
|
||||
const iframe = document.getElementById('code-emu-iframe');
|
||||
const lbl = document.getElementById('emu-panel-title');
|
||||
const btn = document.getElementById('code-emu-btn');
|
||||
if (!panel || !iframe) return;
|
||||
_emuPanelUrl = url;
|
||||
iframe.src = url;
|
||||
if (lbl) lbl.textContent = title || '에뮬레이터';
|
||||
if (!_emuPanelOpen) {
|
||||
_emuPanelOpen = true;
|
||||
panel.style.display = 'flex';
|
||||
if (btn) btn.classList.add('active');
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
}
|
||||
}
|
||||
|
||||
function emuPanelNewTab() {
|
||||
if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank');
|
||||
}
|
||||
|
||||
function openArduinoEmulator() {
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
const btn = document.getElementById('code-emu-btn');
|
||||
if (!file || !file.name.endsWith('.ino')) { window.open('/arduino-emulator.html', 'arduino-emulator'); return; }
|
||||
const btn = document.getElementById('code-emu-btn');
|
||||
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
const fqbn = _csbSelBoard.fqbn;
|
||||
if (!file || !file.name.endsWith('.ino')) {
|
||||
_emuPanelLoad('/arduino-emulator.html', '에뮬레이터');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
const fqbn = _csbSelBoard.fqbn;
|
||||
const fqbnArch = fqbn.split(':')[1] || '';
|
||||
|
||||
if (fqbnArch === 'esp32') {
|
||||
// ESP32 계열 → QEMU (DIO 모드로 서버가 직접 컴파일)
|
||||
// 팝업 차단 방지: 클릭 직후 즉시 창 열고, 완료 후 URL 교체
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 — ${file.name}`);
|
||||
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
|
||||
// Store sketch code so emulator can show it (localStorage: shared across windows)
|
||||
try { localStorage.setItem('esp32_sketch', content); } catch {}
|
||||
api('/api/arduino/qemu/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
|
||||
.then(d => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
if (d.error) {
|
||||
emuWin.close();
|
||||
const isUnsupported = d.error.includes('미지원') || d.error.includes('RISC-V');
|
||||
if (isUnsupported) codeAiAppend('system', '⚠️ ' + d.error);
|
||||
else sendCompileErrorToAI(d.error, file.name, content);
|
||||
toggleEmuPanel();
|
||||
return;
|
||||
}
|
||||
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
_emuPanelLoad(url, `ESP32 — ${file.name}`);
|
||||
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ ' + e.message);
|
||||
emuWin.close();
|
||||
toggleEmuPanel();
|
||||
});
|
||||
|
||||
} else if (fqbnArch === 'esp8266') {
|
||||
// ESP8266 → Native Linux x86 컴파일 + 실행
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 — ${file.name}`);
|
||||
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
|
||||
try { localStorage.setItem('esp8266_sketch', content); } catch {}
|
||||
api('/api/arduino/esp8266/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
|
||||
.then(d => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
if (d.error) {
|
||||
emuWin.close();
|
||||
sendCompileErrorToAI(d.error, file.name, content);
|
||||
toggleEmuPanel();
|
||||
return;
|
||||
}
|
||||
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
|
||||
_emuPanelLoad(url, `ESP8266 — ${file.name}`);
|
||||
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ ' + e.message);
|
||||
emuWin.close();
|
||||
toggleEmuPanel();
|
||||
});
|
||||
|
||||
} else {
|
||||
// AVR 및 기타 → 기존 컴파일 → AVR 에뮬레이터 or 다운로드
|
||||
// AVR 및 기타
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
fetch('/api/arduino/compile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders({}) },
|
||||
body: JSON.stringify({ code: content, board: fqbn, fqbn }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
if (data.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
|
||||
if (data.error) { sendCompileErrorToAI(data.error, file.name, content); return; }
|
||||
if (data.hex) {
|
||||
sessionStorage.setItem('arduino_hex', data.hex);
|
||||
const w = window.open('/arduino-emulator.html', 'arduino-emulator');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
sessionStorage.setItem('arduino_sketch', content);
|
||||
_emuPanelLoad('/arduino-emulator.html', `AVR — ${file.name}`);
|
||||
} else if (data.boardType === 'rp2040') {
|
||||
sessionStorage.setItem('arduino_uf2', data.bin);
|
||||
sessionStorage.setItem('arduino_sketch', content);
|
||||
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${file.name}`);
|
||||
} else if (data.bin) {
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
|
||||
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
|
||||
@@ -992,7 +1037,7 @@ function openArduinoEmulator() {
|
||||
codeAiAppend('system', `✅ 컴파일 완료 (${(data.size/1024).toFixed(1)} KB) — ${ext.slice(1).toUpperCase()} 다운로드됨.\n보드에 올리려면 ⚡ 업로드 버튼을 사용하세요.`);
|
||||
}
|
||||
}).catch(e => {
|
||||
if (btn) { btn.textContent = '🔌 에뮬레이터'; btn.disabled = false; }
|
||||
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
|
||||
codeAiAppend('system', '⚠️ 서버 오류: ' + e.message);
|
||||
});
|
||||
}
|
||||
@@ -1409,33 +1454,31 @@ function codeRunWith(mode) {
|
||||
}
|
||||
|
||||
if (mode === 'esp32') {
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 — ${file.name}`);
|
||||
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... (${file.name})`);
|
||||
try { localStorage.setItem('esp32_sketch', file.content); } catch {}
|
||||
api('/api/arduino/qemu/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: 'esp32:esp32:esp32' }) })
|
||||
.then(d => {
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); emuWin.close(); return; }
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; }
|
||||
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=esp32%3Aesp32%3Aesp32${_proj2}`;
|
||||
_emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=esp32%3Aesp32%3Aesp32${_proj2}`, `ESP32 — ${file.name}`);
|
||||
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); emuWin.close(); });
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); toggleEmuPanel(); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'esp8266') {
|
||||
const emuWin = window.open('/arduino-emulator.html?loading=1', '_blank');
|
||||
if (!emuWin) { codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요'); return; }
|
||||
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 — ${file.name}`);
|
||||
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... (${file.name})`);
|
||||
try { localStorage.setItem('esp8266_sketch', file.content); } catch {}
|
||||
const _esp8266Fqbn = (_csbSelBoard.fqbn.startsWith('esp8266:')) ? _csbSelBoard.fqbn : 'esp8266:esp8266:nodemcuv2';
|
||||
api('/api/arduino/esp8266/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: _esp8266Fqbn }) })
|
||||
.then(d => {
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); emuWin.close(); return; }
|
||||
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; }
|
||||
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
|
||||
emuWin.location.href = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(_esp8266Fqbn)}${_proj2}`;
|
||||
_emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(_esp8266Fqbn)}${_proj2}`, `ESP8266 — ${file.name}`);
|
||||
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); emuWin.close(); });
|
||||
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); toggleEmuPanel(); });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1466,10 +1509,13 @@ function codeRunWith(mode) {
|
||||
// (이미 위에서 처리됨, fallback)
|
||||
codeAiAppend('system', `✅ 컴파일 완료.`);
|
||||
} else if (data.hex) {
|
||||
// AVR/ESP8266 → 에뮬레이터
|
||||
sessionStorage.setItem('arduino_hex', data.hex);
|
||||
const w = window.open('/arduino-emulator.html', '_blank');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||||
sessionStorage.setItem('arduino_sketch', file.content);
|
||||
_emuPanelLoad('/arduino-emulator.html', `AVR — ${file.name}`);
|
||||
} else if (data.boardType === 'rp2040') {
|
||||
sessionStorage.setItem('arduino_uf2', data.bin);
|
||||
sessionStorage.setItem('arduino_sketch', file.content);
|
||||
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${file.name}`);
|
||||
} else if (data.bin) {
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
|
||||
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
|
||||
@@ -5878,6 +5924,7 @@ function cupOpenWith(file) {
|
||||
cupLog(`방식: ${_cupMethodLabel(method)}`, 'info');
|
||||
if (method === 'stm32-uart') cupLog('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
|
||||
else if (method === 'avr-stk500') cupLog('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
|
||||
else if (method === 'rp2040-emulator') cupLog('RP2040: ⚡ 업로드 버튼으로 에뮬레이터 실행 / ⬇ 다운로드로 UF2 파일 저장', 'info');
|
||||
else if (method === 'download-uf2' || method === 'download-bin') cupLog('이 보드는 ⬇ 다운로드 후 수동 복사 방식입니다.', 'warn');
|
||||
else if (method === 'esp32-flash') cupLog('ESP32/ESP8266: 🔌 포트 선택 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js', 'info');
|
||||
if (!navigator.serial) cupLog('⚠ WebSerial 미지원 브라우저입니다. Chrome/Edge를 사용하세요.', 'warn');
|
||||
@@ -5926,13 +5973,13 @@ function _cupGetUploadMethod(fqbn) {
|
||||
if (arch === 'avr') return 'avr-stk500';
|
||||
if (vendor === 'STMicroelectronics') return 'stm32-uart';
|
||||
if (arch === 'samd' || arch === 'sam') return 'download-bin';
|
||||
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'download-uf2';
|
||||
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'rp2040-emulator';
|
||||
if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash';
|
||||
return 'download-bin';
|
||||
}
|
||||
|
||||
function _cupMethodLabel(m) {
|
||||
return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin 다운로드', 'download-uf2':'UF2 드래그&드롭', 'esp32-flash':'esptool-js (브라우저)' }[m] || m;
|
||||
return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin 다운로드', 'download-uf2':'UF2 드래그&드롭', 'rp2040-emulator':'RP2040 에뮬레이터', 'esp32-flash':'esptool-js (브라우저)' }[m] || m;
|
||||
}
|
||||
|
||||
function _cupGetEffectiveFqbn() {
|
||||
@@ -6024,7 +6071,7 @@ async function cupDoDownload() {
|
||||
_cupCompiledData = data;
|
||||
const isHex = !!data.hex;
|
||||
const content = isHex ? data.hex : _cupBase64ToBytes(data.bin || data.merged);
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : (isHex ? '.hex' : '.bin');
|
||||
const ext = (data.boardType === 'uf2' || data.boardType === 'rp2040') ? '.uf2' : (isHex ? '.hex' : '.bin');
|
||||
const blob = new Blob([content], { type: isHex ? 'text/plain' : 'application/octet-stream' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
@@ -6039,6 +6086,16 @@ async function cupDoUpload() {
|
||||
cupTab('log');
|
||||
const method = _cupGetUploadMethod(_cupGetEffectiveFqbn());
|
||||
if (method === 'esp32-flash') { await _cupUploadESP32(); return; }
|
||||
if (method === 'rp2040-emulator') {
|
||||
let data;
|
||||
if (_cupCacheHit()) { data = _cupCompiledData; cupLog('캐시된 컴파일 결과 사용', 'info'); }
|
||||
else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; }
|
||||
if (!data) return;
|
||||
sessionStorage.setItem('arduino_uf2', data.bin);
|
||||
sessionStorage.setItem('arduino_sketch', _cupCode || '');
|
||||
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${_cupFname}`);
|
||||
return;
|
||||
}
|
||||
if (method === 'download-uf2' || method === 'download-bin') { await cupDoDownload(); return; }
|
||||
_cupBusy = true;
|
||||
document.getElementById('cup-btn-upload').disabled = true;
|
||||
@@ -6297,6 +6354,21 @@ async function cupMonSend() {
|
||||
|
||||
function cupMonClear() { const el=document.getElementById('cup-mon-output'); if(el) el.innerHTML=''; _cupMonBuf=''; }
|
||||
|
||||
// Emulator panel resize drag
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const drag = document.getElementById('code-emu-drag');
|
||||
const panel = document.getElementById('code-emu-panel');
|
||||
if (!drag || !panel) return;
|
||||
let startY=0, startH=0;
|
||||
drag.addEventListener('mousedown', e => {
|
||||
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
|
||||
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(120,Math.min(window.innerHeight*.75,startH+dy))+'px'; };
|
||||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
|
||||
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
|
||||
// Upload panel resize drag
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const drag = document.getElementById('code-upload-drag');
|
||||
|
||||
Reference in New Issue
Block a user