esp32blockly/src/main.js

500 lines
15 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';
2026-02-20 07:27:56 +00:00
import {
getDeviceId,
setDeviceId,
getDevice,
canFlashInBrowser,
buildToolbox,
2026-02-25 01:49:34 +00:00
getAllDevices,
2026-02-20 07:27:56 +00:00
} from './devices/registry.js';
2026-02-24 15:43:32 +00:00
import {
setRefreshCallback,
2026-02-25 01:49:34 +00:00
setDeviceListRefreshCallback,
2026-02-24 15:43:32 +00:00
loadAllSavedAddons,
installAddonFromFile,
removeAddon,
getInstalledAddons,
} from './addons/loader.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';
2026-02-20 08:13:27 +00:00
import { initResizablePanels, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js';
import { initProjectsDialog, refreshAll as refreshProjects, refreshDeviceList } from './ui/projectsDialog.js';
import './style.css';
// ─── Blockly Workspace ───────────────────────────────────
2026-02-24 15:43:32 +00:00
// Load saved addons before building toolbox so their categories are included
loadAllSavedAddons();
const workspace = Blockly.inject('blockly-div', {
2026-02-20 07:27:56 +00:00
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',
});
2026-02-24 15:43:32 +00:00
// Now that workspace exists, set the refresh callback for addons
setRefreshCallback(() => {
workspace.updateToolbox(buildToolbox(getDeviceId()));
});
2026-02-25 01:49:34 +00:00
// Rebuild the device <select> from the registry (called when addons register devices)
function rebuildDeviceSelect() {
const devices = getAllDevices();
const current = getDeviceId();
const select = document.getElementById('device-select');
select.innerHTML = '';
for (const [id, profile] of Object.entries(devices)) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = profile.label;
select.appendChild(opt);
}
select.value = current;
}
setDeviceListRefreshCallback(rebuildDeviceSelect);
// ─── 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');
2026-02-20 08:13:27 +00:00
if (blocklyArea && blocklyDiv) {
blocklyDiv.style.width = blocklyArea.offsetWidth + 'px';
blocklyDiv.style.height = blocklyArea.offsetHeight + 'px';
Blockly.svgResize(workspace);
}
}
window.addEventListener('resize', onResize);
onResize();
initResizablePanels();
2026-02-20 08:13:27 +00:00
initPanelToggles();
initProjectTabs();
setProjectsPanelCallbacks({
onDeviceTab: () => refreshDeviceList(),
onExpand: () => refreshProjects(),
});
// ─── UI State Helpers ────────────────────────────────────
2026-02-20 07:27:56 +00:00
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');
2026-02-20 07:57:54 +00:00
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%';
}
2026-02-20 07:27:56 +00:00
// Sync device dropdown with stored device
deviceSelect.value = getDeviceId();
deviceSelect.addEventListener('change', () => {
setDeviceId(deviceSelect.value);
2026-02-24 15:43:32 +00:00
refreshToolbox();
2026-02-20 07:27:56 +00:00
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-24 15:43:32 +00:00
function refreshToolbox() {
workspace.updateToolbox(buildToolbox(getDeviceId()));
}
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';
}
2026-02-20 07:57:54 +00:00
// ─── Serial Capture (reusable promise-based) ─────────────
2026-02-20 07:57:54 +00:00
let captureState = null;
onData((text) => {
2026-02-20 07:57:54 +00:00
if (!captureState) {
2026-02-20 07:49:47 +00:00
appendToTerminal(text);
return;
}
2026-02-20 07:57:54 +00:00
const { startMarker, endMarker } = captureState;
captureState.raw += text;
const raw = captureState.raw;
2026-02-20 07:49:47 +00:00
const startIdx = raw.indexOf(startMarker);
if (startIdx === -1) {
const keep = startMarker.length - 1;
if (raw.length > keep) {
appendToTerminal(raw.substring(0, raw.length - keep));
2026-02-20 07:57:54 +00:00
captureState.raw = raw.substring(raw.length - keep);
}
2026-02-20 07:49:47 +00:00
return;
}
const contentStart = startIdx + startMarker.length;
const endIdx = raw.indexOf(endMarker, contentStart);
if (endIdx === -1) {
2026-02-20 07:57:54 +00:00
if (!captureState.flushedPre && startIdx > 0) {
2026-02-20 07:49:47 +00:00
appendToTerminal(raw.substring(0, startIdx));
2026-02-20 07:57:54 +00:00
captureState.flushedPre = true;
}
2026-02-20 07:49:47 +00:00
return;
}
2026-02-20 07:57:54 +00:00
const content = raw.substring(contentStart, endIdx);
const beforeStart = startIdx > 0 && !captureState.flushedPre
2026-02-20 07:49:47 +00:00
? raw.substring(0, startIdx) : '';
const afterEnd = raw.substring(endIdx + endMarker.length);
2026-02-20 07:57:54 +00:00
const resolve = captureState.resolve;
2026-02-20 07:49:47 +00:00
2026-02-20 07:57:54 +00:00
captureState = null;
2026-02-20 07:49:47 +00:00
if (beforeStart) appendToTerminal(beforeStart);
if (afterEnd.trim()) appendToTerminal(afterEnd);
2026-02-20 07:57:54 +00:00
resolve(content);
});
2026-02-20 07:57:54 +00:00
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);
}
2026-02-20 07:27:56 +00:00
const device = getDevice();
const fw = device.firmware;
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');
}
});
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();
}
});
2026-02-20 07:57:54 +00:00
// ─── Projects Dialog ────────────────────────────────────
2026-02-20 07:57:54 +00:00
initProjectsDialog({
workspace,
captureDeviceOutput,
executeCode,
writeFileToDevice,
isConnected,
});
2026-02-20 08:13:27 +00:00
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') ? '&#9666;' : '&#9656;';
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');
}
});
2026-02-24 15:43:32 +00:00
// ─── Addons Manager UI ──────────────────────────────────
const btnAddons = document.getElementById('btn-addons');
const addonsOverlay = document.getElementById('addons-overlay');
const addonsClose = document.getElementById('addons-close');
const addonFileInput = document.getElementById('addon-file-input');
const addonInstallBtn = document.getElementById('addon-install-btn');
const addonStatus = document.getElementById('addon-status');
const addonsList = document.getElementById('addons-list');
function renderAddonsList() {
const addons = getInstalledAddons();
addonsList.innerHTML = '';
if (!addons.length) {
addonsList.innerHTML = '<li class="addons-empty">No addons installed</li>';
return;
}
for (const addon of addons) {
const li = document.createElement('li');
li.className = 'addons-item';
const nameSpan = document.createElement('span');
nameSpan.className = 'addons-item-name';
nameSpan.textContent = addon.name;
const removeBtn = document.createElement('button');
removeBtn.className = 'addons-item-remove';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove addon (reload required)';
removeBtn.addEventListener('click', () => {
removeAddon(addon.name);
renderAddonsList();
addonStatus.textContent = `"${addon.name}" removed. Reload the page to apply.`;
addonStatus.className = 'addons-status status-warn';
});
li.appendChild(nameSpan);
li.appendChild(removeBtn);
addonsList.appendChild(li);
}
}
btnAddons.addEventListener('click', () => {
addonsOverlay.classList.remove('hidden');
renderAddonsList();
addonStatus.textContent = '';
addonStatus.className = 'addons-status';
});
addonsClose.addEventListener('click', () => {
addonsOverlay.classList.add('hidden');
});
addonsOverlay.addEventListener('click', (e) => {
if (e.target === addonsOverlay) addonsOverlay.classList.add('hidden');
});
addonInstallBtn.addEventListener('click', async () => {
const file = addonFileInput.files[0];
if (!file) {
addonStatus.textContent = 'Select a .js file first.';
addonStatus.className = 'addons-status status-err';
return;
}
try {
const name = await installAddonFromFile(file);
addonStatus.textContent = `"${name}" installed successfully!`;
addonStatus.className = 'addons-status status-ok';
renderAddonsList();
addonFileInput.value = '';
} catch (err) {
addonStatus.textContent = `Error: ${err.message}`;
addonStatus.className = 'addons-status status-err';
}
});