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) => { // Check if we're capturing workspace XML if (workspaceCaptureState) { const { startMarker, endMarker } = workspaceCaptureState; const startIdx = text.indexOf(startMarker); const endIdx = text.indexOf(endMarker); // PRIORITY 1: If already capturing, check for end marker first if (workspaceCaptureState.capturing) { if (endIdx !== -1) { // Found end marker - extract content workspaceCaptureState.buffer += text.substring(0, endIdx); const xmlContent = workspaceCaptureState.buffer.trim(); // Parse and load XML try { const xmlDom = Blockly.Xml.textToDom(xmlContent); Blockly.Xml.domToWorkspace(xmlDom, workspace); appendToTerminal('Workspace loaded successfully!\n'); } catch (parseErr) { appendToTerminal(`\nParse error: ${parseErr.message}\n`); } workspaceCaptureState = null; // Don't display the end marker, but show text after it const afterEnd = text.substring(endIdx + endMarker.length); if (afterEnd.trim()) { appendToTerminal(afterEnd); } return; } else { // Still capturing, accumulate buffer workspaceCaptureState.buffer += text; // Don't display captured content return; } } // PRIORITY 2: Both markers in the same chunk (not yet capturing) if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { const xmlContent = text.substring(startIdx + startMarker.length, endIdx); // Parse and load XML try { const xmlDom = Blockly.Xml.textToDom(xmlContent.trim()); Blockly.Xml.domToWorkspace(xmlDom, workspace); appendToTerminal('Workspace loaded successfully!\n'); } catch (parseErr) { appendToTerminal(`\nParse error: ${parseErr.message}\n`); } workspaceCaptureState = null; // Don't display the markers, but show text before/after const beforeStart = text.substring(0, startIdx); const afterEnd = text.substring(endIdx + endMarker.length); if (beforeStart.trim() || afterEnd.trim()) { appendToTerminal(beforeStart + afterEnd); } return; } // PRIORITY 3: Start marker found, start capturing if (startIdx !== -1) { workspaceCaptureState.capturing = true; workspaceCaptureState.buffer = text.substring(startIdx + startMarker.length); // Don't display the start marker or content after it const beforeStart = text.substring(0, startIdx); if (beforeStart.trim()) { appendToTerminal(beforeStart); } return; } } appendToTerminal(text); }); // ─── 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 { // Convert workspace to XML const xml = Blockly.Xml.workspaceToDom(workspace); const xmlText = Blockly.Xml.domToText(xml); appendToTerminal('\nSaving workspace to device...\n'); await writeFileToDevice(xmlText, 'workspace.xml'); appendToTerminal('Workspace saved to workspace.xml\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}__`; // Set up capture state workspaceCaptureState = { startMarker, endMarker, buffer: '', capturing: false, }; const script = [ `try:`, ` f = open('workspace.xml', 'r')`, ` data = f.read()`, ` f.close()`, ` print('${startMarker}')`, ` print(data, end='')`, ` print('${endMarker}')`, `except Exception as e:`, ` print('Error reading workspace.xml: ' + 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; } }, 5000); } 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'); } });