2026-02-18 15:24:00 +00:00
|
|
|
import * as Blockly from 'blockly';
|
|
|
|
|
import { pythonGenerator } from 'blockly/python';
|
|
|
|
|
import './blocks/esp32_blocks.js';
|
|
|
|
|
import './blocks/esp32_generators.js';
|
2026-02-20 07:27:56 +00:00
|
|
|
import {
|
|
|
|
|
getDeviceId,
|
|
|
|
|
setDeviceId,
|
|
|
|
|
getDevice,
|
|
|
|
|
canFlashInBrowser,
|
|
|
|
|
buildToolbox,
|
|
|
|
|
} from './devices/registry.js';
|
2026-02-18 15:24:00 +00:00
|
|
|
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
|
2026-02-18 15:51:57 +00:00
|
|
|
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
|
2026-02-18 15:24:00 +00:00
|
|
|
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', {
|
2026-02-20 07:27:56 +00:00
|
|
|
toolbox: buildToolbox(getDeviceId()),
|
2026-02-18 15:24:00 +00:00
|
|
|
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 ────────────────────────────────────
|
|
|
|
|
|
2026-02-20 07:27:56 +00:00
|
|
|
const deviceSelect = document.getElementById('device-select');
|
2026-02-18 15:24:00 +00:00
|
|
|
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');
|
2026-02-18 15:51:57 +00:00
|
|
|
const btnSaveWorkspace = document.getElementById('btn-save-workspace');
|
|
|
|
|
const btnLoadWorkspace = document.getElementById('btn-load-workspace');
|
2026-02-18 15:24:00 +00:00
|
|
|
const statusEl = document.getElementById('connection-status');
|
|
|
|
|
const terminalInput = document.getElementById('terminal-input');
|
|
|
|
|
|
2026-02-20 07:27:56 +00:00
|
|
|
// 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)';
|
|
|
|
|
|
2026-02-18 15:24:00 +00:00
|
|
|
function setConnectedUI(connected) {
|
|
|
|
|
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
|
|
|
|
|
btnRun.disabled = !connected;
|
|
|
|
|
btnStop.disabled = !connected;
|
|
|
|
|
btnSave.disabled = !connected;
|
2026-02-18 15:51:57 +00:00
|
|
|
btnSaveWorkspace.disabled = !connected;
|
|
|
|
|
btnLoadWorkspace.disabled = !connected;
|
2026-02-18 15:24:00 +00:00
|
|
|
terminalInput.disabled = !connected;
|
|
|
|
|
statusEl.textContent = connected ? 'Connected' : 'Disconnected';
|
|
|
|
|
statusEl.className = connected ? 'status-connected' : 'status-disconnected';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Serial Event Listeners ──────────────────────────────
|
|
|
|
|
|
2026-02-18 15:51:57 +00:00
|
|
|
// 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);
|
|
|
|
|
});
|
2026-02-18 15:24:00 +00:00
|
|
|
|
|
|
|
|
// ─── 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`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-18 15:39:05 +00:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-18 15:24:00 +00:00
|
|
|
btnFlash.addEventListener('click', async () => {
|
2026-02-18 15:39:05 +00:00
|
|
|
if (isConnected()) {
|
2026-02-18 15:24:00 +00:00
|
|
|
await disconnect();
|
|
|
|
|
setConnectedUI(false);
|
2026-02-18 15:39:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 07:27:56 +00:00
|
|
|
const device = getDevice();
|
|
|
|
|
const fw = device.firmware;
|
2026-02-18 15:39:05 +00:00
|
|
|
|
2026-02-20 07:27:56 +00:00
|
|
|
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');
|
2026-02-18 15:24:00 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-18 15:51:57 +00:00
|
|
|
// ─── 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-18 15:24:00 +00:00
|
|
|
terminalInput.addEventListener('keydown', async (e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
const text = terminalInput.value;
|
|
|
|
|
terminalInput.value = '';
|
|
|
|
|
await writeString(text + '\r\n');
|
|
|
|
|
}
|
|
|
|
|
});
|