arduino stk500 uploader and serial monitor implemented, need server side compiling
parent
952ea2b721
commit
18ea585320
26
index.html
26
index.html
|
|
@ -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">×</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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 { /* */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
};
|
||||
216
src/main.js
216
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
|
||||
? '<span class="icon">⏻</span> Disconnect Monitor'
|
||||
: '<span class="icon">▶</span> Serial Monitor';
|
||||
terminalInput.disabled = !connected;
|
||||
statusEl.textContent = connected ? 'Monitoring' : 'Disconnected';
|
||||
statusEl.className = connected ? 'status-connected' : 'status-disconnected';
|
||||
} else {
|
||||
btnConnect.innerHTML = connected
|
||||
? '<span class="icon">⏻</span> Disconnect'
|
||||
: '<span class="icon">▶</span> Connect';
|
||||
btnRun.disabled = !connected;
|
||||
btnStop.disabled = !connected;
|
||||
btnSave.disabled = !connected;
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
156
src/style.css
156
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); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue