arduino stk500 uploader and serial monitor implemented, need server side compiling

main
Jake 2026-02-28 23:55:30 +08:00
parent 952ea2b721
commit 18ea585320
15 changed files with 1974 additions and 22 deletions

View File

@ -16,6 +16,7 @@
<option value="esp32s3">ESP32-S3</option>
<option value="microbit">micro:bit</option>
<option value="rp2040">RP2040 (Pico)</option>
<option value="arduino_uno">Arduino Uno/Nano</option>
</select>
</div>
<div class="toolbar-actions">
@ -197,6 +198,31 @@
</div>
</div>
<!-- Upload .hex overlay (Arduino STK500) -->
<div id="hex-upload-overlay" class="hidden">
<div id="hex-upload-modal">
<div class="board-select-header">
<h3>Upload .hex to Arduino</h3>
<button id="hex-upload-close" title="Close">&times;</button>
</div>
<p class="board-select-description">Select your board type, choose a compiled <code>.hex</code> file, then click Upload. The browser will prompt you to pick a serial port.</p>
<div class="hex-upload-fields">
<label class="hex-field-label" for="hex-board-select">Board</label>
<select id="hex-board-select">
<option value="uno">Arduino Uno (115200 baud)</option>
<option value="nano">Arduino Nano — old bootloader (57600)</option>
<option value="nano_new">Arduino Nano — new bootloader (115200)</option>
</select>
<label class="hex-field-label" for="hex-file-input">.hex file</label>
<input type="file" id="hex-file-input" accept=".hex" />
</div>
<div class="board-select-actions">
<button id="hex-upload-btn">Upload</button>
</div>
<div id="hex-upload-status" class="hex-upload-status"></div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -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"

137
server/index.js Normal file
View File

@ -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}`);
});

View File

@ -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);

39
src/arduino/compiler.js Normal file
View File

@ -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;
}

387
src/arduino/stk500.js Normal file
View File

@ -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<void>}
*/
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 { /* */ }
}
}
}

View File

@ -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 <Servo.h>');
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 <Servo.h>');
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';
};
}

View File

@ -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');
},
};

View File

@ -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,
],
};

View File

@ -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").

149
src/generators/arduino.js Normal file
View File

@ -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();

View File

@ -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 <math.h>');
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 <math.h>');
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 <math.h>');
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 <math.h>');
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;
};

View File

@ -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 = '&#9655;';
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 = '&#128190;';
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Download .ino';
btnSave.title = 'Download generated code as .ino file';
btnSave.disabled = false;
btnConnect.querySelector('.icon').innerHTML = '&#9654;';
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 = '&#9655;';
btnRun.childNodes[btnRun.childNodes.length - 1].textContent = ' Run';
btnRun.title = 'Upload and run code';
btnRun.disabled = !isConnected();
btnSave.querySelector('.icon').innerHTML = '&#128190;';
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Save';
btnSave.title = 'Save code to device as main.py';
btnSave.disabled = !isConnected();
btnConnect.querySelector('.icon').innerHTML = '&#9654;';
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,20 +242,26 @@ 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';
if (isArduinoDevice()) {
btnConnect.innerHTML = connected
? '<span class="icon">&#9211;</span> Disconnect Monitor'
: '<span class="icon">&#9654;</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">&#9211;</span> Disconnect'
: '<span class="icon">&#9654;</span> Connect';
btnRun.disabled = !connected;
btnStop.disabled = !connected;
btnSave.disabled = !connected;
@ -195,6 +269,7 @@ function setConnectedUI(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');

View File

@ -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();

View File

@ -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); }