import * as Blockly from 'blockly'; import { pythonGenerator } from 'blockly/python'; import './blocks/esp32_blocks.js'; import './blocks/esp32_generators.js'; import { getDeviceId, setDeviceId, getDevice, canFlashInBrowser, buildToolbox, } from './devices/registry.js'; import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js'; import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js'; import { flashFirmware } from './serial/flasher.js'; import { appendToTerminal, clearTerminal } from './ui/terminal.js'; import { initResizablePanels } from './ui/panels.js'; import './style.css'; // ─── Blockly Workspace ─────────────────────────────────── const workspace = Blockly.inject('blockly-div', { toolbox: buildToolbox(getDeviceId()), theme: Blockly.Themes.Dark, grid: { spacing: 25, length: 3, colour: '#333', snap: true }, zoom: { controls: true, wheel: true, startScale: 0.9, maxScale: 3, minScale: 0.3, scaleSpeed: 1.2 }, trashcan: true, renderer: 'zelos', }); // ─── Live Code Preview ─────────────────────────────────── const codeOutput = document.getElementById('code-output'); function updateCodePreview() { const code = pythonGenerator.workspaceToCode(workspace); codeOutput.textContent = code || '# Drag blocks to generate MicroPython code'; } workspace.addChangeListener((event) => { if (event.isUiEvent) return; updateCodePreview(); }); updateCodePreview(); // ─── Workspace Persistence (localStorage) ──────────────── const STORAGE_KEY = 'esp32block_workspace'; function saveWorkspace() { const state = Blockly.serialization.workspaces.save(workspace); localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function loadWorkspace() { const json = localStorage.getItem(STORAGE_KEY); if (json) { try { const state = JSON.parse(json); Blockly.serialization.workspaces.load(state, workspace); } catch (_) { /* corrupted state, ignore */ } } } workspace.addChangeListener((event) => { if (event.isUiEvent) return; saveWorkspace(); }); loadWorkspace(); // ─── Resize Handling ───────────────────────────────────── function onResize() { const blocklyArea = document.getElementById('blockly-area'); const blocklyDiv = document.getElementById('blockly-div'); blocklyDiv.style.width = blocklyArea.offsetWidth + 'px'; blocklyDiv.style.height = blocklyArea.offsetHeight + 'px'; Blockly.svgResize(workspace); } window.addEventListener('resize', onResize); onResize(); initResizablePanels(); // ─── UI State Helpers ──────────────────────────────────── const deviceSelect = document.getElementById('device-select'); const btnConnect = document.getElementById('btn-connect'); const btnFlash = document.getElementById('btn-flash'); const btnRun = document.getElementById('btn-run'); const btnStop = document.getElementById('btn-stop'); const btnSave = document.getElementById('btn-save'); const btnSaveWorkspace = document.getElementById('btn-save-workspace'); const btnLoadWorkspace = document.getElementById('btn-load-workspace'); const statusEl = document.getElementById('connection-status'); const terminalInput = document.getElementById('terminal-input'); // Sync device dropdown with stored device deviceSelect.value = getDeviceId(); deviceSelect.addEventListener('change', () => { setDeviceId(deviceSelect.value); workspace.updateToolbox(buildToolbox(getDeviceId())); updateCodePreview(); // Update Flash button tooltip/label based on device btnFlash.title = canFlashInBrowser() ? 'Flash MicroPython firmware' : 'Download firmware (drag to device)'; }); btnFlash.title = canFlashInBrowser() ? 'Flash MicroPython firmware' : 'Download firmware (drag to device)'; function setConnectedUI(connected) { btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect'; btnRun.disabled = !connected; btnStop.disabled = !connected; btnSave.disabled = !connected; btnSaveWorkspace.disabled = !connected; btnLoadWorkspace.disabled = !connected; terminalInput.disabled = !connected; statusEl.textContent = connected ? 'Connected' : 'Disconnected'; statusEl.className = connected ? 'status-connected' : 'status-disconnected'; } // ─── Serial Event Listeners ────────────────────────────── // Workspace loading state let workspaceCaptureState = null; onData((text) => { if (!workspaceCaptureState) { appendToTerminal(text); return; } const { startMarker, endMarker } = workspaceCaptureState; workspaceCaptureState.raw += text; const raw = workspaceCaptureState.raw; const startIdx = raw.indexOf(startMarker); if (startIdx === -1) { // No start marker yet — flush text that can't be part of the marker const keep = startMarker.length - 1; if (raw.length > keep) { appendToTerminal(raw.substring(0, raw.length - keep)); workspaceCaptureState.raw = raw.substring(raw.length - keep); } return; } const contentStart = startIdx + startMarker.length; const endIdx = raw.indexOf(endMarker, contentStart); if (endIdx === -1) { // Have start but no end yet — show text before start marker once if (!workspaceCaptureState.flushedPre && startIdx > 0) { appendToTerminal(raw.substring(0, startIdx)); workspaceCaptureState.flushedPre = true; } return; } // Both markers found — extract content and load const jsonContent = raw.substring(contentStart, endIdx).trim(); const beforeStart = startIdx > 0 && !workspaceCaptureState.flushedPre ? raw.substring(0, startIdx) : ''; const afterEnd = raw.substring(endIdx + endMarker.length); workspaceCaptureState = null; if (beforeStart) appendToTerminal(beforeStart); try { const state = JSON.parse(jsonContent); Blockly.serialization.workspaces.load(state, workspace); appendToTerminal('Workspace loaded successfully!\n'); } catch (parseErr) { appendToTerminal(`\nParse error: ${parseErr.message}\n`); } if (afterEnd.trim()) appendToTerminal(afterEnd); }); // ─── Toolbar Buttons ───────────────────────────────────── btnConnect.addEventListener('click', async () => { try { if (isConnected()) { await disconnect(); setConnectedUI(false); appendToTerminal('\n--- Disconnected ---\n'); } else { await connect(); setConnectedUI(true); appendToTerminal('--- Connected ---\n'); } } catch (err) { appendToTerminal(`\nConnection error: ${err.message}\n`); } }); const flashOverlay = document.getElementById('flash-overlay'); const flashLog = document.getElementById('flash-log'); const flashFill = document.getElementById('flash-progress-fill'); const flashPctText = document.getElementById('flash-progress-text'); const flashCloseBtn = document.getElementById('flash-close'); function showFlashOverlay() { flashLog.textContent = ''; flashFill.style.width = '0%'; flashPctText.textContent = '0%'; flashCloseBtn.classList.add('hidden'); flashOverlay.classList.remove('hidden'); } function appendFlashLog(msg) { flashLog.textContent += msg; flashLog.scrollTop = flashLog.scrollHeight; } function setFlashProgress(pct) { flashFill.style.width = pct + '%'; flashPctText.textContent = pct + '%'; } flashCloseBtn.addEventListener('click', () => { flashOverlay.classList.add('hidden'); }); btnFlash.addEventListener('click', async () => { if (isConnected()) { await disconnect(); setConnectedUI(false); } const device = getDevice(); const fw = device.firmware; if (canFlashInBrowser()) { showFlashOverlay(); try { await flashFirmware( (msg) => appendFlashLog(msg), (pct) => setFlashProgress(pct), ); setFlashProgress(100); appendFlashLog('\nFlash complete! You can now Connect to use the device.\n'); } catch (err) { appendFlashLog(`\nFlash error: ${err.message}\n`); } finally { flashCloseBtn.classList.remove('hidden'); } } else { // Download firmware: open URL and show instructions window.open(fw.url, '_blank'); appendToTerminal(`\n--- Firmware: ${fw.label} ---\n`); appendToTerminal(`Download opened in new tab: ${fw.url}\n`); if (fw.instructions) { appendToTerminal(`${fw.instructions}\n`); } appendToTerminal('After flashing, connect here to run code.\n'); } }); btnRun.addEventListener('click', async () => { const code = pythonGenerator.workspaceToCode(workspace); if (!code.trim()) { appendToTerminal('\nNo code to run. Add some blocks!\n'); return; } appendToTerminal('\n>>> Running...\n'); try { await executeCode(code); } catch (err) { appendToTerminal(`\nRun error: ${err.message}\n`); } }); btnStop.addEventListener('click', async () => { try { await stopExecution(); appendToTerminal('\n--- Stopped ---\n'); } catch (err) { appendToTerminal(`\nStop error: ${err.message}\n`); } }); btnSave.addEventListener('click', async () => { const code = pythonGenerator.workspaceToCode(workspace); if (!code.trim()) { appendToTerminal('\nNo code to save.\n'); return; } appendToTerminal('\nSaving to device as main.py...\n'); try { await saveToDevice(code); } catch (err) { appendToTerminal(`\nSave error: ${err.message}\n`); } }); // ─── Workspace Save/Load ─────────────────────────────────── async function saveWorkspaceToDevice() { try { const state = Blockly.serialization.workspaces.save(workspace); const json = JSON.stringify(state); appendToTerminal('\nSaving workspace to device...\n'); await writeFileToDevice(json, 'workspace.json'); appendToTerminal('Workspace saved to workspace.json\n'); } catch (err) { appendToTerminal(`\nSave workspace error: ${err.message}\n`); } } async function loadWorkspaceFromDevice() { try { appendToTerminal('\nLoading workspace from device...\n'); // Use unique markers to identify workspace content const timestamp = Date.now(); const startMarker = `__WS_START_${timestamp}__`; const endMarker = `__WS_END_${timestamp}__`; workspaceCaptureState = { startMarker, endMarker, raw: '', flushedPre: false, }; const script = [ `try:`, ` f = open('workspace.json', 'r')`, ` data = f.read()`, ` f.close()`, ` print('${startMarker}')`, ` print(data, end='')`, ` print('${endMarker}')`, `except Exception as e:`, ` print('Error reading workspace.json: ' + str(e))`, ].join('\n'); await executeCode(script); // Clean up capture state after timeout setTimeout(() => { if (workspaceCaptureState) { appendToTerminal('\nTimeout waiting for workspace data\n'); workspaceCaptureState = null; } }, 10000); } catch (err) { appendToTerminal(`\nLoad workspace error: ${err.message}\n`); workspaceCaptureState = null; } } btnSaveWorkspace.addEventListener('click', async () => { await saveWorkspaceToDevice(); }); btnLoadWorkspace.addEventListener('click', async () => { await loadWorkspaceFromDevice(); }); terminalInput.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { const text = terminalInput.value; terminalInput.value = ''; await writeString(text + '\r\n'); } });