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, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js'; import { initProjectsDialog, refreshAll as refreshProjects, refreshDeviceList } from './ui/projectsDialog.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'); if (blocklyArea && blocklyDiv) { blocklyDiv.style.width = blocklyArea.offsetWidth + 'px'; blocklyDiv.style.height = blocklyArea.offsetHeight + 'px'; Blockly.svgResize(workspace); } } window.addEventListener('resize', onResize); onResize(); initResizablePanels(); initPanelToggles(); initProjectTabs(); setProjectsPanelCallbacks({ onDeviceTab: () => refreshDeviceList(), onExpand: () => refreshProjects(), }); // ─── 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 btnProjects = document.getElementById('btn-projects'); const statusEl = document.getElementById('connection-status'); const terminalInput = document.getElementById('terminal-input'); const sendOverlayEl = document.getElementById('send-overlay'); const sendModalTitle = document.getElementById('send-modal-title'); const sendProgressFill = document.getElementById('send-progress-fill'); const sendProgressText = document.getElementById('send-progress-text'); function showSendProgress(title = 'Sending code to device') { sendModalTitle.textContent = title; sendProgressFill.style.width = '0%'; sendProgressText.textContent = '0%'; sendOverlayEl.classList.remove('hidden'); } function updateSendProgress(sent, total) { const pct = total > 0 ? Math.round((sent / total) * 100) : 0; sendProgressFill.style.width = pct + '%'; sendProgressText.textContent = pct + '%'; } function hideSendProgress() { sendOverlayEl.classList.add('hidden'); sendProgressFill.style.width = '0%'; } // 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; terminalInput.disabled = !connected; statusEl.textContent = connected ? 'Connected' : 'Disconnected'; statusEl.className = connected ? 'status-connected' : 'status-disconnected'; } // ─── Serial Capture (reusable promise-based) ───────────── let captureState = null; onData((text) => { if (!captureState) { appendToTerminal(text); return; } const { startMarker, endMarker } = captureState; captureState.raw += text; const raw = captureState.raw; const startIdx = raw.indexOf(startMarker); if (startIdx === -1) { const keep = startMarker.length - 1; if (raw.length > keep) { appendToTerminal(raw.substring(0, raw.length - keep)); captureState.raw = raw.substring(raw.length - keep); } return; } const contentStart = startIdx + startMarker.length; const endIdx = raw.indexOf(endMarker, contentStart); if (endIdx === -1) { if (!captureState.flushedPre && startIdx > 0) { appendToTerminal(raw.substring(0, startIdx)); captureState.flushedPre = true; } return; } const content = raw.substring(contentStart, endIdx); const beforeStart = startIdx > 0 && !captureState.flushedPre ? raw.substring(0, startIdx) : ''; const afterEnd = raw.substring(endIdx + endMarker.length); const resolve = captureState.resolve; captureState = null; if (beforeStart) appendToTerminal(beforeStart); if (afterEnd.trim()) appendToTerminal(afterEnd); resolve(content); }); function captureDeviceOutput(script) { return new Promise((resolve, reject) => { const ts = Date.now(); const sm = `__CAP_S_${ts}__`; const em = `__CAP_E_${ts}__`; captureState = { startMarker: sm, endMarker: em, raw: '', flushedPre: false, resolve, }; const wrapped = `print('${sm}',end='')\n${script}\nprint('${em}',end='')`; executeCode(wrapped).catch(err => { captureState = null; reject(err); }); setTimeout(() => { if (captureState?.resolve === resolve) { captureState = null; reject(new Error('Timeout waiting for device response')); } }, 10000); }); } // ─── 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'); showSendProgress('Sending code to device'); try { await executeCode(code, { onProgress: (sent, total) => updateSendProgress(sent, total), }); } catch (err) { appendToTerminal(`\nRun error: ${err.message}\n`); } finally { hideSendProgress(); } }); 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'); showSendProgress('Saving to device'); try { await saveToDevice(code, 'main.py', { onProgress: (sent, total) => updateSendProgress(sent, total), }); } catch (err) { appendToTerminal(`\nSave error: ${err.message}\n`); } finally { hideSendProgress(); } }); // ─── Projects Dialog ──────────────────────────────────── initProjectsDialog({ workspace, captureDeviceOutput, executeCode, writeFileToDevice, isConnected, }); btnProjects.addEventListener('click', () => { const panel = document.getElementById('projects-panel'); panel.classList.toggle('collapsed'); const btn = panel.querySelector('.panel-toggle'); if (btn) btn.innerHTML = panel.classList.contains('collapsed') ? '◂' : '▸'; refreshProjects(); window.dispatchEvent(new Event('resize')); }); terminalInput.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { const text = terminalInput.value; terminalInput.value = ''; await writeString(text + '\r\n'); } });