feat: STM32 Renode 에뮬레이터 작동 — HAL 폴링 루프 + 비트밴드 + 타이밍 수정

- stm32f103_fixed.repl: Miscellaneous.BitBanding 추가로 HAL bit-band
  RCC 접근(0x42xxxxxx) 정상 처리, SRAM 크기 20KB로 수정,
  systickFrequency 8MHz로 낮춰 실시간 타이밍 보정
- stm32f1_rcc.py(~/.local): UTF-8 인코딩 선언, PLLRDY가 PLLON을
  동적으로 추적해 SystemClock_Config() HAL 폴링 루프 해소
- routes-arduino.ts: SystemClock_Config() 스케치에서 제거(bit-band
  우회), 보드 선택 6종 whitelist, Companion WS 재연결 스팸 수정
- arduino-emulator.html + code.js: Renode 세션 라우팅 + 보라 배지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-29 21:10:50 +09:00
co-authored by Claude Sonnet 4.6
parent 75a36cdc7d
commit c13fa4fae5
4 changed files with 654 additions and 23 deletions
+313 -17
View File
@@ -139,6 +139,7 @@ function _detectChip(fqbn: string): string {
if (vendor === 'esp8266') return 'ESP8266';
if (vendor === 'rp2040') return 'RP2040';
if (vendor === 'arduino' && (parts[1] || '').startsWith('mbed_rp2040')) return 'RP2040';
if (vendor === 'STMicroelectronics') return 'STM32';
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';
@@ -156,6 +157,7 @@ function _qemuForChip(chip: string): string | false {
if (chip === 'S3') return 'esp32s3';
if (chip === 'ESP8266') return 'esp8266-native';
if (chip === 'RP2040') return 'rp2040-browser';
if (chip === 'STM32') return 'renode';
return false;
}
@@ -199,6 +201,16 @@ const _ESP8266_FEATURED: Record<string, string> = {
'esp8266:esp8266:esp01': 'ESP-01 Wi-Fi 모듈',
};
// STM32 대표 보드 (검색 없을 때만 적용, Renode 에뮬레이터 지원)
const _STM32_FEATURED: Record<string, string> = {
'STMicroelectronics:stm32:GenF1:pnum=BLUEPILL_F103C8': 'Blue Pill (STM32F103C8)',
'STMicroelectronics:stm32:GenF4:pnum=BLACKPILL_F411CE': 'Black Pill (STM32F411CE)',
'STMicroelectronics:stm32:GenF4:pnum=BLACKPILL_F401CC': 'Black Pill (STM32F401CC)',
'STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F103RB': 'Nucleo-64 F103RB',
'STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F401RE': 'Nucleo-64 F401RE',
'STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE': 'Nucleo-64 F446RE',
};
// ── Arduino board listall (FQBN 목록) ────────────────────────────────────────
app.get('/api/arduino/boards/listall', async (req, res) => {
const sess = getSessionUser(req);
@@ -223,26 +235,28 @@ app.get('/api/arduino/boards/listall', async (req, res) => {
let boards: BoardEntry[];
if (q) {
// 검색: 전체에서 매칭
// 검색: 전체에서 매칭 (STM32 포함)
boards = allBoards.filter(b => b.name.toLowerCase().includes(q) || b.fqbn.toLowerCase().includes(q));
} else {
// 기본 목록: ESP32/ESP8266은 화이트리스트만, 나머지(AVR)는 전체
const esp32Featured = Object.entries(_ESP32_FEATURED)
.map(([fqbn, label]) => {
// 기본 목록: ESP32/ESP8266/STM32는 화이트리스트만, 나머지(AVR)는 전체
const makeFeatured = (map: Record<string, string>): BoardEntry[] =>
Object.entries(map).map(([fqbn, label]) => {
// featured는 pnum 포함 FQBN이라 allBoards에 없을 수 있음 → 직접 생성
const found = allBoards.find(b => b.fqbn === fqbn);
if (!found) return null;
return { ...found, name: label };
})
.filter(Boolean) as BoardEntry[];
const esp8266Featured = Object.entries(_ESP8266_FEATURED)
.map(([fqbn, label]) => {
const found = allBoards.find(b => b.fqbn === fqbn);
if (!found) return null;
return { ...found, name: label };
})
.filter(Boolean) as BoardEntry[];
const others = allBoards.filter(b => !b.fqbn.startsWith('esp32:') && !b.fqbn.startsWith('esp8266:'));
boards = [...esp32Featured, ...esp8266Featured, ...others];
const chip = _detectChip(fqbn);
return found
? { ...found, name: label }
: { fqbn, name: label, chip, qemu: _qemuForChip(chip), variants: 1 };
});
const esp32Featured = makeFeatured(_ESP32_FEATURED);
const esp8266Featured = makeFeatured(_ESP8266_FEATURED);
const stm32Featured = makeFeatured(_STM32_FEATURED);
const others = allBoards.filter(b =>
!b.fqbn.startsWith('esp32:') &&
!b.fqbn.startsWith('esp8266:') &&
!b.fqbn.startsWith('STMicroelectronics:')
);
boards = [...esp32Featured, ...esp8266Featured, ...stm32Featured, ...others];
}
// 정렬: esp32:esp32:esp32 최상단 고정, 나머지 알파벳순
@@ -1802,4 +1816,286 @@ app.post('/api/arduino/upload', async (req, res) => {
}
});
// ── Renode STM32 emulator sessions ───────────────────────────────────────────
const _renodePath = require('os').homedir() + '/.local/lib/renode/renode';
const _renodeRoot = require('os').homedir() + '/.local/lib/renode';
interface STM32RenodeInfo { baseRepl: string; uart: string; svdTag: string }
function _stm32RenodeInfo(fqbn: string): STM32RenodeInfo {
const parts = fqbn.split(':');
const board = (parts[2] || '').toLowerCase();
const opts = (parts[3] || '').toLowerCase();
if (board.startsWith('genf4') || opts.includes('f4') || opts.includes('f401') || opts.includes('f411') || opts.includes('f446') || opts.includes('f407'))
return { baseRepl: 'stm32f4.repl', uart: 'usart2', svdTag: '' };
if (opts.includes('f429'))
return { baseRepl: 'stm32f429.repl', uart: 'usart2', svdTag: '' };
if (board.startsWith('genf0') || opts.includes('f0') || opts.includes('f072') || opts.includes('f042'))
return { baseRepl: 'stm32f072.repl', uart: 'usart1', svdTag: '' };
return { baseRepl: 'cpus/stm32f103.repl', uart: 'usart1', svdTag: '' };
}
function _buildCustomRepl(tmpDir: string, info: STM32RenodeInfo): string {
// stm32f103_fixed.repl: built-in + PWR Python mock (HAL polling 루프 픽스)
const fixedReplPath = '/home/kim/homeclaw/stm32f103_fixed.repl';
if (require('fs').existsSync(fixedReplPath)) {
const replPath = tmpDir + '/platform.repl';
require('fs').copyFileSync(fixedReplPath, replPath);
return replPath;
}
// Fallback: built-in repl
const builtInReplPath = _renodeRoot + '/platforms/' + info.baseRepl;
if (require('fs').existsSync(builtInReplPath)) {
const replPath = tmpDir + '/platform.repl';
require('fs').copyFileSync(builtInReplPath, replPath);
return replPath;
}
// Last resort: minimal repl with Python mocks
const rccPy = _renodeRoot + '/scripts/pydev/stm32f1_rcc.py';
const flashPy = _renodeRoot + '/scripts/pydev/stm32f1_flash.py';
const pwrPy = _renodeRoot + '/scripts/pydev/stm32f1_pwr.py';
const content = `sram: Memory.MappedMemory @ sysbus 0x20000000
size: 0x10000
flash: Memory.MappedMemory @ sysbus 0x08000000
size: 0x20000
usart1: UART.STM32_UART @ sysbus <0x40013800, +0x100>
-> nvic@37
nvic: IRQControllers.NVIC @ sysbus 0xE000E000
priorityMask: 0xF0
systickFrequency: 72000000
IRQ -> cpu@0
cpu: CPU.CortexM @ sysbus
cpuType: "cortex-m3"
nvic: nvic
rcc: Python.PythonPeripheral @ sysbus 0x40021000
size: 0x400
initable: true
filename: "${rccPy}"
flash_ctrl: Python.PythonPeripheral @ sysbus 0x40022000
size: 0x400
initable: true
filename: "${flashPy}"
pwr: Python.PythonPeripheral @ sysbus 0x40007000
size: 0x400
initable: true
filename: "${pwrPy}"
`;
const replPath = tmpDir + '/platform.repl';
require('fs').writeFileSync(replPath, content);
return replPath;
}
function _freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = require('net').createServer();
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as any).port;
srv.close(() => resolve(port));
});
srv.on('error', reject);
});
}
interface RenodeSession {
proc: any | null;
socket: any | null;
tmpDir: string;
fqbn: string;
sseConns: Set<any>;
timer: ReturnType<typeof setTimeout>;
idleTimer?: ReturnType<typeof setTimeout>;
}
const renodeSessions = new Map<string, RenodeSession>();
function killRenodeSession(id: string) {
const s = renodeSessions.get(id);
if (!s) return;
renodeSessions.delete(id);
clearTimeout(s.timer);
if (s.idleTimer) clearTimeout(s.idleTimer);
if (s.socket) { try { s.socket.destroy(); } catch {} }
if (s.proc) { try { s.proc.kill(); } catch {} }
try { require('fs').rmSync(s.tmpDir, { recursive: true, force: true }); } catch {}
}
app.post('/api/arduino/renode/start', async (req, res) => {
const sess = getSessionUser(req);
if (!sess) return res.status(401).json({ error: 'Not authenticated' });
const { code, fqbn: rawFqbn } = req.body || {};
if (!code) return res.status(400).json({ error: 'code가 필요합니다' });
if (!require('fs').existsSync(_renodePath))
return res.json({ error: 'Renode가 설치되지 않았습니다 (~/.local/lib/renode/)' });
const id = 'renode_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
const tmpDir = require('os').tmpdir() + '/' + id;
require('fs').mkdirSync(tmpDir, { recursive: true });
const fqbn = rawFqbn && /^[\w\-:.]+$/.test(rawFqbn) ? rawFqbn : 'STMicroelectronics:stm32:GenF1:pnum=BLUEPILL_F103C8';
try {
const cliPath = await getArduinoCli();
if (!cliPath) throw new Error('arduino-cli 없음');
const sketchDir = tmpDir + '/sketch';
const outDir = tmpDir + '/out';
require('fs').mkdirSync(sketchDir);
require('fs').mkdirSync(outDir);
// while(!Serial) 제거 — STM32에서 USB CDC 없으면 무한 루프
let patchedCode = code.replace(/while\s*\(\s*!\s*Serial\s*\)\s*\{[^}]*\}/g, '');
patchedCode = patchedCode.replace(/while\s*\(\s*!\s*Serial\s*\)\s*;/g, '');
patchedCode = patchedCode.replace(/while\s*\(\s*!\s*Serial\s*\)\s*delay\s*\(\s*\d+\s*\)\s*;/g, '');
// SystemClock_Config() 제거 — HAL이 비트밴드(0x42xxxxxx)로 RCC에 접근하는데
// Renode가 비트밴드를 처리 못해서 무한 루프 → 호출 제거 (Renode는 클럭 설정 불필요)
patchedCode = patchedCode.replace(/SystemClock_Config\s*\(\s*\)\s*;/g, '/* SystemClock_Config skipped for Renode */;');
require('fs').writeFileSync(sketchDir + '/sketch.ino', patchedCode);
const execQ = (cmd: string, ms = 60000): Promise<{ stdout: string; stderr: string }> =>
new Promise(r => require('child_process').exec(cmd, { timeout: ms, maxBuffer: 32 * 1024 * 1024 },
(e: any, o: string, err: string) => r({ stdout: o || '', stderr: err || '' })));
const coreId = fqbn.split(':').slice(0, 2).join(':');
const installedOut = await execQ(`"${cliPath}" core list --format json`);
const installedIds: string[] = JSON.parse(installedOut.stdout || '{}')?.platforms?.map((p: any) => p.id) || [];
if (!installedIds.includes(coreId)) {
await execQ(`"${cliPath}" core install "${coreId}"`, 300000);
}
const compileOut = await execQ(`"${cliPath}" compile --fqbn "${fqbn}" --output-dir "${outDir}" "${sketchDir}"`, 120000);
const outFiles = require('fs').readdirSync(outDir) as string[];
const elfFile = outFiles.find(f => f.endsWith('.elf'));
const binFile = outFiles.find(f => f.endsWith('.bin') && !f.endsWith('.merged.bin') && !f.endsWith('_boot.bin'));
if (!elfFile && !binFile) {
require('fs').rmSync(tmpDir, { recursive: true, force: true });
return res.json({ error: compileOut.stderr || compileOut.stdout || '컴파일 실패' });
}
const fwPath = elfFile ? outDir + '/' + elfFile : outDir + '/' + binFile;
const loadCmd = elfFile ? `sysbus LoadELF @${fwPath}` : `sysbus LoadBinary @${fwPath} 0x08000000`;
const uartPort = await _freePort();
const info = _stm32RenodeInfo(fqbn);
const replPath = _buildCustomRepl(tmpDir, info);
const resc = [
`emulation CreateServerSocketTerminal ${uartPort} "uart_term" false`,
'',
'using sysbus',
'mach create "STM32"',
`machine LoadPlatformDescription @${replPath}`,
loadCmd,
`connector Connect sysbus.${info.uart} uart_term`,
'',
'start',
].join('\n');
require('fs').writeFileSync(tmpDir + '/run.resc', resc);
const proc = require('child_process').spawn(
_renodePath,
['--disable-xwt', '--console', tmpDir + '/run.resc'],
{ stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } }
);
const session: RenodeSession = {
proc, socket: null, tmpDir, fqbn,
sseConns: new Set(),
timer: setTimeout(() => killRenodeSession(id), 30 * 60 * 1000),
};
renodeSessions.set(id, session);
proc.stdout.on('data', () => {});
proc.stderr.on('data', (d: Buffer) => {
const txt = d.toString('utf-8');
console.error('[Renode]', txt.trim());
const msg = 'data: ' + JSON.stringify({ serial: '[Renode] ' + txt }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
proc.on('exit', (code: number | null) => {
console.error('[Renode] exited', code);
const msg = 'data: ' + JSON.stringify({ exit: code ?? 0 }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
res.json({ id });
// UART 소켓 연결 (포트 열릴 때까지 재시도)
const connectUart = (retries = 60): Promise<any> => new Promise((resolve, reject) => {
const sock = require('net').connect(uartPort, '127.0.0.1', () => resolve(sock));
sock.once('error', (err: Error) => {
sock.destroy();
if (retries <= 0) return reject(new Error('UART 소켓 연결 타임아웃'));
setTimeout(() => connectUart(retries - 1).then(resolve, reject), 500);
});
});
connectUart().then(sock => {
console.log(`[Renode UART] connected: ${id}, sseConns=${session.sseConns.size}`);
session.socket = sock;
const connMsg = 'data: ' + JSON.stringify({ serial: '[Renode] UART 연결됨\n' }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(connMsg); } catch {}
sock.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf-8');
console.log(`[Renode UART] ${text.length}B → ${session.sseConns.size} conns`);
const msg = 'data: ' + JSON.stringify({ serial: text }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
sock.on('close', () => {
console.log(`[Renode UART] closed: ${id}`);
const msg = 'data: ' + JSON.stringify({ exit: 0 }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
sock.on('error', (err: Error) => {
const msg = 'data: ' + JSON.stringify({ serial: `[UART 오류] ${err.message}\n` }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
}).catch((e: Error) => {
console.error(`[Renode UART] connectUart failed:`, e.message);
const msg = 'data: ' + JSON.stringify({ serial: `[오류] ${e.message}\n` }) + '\n\n';
for (const conn of session.sseConns) try { conn.write(msg); } catch {}
});
} catch (e: any) {
require('fs').rmSync(tmpDir, { recursive: true, force: true });
res.json({ error: e.message });
}
});
app.get('/api/arduino/renode/:id/output', (req, res) => {
const id = req.params.id;
const s = renodeSessions.get(id);
if (!s) return res.status(404).json({ error: 'session not found' });
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('\n');
if (s.socket) res.write('data: ' + JSON.stringify({ serial: '[Renode] UART 연결됨\n' }) + '\n\n');
s.sseConns.add(res);
if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = undefined; }
req.on('close', () => {
s.sseConns.delete(res);
if (s.sseConns.size === 0)
s.idleTimer = setTimeout(() => killRenodeSession(id), 5 * 60 * 1000);
});
});
app.post('/api/arduino/renode/:id/input', (req, res) => {
const id = req.params.id;
const s = renodeSessions.get(id);
if (!s) return res.status(404).json({ error: 'session not found' });
const text: string = (req.body as any)?.text || '';
if (s.socket && text) s.socket.write(Buffer.from(text, 'utf-8'));
res.json({ ok: true });
});
app.delete('/api/arduino/renode/:id', (req, res) => {
killRenodeSession(req.params.id);
res.json({ ok: true });
});
}
+229
View File
@@ -0,0 +1,229 @@
// STM32F103 fixed platform — PWR Python mock 추가로 HAL 폴링 루프 해결
// SVD 태그는 writes를 무시해서 HAL_PWR_EnableBkUpAccess()가 무한루프에 빠짐
// Python mock은 write를 저장하고 read에서 되돌려줘서 HAL이 정상 진행됨
fsmcBank1: Memory.MappedMemory @ sysbus 0x60000000
size: 0x10000000
sram: Memory.MappedMemory @ sysbus 0x20000000
size: 0x5000
flash: Memory.MappedMemory @ sysbus 0x00000000
size: 0x20000000
usart1: UART.STM32_UART @ sysbus <0x40013800, +0x100>
-> nvic@37
usart2: UART.STM32_UART @ sysbus <0x40004400, +0x100>
-> nvic@38
usart3: UART.STM32_UART @ sysbus <0x40004800, +0x100>
-> nvic@39
usart4: UART.STM32_UART @ sysbus <0x40004C00, +0x100>
-> nvic@52
usart5: UART.STM32_UART @ sysbus <0x40005000, +0x100>
-> nvic@53
nvic: IRQControllers.NVIC @ sysbus 0xE000E000
priorityMask: 0xF0
systickFrequency: 8000000
IRQ -> cpu@0
cpu: CPU.CortexM @ sysbus
cpuType: "cortex-m3"
nvic: nvic
i2c1: I2C.STM32F4_I2C @ sysbus 0x40005400
EventInterrupt -> nvic@31
ErrorInterrupt -> nvic@32
i2c2: I2C.STM32F4_I2C @ sysbus 0x40005800
EventInterrupt -> nvic@33
ErrorInterrupt -> nvic@34
exti: IRQControllers.STM32F4_EXTI @ sysbus 0x40010400
numberOfOutputLines: 19
[0-4] -> nvic@[6-10]
[5-9] -> nvicInput23@[0-4]
[10-15] -> nvicInput40@[0-5]
nvicInput23: Miscellaneous.CombinedInput @ none
numberOfInputs: 5
-> nvic@23
nvicInput40: Miscellaneous.CombinedInput @ none
numberOfInputs: 6
-> nvic@40
gpioPortA: GPIOPort.STM32F1GPIOPort @ sysbus <0x40010800, +0x400>
invertedAFPins: [[7, 1]]
[0-15] -> exti@[0-15]
gpioPortB: GPIOPort.STM32F1GPIOPort @ sysbus <0x40010C00, +0x400>
invertedAFPins: [[13, 1], [14, 1], [15, 1], [0, 1], [1, 1]]
[0-15] -> exti@[0-15]
gpioPortC: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011000, +0x400>
[0-15] -> exti@[0-15]
gpioPortD: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011400, +0x400>
[0-15] -> exti@[0-15]
gpioPortE: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011800, +0x400>
invertedAFPins: [[8, 1], [10, 1], [12, 1]]
[0-15] -> exti@[0-15]
gpioPortF: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011C00, +0x400>
[0-15] -> exti@[0-15]
gpioPortG: GPIOPort.STM32F1GPIOPort @ sysbus <0x40012000, +0x400>
[0-15] -> exti@[0-15]
timer1: Timers.STM32_Timer @ sysbus 0x40012c00
BreakInterrupt -> nvic@24
UpdateInterrupt -> nvic@25
[TriggerInterrupt, CommutationInterrupt] -> nvic@[26, 26]
CaptureCompareInterrupt -> nvic@27
frequency: 10000000
initialLimit: 0xFFFF
timer2: Timers.STM32_Timer @ sysbus 0x40000000
-> nvic@28
frequency: 10000000
initialLimit: 0xFFFF
timer3: Timers.STM32_Timer @ sysbus 0x40000400
-> nvic@29
frequency: 10000000
initialLimit: 0xFFFF
timer4: Timers.STM32_Timer @ sysbus 0x40000800
-> nvic@30
frequency: 10000000
initialLimit: 0xFFFF
timer5: Timers.STM32_Timer @ sysbus 0x40000C00
-> nvic@50
frequency: 10000000
initialLimit: 0xFFFF
timer6: Timers.STM32_Timer @ sysbus 0x40001000
-> nvic@54
frequency: 10000000
initialLimit: 0xFFFF
timer7: Timers.STM32_Timer @ sysbus 0x40001400
-> nvic@55
frequency: 10000000
initialLimit: 0xFFFF
timer8: Timers.STM32_Timer @ sysbus 0x40013400
BreakInterrupt -> nvic@43
UpdateInterrupt -> nvic@44
[TriggerInterrupt, CommutationInterrupt] -> nvic@[45, 45]
CaptureCompareInterrupt -> nvic@46
frequency: 10000000
initialLimit: 0xFFFF
timer9: Timers.STM32_Timer @ sysbus 0x40014c00
-> nvic@24
frequency: 10000000
initialLimit: 0xFFFF
timer10: Timers.STM32_Timer @ sysbus 0x40015000
-> nvic@25
frequency: 10000000
initialLimit: 0xFFFF
timer11: Timers.STM32_Timer @ sysbus 0x40015400
-> nvic@26
frequency: 10000000
initialLimit: 0xFFFF
timer12: Timers.STM32_Timer @ sysbus 0x40001800
-> nvic@43
frequency: 10000000
initialLimit: 0xFFFF
timer13: Timers.STM32_Timer @ sysbus 0x40001C00
-> nvic@44
frequency: 10000000
initialLimit: 0xFFFF
timer14: Timers.STM32_Timer @ sysbus 0x40002000
-> nvic@45
frequency: 10000000
initialLimit: 0xFFFF
afio: GPIOPort.STM32F1AFIO @ sysbus 0x40010000
gpioPorts: [gpioPortA, gpioPortB, gpioPortC, gpioPortD, gpioPortE, gpioPortF, gpioPortG]
timer1:
0 -> gpioPortA#08@01 | gpioPortE#09@01 | gpioPortB#13@01 | gpioPortA#07@01 | gpioPortE#08@01
1 -> gpioPortA#09@01 | gpioPortE#11@01 | gpioPortB#14@01 | gpioPortB#00@01 | gpioPortE#10@01
2 -> gpioPortA#10@01 | gpioPortE#13@01 | gpioPortB#15@01 | gpioPortB#01@01 | gpioPortE#12@01
3 -> gpioPortA#11@01 | gpioPortE#14@01
timer2:
0 -> gpioPortA#00@02 | gpioPortA#15@02
1 -> gpioPortA#01@02 | gpioPortB#03@02
2 -> gpioPortA#02@02 | gpioPortB#10@02
3 -> gpioPortA#03@02 | gpioPortB#11@02
timer3:
0 -> gpioPortA#06@03 | gpioPortB#04@03 | gpioPortC#06@03
1 -> gpioPortA#07@03 | gpioPortB#05@03 | gpioPortC#07@03
2 -> gpioPortB#00@03 | gpioPortC#08@03
3 -> gpioPortB#01@03 | gpioPortC#09@03
timer4:
0 -> gpioPortB#06@04 | gpioPortD#12@04
1 -> gpioPortB#07@04 | gpioPortD#13@04
2 -> gpioPortB#08@04 | gpioPortD#14@04
3 -> gpioPortB#09@04 | gpioPortD#15@04
timer5:
3 -> gpioPortA#03@05
timer9:
0 -> gpioPortA#02@09 | gpioPortE#05@09
1 -> gpioPortA#03@09 | gpioPortE#06@09
timer10:
0 -> gpioPortB#08@10 | gpioPortF#06@10
timer11:
0 -> gpioPortB#09@11 | gpioPortF#07@11
timer13:
0 -> gpioPortA#06@13 | gpioPortF#08@13
timer14:
0 -> gpioPortA#07@14 | gpioPortF#09@14
// 비트밴드: Cortex-M3 bit-band 영역을 실제 주변장치 주소로 변환
bitbandPeripherals: Miscellaneous.BitBanding @ sysbus <0x42000000, +0x2000000>
peripheralBase: 0x40000000
// Python mocks: SVD 태그 대신 실제 write-echo 구현으로 HAL 폴링 정상화
rcc: Python.PythonPeripheral @ sysbus 0x40021000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_rcc.py"
flash_ctrl: Python.PythonPeripheral @ sysbus 0x40022000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_flash.py"
pwr: Python.PythonPeripheral @ sysbus 0x40007000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_pwr.py"
sysbus:
init:
ApplySVD @/home/kim/.local/lib/renode/svd/STM32F103.svd.gz
+75
View File
@@ -3019,6 +3019,77 @@ function initEsp32Mode(sessionId, fname, fqbn) {
_adcShowSection();
}
// ── STM32 Renode mode ─────────────────────────────────────────────────────────
let _renodeSessionId = null;
let _renodeEvtSource = null;
function initSTM32RenodeMode(sessionId, fname, fqbn) {
_renodeSessionId = sessionId;
document.querySelector('.header h1').textContent = '🖥️ STM32 Emulator (Renode)';
document.getElementById('filename').textContent = fname;
document.getElementById('chip-badge')?.remove();
const badge = document.createElement('span');
badge.id = 'chip-badge';
badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;';
const family = fqbn.includes('GenF4') || fqbn.includes('f4') ? 'STM32F4' : 'STM32F1';
badge.textContent = family + ' · Renode';
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
document.getElementById('filename').after(badge);
document.querySelector('.board-svg')?.style && (document.querySelector('.board-svg').style.display = 'none');
document.querySelector('.board-title')?.style && (document.querySelector('.board-title').style.display = 'none');
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
document.getElementById('renode-stop-btn')?.remove();
const toolbar = document.querySelector('.toolbar-right') || document.querySelector('.toolbar');
if (toolbar) {
const stopBtn = document.createElement('button');
stopBtn.id = 'renode-stop-btn'; stopBtn.textContent = '■ 정지';
stopBtn.style.cssText = 'font-size:12px;padding:3px 8px;background:#3a1010;border:1px solid #cc4444;color:#ff8888;border-radius:4px;cursor:pointer';
stopBtn.onclick = () => {
fetch('/api/arduino/renode/' + _renodeSessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
if (_renodeEvtSource) { _renodeEvtSource.close(); _renodeEvtSource = null; }
setStatus('off', '정지됨');
};
toolbar.append(stopBtn);
}
const serialInput = document.getElementById('serial-input');
const serialSend = document.querySelector('.serial-send');
if (serialInput) serialInput.disabled = false;
if (serialSend) serialSend.removeAttribute('disabled');
const doSend = () => {
const text = serialInput?.value || '';
if (!text) return;
fetch('/api/arduino/renode/' + _renodeSessionId + '/input', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text + '\n' }),
}).catch(() => {});
if (serialInput) serialInput.value = '';
};
serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSend(); });
serialSend?.addEventListener('click', doSend);
setStatus('loading', 'Renode 기동 중...');
_renodeEvtSource = new EventSource('/api/arduino/renode/' + sessionId + '/output', { withCredentials: true });
_renodeEvtSource.onopen = () => setStatus('running', '실행 중');
_renodeEvtSource.onerror = () => setStatus('error', '연결 오류');
_renodeEvtSource.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.serial) appendSerial(d.serial);
if (d.exit !== undefined) setStatus('off', '종료');
} catch {}
};
window.addEventListener('beforeunload', () => {
fetch('/api/arduino/renode/' + sessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
});
}
function _buildEsp32PinPopup() {
const body = document.getElementById('pin-popup-body');
if (!body) return;
@@ -4231,6 +4302,10 @@ async function boot() {
const savedSketch = localStorage.getItem('esp8266_sketch');
if (savedSketch) { _esp8266SketchCode = savedSketch; localStorage.removeItem('esp8266_sketch'); }
initEsp8266Mode(qemuSession, fname, fqbn);
} else if (qemuSession.startsWith('renode_')) {
const savedSketch = localStorage.getItem('stm32_sketch');
if (savedSketch) { localStorage.removeItem('stm32_sketch'); }
initSTM32RenodeMode(qemuSession, fname, fqbn);
} else {
// Restore sketch code from code editor (localStorage: shared across windows)
const savedSketch = localStorage.getItem('esp32_sketch');
+37 -6
View File
@@ -610,6 +610,7 @@ function _termOnRunSuccess(shellId) {
// ── Companion WebSocket (local Python execution) ───────────────────────────
let companionWs = null;
let companionConnected = false;
let companionEverConnected = false; // 한 번이라도 성공한 적 있는지
const COMPANION_URL = 'ws://127.0.0.1:9000';
let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready
let companionHome = ''; // home directory from companion
@@ -618,11 +619,12 @@ const localShellIds = new Set(); // shell IDs routed to companion PTY (tab id =
function companionConnect() {
if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return;
try { companionWs = new WebSocket(COMPANION_URL); } catch { return; }
companionWs.onopen = () => { companionConnected = true; updateCompanionStatus(true); };
companionWs.onopen = () => { companionConnected = true; companionEverConnected = true; updateCompanionStatus(true); };
companionWs.onclose = () => {
companionConnected = false; companionWs = null;
updateCompanionStatus(false);
setTimeout(companionConnect, 5000);
// 한 번이라도 연결됐던 경우에만 자동 재연결 (미연결 상태서 콘솔 에러 spam 방지)
if (companionEverConnected) setTimeout(companionConnect, 5000);
};
companionWs.onerror = () => { companionConnected = false; };
companionWs.onmessage = (ev) => {
@@ -1009,6 +1011,29 @@ function openArduinoEmulator() {
toggleEmuPanel();
});
} else if (fqbn.startsWith('STMicroelectronics:stm32')) {
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
_emuPanelLoad('/arduino-emulator.html?loading=1', `STM32 — ${file.name}`);
codeAiAppend('system', `🖥️ STM32 Renode 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
try { localStorage.setItem('stm32_sketch', content); } catch {}
api('/api/arduino/renode/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
.then(d => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
if (d.error) {
sendCompileErrorToAI(d.error, file.name, content);
toggleEmuPanel();
return;
}
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
_emuPanelLoad(url, `STM32 — ${file.name}`);
codeAiAppend('system', `✅ STM32 Renode 에뮬레이터 실행 중.`);
}).catch(e => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
} else {
// AVR 및 기타
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
@@ -1264,7 +1289,7 @@ const CODE_RUN_MODES = {
{ id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' },
],
ino: [
{ id: 'arduino', icon: '🔌', label: 'Arduino 에뮬레이터', desc: 'AVR 브라우저 에뮬레이터 (avr8js)' },
{ id: 'arduino', icon: '🔌', label: '에뮬레이터', desc: 'AVR/STM32/ESP 에뮬레이터 (보드에 따라 자동 선택)' },
{ id: 'esp32', icon: '⚡', label: 'ESP32 플래시', desc: 'Web Serial로 ESP32에 업로드' },
{ id: 'terminal', icon: '⌨', label: 'arduino-cli', desc: '터미널에서 빌드/업로드' },
],
@@ -1485,8 +1510,9 @@ function codeRunWith(mode) {
if (mode === 'arduino') {
const _isEsp32Board = (_csbSelBoard.fqbn.split(':')[1] || '') === 'esp32';
const _isEsp8266Board = (_csbSelBoard.fqbn.split(':')[0] || '') === 'esp8266';
if (_isEsp32Board || _isEsp8266Board) {
// ESP32/ESP8266 계열 — 팝업 차단 방지를 위해 openArduinoEmulator()와 같은 경로 사용
const _isStm32Board = _csbSelBoard.fqbn.startsWith('STMicroelectronics:stm32');
if (_isEsp32Board || _isEsp8266Board || _isStm32Board) {
// ESP32/ESP8266/STM32 계열 — openArduinoEmulator()로 에뮬레이터 실행
openArduinoEmulator(); return;
}
codeAiAppend('system', `🔌 컴파일 중... (${file.name}) [${_csbSelBoard.name}]`);
@@ -4753,6 +4779,9 @@ function _csbBoardSelSave(board) {
if (board.qemu === 'esp8266-native') {
badge.textContent = '🖥 Native'; badge.style.display = 'inline';
badge.style.background = '#0c3a4a'; badge.style.color = '#67e8f9'; badge.style.borderColor = '#0e7490';
} else if (board.qemu === 'renode') {
badge.textContent = '🖥 Renode'; badge.style.display = 'inline';
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
} else if (board.qemu) {
badge.textContent = '🖥 QEMU'; badge.style.display = 'inline';
badge.style.background = board.qemu === 'esp32s3' ? '#1a3a5a' : '#1a3a1a';
@@ -4794,7 +4823,9 @@ async function csbBoardSelSearch() {
const sel = b.fqbn === _csbSelBoard.fqbn;
const qemuBadge = b.qemu === 'esp8266-native'
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#0c3a4a;color:#67e8f9;border:1px solid #0e7490">🖥 Native</span>`
: b.qemu
: b.qemu === 'renode'
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#1a0a3a;color:#c084fc;border:1px solid #7c3aed">🖥 Renode</span>`
: b.qemu
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;
background:${b.qemu==='esp32s3'?'#1a3a5a':'#1a3a1a'};
color:${b.qemu==='esp32s3'?'#60a5fa':'#22c55e'};