diff --git a/index.html b/index.html index 07cb5a8..ab389a5 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,7 @@ +
@@ -197,6 +198,31 @@
+ + + diff --git a/package.json b/package.json index 95a08b3..2ac6b0c 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,13 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "server": "node server/index.js" }, "dependencies": { "blockly": "^11.2.1", - "esptool-js": "^0.5.0" + "esptool-js": "^0.5.0", + "express": "^5.2.1" }, "devDependencies": { "vite": "^6.1.0" diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..2a76ad6 --- /dev/null +++ b/server/index.js @@ -0,0 +1,137 @@ +import express from 'express'; +import { execFile } from 'node:child_process'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(express.json({ limit: '1mb' })); + +// ─── Helpers ───────────────────────────────────────────── + +const CLI = process.env.ARDUINO_CLI || 'arduino-cli'; + +function run(args, timeout = 120_000) { + return new Promise((resolve, reject) => { + execFile(CLI, args, { timeout }, (err, stdout, stderr) => { + if (err) { + const msg = stderr?.trim() || stdout?.trim() || err.message; + reject(new Error(msg)); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +async function writeSketchDir(code) { + const dir = await mkdtemp(join(tmpdir(), 'arduino-sketch-')); + const sketchDir = join(dir, 'sketch'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(sketchDir, { recursive: true }); + await writeFile(join(sketchDir, 'sketch.ino'), code, 'utf-8'); + return { dir, sketchDir }; +} + +async function cleanupDir(dir) { + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +// ─── Routes ────────────────────────────────────────────── + +app.get('/api/arduino/status', async (_req, res) => { + try { + const { stdout } = await run(['version', '--format', 'json']); + const info = JSON.parse(stdout); + res.json({ ok: true, version: info.VersionString || info.version || stdout.trim() }); + } catch (err) { + res.status(503).json({ ok: false, error: `arduino-cli not available: ${err.message}` }); + } +}); + +app.get('/api/arduino/boards', async (_req, res) => { + try { + const { stdout } = await run(['board', 'list', '--format', 'json']); + const raw = JSON.parse(stdout); + + // arduino-cli >=0.35 returns { detected_ports: [...] } + // older versions return a flat array + const ports = Array.isArray(raw) ? raw : (raw.detected_ports || []); + + const boards = ports + .filter(p => p.port) + .map(entry => { + const port = entry.port?.address || entry.port?.label || entry.address || ''; + const matchingBoards = entry.matching_boards || entry.boards || []; + const first = matchingBoards[0] || {}; + return { + port, + name: first.name || entry.port?.protocol_label || 'Unknown', + fqbn: first.fqbn || '', + }; + }) + .filter(b => b.port); + + res.json(boards); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/arduino/compile', async (req, res) => { + const { code } = req.body; + if (!code) return res.status(400).json({ error: 'No code provided' }); + + let dir; + try { + ({ dir } = await writeSketchDir(code)); + const sketchDir = join(dir, 'sketch'); + const { stdout, stderr } = await run([ + 'compile', + '--fqbn', req.body.fqbn || 'arduino:avr:uno', + sketchDir, + ]); + res.json({ ok: true, output: stdout + stderr }); + } catch (err) { + res.status(400).json({ ok: false, error: err.message }); + } finally { + if (dir) cleanupDir(dir); + } +}); + +app.post('/api/arduino/upload', async (req, res) => { + const { code, port, fqbn } = req.body; + if (!code) return res.status(400).json({ error: 'No code provided' }); + if (!port) return res.status(400).json({ error: 'No port specified' }); + + let dir; + try { + ({ dir } = await writeSketchDir(code)); + const sketchDir = join(dir, 'sketch'); + const { stdout, stderr } = await run([ + 'compile', + '--upload', + '--fqbn', fqbn || 'arduino:avr:uno', + '--port', port, + sketchDir, + ]); + res.json({ ok: true, output: stdout + stderr }); + } catch (err) { + res.status(400).json({ ok: false, error: err.message }); + } finally { + if (dir) cleanupDir(dir); + } +}); + +// ─── Start ─────────────────────────────────────────────── + +app.listen(PORT, () => { + console.log(`Arduino CLI server listening on http://localhost:${PORT}`); + console.log(`Using CLI: ${CLI}`); +}); diff --git a/src/addons/loader.js b/src/addons/loader.js index e7ea86b..5337c82 100644 --- a/src/addons/loader.js +++ b/src/addons/loader.js @@ -1,5 +1,6 @@ import * as Blockly from 'blockly'; import { pythonGenerator } from 'blockly/python'; +import { arduinoGenerator } from '../generators/arduino.js'; import { registerAddonCategories, clearAddonCategories, @@ -44,6 +45,7 @@ function makeAddonApi() { return { Blockly, pythonGenerator, + arduinoGenerator, getDeviceId, registerCategories(categories) { registerAddonCategories(categories); diff --git a/src/arduino/compiler.js b/src/arduino/compiler.js new file mode 100644 index 0000000..cf6d82a --- /dev/null +++ b/src/arduino/compiler.js @@ -0,0 +1,39 @@ +const BASE = '/api/arduino'; + +export async function checkArduinoCli() { + const res = await fetch(`${BASE}/status`); + if (!res.ok) throw new Error('Arduino CLI server not reachable'); + return res.json(); +} + +export async function listBoards() { + const res = await fetch(`${BASE}/boards`); + if (!res.ok) throw new Error('Failed to list boards'); + return res.json(); +} + +export async function compileAndUpload(code, port, fqbn) { + const res = await fetch(`${BASE}/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, port, fqbn }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Compile/upload failed'); + } + return data; +} + +export async function compileOnly(code) { + const res = await fetch(`${BASE}/compile`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Compilation failed'); + } + return data; +} diff --git a/src/arduino/stk500.js b/src/arduino/stk500.js new file mode 100644 index 0000000..75e3f32 --- /dev/null +++ b/src/arduino/stk500.js @@ -0,0 +1,387 @@ +// STK500v1 programmer for Arduino Uno/Nano via WebSerial. +// Implements the subset of the protocol needed to upload Intel HEX firmware +// through the Optiboot bootloader (115200 baud) or older bootloaders (57600). + +// ─── STK500 constants ──────────────────────────────────── + +const Cmnd_STK_GET_SYNC = 0x30; +const Cmnd_STK_SET_DEVICE = 0x42; +const Cmnd_STK_ENTER_PROGMODE = 0x50; +const Cmnd_STK_LEAVE_PROGMODE = 0x51; +const Cmnd_STK_LOAD_ADDRESS = 0x55; +const Cmnd_STK_PROG_PAGE = 0x64; +const Cmnd_STK_READ_PAGE = 0x74; +const Cmnd_STK_READ_SIGN = 0x75; +const Sync_CRC_EOP = 0x20; +const Resp_STK_INSYNC = 0x14; +const Resp_STK_OK = 0x10; + +// ─── Board profiles ───────────────────────────────────── + +export const BOARDS = { + uno: { + name: 'Arduino Uno', + baudRate: 115200, + signature: new Uint8Array([0x1e, 0x95, 0x0f]), + pageSize: 128, + timeout: 400, + }, + nano: { + name: 'Arduino Nano', + baudRate: 57600, + signature: new Uint8Array([0x1e, 0x95, 0x0f]), + pageSize: 128, + timeout: 400, + }, + nano_new: { + name: 'Arduino Nano (new bootloader)', + baudRate: 115200, + signature: new Uint8Array([0x1e, 0x95, 0x0f]), + pageSize: 128, + timeout: 400, + }, +}; + +// ─── Intel HEX parser ─────────────────────────────────── + +export function parseHex(hexString) { + const lines = hexString.split(/\r?\n/).filter(l => l.startsWith(':')); + let baseAddress = 0; + let maxAddress = 0; + const segments = []; + + for (const line of lines) { + const byteCount = parseInt(line.substring(1, 3), 16); + const address = parseInt(line.substring(3, 7), 16); + const recordType = parseInt(line.substring(7, 9), 16); + + if (recordType === 0x00) { + const data = new Uint8Array(byteCount); + for (let i = 0; i < byteCount; i++) { + data[i] = parseInt(line.substring(9 + i * 2, 11 + i * 2), 16); + } + const absAddress = baseAddress + address; + segments.push({ address: absAddress, data }); + const end = absAddress + byteCount; + if (end > maxAddress) maxAddress = end; + } else if (recordType === 0x02) { + baseAddress = parseInt(line.substring(9, 13), 16) << 4; + } else if (recordType === 0x04) { + baseAddress = parseInt(line.substring(9, 13), 16) << 16; + } else if (recordType === 0x01) { + break; + } + } + + const buffer = new Uint8Array(maxAddress); + buffer.fill(0xff); + for (const seg of segments) { + buffer.set(seg.data, seg.address); + } + return buffer; +} + +// ─── Low-level serial helpers ─────────────────────────── + +async function serialWrite(writer, data) { + if (data instanceof Array) data = new Uint8Array(data); + await writer.write(data); +} + +// Buffered reader: WebSerial delivers variable-size chunks, so we need to +// accumulate bytes and serve exact-length reads from the buffer. +function createBufferedReader(rawReader) { + let buffer = []; + let pending = null; // { needed, resolve, reject, timer } + + const pump = async () => { + try { + while (true) { + const { value, done } = await rawReader.read(); + if (done) break; + if (value) { + for (let i = 0; i < value.length; i++) buffer.push(value[i]); + checkPending(); + } + } + } catch { + // port closed / cancelled + if (pending) pending.reject(new Error('Serial read cancelled')); + } + }; + + function checkPending() { + if (pending && buffer.length >= pending.needed) { + clearTimeout(pending.timer); + const bytes = new Uint8Array(buffer.splice(0, pending.needed)); + const p = pending; + pending = null; + p.resolve(bytes); + } + } + + pump(); + + return { + read(length, timeout) { + // If we already have enough buffered, return immediately + if (buffer.length >= length) { + return Promise.resolve(new Uint8Array(buffer.splice(0, length))); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending = null; + reject(new Error(`Read timeout (expected ${length} bytes, got ${buffer.length})`)); + }, timeout); + pending = { needed: length, resolve, reject, timer }; + }); + }, + drain() { + buffer.length = 0; + }, + get rawReader() { return rawReader; }, + }; +} + +// ─── STK500 protocol steps ────────────────────────────── + +async function sync(writer, reader, timeout) { + const cmd = new Uint8Array([Cmnd_STK_GET_SYNC, Sync_CRC_EOP]); + for (let attempt = 0; attempt < 10; attempt++) { + reader.drain(); + try { + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] === Resp_STK_INSYNC && resp[1] === Resp_STK_OK) { + return true; + } + } catch { + // retry + } + await new Promise(r => setTimeout(r, 30)); + } + throw new Error('Failed to sync with bootloader after 10 attempts'); +} + +async function readSignature(writer, reader, timeout) { + const cmd = new Uint8Array([Cmnd_STK_READ_SIGN, Sync_CRC_EOP]); + await serialWrite(writer, cmd); + const resp = await reader.read(5, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[4] !== Resp_STK_OK) { + throw new Error('Failed to read device signature'); + } + return resp.slice(1, 4); +} + +async function setDeviceParameters(writer, reader, pageSize, timeout) { + const cmd = new Uint8Array([ + Cmnd_STK_SET_DEVICE, + 0x86, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x03, + 0xff, 0xff, 0xff, 0xff, + (pageSize >> 8) & 0xff, pageSize & 0xff, + 0x00, 0x00, + 0x00, 0x00, 0x80, 0x00, + Sync_CRC_EOP, + ]); + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[1] !== Resp_STK_OK) { + throw new Error('Failed to set device parameters'); + } +} + +async function enterProgMode(writer, reader, timeout) { + const cmd = new Uint8Array([Cmnd_STK_ENTER_PROGMODE, Sync_CRC_EOP]); + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[1] !== Resp_STK_OK) { + throw new Error('Failed to enter programming mode'); + } +} + +async function leaveProgMode(writer, reader, timeout) { + const cmd = new Uint8Array([Cmnd_STK_LEAVE_PROGMODE, Sync_CRC_EOP]); + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[1] !== Resp_STK_OK) { + throw new Error('Failed to leave programming mode'); + } +} + +async function loadAddress(writer, reader, addr, timeout) { + const cmd = new Uint8Array([ + Cmnd_STK_LOAD_ADDRESS, + addr & 0xff, + (addr >> 8) & 0xff, + Sync_CRC_EOP, + ]); + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[1] !== Resp_STK_OK) { + throw new Error('Failed to load address'); + } +} + +async function programPage(writer, reader, data, timeout) { + const sizeHigh = (data.length >> 8) & 0xff; + const sizeLow = data.length & 0xff; + const cmd = new Uint8Array([ + Cmnd_STK_PROG_PAGE, + sizeHigh, sizeLow, + 0x46, // 'F' for flash memory + ...data, + Sync_CRC_EOP, + ]); + await serialWrite(writer, cmd); + const resp = await reader.read(2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[1] !== Resp_STK_OK) { + throw new Error('Failed to program page'); + } +} + +async function readPage(writer, reader, size, timeout) { + const cmd = new Uint8Array([ + Cmnd_STK_READ_PAGE, + (size >> 8) & 0xff, + size & 0xff, + 0x46, // 'F' for flash + Sync_CRC_EOP, + ]); + await serialWrite(writer, cmd); + const resp = await reader.read(size + 2, timeout); + if (resp[0] !== Resp_STK_INSYNC || resp[resp.length - 1] !== Resp_STK_OK) { + throw new Error('Failed to read page'); + } + return resp.slice(1, 1 + size); +} + +// ─── DTR reset ────────────────────────────────────────── +// Arduino boards reset when DTR transitions HIGH → LOW. The RC circuit +// (100nF cap + 10k pull-up) converts the falling edge into a brief LOW +// pulse on the RESET pin. We hold DTR HIGH first to guarantee a clean +// falling edge, then wait for the bootloader to start (~100-250ms). + +async function resetBoard(port) { + await port.setSignals({ dataTerminalReady: false, requestToSend: false }); + await new Promise(r => setTimeout(r, 50)); + await port.setSignals({ dataTerminalReady: true, requestToSend: true }); + await new Promise(r => setTimeout(r, 100)); + await port.setSignals({ dataTerminalReady: false, requestToSend: false }); + await new Promise(r => setTimeout(r, 100)); +} + +// ─── Main upload function ─────────────────────────────── + +/** + * Upload an Intel HEX file to an Arduino via WebSerial. + * + * @param {string} hexString - The Intel HEX file contents as a string + * @param {object} [options] - Options + * @param {string} [options.board='uno'] - Board key from BOARDS + * @param {function} [options.onProgress] - Callback: (status: string, percent: number) => void + * @param {SerialPort} [options.port] - An already-opened SerialPort. If not provided, will prompt user. + * @returns {Promise} + */ +export async function uploadHex(hexString, options = {}) { + const boardKey = options.board || 'uno'; + const board = typeof boardKey === 'object' ? boardKey : BOARDS[boardKey]; + if (!board) throw new Error(`Unknown board: ${boardKey}`); + const onProgress = options.onProgress || (() => {}); + + const hexData = parseHex(hexString); + if (hexData.length === 0) throw new Error('Hex file is empty'); + + onProgress('Connecting...', 0); + + // Open a raw binary serial port (separate from the REPL text connection) + let port = options.port || null; + let weOpened = false; + + if (!port) { + port = await navigator.serial.requestPort(); + } + + // Close if already open, then open fresh with correct baud rate + try { await port.close(); } catch { /* not open */ } + await port.open({ baudRate: board.baudRate }); + weOpened = true; + + let writer, rawReader, reader; + try { + writer = port.writable.getWriter(); + rawReader = port.readable.getReader(); + reader = createBufferedReader(rawReader); + + onProgress('Resetting board...', 5); + await resetBoard(port); + + // Start syncing immediately — don't waste time draining + reader.drain(); + + onProgress('Syncing with bootloader...', 10); + await sync(writer, reader, board.timeout); + + onProgress('Reading signature...', 20); + const sig = await readSignature(writer, reader, board.timeout); + if (board.signature[0] !== sig[0] || board.signature[1] !== sig[1] || board.signature[2] !== sig[2]) { + const got = Array.from(sig).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '); + const expected = Array.from(board.signature).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '); + throw new Error(`Signature mismatch: got ${got}, expected ${expected}`); + } + + onProgress('Setting device parameters...', 25); + await setDeviceParameters(writer, reader, board.pageSize, board.timeout); + + onProgress('Entering programming mode...', 30); + await enterProgMode(writer, reader, board.timeout); + + // Program pages + const totalPages = Math.ceil(hexData.length / board.pageSize); + for (let page = 0; page < totalPages; page++) { + const addr = (page * board.pageSize) >> 1; // word address + const chunk = hexData.subarray( + page * board.pageSize, + Math.min((page + 1) * board.pageSize, hexData.length) + ); + + await loadAddress(writer, reader, addr, board.timeout); + await programPage(writer, reader, chunk, board.timeout); + + const pct = 35 + Math.round((page / totalPages) * 40); + onProgress(`Writing page ${page + 1}/${totalPages}...`, pct); + } + + // Verify pages + onProgress('Verifying...', 75); + for (let page = 0; page < totalPages; page++) { + const addr = (page * board.pageSize) >> 1; + const chunk = hexData.subarray( + page * board.pageSize, + Math.min((page + 1) * board.pageSize, hexData.length) + ); + + await loadAddress(writer, reader, addr, board.timeout); + const readBack = await readPage(writer, reader, chunk.length, board.timeout); + + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] !== readBack[i]) { + throw new Error(`Verification failed at byte ${page * board.pageSize + i}`); + } + } + + const pct = 75 + Math.round((page / totalPages) * 20); + onProgress(`Verifying page ${page + 1}/${totalPages}...`, pct); + } + + onProgress('Finishing...', 97); + await leaveProgMode(writer, reader, board.timeout); + + onProgress('Upload complete!', 100); + } finally { + try { rawReader.releaseLock(); } catch { /* */ } + try { writer.releaseLock(); } catch { /* */ } + if (weOpened) { + try { await port.close(); } catch { /* */ } + } + } +} diff --git a/src/blocks/arduino_generators.js b/src/blocks/arduino_generators.js new file mode 100644 index 0000000..66d62c6 --- /dev/null +++ b/src/blocks/arduino_generators.js @@ -0,0 +1,214 @@ +import { arduinoGenerator, Order } from '../generators/arduino.js'; + +// ─── Pin I/O ────────────────────────────────────────────── + +arduinoGenerator.forBlock['pin_set_mode'] = function (block) { + const pin = block.getFieldValue('PIN'); + const mode = block.getFieldValue('MODE'); + const modeMap = { OUT: 'OUTPUT', IN: 'INPUT', IN_PULLUP: 'INPUT_PULLUP' }; + arduinoGenerator.addSetupCode(`pinMode(${pin}, ${modeMap[mode] || 'OUTPUT'});`); + return ''; +}; + +arduinoGenerator.forBlock['pin_digital_write'] = function (block) { + const pin = block.getFieldValue('PIN'); + const value = block.getFieldValue('VALUE'); + return `digitalWrite(${pin}, ${value === '1' ? 'HIGH' : 'LOW'});\n`; +}; + +arduinoGenerator.forBlock['pin_digital_read'] = function (block) { + const pin = block.getFieldValue('PIN'); + return [`digitalRead(${pin})`, Order.FUNCTION_CALL]; +}; + +// ─── PWM ────────────────────────────────────────────────── + +arduinoGenerator.forBlock['pwm_init'] = function () { + return ''; +}; + +arduinoGenerator.forBlock['pwm_set_duty'] = function (block) { + const pin = block.getFieldValue('PIN'); + const duty = arduinoGenerator.valueToCode(block, 'DUTY', Order.NONE) || '0'; + return `analogWrite(${pin}, ${duty});\n`; +}; + +arduinoGenerator.forBlock['pwm_set_freq'] = function () { + return '// PWM frequency change not supported on AVR\n'; +}; + +// ─── ADC ────────────────────────────────────────────────── + +arduinoGenerator.forBlock['adc_read'] = function (block) { + const pin = block.getFieldValue('PIN'); + return [`analogRead(${pin})`, Order.FUNCTION_CALL]; +}; + +// ─── Sensors (sonar / HC-SR04) ──────────────────────────── + +arduinoGenerator.forBlock['sonar_distance'] = function (block) { + const trig = arduinoGenerator.valueToCode(block, 'TRIG', Order.NONE) || '2'; + const echo = arduinoGenerator.valueToCode(block, 'ECHO', Order.NONE) || '3'; + + arduinoGenerator.addDeclaration('sonar_helper', + 'float _sonar_cm(int trigPin, int echoPin) {\n' + + ' pinMode(trigPin, OUTPUT);\n' + + ' pinMode(echoPin, INPUT);\n' + + ' digitalWrite(trigPin, LOW);\n' + + ' delayMicroseconds(2);\n' + + ' digitalWrite(trigPin, HIGH);\n' + + ' delayMicroseconds(10);\n' + + ' digitalWrite(trigPin, LOW);\n' + + ' long d = pulseIn(echoPin, HIGH, 30000);\n' + + ' return d > 0 ? d / 58.0 : -1;\n' + + '}' + ); + + return [`_sonar_cm(${trig}, ${echo})`, Order.FUNCTION_CALL]; +}; + +// ─── Time ───────────────────────────────────────────────── + +arduinoGenerator.forBlock['sleep_seconds'] = function (block) { + const seconds = arduinoGenerator.valueToCode(block, 'SECONDS', Order.MULTIPLICATIVE) || '1'; + return `delay(${seconds} * 1000);\n`; +}; + +arduinoGenerator.forBlock['sleep_ms'] = function (block) { + const ms = arduinoGenerator.valueToCode(block, 'MS', Order.NONE) || '100'; + return `delay(${ms});\n`; +}; + +arduinoGenerator.forBlock['ticks_ms'] = function () { + return ['millis()', Order.FUNCTION_CALL]; +}; + +// ─── Random ─────────────────────────────────────────────── + +arduinoGenerator.forBlock['random_int'] = function (block) { + const from_ = arduinoGenerator.valueToCode(block, 'FROM', Order.NONE) || '0'; + const to = arduinoGenerator.valueToCode(block, 'TO', Order.ADDITIVE) || '10'; + return [`random(${from_}, ${to} + 1)`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['random_float'] = function () { + return ['(random(10001) / 10000.0)', Order.MULTIPLICATIVE]; +}; + +arduinoGenerator.forBlock['random_uniform'] = function (block) { + const low = arduinoGenerator.valueToCode(block, 'LOW', Order.NONE) || '0'; + const high = arduinoGenerator.valueToCode(block, 'HIGH', Order.NONE) || '1'; + return [`(${low} + (random(10001) / 10000.0) * (${high} - ${low}))`, Order.ADDITIVE]; +}; + +arduinoGenerator.forBlock['random_seed'] = function (block) { + const seed = arduinoGenerator.valueToCode(block, 'SEED', Order.NONE) || '0'; + return `randomSeed(${seed});\n`; +}; + +// ─── Print ──────────────────────────────────────────────── + +arduinoGenerator.forBlock['print_text'] = function (block) { + arduinoGenerator.addSetupCode('Serial.begin(9600);'); + const text = arduinoGenerator.valueToCode(block, 'TEXT', Order.NONE) || '""'; + return `Serial.println(${text});\n`; +}; + +// ─── Sound (shared blocks) ──────────────────────────────── + +arduinoGenerator.forBlock['sound_play_tone'] = function (block) { + const pin = block.getFieldValue('PIN'); + const freq = arduinoGenerator.valueToCode(block, 'FREQ', Order.NONE) || '440'; + const duration = arduinoGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500'; + return `tone(${pin}, ${freq}, ${duration});\n`; +}; + +arduinoGenerator.forBlock['sound_stop'] = function (block) { + const pin = block.getFieldValue('PIN'); + return `noTone(${pin});\n`; +}; + +arduinoGenerator.forBlock['sound_play_tone_speaker'] = function () { + return '// Built-in speaker not available on Arduino\n'; +}; + +arduinoGenerator.forBlock['sound_stop_speaker'] = function () { + return '// Built-in speaker not available on Arduino\n'; +}; + +// ─── Arduino-specific blocks ────────────────────────────── + +arduinoGenerator.forBlock['arduino_builtin_led'] = function (block) { + const state = block.getFieldValue('STATE'); + arduinoGenerator.addSetupCode('pinMode(LED_BUILTIN, OUTPUT);'); + return `digitalWrite(LED_BUILTIN, ${state === '1' ? 'HIGH' : 'LOW'});\n`; +}; + +arduinoGenerator.forBlock['arduino_analog_write'] = function (block) { + const pin = block.getFieldValue('PIN'); + const value = arduinoGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0'; + return `analogWrite(${pin}, ${value});\n`; +}; + +arduinoGenerator.forBlock['arduino_analog_read'] = function (block) { + const ch = block.getFieldValue('CHANNEL'); + return [`analogRead(A${ch})`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['arduino_servo_attach'] = function (block) { + const pin = block.getFieldValue('PIN'); + arduinoGenerator.addInclude('include_servo', '#include '); + arduinoGenerator.addDeclaration(`servo_decl_${pin}`, `Servo servo_${pin};`); + arduinoGenerator.addSetupCode(`servo_${pin}.attach(${pin});`); + return ''; +}; + +arduinoGenerator.forBlock['arduino_servo_write'] = function (block) { + const pin = block.getFieldValue('PIN'); + const angle = arduinoGenerator.valueToCode(block, 'ANGLE', Order.NONE) || '90'; + arduinoGenerator.addInclude('include_servo', '#include '); + arduinoGenerator.addDeclaration(`servo_decl_${pin}`, `Servo servo_${pin};`); + arduinoGenerator.addSetupCode(`servo_${pin}.attach(${pin});`); + return `servo_${pin}.write(${angle});\n`; +}; + +arduinoGenerator.forBlock['arduino_tone'] = function (block) { + const pin = block.getFieldValue('PIN'); + const freq = arduinoGenerator.valueToCode(block, 'FREQ', Order.NONE) || '440'; + const duration = arduinoGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500'; + return `tone(${pin}, ${freq}, ${duration});\n`; +}; + +arduinoGenerator.forBlock['arduino_no_tone'] = function (block) { + const pin = block.getFieldValue('PIN'); + return `noTone(${pin});\n`; +}; + +arduinoGenerator.forBlock['arduino_map'] = function (block) { + const value = arduinoGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0'; + const fromLow = arduinoGenerator.valueToCode(block, 'FROM_LOW', Order.NONE) || '0'; + const fromHigh = arduinoGenerator.valueToCode(block, 'FROM_HIGH', Order.NONE) || '1023'; + const toLow = arduinoGenerator.valueToCode(block, 'TO_LOW', Order.NONE) || '0'; + const toHigh = arduinoGenerator.valueToCode(block, 'TO_HIGH', Order.NONE) || '255'; + return [`map(${value}, ${fromLow}, ${fromHigh}, ${toLow}, ${toHigh})`, Order.FUNCTION_CALL]; +}; + +// ─── Blocks not applicable to Arduino ───────────────────── + +const unsupportedBlocks = [ + 'wifi_connect', 'wifi_get_ip', + 'neopixel_init', 'neopixel_set_color', 'neopixel_show', + 'colour_rgb', 'tuple_create_3', + 'i2c_init', 'i2c_scan', 'i2c_writeto', 'i2c_readfrom', + 'hid_key_press', 'hid_key_down', 'hid_key_up', 'hid_keyboard_type', + 'hid_mouse_move', 'hid_mouse_click', 'hid_mouse_scroll', + 'hid_gamepad_button', 'hid_gamepad_axis', + 'microbit_display_all', + 'superbit_motor_run', 'superbit_motor_stop_all', +]; + +for (const blockType of unsupportedBlocks) { + arduinoGenerator.forBlock[blockType] = function () { + return '// Block not supported on Arduino\n'; + }; +} diff --git a/src/blocks/esp32_blocks.js b/src/blocks/esp32_blocks.js index 0d20135..f430cb1 100644 --- a/src/blocks/esp32_blocks.js +++ b/src/blocks/esp32_blocks.js @@ -672,3 +672,134 @@ Blockly.Blocks['sonar_distance'] = { this.setTooltip('HC-SR04 style ultrasonic distance in cm. Trigger and echo pin numbers.'); }, }; + +// ─── Arduino-specific blocks ────────────────────────────── + +Blockly.Blocks['arduino_builtin_led'] = { + init() { + this.appendDummyInput() + .appendField('set built-in LED') + .appendField(new Blockly.FieldDropdown([ + ['ON', '1'], + ['OFF', '0'], + ]), 'STATE'); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(230); + this.setTooltip('Turn the built-in LED (pin 13) on or off'); + }, +}; + +Blockly.Blocks['arduino_analog_write'] = { + init() { + this.appendValueInput('VALUE') + .setCheck('Number') + .appendField('analog write pin') + .appendField(new Blockly.FieldDropdown([ + ['3', '3'], ['5', '5'], ['6', '6'], + ['9', '9'], ['10', '10'], ['11', '11'], + ]), 'PIN') + .appendField('value'); + this.appendDummyInput().appendField('(0-255)'); + this.setInputsInline(true); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(160); + this.setTooltip('Write a PWM value (0-255) to a PWM-capable pin'); + }, +}; + +Blockly.Blocks['arduino_analog_read'] = { + init() { + this.appendDummyInput() + .appendField('analog read') + .appendField(new Blockly.FieldDropdown([ + ['A0', '0'], ['A1', '1'], ['A2', '2'], + ['A3', '3'], ['A4', '4'], ['A5', '5'], + ]), 'CHANNEL'); + this.setOutput(true, 'Number'); + this.setColour(30); + this.setTooltip('Read analog value from an ADC channel (0-1023)'); + }, +}; + +Blockly.Blocks['arduino_servo_attach'] = { + init() { + this.appendDummyInput() + .appendField('attach servo on pin') + .appendField(new Blockly.FieldDropdown([ + ['9', '9'], ['10', '10'], ['11', '11'], + ['3', '3'], ['5', '5'], ['6', '6'], + ]), 'PIN'); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(260); + this.setTooltip('Attach a servo motor to a PWM pin'); + }, +}; + +Blockly.Blocks['arduino_servo_write'] = { + init() { + this.appendValueInput('ANGLE') + .setCheck('Number') + .appendField('set servo on pin') + .appendField(new Blockly.FieldDropdown([ + ['9', '9'], ['10', '10'], ['11', '11'], + ['3', '3'], ['5', '5'], ['6', '6'], + ]), 'PIN') + .appendField('to'); + this.appendDummyInput().appendField('degrees'); + this.setInputsInline(true); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(260); + this.setTooltip('Set servo angle (0-180 degrees)'); + }, +}; + +Blockly.Blocks['arduino_tone'] = { + init() { + this.appendDummyInput() + .appendField('play tone on pin') + .appendField(new Blockly.FieldNumber(8, 0, 13, 1), 'PIN'); + this.appendValueInput('FREQ') + .setCheck('Number') + .appendField('freq'); + this.appendDummyInput().appendField('Hz'); + this.appendValueInput('DURATION') + .setCheck('Number') + .appendField('for'); + this.appendDummyInput().appendField('ms'); + this.setInputsInline(true); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(300); + this.setTooltip('Play a tone on a digital pin using PWM'); + }, +}; + +Blockly.Blocks['arduino_no_tone'] = { + init() { + this.appendDummyInput() + .appendField('stop tone on pin') + .appendField(new Blockly.FieldNumber(8, 0, 13, 1), 'PIN'); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour(300); + this.setTooltip('Stop any tone playing on a digital pin'); + }, +}; + +Blockly.Blocks['arduino_map'] = { + init() { + this.appendValueInput('VALUE').setCheck('Number').appendField('map'); + this.appendValueInput('FROM_LOW').setCheck('Number').appendField('from'); + this.appendValueInput('FROM_HIGH').setCheck('Number').appendField('-'); + this.appendValueInput('TO_LOW').setCheck('Number').appendField('to'); + this.appendValueInput('TO_HIGH').setCheck('Number').appendField('-'); + this.setInputsInline(true); + this.setOutput(true, 'Number'); + this.setColour(200); + this.setTooltip('Re-map a number from one range to another'); + }, +}; diff --git a/src/devices/arduino_uno.js b/src/devices/arduino_uno.js new file mode 100644 index 0000000..44a9a33 --- /dev/null +++ b/src/devices/arduino_uno.js @@ -0,0 +1,92 @@ +import { pinIoCategory } from '../blocks/categories/pinIo.js'; +import { sensorsCategory } from '../blocks/categories/sensors.js'; +import { timeCategory } from '../blocks/categories/time.js'; +import { serialPrintCategory } from '../blocks/categories/serialPrint.js'; +import { randomCategory } from '../blocks/categories/random.js'; + +const analogCategory = { + kind: 'category', + name: 'Analog', + colour: '30', + contents: [ + { kind: 'block', type: 'arduino_analog_read' }, + { + kind: 'block', + type: 'arduino_analog_write', + inputs: { + VALUE: { shadow: { type: 'math_number', fields: { NUM: 128 } } }, + }, + }, + { + kind: 'block', + type: 'arduino_map', + inputs: { + VALUE: { shadow: { type: 'math_number', fields: { NUM: 512 } } }, + FROM_LOW: { shadow: { type: 'math_number', fields: { NUM: 0 } } }, + FROM_HIGH: { shadow: { type: 'math_number', fields: { NUM: 1023 } } }, + TO_LOW: { shadow: { type: 'math_number', fields: { NUM: 0 } } }, + TO_HIGH: { shadow: { type: 'math_number', fields: { NUM: 255 } } }, + }, + }, + ], +}; + +const servoCategory = { + kind: 'category', + name: 'Servo', + colour: '260', + contents: [ + { kind: 'block', type: 'arduino_servo_attach' }, + { + kind: 'block', + type: 'arduino_servo_write', + inputs: { + ANGLE: { shadow: { type: 'math_number', fields: { NUM: 90 } } }, + }, + }, + ], +}; + +const arduinoSoundCategory = { + kind: 'category', + name: 'Sound', + colour: '300', + contents: [ + { + kind: 'block', + type: 'arduino_tone', + inputs: { + FREQ: { shadow: { type: 'math_number', fields: { NUM: 440 } } }, + DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } }, + }, + }, + { kind: 'block', type: 'arduino_no_tone' }, + ], +}; + +const arduinoPinIoCategory = { + kind: 'category', + name: 'Pin I/O', + colour: '230', + contents: [ + ...pinIoCategory.contents, + { kind: 'block', type: 'arduino_builtin_led' }, + ], +}; + +export const arduinoUno = { + id: 'arduino_uno', + label: 'Arduino Uno/Nano', + language: 'arduino', + firmware: null, + categories: [ + arduinoPinIoCategory, + analogCategory, + servoCategory, + arduinoSoundCategory, + sensorsCategory, + timeCategory, + serialPrintCategory, + randomCategory, + ], +}; diff --git a/src/devices/registry.js b/src/devices/registry.js index 9583dc1..3de0482 100644 --- a/src/devices/registry.js +++ b/src/devices/registry.js @@ -1,6 +1,7 @@ import { esp32s3 } from './esp32s3.js'; import { microbit } from './microbit.js'; import { rp2040 } from './rp2040.js'; +import { arduinoUno } from './arduino_uno.js'; import { builtinCategories } from '../blocks/categories/builtins.js'; const devices = {}; @@ -12,6 +13,7 @@ function register(profile) { register(esp32s3); register(microbit); register(rp2040); +register(arduinoUno); /** * Register a new device at runtime (e.g. a sub-device like "ESP32 robot"). diff --git a/src/generators/arduino.js b/src/generators/arduino.js new file mode 100644 index 0000000..b577088 --- /dev/null +++ b/src/generators/arduino.js @@ -0,0 +1,149 @@ +import * as Blockly from 'blockly'; + +// C++ operator precedence (lower number = tighter binding) +export const Order = { + ATOMIC: 0, + MEMBER: 1, + FUNCTION_CALL: 2, + POSTFIX: 3, + UNARY_PREFIX: 4, + MULTIPLICATIVE: 5, + ADDITIVE: 6, + SHIFT: 7, + RELATIONAL: 8, + EQUALITY: 9, + BITWISE_AND: 10, + BITWISE_XOR: 11, + BITWISE_OR: 12, + LOGICAL_AND: 13, + LOGICAL_OR: 14, + CONDITIONAL: 15, + ASSIGNMENT: 16, + COMMA: 17, + NONE: 99, +}; + +class ArduinoGenerator extends Blockly.CodeGenerator { + constructor() { + super('Arduino'); + this.INDENT = ' '; + + this.addReservedWords( + 'setup,loop,void,int,long,float,double,char,bool,byte,unsigned,' + + 'String,HIGH,LOW,INPUT,OUTPUT,INPUT_PULLUP,LED_BUILTIN,true,false,' + + 'Serial,Wire,SPI,Servo,pinMode,digitalWrite,digitalRead,' + + 'analogWrite,analogRead,delay,delayMicroseconds,millis,micros,' + + 'tone,noTone,pulseIn,map,constrain,min,max,abs,random,randomSeed,' + + 'sizeof,return,if,else,for,while,do,switch,case,break,continue,' + + 'define,include,class,struct,enum,typedef,static,const,volatile' + ); + + this.ORDER_OVERRIDES = [ + [Order.FUNCTION_CALL, Order.MEMBER], + [Order.MEMBER, Order.FUNCTION_CALL], + ]; + } + + init(workspace) { + super.init(workspace); + this.definitions_ = Object.create(null); + this.setupCode_ = []; + this.functionDefinitions_ = Object.create(null); + if (!this.nameDB_) { + this.nameDB_ = new Blockly.Names(this.RESERVED_WORDS_); + } else { + this.nameDB_.reset(); + } + this.nameDB_.setVariableMap(workspace.getVariableMap()); + this.nameDB_.populateVariables(workspace); + this.nameDB_.populateProcedures(workspace); + this.isInitialized = true; + } + + finish(code) { + const includes = []; + const globals = []; + for (const [key, val] of Object.entries(this.definitions_)) { + if (val.startsWith('#include')) { + includes.push(val); + } else { + globals.push(val); + } + } + + const funcDefs = Object.values(this.functionDefinitions_); + + const setupBody = this.setupCode_.map(s => this.INDENT + s).join('\n'); + const indentedCode = code ? this.prefixLines(code, this.INDENT) : ''; + + const parts = []; + if (includes.length) parts.push(includes.join('\n')); + if (globals.length) parts.push(globals.join('\n')); + if (funcDefs.length) parts.push(funcDefs.join('\n\n')); + + const setupContent = [setupBody, indentedCode].filter(Boolean).join('\n'); + parts.push(`void setup() {\n${setupContent}\n}`); + parts.push('void loop() {\n}'); + + super.finish(''); + this.isInitialized = false; + return parts.join('\n\n') + '\n'; + } + + scrubNakedValue(line) { + return line + ';\n'; + } + + quote_(str) { + str = str.replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/"/g, '\\"'); + return '"' + str + '"'; + } + + scrub_(block, code, opt_thisOnly) { + let commentCode = ''; + if (!block.outputConnection || !block.outputConnection.targetBlock()) { + let comment = block.getCommentText(); + if (comment) { + comment = Blockly.utils.string.wrap(comment, this.COMMENT_WRAP - 3); + commentCode += this.prefixLines(comment + '\n', '// '); + } + for (let i = 0; i < block.inputList.length; i++) { + const input = block.inputList[i]; + if (input.connection && input.connection.type === 1) { + const childBlock = input.connection.targetBlock(); + if (childBlock) { + comment = this.allNestedComments(childBlock); + if (comment) { + commentCode += this.prefixLines(comment, '// '); + } + } + } + } + } + const nextBlock = block.nextConnection && block.nextConnection.targetBlock(); + const nextCode = opt_thisOnly ? '' : this.blockToCode(nextBlock); + return commentCode + code + nextCode; + } + + getVariableName(nameOrId) { + return this.nameDB_.getName(nameOrId, Blockly.Names.NameType.VARIABLE); + } + + addSetupCode(code) { + if (this.setupCode_.indexOf(code) === -1) { + this.setupCode_.push(code); + } + } + + addInclude(key, code) { + this.definitions_[key] = code; + } + + addDeclaration(key, code) { + this.definitions_[key] = code; + } +} + +export const arduinoGenerator = new ArduinoGenerator(); diff --git a/src/generators/arduino_builtins.js b/src/generators/arduino_builtins.js new file mode 100644 index 0000000..d3d827a --- /dev/null +++ b/src/generators/arduino_builtins.js @@ -0,0 +1,430 @@ +import * as Blockly from 'blockly'; +import { arduinoGenerator, Order } from './arduino.js'; + +// ═══════════════════════════════════════════════════════════ +// Logic +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['controls_if'] = function (block) { + let n = 0; + let code = ''; + if (arduinoGenerator.STATEMENT_PREFIX) { + code += arduinoGenerator.injectId(arduinoGenerator.STATEMENT_PREFIX, block); + } + do { + const conditionCode = arduinoGenerator.valueToCode(block, 'IF' + n, Order.NONE) || 'false'; + let branchCode = arduinoGenerator.statementToCode(block, 'DO' + n); + if (arduinoGenerator.STATEMENT_SUFFIX) { + branchCode = arduinoGenerator.prefixLines( + arduinoGenerator.injectId(arduinoGenerator.STATEMENT_SUFFIX, block), arduinoGenerator.INDENT + ) + branchCode; + } + code += (n > 0 ? ' else ' : '') + 'if (' + conditionCode + ') {\n' + branchCode + '}'; + n++; + } while (block.getInput('IF' + n)); + if (block.getInput('ELSE') || block.getInput('ELSE')) { + let branchCode = arduinoGenerator.statementToCode(block, 'ELSE'); + if (arduinoGenerator.STATEMENT_SUFFIX) { + branchCode = arduinoGenerator.prefixLines( + arduinoGenerator.injectId(arduinoGenerator.STATEMENT_SUFFIX, block), arduinoGenerator.INDENT + ) + branchCode; + } + code += ' else {\n' + branchCode + '}'; + } + return code + '\n'; +}; + +arduinoGenerator.forBlock['logic_compare'] = function (block) { + const OPERATORS = { + EQ: '==', NEQ: '!=', LT: '<', LTE: '<=', GT: '>', GTE: '>=', + }; + const op = OPERATORS[block.getFieldValue('OP')]; + const order = (op === '==' || op === '!=') ? Order.EQUALITY : Order.RELATIONAL; + const a = arduinoGenerator.valueToCode(block, 'A', order) || '0'; + const b = arduinoGenerator.valueToCode(block, 'B', order) || '0'; + return [a + ' ' + op + ' ' + b, order]; +}; + +arduinoGenerator.forBlock['logic_operation'] = function (block) { + const op = block.getFieldValue('OP') === 'AND' ? '&&' : '||'; + const order = op === '&&' ? Order.LOGICAL_AND : Order.LOGICAL_OR; + let a = arduinoGenerator.valueToCode(block, 'A', order); + let b = arduinoGenerator.valueToCode(block, 'B', order); + if (!a && !b) { + a = 'false'; + b = 'false'; + } else { + if (!a) a = 'true'; + if (!b) b = 'true'; + } + return [a + ' ' + op + ' ' + b, order]; +}; + +arduinoGenerator.forBlock['logic_negate'] = function (block) { + const order = Order.UNARY_PREFIX; + const arg = arduinoGenerator.valueToCode(block, 'BOOL', order) || 'true'; + return ['!' + arg, order]; +}; + +arduinoGenerator.forBlock['logic_boolean'] = function (block) { + return [block.getFieldValue('BOOL') === 'TRUE' ? 'true' : 'false', Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['logic_null'] = function () { + return ['NULL', Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['logic_ternary'] = function (block) { + const cond = arduinoGenerator.valueToCode(block, 'IF', Order.CONDITIONAL) || 'false'; + const thenVal = arduinoGenerator.valueToCode(block, 'THEN', Order.CONDITIONAL) || 'NULL'; + const elseVal = arduinoGenerator.valueToCode(block, 'ELSE', Order.CONDITIONAL) || 'NULL'; + return ['(' + cond + ' ? ' + thenVal + ' : ' + elseVal + ')', Order.CONDITIONAL]; +}; + +// ═══════════════════════════════════════════════════════════ +// Loops +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['controls_repeat_ext'] = function (block) { + let repeats = arduinoGenerator.valueToCode(block, 'TIMES', Order.ASSIGNMENT) || '0'; + let branch = arduinoGenerator.statementToCode(block, 'DO'); + branch = arduinoGenerator.addLoopTrap(branch, block); + let loopVar = arduinoGenerator.nameDB_.getDistinctName('count', Blockly.Names.NameType.VARIABLE); + return `for (int ${loopVar} = 0; ${loopVar} < ${repeats}; ${loopVar}++) {\n${branch}}\n`; +}; + +arduinoGenerator.forBlock['controls_whileUntil'] = function (block) { + const until = block.getFieldValue('MODE') === 'UNTIL'; + let cond = arduinoGenerator.valueToCode(block, 'BOOL', until ? Order.UNARY_PREFIX : Order.NONE) || 'false'; + let branch = arduinoGenerator.statementToCode(block, 'DO'); + branch = arduinoGenerator.addLoopTrap(branch, block); + if (until) { + cond = '!' + cond; + } + return `while (${cond}) {\n${branch}}\n`; +}; + +arduinoGenerator.forBlock['controls_for'] = function (block) { + const variable0 = arduinoGenerator.getVariableName(block.getFieldValue('VAR')); + const from_ = arduinoGenerator.valueToCode(block, 'FROM', Order.ASSIGNMENT) || '0'; + const to = arduinoGenerator.valueToCode(block, 'TO', Order.ASSIGNMENT) || '0'; + const by = arduinoGenerator.valueToCode(block, 'BY', Order.ASSIGNMENT) || '1'; + return `for (int ${variable0} = ${from_}; ${variable0} <= ${to}; ${variable0} += ${by}) {\n` + + arduinoGenerator.statementToCode(block, 'DO') + '}\n'; +}; + +arduinoGenerator.forBlock['controls_forEach'] = function (block) { + // C++ for-each is limited; generate index-based loop comment + const variable0 = arduinoGenerator.getVariableName(block.getFieldValue('VAR')); + const list = arduinoGenerator.valueToCode(block, 'LIST', Order.ASSIGNMENT) || '""'; + let branch = arduinoGenerator.statementToCode(block, 'DO'); + return `// for-each not directly supported; use indexed loop\n`; +}; + +arduinoGenerator.forBlock['controls_flow_statements'] = function (block) { + switch (block.getFieldValue('FLOW')) { + case 'BREAK': return 'break;\n'; + case 'CONTINUE': return 'continue;\n'; + } + return ''; +}; + +// ═══════════════════════════════════════════════════════════ +// Math +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['math_number'] = function (block) { + const num = Number(block.getFieldValue('NUM')); + const order = num >= 0 ? Order.ATOMIC : Order.UNARY_PREFIX; + return [String(num), order]; +}; + +arduinoGenerator.forBlock['math_arithmetic'] = function (block) { + const OPERATORS = { + ADD: [' + ', Order.ADDITIVE], + MINUS: [' - ', Order.ADDITIVE], + MULTIPLY: [' * ', Order.MULTIPLICATIVE], + DIVIDE: [' / ', Order.MULTIPLICATIVE], + POWER: [null, Order.FUNCTION_CALL], + }; + const tuple = OPERATORS[block.getFieldValue('OP')]; + const op = tuple[0]; + const order = tuple[1]; + const a = arduinoGenerator.valueToCode(block, 'A', order) || '0'; + const b = arduinoGenerator.valueToCode(block, 'B', order) || '0'; + if (!op) { + return [`pow(${a}, ${b})`, Order.FUNCTION_CALL]; + } + return [a + op + b, order]; +}; + +arduinoGenerator.forBlock['math_single'] = function (block) { + const op = block.getFieldValue('OP'); + let arg = arduinoGenerator.valueToCode(block, 'NUM', Order.NONE) || '0'; + arduinoGenerator.addInclude('include_math', '#include '); + switch (op) { + case 'ROOT': return [`sqrt(${arg})`, Order.FUNCTION_CALL]; + case 'ABS': return [`abs(${arg})`, Order.FUNCTION_CALL]; + case 'NEG': return [`-${arg}`, Order.UNARY_PREFIX]; + case 'LN': return [`log(${arg})`, Order.FUNCTION_CALL]; + case 'LOG10': return [`log10(${arg})`, Order.FUNCTION_CALL]; + case 'EXP': return [`exp(${arg})`, Order.FUNCTION_CALL]; + case 'POW10': return [`pow(10, ${arg})`, Order.FUNCTION_CALL]; + } + return [arg, Order.NONE]; +}; + +arduinoGenerator.forBlock['math_trig'] = function (block) { + const op = block.getFieldValue('OP'); + const arg = arduinoGenerator.valueToCode(block, 'NUM', Order.NONE) || '0'; + arduinoGenerator.addInclude('include_math', '#include '); + const RAD = `(${arg} * M_PI / 180.0)`; + switch (op) { + case 'SIN': return [`sin(${RAD})`, Order.FUNCTION_CALL]; + case 'COS': return [`cos(${RAD})`, Order.FUNCTION_CALL]; + case 'TAN': return [`tan(${RAD})`, Order.FUNCTION_CALL]; + case 'ASIN': return [`(asin(${arg}) * 180.0 / M_PI)`, Order.MULTIPLICATIVE]; + case 'ACOS': return [`(acos(${arg}) * 180.0 / M_PI)`, Order.MULTIPLICATIVE]; + case 'ATAN': return [`(atan(${arg}) * 180.0 / M_PI)`, Order.MULTIPLICATIVE]; + } + return ['0', Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['math_constant'] = function (block) { + arduinoGenerator.addInclude('include_math', '#include '); + const CONSTANTS = { + PI: ['M_PI', Order.ATOMIC], + E: ['M_E', Order.ATOMIC], + GOLDEN_RATIO: ['1.61803398875', Order.ATOMIC], + SQRT2: ['M_SQRT2', Order.ATOMIC], + SQRT1_2: ['M_SQRT1_2', Order.ATOMIC], + INFINITY: ['INFINITY', Order.ATOMIC], + }; + return CONSTANTS[block.getFieldValue('CONSTANT')] || ['0', Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['math_number_property'] = function (block) { + const property = block.getFieldValue('PROPERTY'); + const num = arduinoGenerator.valueToCode(block, 'NUMBER_TO_CHECK', Order.MULTIPLICATIVE) || '0'; + switch (property) { + case 'EVEN': return [`((int)(${num}) % 2 == 0)`, Order.EQUALITY]; + case 'ODD': return [`((int)(${num}) % 2 == 1)`, Order.EQUALITY]; + case 'PRIME': + arduinoGenerator.addDeclaration('_is_prime', + 'bool _is_prime(int n) {\n' + + ' if (n < 2) return false;\n' + + ' for (int i = 2; i * i <= n; i++) { if (n % i == 0) return false; }\n' + + ' return true;\n' + + '}' + ); + return [`_is_prime((int)(${num}))`, Order.FUNCTION_CALL]; + case 'WHOLE': return [`(${num} == (int)(${num}))`, Order.EQUALITY]; + case 'POSITIVE': return [`(${num} > 0)`, Order.RELATIONAL]; + case 'NEGATIVE': return [`(${num} < 0)`, Order.RELATIONAL]; + case 'DIVISIBLE_BY': + const divisor = arduinoGenerator.valueToCode(block, 'DIVISOR', Order.MULTIPLICATIVE) || '1'; + return [`((int)(${num}) % (int)(${divisor}) == 0)`, Order.EQUALITY]; + } + return ['false', Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['math_round'] = function (block) { + const op = block.getFieldValue('OP'); + const arg = arduinoGenerator.valueToCode(block, 'NUM', Order.NONE) || '0'; + arduinoGenerator.addInclude('include_math', '#include '); + switch (op) { + case 'ROUND': return [`round(${arg})`, Order.FUNCTION_CALL]; + case 'ROUNDUP': return [`ceil(${arg})`, Order.FUNCTION_CALL]; + case 'ROUNDDOWN': return [`floor(${arg})`, Order.FUNCTION_CALL]; + } + return [arg, Order.NONE]; +}; + +arduinoGenerator.forBlock['math_modulo'] = function (block) { + const a = arduinoGenerator.valueToCode(block, 'DIVIDEND', Order.MULTIPLICATIVE) || '0'; + const b = arduinoGenerator.valueToCode(block, 'DIVISOR', Order.MULTIPLICATIVE) || '0'; + return [`(int)(${a}) % (int)(${b})`, Order.MULTIPLICATIVE]; +}; + +arduinoGenerator.forBlock['math_constrain'] = function (block) { + const val = arduinoGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0'; + const low = arduinoGenerator.valueToCode(block, 'LOW', Order.NONE) || '0'; + const high = arduinoGenerator.valueToCode(block, 'HIGH', Order.NONE) || '100'; + return [`constrain(${val}, ${low}, ${high})`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['math_random_int'] = function (block) { + const from_ = arduinoGenerator.valueToCode(block, 'FROM', Order.NONE) || '0'; + const to = arduinoGenerator.valueToCode(block, 'TO', Order.ADDITIVE) || '100'; + return [`random(${from_}, ${to} + 1)`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['math_random_float'] = function () { + return ['(random(10001) / 10000.0)', Order.MULTIPLICATIVE]; +}; + +// ═══════════════════════════════════════════════════════════ +// Text +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['text'] = function (block) { + return [arduinoGenerator.quote_(block.getFieldValue('TEXT')), Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['text_join'] = function (block) { + const count = block.itemCount_; + if (count === 0) { + return ['String("")', Order.FUNCTION_CALL]; + } + if (count === 1) { + const element = arduinoGenerator.valueToCode(block, 'ADD0', Order.NONE) || '""'; + return [`String(${element})`, Order.FUNCTION_CALL]; + } + const elements = []; + for (let i = 0; i < count; i++) { + elements.push(arduinoGenerator.valueToCode(block, 'ADD' + i, Order.NONE) || '""'); + } + // Build concatenation using String() + let code = `String(${elements[0]})`; + for (let i = 1; i < elements.length; i++) { + code += ` + String(${elements[i]})`; + } + return [code, Order.ADDITIVE]; +}; + +arduinoGenerator.forBlock['text_append'] = function (block) { + const varName = arduinoGenerator.getVariableName(block.getFieldValue('VAR')); + const value = arduinoGenerator.valueToCode(block, 'TEXT', Order.NONE) || '""'; + return `${varName} += String(${value});\n`; +}; + +arduinoGenerator.forBlock['text_length'] = function (block) { + const text = arduinoGenerator.valueToCode(block, 'VALUE', Order.MEMBER) || '""'; + return [`String(${text}).length()`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['text_isEmpty'] = function (block) { + const text = arduinoGenerator.valueToCode(block, 'VALUE', Order.MEMBER) || '""'; + return [`(String(${text}).length() == 0)`, Order.EQUALITY]; +}; + +arduinoGenerator.forBlock['text_indexOf'] = function (block) { + const operator = block.getFieldValue('END') === 'FIRST' ? 'indexOf' : 'lastIndexOf'; + const substr = arduinoGenerator.valueToCode(block, 'FIND', Order.NONE) || '""'; + const text = arduinoGenerator.valueToCode(block, 'VALUE', Order.MEMBER) || '""'; + return [`String(${text}).${operator}(String(${substr}))`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['text_charAt'] = function (block) { + const where = block.getFieldValue('WHERE') || 'FROM_START'; + const text = arduinoGenerator.valueToCode(block, 'VALUE', Order.MEMBER) || '""'; + let at; + switch (where) { + case 'FROM_START': + at = arduinoGenerator.valueToCode(block, 'AT', Order.NONE) || '1'; + return [`String(String(${text}).charAt(${at} - 1))`, Order.FUNCTION_CALL]; + case 'FROM_END': + at = arduinoGenerator.valueToCode(block, 'AT', Order.NONE) || '1'; + return [`String(String(${text}).charAt(String(${text}).length() - ${at}))`, Order.FUNCTION_CALL]; + case 'FIRST': + return [`String(String(${text}).charAt(0))`, Order.FUNCTION_CALL]; + case 'LAST': + return [`String(String(${text}).charAt(String(${text}).length() - 1))`, Order.FUNCTION_CALL]; + case 'RANDOM': + return [`String(String(${text}).charAt(random(String(${text}).length())))`, Order.FUNCTION_CALL]; + } + return ['""', Order.ATOMIC]; +}; + +// ═══════════════════════════════════════════════════════════ +// Variables +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['variables_get'] = function (block) { + const varName = arduinoGenerator.getVariableName(block.getFieldValue('VAR')); + return [varName, Order.ATOMIC]; +}; + +arduinoGenerator.forBlock['variables_set'] = function (block) { + const varName = arduinoGenerator.getVariableName(block.getFieldValue('VAR')); + const value = arduinoGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || '0'; + return `${varName} = ${value};\n`; +}; + +// ═══════════════════════════════════════════════════════════ +// Functions / Procedures +// ═══════════════════════════════════════════════════════════ + +arduinoGenerator.forBlock['procedures_defreturn'] = function (block) { + const funcName = arduinoGenerator.getProcedureName(block.getFieldValue('NAME')); + let xfix1 = ''; + if (arduinoGenerator.STATEMENT_PREFIX) { + xfix1 += arduinoGenerator.injectId(arduinoGenerator.STATEMENT_PREFIX, block); + } + if (arduinoGenerator.STATEMENT_SUFFIX) { + xfix1 += arduinoGenerator.injectId(arduinoGenerator.STATEMENT_SUFFIX, block); + } + if (xfix1) xfix1 = arduinoGenerator.prefixLines(xfix1, arduinoGenerator.INDENT); + + let branch = arduinoGenerator.statementToCode(block, 'STACK'); + let returnValue = arduinoGenerator.valueToCode(block, 'RETURN', Order.NONE) || ''; + let xfix2 = ''; + if (branch && returnValue) { + xfix2 = xfix1; + } + if (returnValue) { + returnValue = arduinoGenerator.INDENT + 'return ' + returnValue + ';\n'; + } + + const args = []; + const variables = block.getVars(); + for (let i = 0; i < variables.length; i++) { + args.push('float ' + arduinoGenerator.getVariableName(variables[i])); + } + + const returnType = returnValue ? 'float' : 'void'; + let code = `${returnType} ${funcName}(${args.join(', ')}) {\n${xfix1}${branch}${xfix2}${returnValue}}`; + arduinoGenerator.functionDefinitions_[funcName] = code; + return null; +}; + +arduinoGenerator.forBlock['procedures_defnoreturn'] = + arduinoGenerator.forBlock['procedures_defreturn']; + +arduinoGenerator.forBlock['procedures_callreturn'] = function (block) { + const funcName = arduinoGenerator.getProcedureName(block.getFieldValue('NAME')); + const args = []; + const variables = block.getVars(); + for (let i = 0; i < variables.length; i++) { + args.push(arduinoGenerator.valueToCode(block, 'ARG' + i, Order.NONE) || '0'); + } + return [`${funcName}(${args.join(', ')})`, Order.FUNCTION_CALL]; +}; + +arduinoGenerator.forBlock['procedures_callnoreturn'] = function (block) { + const funcName = arduinoGenerator.getProcedureName(block.getFieldValue('NAME')); + const args = []; + const variables = block.getVars(); + for (let i = 0; i < variables.length; i++) { + args.push(arduinoGenerator.valueToCode(block, 'ARG' + i, Order.NONE) || '0'); + } + return `${funcName}(${args.join(', ')});\n`; +}; + +arduinoGenerator.forBlock['procedures_ifreturn'] = function (block) { + const cond = arduinoGenerator.valueToCode(block, 'CONDITION', Order.NONE) || 'false'; + let code = 'if (' + cond + ') {\n'; + if (arduinoGenerator.STATEMENT_SUFFIX) { + code += arduinoGenerator.prefixLines( + arduinoGenerator.injectId(arduinoGenerator.STATEMENT_SUFFIX, block), arduinoGenerator.INDENT + ); + } + if (block.hasReturnValue_) { + const value = arduinoGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0'; + code += arduinoGenerator.INDENT + 'return ' + value + ';\n'; + } else { + code += arduinoGenerator.INDENT + 'return;\n'; + } + code += '}\n'; + return code; +}; diff --git a/src/main.js b/src/main.js index b3f4761..8e61e56 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,10 @@ 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, @@ -18,13 +21,14 @@ import { removeAddon, getInstalledAddons, } from './addons/loader.js'; -import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.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 { 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, refreshDeviceList } from './ui/projectsDialog.js'; +import { uploadHex, BOARDS } from './arduino/stk500.js'; import './style.css'; // ─── Blockly Workspace ─────────────────────────────────── @@ -67,13 +71,31 @@ setDeviceListRefreshCallback(rebuildDeviceSelect); 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 = pythonGenerator.workspaceToCode(workspace); - codeOutput.textContent = code || '# Drag blocks to generate MicroPython code'; + 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) => { @@ -167,6 +189,52 @@ function hideSendProgress() { 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; + + 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(); + + 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', () => { @@ -174,26 +242,33 @@ deviceSelect.addEventListener('change', () => { refreshToolbox(); refreshCustomizer(); updateCodePreview(); - btnFlash.title = canFlashInBrowser() - ? 'Flash MicroPython firmware' - : 'Download firmware (drag to device)'; + updateDeviceUI(); }); -btnFlash.title = canFlashInBrowser() - ? 'Flash MicroPython firmware' - : 'Download firmware (drag to device)'; +updateDeviceUI(); 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'; + if (isArduinoDevice()) { + btnConnect.innerHTML = connected + ? ' Disconnect Monitor' + : ' Serial Monitor'; + terminalInput.disabled = !connected; + statusEl.textContent = connected ? 'Monitoring' : 'Disconnected'; + statusEl.className = connected ? 'status-connected' : 'status-disconnected'; + } else { + btnConnect.innerHTML = 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) ───────────── @@ -322,6 +397,8 @@ flashCloseBtn.addEventListener('click', () => { }); btnFlash.addEventListener('click', async () => { + if (isArduinoDevice()) return; + if (isConnected()) { await disconnect(); setConnectedUI(false); @@ -329,6 +406,7 @@ btnFlash.addEventListener('click', async () => { const device = getDevice(); const fw = device.firmware; + if (!fw) return; if (canFlashInBrowser()) { showFlashOverlay(); @@ -357,7 +435,13 @@ btnFlash.addEventListener('click', async () => { }); btnRun.addEventListener('click', async () => { - const code = pythonGenerator.workspaceToCode(workspace); + 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; @@ -385,7 +469,26 @@ btnStop.addEventListener('click', async () => { }); btnSave.addEventListener('click', async () => { - const code = pythonGenerator.workspaceToCode(workspace); + 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; @@ -430,6 +533,83 @@ terminalInput.addEventListener('keydown', async (e) => { } }); +// ─── 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'); diff --git a/src/serial/connection.js b/src/serial/connection.js index 1f3b388..2a72598 100644 --- a/src/serial/connection.js +++ b/src/serial/connection.js @@ -6,13 +6,18 @@ export function isConnected() { return port !== null; } +export function getPort() { + return port; +} + export function getWriter() { return writer; } -export async function connect(baudRate = 115200) { +export async function connect(baudRate = 115200, existingPort = null) { if (port) return port; - port = await navigator.serial.requestPort(); + port = existingPort || await navigator.serial.requestPort(); + try { await port.close(); } catch { /* already closed or never opened */ } await port.open({ baudRate }); const textDecoder = new TextDecoderStream(); diff --git a/src/style.css b/src/style.css index 8c11f07..bc5e91d 100644 --- a/src/style.css +++ b/src/style.css @@ -1017,3 +1017,159 @@ html, body { color: var(--bg-toolbar); border-color: var(--accent); } + +/* --- Hex Upload Overlay (Arduino) --- */ +#hex-upload-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +#hex-upload-modal { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px 28px; + width: 460px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; +} + +.board-select-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.board-select-header h3 { + margin: 0; + color: var(--accent); + font-size: 16px; +} + +.board-select-header button { + background: none; + border: none; + color: var(--text-muted); + font-size: 22px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} +.board-select-header button:hover { + color: var(--text-primary); +} + +.board-select-description { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; +} + +.board-list { + list-style: none; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + min-height: 60px; + max-height: 200px; + overflow-y: auto; + padding: 4px; +} + +.board-list .board-empty { + padding: 12px 10px; + color: var(--text-muted); + font-size: 12px; + font-style: italic; + text-align: center; +} + +.board-list .board-item { + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + font-size: 13px; + color: var(--text-primary); + transition: background 0.1s; +} + +.board-list .board-item:hover { + background: var(--bg-surface); +} + +.board-list .board-item.selected { + background: var(--accent); + color: var(--bg-toolbar); +} + +.board-select-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.board-select-actions button { + background: var(--accent); + color: var(--bg-toolbar); + border: none; + border-radius: var(--radius); + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.board-select-actions button:hover { + opacity: 0.85; +} + +/* --- Hex Upload Modal fields --- */ +.hex-upload-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: center; +} + +.hex-field-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hex-upload-fields select, +.hex-upload-fields input[type="file"] { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 13px; + outline: none; +} + +.hex-upload-fields select:focus { + border-color: var(--accent); +} + +.hex-upload-status { + font-size: 12px; + min-height: 18px; + color: var(--text-muted); +} +.hex-upload-status.status-err { color: var(--red); } +.hex-upload-status.status-ok { color: var(--green); }