828 lines
26 KiB
JavaScript
828 lines
26 KiB
JavaScript
import * as Blockly from 'blockly';
|
|
import { pythonGenerator } from 'blockly/python';
|
|
import { arduinoGenerator } from './generators/arduino.js';
|
|
import './generators/arduino_builtins.js';
|
|
import './blocks/esp32_blocks.js';
|
|
import './blocks/esp32_generators.js';
|
|
import './blocks/arduino_generators.js';
|
|
import {
|
|
getDeviceId,
|
|
setDeviceId,
|
|
getDevice,
|
|
canFlashInBrowser,
|
|
buildToolbox,
|
|
getAllDevices,
|
|
} from './devices/registry.js';
|
|
import {
|
|
setRefreshCallback,
|
|
setDeviceListRefreshCallback,
|
|
loadAllSavedAddons,
|
|
installAddonFromFile,
|
|
removeAddon,
|
|
getInstalledAddons,
|
|
} from './addons/loader.js';
|
|
import { connect, disconnect, isConnected, getPort, onData, writeString } from './serial/connection.js';
|
|
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
|
|
import { flashFirmware } from './serial/flasher.js';
|
|
import { flashPicoFirmware } from './serial/picoFlasher.js';
|
|
import { appendToTerminal, clearTerminal } from './ui/terminal.js';
|
|
import { initResizablePanels, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js';
|
|
import { initToolboxCustomizer, toggleCustomizeMode, refreshCustomizer } from './ui/toolboxCustomizer.js';
|
|
import {
|
|
initProjectsDialog,
|
|
refreshAll as refreshProjects,
|
|
loadWorkspaceFromDevice,
|
|
saveCurrentWorkspaceToDevice,
|
|
} from './ui/projectsDialog.js';
|
|
import { initRobotPanel, syncRobotPanelDevice } from './ui/robotPanel.js';
|
|
import { uploadHex, BOARDS } from './arduino/stk500.js';
|
|
import './style.css';
|
|
|
|
// ─── Blockly Workspace ───────────────────────────────────
|
|
|
|
// Load saved addons before building toolbox so their categories are included
|
|
loadAllSavedAddons();
|
|
|
|
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',
|
|
});
|
|
|
|
// Now that workspace exists, set the refresh callback for addons
|
|
setRefreshCallback(() => {
|
|
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Toolbox customizer (show/hide categories & blocks)
|
|
initToolboxCustomizer(refreshToolbox);
|
|
document.getElementById('btn-customize')?.addEventListener('click', toggleCustomizeMode);
|
|
|
|
// ─── Generator Selection ─────────────────────────────────
|
|
|
|
function isArduinoDevice() {
|
|
const device = getDevice();
|
|
return device && device.language === 'arduino';
|
|
}
|
|
|
|
function getActiveGenerator() {
|
|
return isArduinoDevice() ? arduinoGenerator : pythonGenerator;
|
|
}
|
|
|
|
function getGeneratedCode() {
|
|
return getActiveGenerator().workspaceToCode(workspace);
|
|
}
|
|
|
|
// ─── Live Code Preview ───────────────────────────────────
|
|
|
|
const codeOutput = document.getElementById('code-output');
|
|
|
|
function updateCodePreview() {
|
|
const code = getGeneratedCode();
|
|
const placeholder = isArduinoDevice()
|
|
? '// Drag blocks to generate Arduino code'
|
|
: '# Drag blocks to generate MicroPython code';
|
|
codeOutput.textContent = code || placeholder;
|
|
}
|
|
|
|
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({
|
|
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 btnLoad = document.getElementById('btn-load');
|
|
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%';
|
|
}
|
|
|
|
// ─── Arduino-specific UI state ───────────────────────────
|
|
|
|
function updateDeviceUI() {
|
|
const arduino = isArduinoDevice();
|
|
|
|
if (arduino) {
|
|
btnRun.querySelector('.icon').innerHTML = '▷';
|
|
btnRun.childNodes[btnRun.childNodes.length - 1].textContent = ' Upload .hex';
|
|
btnRun.title = 'Upload a compiled .hex file to Arduino via WebSerial';
|
|
btnRun.disabled = false;
|
|
|
|
btnSave.querySelector('.icon').innerHTML = '💾';
|
|
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Download .ino';
|
|
btnSave.title = 'Download generated code as .ino file';
|
|
btnSave.disabled = false;
|
|
btnLoad.querySelector('.icon').innerHTML = '📥';
|
|
btnLoad.childNodes[btnLoad.childNodes.length - 1].textContent = ' Load';
|
|
btnLoad.title = 'Load is unavailable for Arduino devices';
|
|
btnLoad.disabled = true;
|
|
|
|
btnConnect.querySelector('.icon').innerHTML = '▶';
|
|
btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = ' Serial Monitor';
|
|
btnConnect.title = 'Open serial monitor via WebSerial';
|
|
|
|
btnFlash.classList.add('hidden');
|
|
btnStop.disabled = false;
|
|
} else {
|
|
btnRun.querySelector('.icon').innerHTML = '▷';
|
|
btnRun.childNodes[btnRun.childNodes.length - 1].textContent = ' Run';
|
|
btnRun.title = 'Upload and run code';
|
|
btnRun.disabled = !isConnected();
|
|
|
|
btnSave.querySelector('.icon').innerHTML = '💾';
|
|
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Save';
|
|
btnSave.title = 'Save code to device as main.py';
|
|
btnSave.disabled = !isConnected();
|
|
btnLoad.querySelector('.icon').innerHTML = '📥';
|
|
btnLoad.childNodes[btnLoad.childNodes.length - 1].textContent = ' Load';
|
|
btnLoad.title = 'Load block layout from main.blk';
|
|
btnLoad.disabled = !isConnected();
|
|
|
|
btnConnect.querySelector('.icon').innerHTML = '▶';
|
|
btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = isConnected() ? ' Disconnect' : ' Connect';
|
|
btnConnect.title = 'Connect to device via Web Serial';
|
|
|
|
btnFlash.classList.remove('hidden');
|
|
btnFlash.title = canFlashInBrowser()
|
|
? 'Flash MicroPython firmware'
|
|
: 'Download firmware (drag to device)';
|
|
|
|
btnStop.disabled = !isConnected();
|
|
}
|
|
}
|
|
|
|
// Sync device dropdown with stored device
|
|
deviceSelect.value = getDeviceId();
|
|
deviceSelect.addEventListener('change', () => {
|
|
setDeviceId(deviceSelect.value);
|
|
refreshToolbox();
|
|
refreshCustomizer();
|
|
syncRobotPanelDevice();
|
|
updateCodePreview();
|
|
updateDeviceUI();
|
|
});
|
|
updateDeviceUI();
|
|
|
|
function refreshToolbox() {
|
|
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
|
}
|
|
|
|
function setConnectedUI(connected) {
|
|
if (isArduinoDevice()) {
|
|
btnConnect.innerHTML = connected
|
|
? '<span class="icon">⏻</span> Disconnect Monitor'
|
|
: '<span class="icon">▶</span> Serial Monitor';
|
|
terminalInput.disabled = !connected;
|
|
statusEl.textContent = connected ? 'Monitoring' : 'Disconnected';
|
|
statusEl.className = connected ? 'status-connected' : 'status-disconnected';
|
|
} else {
|
|
btnConnect.innerHTML = connected
|
|
? '<span class="icon">⏻</span> Disconnect'
|
|
: '<span class="icon">▶</span> Connect';
|
|
btnRun.disabled = !connected;
|
|
btnStop.disabled = !connected;
|
|
btnSave.disabled = !connected;
|
|
btnLoad.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');
|
|
const esp32FlashOverlay = document.getElementById('esp32-flash-overlay');
|
|
const esp32FlashClose = document.getElementById('esp32-flash-close');
|
|
const esp32FlashStart = document.getElementById('esp32-flash-start');
|
|
const esp32FlashVariant = document.getElementById('esp32-variant-select');
|
|
const esp32FlashStatus = document.getElementById('esp32-flash-status');
|
|
|
|
const FW_BASE = import.meta.env.BASE_URL + 'firmware/';
|
|
|
|
const ESP32_FIRMWARE_OPTIONS = {
|
|
esp32: {
|
|
label: 'ESP32',
|
|
url: FW_BASE + 'ESP32_GENERIC-20260406-v1.28.0.bin',
|
|
flashAddress: 0x1000,
|
|
},
|
|
esp32s2: {
|
|
label: 'ESP32-S2',
|
|
url: FW_BASE + 'ESP32_GENERIC_S2-20260406-v1.28.0.bin',
|
|
flashAddress: 0x1000,
|
|
resetMode: 'no_reset',
|
|
},
|
|
esp32s3: {
|
|
label: 'ESP32-S3',
|
|
url: FW_BASE + 'ESP32_GENERIC_S3-20251209-v1.27.0.bin',
|
|
flashAddress: 0x0,
|
|
},
|
|
esp32c3: {
|
|
label: 'ESP32-C3',
|
|
url: FW_BASE + 'ESP32_GENERIC_C3-20260406-v1.28.0.bin',
|
|
flashAddress: 0x0,
|
|
},
|
|
};
|
|
|
|
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');
|
|
});
|
|
|
|
function openEsp32FlashChooser() {
|
|
if (!esp32FlashOverlay) return;
|
|
if (esp32FlashStatus) {
|
|
esp32FlashStatus.textContent = '';
|
|
esp32FlashStatus.className = 'hex-upload-status';
|
|
}
|
|
esp32FlashOverlay.classList.remove('hidden');
|
|
}
|
|
|
|
function closeEsp32FlashChooser() {
|
|
if (!esp32FlashOverlay) return;
|
|
esp32FlashOverlay.classList.add('hidden');
|
|
}
|
|
|
|
esp32FlashClose?.addEventListener('click', closeEsp32FlashChooser);
|
|
|
|
esp32FlashOverlay?.addEventListener('click', (event) => {
|
|
if (event.target === esp32FlashOverlay) closeEsp32FlashChooser();
|
|
});
|
|
|
|
btnFlash.addEventListener('click', async () => {
|
|
if (isArduinoDevice()) return;
|
|
|
|
const device = getDevice();
|
|
const fw = device.firmware;
|
|
if (!fw) return;
|
|
|
|
if (fw.flashMethod === 'webusb') {
|
|
if (isConnected()) {
|
|
await disconnect();
|
|
setConnectedUI(false);
|
|
}
|
|
showFlashOverlay();
|
|
try {
|
|
await flashPicoFirmware(
|
|
(msg) => appendFlashLog(msg),
|
|
(pct) => setFlashProgress(pct),
|
|
{ firmwareUrl: fw.url },
|
|
);
|
|
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');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (canFlashInBrowser()) {
|
|
openEsp32FlashChooser();
|
|
return;
|
|
}
|
|
|
|
if (isConnected()) {
|
|
await disconnect();
|
|
setConnectedUI(false);
|
|
}
|
|
|
|
{
|
|
const a = document.createElement('a');
|
|
a.href = fw.url;
|
|
a.download = fw.url.split('/').pop();
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
appendToTerminal(`\n--- Firmware: ${fw.label} ---\n`);
|
|
appendToTerminal(`Downloading: ${a.download}\n`);
|
|
if (fw.instructions) {
|
|
appendToTerminal(`${fw.instructions}\n`);
|
|
}
|
|
appendToTerminal('After flashing, connect here to run code.\n');
|
|
}
|
|
});
|
|
|
|
esp32FlashStart?.addEventListener('click', async () => {
|
|
if (isConnected()) {
|
|
await disconnect();
|
|
setConnectedUI(false);
|
|
appendToTerminal('\n--- Disconnected for firmware flash ---\n');
|
|
}
|
|
|
|
const variantKey = esp32FlashVariant?.value || 'esp32s3';
|
|
const variant = ESP32_FIRMWARE_OPTIONS[variantKey];
|
|
if (!variant) {
|
|
if (esp32FlashStatus) {
|
|
esp32FlashStatus.textContent = 'Invalid board family selected.';
|
|
esp32FlashStatus.className = 'hex-upload-status status-err';
|
|
}
|
|
return;
|
|
}
|
|
|
|
closeEsp32FlashChooser();
|
|
showFlashOverlay();
|
|
appendFlashLog(`Selected target: ${variant.label}\n`);
|
|
|
|
try {
|
|
await flashFirmware(
|
|
(msg) => appendFlashLog(msg),
|
|
(pct) => setFlashProgress(pct),
|
|
{
|
|
firmwareUrl: variant.url,
|
|
targetLabel: variant.label,
|
|
flashAddress: variant.flashAddress ?? 0x0,
|
|
resetMode: variant.resetMode || 'default_reset',
|
|
},
|
|
);
|
|
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 () => {
|
|
if (isArduinoDevice()) {
|
|
document.getElementById('hex-upload-overlay').classList.remove('hidden');
|
|
document.getElementById('hex-upload-status').textContent = '';
|
|
return;
|
|
}
|
|
|
|
const code = getGeneratedCode();
|
|
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 () => {
|
|
if (isArduinoDevice()) {
|
|
const code = getGeneratedCode();
|
|
if (!code.trim()) {
|
|
appendToTerminal('\nNo code to download.\n');
|
|
return;
|
|
}
|
|
const blob = new Blob([code], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'sketch.ino';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
appendToTerminal('\n--- Downloaded sketch.ino ---\n');
|
|
return;
|
|
}
|
|
|
|
const code = getGeneratedCode();
|
|
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),
|
|
});
|
|
const layoutFile = await saveCurrentWorkspaceToDevice('main');
|
|
if (layoutFile) {
|
|
appendToTerminal(`Saved Blockly layout to ${layoutFile}\n`);
|
|
refreshProjects();
|
|
}
|
|
} catch (err) {
|
|
appendToTerminal(`\nSave error: ${err.message}\n`);
|
|
} finally {
|
|
hideSendProgress();
|
|
}
|
|
});
|
|
|
|
btnLoad.addEventListener('click', async () => {
|
|
if (isArduinoDevice()) {
|
|
appendToTerminal('\nLoad is not available for Arduino devices.\n');
|
|
return;
|
|
}
|
|
appendToTerminal('\nLoading Blockly layout from device...\n');
|
|
try {
|
|
const layoutFile = await loadWorkspaceFromDevice('main');
|
|
if (!layoutFile) {
|
|
appendToTerminal('Load skipped: no layout name available.\n');
|
|
return;
|
|
}
|
|
updateCodePreview();
|
|
appendToTerminal(`Loaded Blockly layout from ${layoutFile}\n`);
|
|
refreshProjects();
|
|
} catch (err) {
|
|
appendToTerminal(`\nLoad error: ${err.message}\n`);
|
|
}
|
|
});
|
|
|
|
// ─── Projects Dialog ────────────────────────────────────
|
|
|
|
initProjectsDialog({
|
|
workspace,
|
|
captureDeviceOutput,
|
|
executeCode,
|
|
writeFileToDevice,
|
|
isConnected,
|
|
});
|
|
|
|
initRobotPanel({ workspace, getDeviceId });
|
|
|
|
terminalInput.addEventListener('keydown', async (e) => {
|
|
if (e.key === 'Enter') {
|
|
const text = terminalInput.value;
|
|
terminalInput.value = '';
|
|
await writeString(text + '\r\n');
|
|
}
|
|
});
|
|
|
|
// ─── Hex Upload Modal (Arduino STK500) ───────────────────
|
|
|
|
const hexOverlay = document.getElementById('hex-upload-overlay');
|
|
const hexCloseBtn = document.getElementById('hex-upload-close');
|
|
const hexUploadBtn = document.getElementById('hex-upload-btn');
|
|
const hexFileInput = document.getElementById('hex-file-input');
|
|
const hexBoardSelect = document.getElementById('hex-board-select');
|
|
const hexStatus = document.getElementById('hex-upload-status');
|
|
|
|
if (hexCloseBtn) {
|
|
hexCloseBtn.addEventListener('click', () => {
|
|
hexOverlay.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
if (hexOverlay) {
|
|
hexOverlay.addEventListener('click', (e) => {
|
|
if (e.target === hexOverlay) hexOverlay.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
if (hexUploadBtn) {
|
|
hexUploadBtn.addEventListener('click', async () => {
|
|
const file = hexFileInput.files[0];
|
|
if (!file) {
|
|
hexStatus.textContent = 'Select a .hex file first.';
|
|
hexStatus.className = 'hex-upload-status status-err';
|
|
return;
|
|
}
|
|
|
|
const boardKey = hexBoardSelect.value;
|
|
hexOverlay.classList.add('hidden');
|
|
|
|
// Grab the existing serial port before tearing down the text-mode connection
|
|
const existingPort = getPort();
|
|
const wasConnected = isConnected();
|
|
|
|
if (wasConnected) {
|
|
await disconnect();
|
|
setConnectedUI(false);
|
|
appendToTerminal('\n--- Disconnected for upload ---\n');
|
|
}
|
|
|
|
const hexString = await file.text();
|
|
appendToTerminal(`\n--- Uploading ${file.name} (${boardKey})... ---\n`);
|
|
showSendProgress('Uploading .hex');
|
|
try {
|
|
await uploadHex(hexString, {
|
|
board: boardKey,
|
|
port: existingPort || undefined,
|
|
onProgress: (status, pct) => {
|
|
sendProgressFill.style.width = Math.round(pct) + '%';
|
|
sendProgressText.textContent = status;
|
|
appendToTerminal(` ${status} (${Math.round(pct)}%)\n`);
|
|
},
|
|
});
|
|
appendToTerminal('--- Upload complete! ---\n');
|
|
} catch (err) {
|
|
appendToTerminal(`\nUpload error: ${err.message}\n`);
|
|
} finally {
|
|
hideSendProgress();
|
|
}
|
|
|
|
// Resume serial monitor on the same port (small delay for the board to reboot)
|
|
if (existingPort) {
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
try {
|
|
await connect(115200, existingPort);
|
|
setConnectedUI(true);
|
|
appendToTerminal('--- Serial monitor resumed ---\n');
|
|
} catch (err) {
|
|
appendToTerminal(`--- Could not resume monitor: ${err.message} ---\n`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── 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';
|
|
}
|
|
});
|