esp32blockly/src/main.js

371 lines
12 KiB
JavaScript
Raw Normal View History

import * as Blockly from 'blockly';
import { pythonGenerator } from 'blockly/python';
import './blocks/esp32_blocks.js';
import './blocks/esp32_generators.js';
import { toolbox } from './blocks/toolbox.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,
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 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');
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);
}
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');
}
});
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');
}
});