basic blockly code editor, python generated, and upload system working

main
Jake 2026-02-18 23:24:00 +08:00
commit 2c00738b07
14 changed files with 1411 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Dependencies
node_modules/
package-lock.json
# Build output
dist/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local

61
index.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32-S3 Blockly IDE</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<!-- Top toolbar -->
<header id="toolbar">
<div class="toolbar-left">
<span class="app-title">ESP32-S3 Blockly</span>
</div>
<div class="toolbar-actions">
<button id="btn-connect" title="Connect to ESP32 via Web Serial">
<span class="icon">&#9654;</span> Connect
</button>
<button id="btn-flash" title="Flash MicroPython firmware" disabled>
<span class="icon">&#9889;</span> Flash FW
</button>
<button id="btn-run" title="Upload and run code" disabled>
<span class="icon">&#9655;</span> Run
</button>
<button id="btn-stop" title="Stop running code" disabled>
<span class="icon">&#9632;</span> Stop
</button>
<button id="btn-save" title="Save code to device as main.py" disabled>
<span class="icon">&#128190;</span> Save
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
</div>
</header>
<!-- Main workspace area -->
<main id="workspace-container">
<!-- Blockly editor fills the top area -->
<div id="blockly-area">
<div id="blockly-div"></div>
</div>
<!-- Bottom panels: code preview + serial terminal -->
<div id="bottom-panels">
<div id="resize-handle-h" class="resize-handle horizontal"></div>
<div id="code-panel">
<div class="panel-header">Generated Code</div>
<pre id="code-preview"><code id="code-output"></code></pre>
</div>
<div id="terminal-panel">
<div class="panel-header">Serial Terminal</div>
<div id="terminal-output"></div>
<div id="terminal-input-row">
<input type="text" id="terminal-input" placeholder="Type command…" disabled />
</div>
</div>
</div>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "esp32block",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"blockly": "^11.2.1",
"esptool-js": "^0.5.0"
},
"devDependencies": {
"vite": "^6.1.0"
}
}

294
src/blocks/esp32_blocks.js Normal file
View File

@ -0,0 +1,294 @@
import * as Blockly from 'blockly';
// ─── Pin I/O ──────────────────────────────────────────────
Blockly.Blocks['pin_set_mode'] = {
init() {
this.appendDummyInput()
.appendField('set pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN')
.appendField('as')
.appendField(new Blockly.FieldDropdown([
['OUTPUT', 'OUT'],
['INPUT', 'IN'],
['INPUT_PULLUP', 'PULL_UP'],
]), 'MODE');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Configure a GPIO pin mode');
},
};
Blockly.Blocks['pin_digital_write'] = {
init() {
this.appendDummyInput()
.appendField('digital write pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN')
.appendField('to')
.appendField(new Blockly.FieldDropdown([
['HIGH', '1'],
['LOW', '0'],
]), 'VALUE');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Write HIGH or LOW to a digital pin');
},
};
Blockly.Blocks['pin_digital_read'] = {
init() {
this.appendDummyInput()
.appendField('digital read pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN');
this.setOutput(true, 'Number');
this.setColour(230);
this.setTooltip('Read digital value from a pin (0 or 1)');
},
};
// ─── PWM ──────────────────────────────────────────────────
Blockly.Blocks['pwm_init'] = {
init() {
this.appendDummyInput()
.appendField('init PWM on pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN')
.appendField('freq')
.appendField(new Blockly.FieldNumber(1000, 1, 40000000, 1), 'FREQ')
.appendField('Hz');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(160);
this.setTooltip('Initialize PWM on a pin with a frequency');
},
};
Blockly.Blocks['pwm_set_duty'] = {
init() {
this.appendValueInput('DUTY')
.setCheck('Number')
.appendField('set PWM duty on pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN')
.appendField('to');
this.appendDummyInput()
.appendField('(0-1023)');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(160);
this.setTooltip('Set PWM duty cycle (0-1023)');
},
};
Blockly.Blocks['pwm_set_freq'] = {
init() {
this.appendValueInput('FREQ')
.setCheck('Number')
.appendField('set PWM freq on pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'PIN')
.appendField('to');
this.appendDummyInput()
.appendField('Hz');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(160);
this.setTooltip('Set PWM frequency in Hz');
},
};
// ─── ADC ──────────────────────────────────────────────────
Blockly.Blocks['adc_read'] = {
init() {
this.appendDummyInput()
.appendField('read ADC on pin')
.appendField(new Blockly.FieldNumber(1, 0, 20, 1), 'PIN');
this.setOutput(true, 'Number');
this.setColour(30);
this.setTooltip('Read analog value from ADC pin (0-4095)');
},
};
// ─── Time ─────────────────────────────────────────────────
Blockly.Blocks['sleep_seconds'] = {
init() {
this.appendValueInput('SECONDS')
.setCheck('Number')
.appendField('sleep');
this.appendDummyInput()
.appendField('seconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(120);
this.setTooltip('Pause execution for N seconds');
},
};
Blockly.Blocks['sleep_ms'] = {
init() {
this.appendValueInput('MS')
.setCheck('Number')
.appendField('sleep');
this.appendDummyInput()
.appendField('milliseconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(120);
this.setTooltip('Pause execution for N milliseconds');
},
};
Blockly.Blocks['ticks_ms'] = {
init() {
this.appendDummyInput()
.appendField('ticks (ms)');
this.setOutput(true, 'Number');
this.setColour(120);
this.setTooltip('Get millisecond tick counter');
},
};
// ─── WiFi ─────────────────────────────────────────────────
Blockly.Blocks['wifi_connect'] = {
init() {
this.appendDummyInput()
.appendField('connect WiFi SSID')
.appendField(new Blockly.FieldTextInput('MyNetwork'), 'SSID')
.appendField('password')
.appendField(new Blockly.FieldTextInput('password'), 'PASSWORD');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(290);
this.setTooltip('Connect to a WiFi network');
},
};
Blockly.Blocks['wifi_get_ip'] = {
init() {
this.appendDummyInput()
.appendField('WiFi IP address');
this.setOutput(true, 'String');
this.setColour(290);
this.setTooltip('Get the current IP address');
},
};
// ─── NeoPixel ─────────────────────────────────────────────
Blockly.Blocks['neopixel_init'] = {
init() {
this.appendDummyInput()
.appendField('init NeoPixel on pin')
.appendField(new Blockly.FieldNumber(48, 0, 48, 1), 'PIN')
.appendField('with')
.appendField(new Blockly.FieldNumber(1, 1, 1024, 1), 'NUM')
.appendField('LEDs');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(10);
this.setTooltip('Initialize a NeoPixel strip');
},
};
Blockly.Blocks['neopixel_set_color'] = {
init() {
this.appendDummyInput()
.appendField('set NeoPixel #')
.appendField(new Blockly.FieldNumber(0, 0, 1023, 1), 'INDEX');
this.appendValueInput('R').setCheck('Number').appendField('R');
this.appendValueInput('G').setCheck('Number').appendField('G');
this.appendValueInput('B').setCheck('Number').appendField('B');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(10);
this.setTooltip('Set NeoPixel color (R, G, B values 0-255)');
},
};
Blockly.Blocks['neopixel_show'] = {
init() {
this.appendDummyInput()
.appendField('NeoPixel show');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(10);
this.setTooltip('Push color data to NeoPixel strip');
},
};
// ─── I2C ──────────────────────────────────────────────────
Blockly.Blocks['i2c_init'] = {
init() {
this.appendDummyInput()
.appendField('init I2C SDA pin')
.appendField(new Blockly.FieldNumber(8, 0, 48, 1), 'SDA')
.appendField('SCL pin')
.appendField(new Blockly.FieldNumber(9, 0, 48, 1), 'SCL')
.appendField('freq')
.appendField(new Blockly.FieldNumber(400000, 1, 1000000, 1), 'FREQ');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(200);
this.setTooltip('Initialize I2C bus');
},
};
Blockly.Blocks['i2c_scan'] = {
init() {
this.appendDummyInput()
.appendField('I2C scan devices');
this.setOutput(true, 'Array');
this.setColour(200);
this.setTooltip('Scan for I2C devices, returns list of addresses');
},
};
Blockly.Blocks['i2c_writeto'] = {
init() {
this.appendValueInput('DATA')
.setCheck(null)
.appendField('I2C write to address')
.appendField(new Blockly.FieldNumber(0, 0, 127, 1), 'ADDR')
.appendField('data');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(200);
this.setTooltip('Write data bytes to an I2C device');
},
};
Blockly.Blocks['i2c_readfrom'] = {
init() {
this.appendDummyInput()
.appendField('I2C read from address')
.appendField(new Blockly.FieldNumber(0, 0, 127, 1), 'ADDR')
.appendField('bytes')
.appendField(new Blockly.FieldNumber(1, 1, 256, 1), 'NBYTES');
this.setOutput(true, null);
this.setColour(200);
this.setTooltip('Read N bytes from an I2C device');
},
};
// ─── Print ────────────────────────────────────────────────
Blockly.Blocks['print_text'] = {
init() {
this.appendValueInput('TEXT')
.setCheck(null)
.appendField('print');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(60);
this.setTooltip('Print to serial output');
},
};

View File

@ -0,0 +1,162 @@
import { pythonGenerator, Order } from 'blockly/python';
// ─── Pin I/O ──────────────────────────────────────────────
function ensurePinVar(pin, mode) {
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
const varName = `pin_${pin}`;
const modeStr = mode === 'IN' ? 'Pin.IN' : 'Pin.OUT';
pythonGenerator.definitions_[`pin_init_${pin}`] = `${varName} = Pin(${pin}, ${modeStr})`;
return varName;
}
pythonGenerator.forBlock['pin_set_mode'] = function (block) {
const pin = block.getFieldValue('PIN');
const mode = block.getFieldValue('MODE');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
const varName = `pin_${pin}`;
const pyMode = `Pin.${mode}`;
pythonGenerator.definitions_[`pin_init_${pin}`] = `${varName} = Pin(${pin}, ${pyMode})`;
return '';
};
pythonGenerator.forBlock['pin_digital_write'] = function (block) {
const pin = block.getFieldValue('PIN');
const value = block.getFieldValue('VALUE');
const varName = ensurePinVar(pin, 'OUT');
return `${varName}.value(${value})\n`;
};
pythonGenerator.forBlock['pin_digital_read'] = function (block) {
const pin = block.getFieldValue('PIN');
const varName = ensurePinVar(pin, 'IN');
return [`${varName}.value()`, Order.FUNCTION_CALL];
};
// ─── PWM ──────────────────────────────────────────────────
pythonGenerator.forBlock['pwm_init'] = function (block) {
const pin = block.getFieldValue('PIN');
const freq = block.getFieldValue('FREQ');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return `pwm_${pin} = PWM(Pin(${pin}), freq=${freq})\n`;
};
pythonGenerator.forBlock['pwm_set_duty'] = function (block) {
const pin = block.getFieldValue('PIN');
const duty = pythonGenerator.valueToCode(block, 'DUTY', Order.NONE) || '0';
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return `pwm_${pin}.duty(${duty})\n`;
};
pythonGenerator.forBlock['pwm_set_freq'] = function (block) {
const pin = block.getFieldValue('PIN');
const freq = pythonGenerator.valueToCode(block, 'FREQ', Order.NONE) || '1000';
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return `pwm_${pin}.freq(${freq})\n`;
};
// ─── ADC ──────────────────────────────────────────────────
pythonGenerator.forBlock['adc_read'] = function (block) {
const pin = block.getFieldValue('PIN');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
pythonGenerator.definitions_[`adc_init_${pin}`] = `adc_${pin} = ADC(Pin(${pin}))`;
return [`adc_${pin}.read()`, Order.FUNCTION_CALL];
};
// ─── Time ─────────────────────────────────────────────────
pythonGenerator.forBlock['sleep_seconds'] = function (block) {
const seconds = pythonGenerator.valueToCode(block, 'SECONDS', Order.NONE) || '1';
pythonGenerator.definitions_['import_time'] = 'import time';
return `time.sleep(${seconds})\n`;
};
pythonGenerator.forBlock['sleep_ms'] = function (block) {
const ms = pythonGenerator.valueToCode(block, 'MS', Order.NONE) || '100';
pythonGenerator.definitions_['import_time'] = 'import time';
return `time.sleep_ms(${ms})\n`;
};
pythonGenerator.forBlock['ticks_ms'] = function () {
pythonGenerator.definitions_['import_time'] = 'import time';
return ['time.ticks_ms()', Order.FUNCTION_CALL];
};
// ─── WiFi ─────────────────────────────────────────────────
pythonGenerator.forBlock['wifi_connect'] = function (block) {
const ssid = block.getFieldValue('SSID');
const password = block.getFieldValue('PASSWORD');
pythonGenerator.definitions_['import_network'] = 'import network';
return [
`wlan = network.WLAN(network.STA_IF)\n`,
`wlan.active(True)\n`,
`wlan.connect('${ssid}', '${password}')\n`,
`while not wlan.isconnected():\n`,
` time.sleep(0.1)\n`,
`print('Connected:', wlan.ifconfig()[0])\n`,
].join('');
};
pythonGenerator.forBlock['wifi_get_ip'] = function () {
pythonGenerator.definitions_['import_network'] = 'import network';
return ['network.WLAN(network.STA_IF).ifconfig()[0]', Order.FUNCTION_CALL];
};
// ─── NeoPixel ─────────────────────────────────────────────
pythonGenerator.forBlock['neopixel_init'] = function (block) {
const pin = block.getFieldValue('PIN');
const num = block.getFieldValue('NUM');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
pythonGenerator.definitions_['import_neopixel'] = 'import neopixel';
return `np = neopixel.NeoPixel(Pin(${pin}), ${num})\n`;
};
pythonGenerator.forBlock['neopixel_set_color'] = function (block) {
const index = block.getFieldValue('INDEX');
const r = pythonGenerator.valueToCode(block, 'R', Order.NONE) || '0';
const g = pythonGenerator.valueToCode(block, 'G', Order.NONE) || '0';
const b = pythonGenerator.valueToCode(block, 'B', Order.NONE) || '0';
return `np[${index}] = (${r}, ${g}, ${b})\n`;
};
pythonGenerator.forBlock['neopixel_show'] = function () {
return 'np.write()\n';
};
// ─── I2C ──────────────────────────────────────────────────
pythonGenerator.forBlock['i2c_init'] = function (block) {
const sda = block.getFieldValue('SDA');
const scl = block.getFieldValue('SCL');
const freq = block.getFieldValue('FREQ');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return `i2c = I2C(0, sda=Pin(${sda}), scl=Pin(${scl}), freq=${freq})\n`;
};
pythonGenerator.forBlock['i2c_scan'] = function () {
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return ['i2c.scan()', Order.FUNCTION_CALL];
};
pythonGenerator.forBlock['i2c_writeto'] = function (block) {
const addr = block.getFieldValue('ADDR');
const data = pythonGenerator.valueToCode(block, 'DATA', Order.NONE) || 'b""';
return `i2c.writeto(${addr}, ${data})\n`;
};
pythonGenerator.forBlock['i2c_readfrom'] = function (block) {
const addr = block.getFieldValue('ADDR');
const nbytes = block.getFieldValue('NBYTES');
return [`i2c.readfrom(${addr}, ${nbytes})`, Order.FUNCTION_CALL];
};
// ─── Print ────────────────────────────────────────────────
pythonGenerator.forBlock['print_text'] = function (block) {
const text = pythonGenerator.valueToCode(block, 'TEXT', Order.NONE) || "''";
return `print(${text})\n`;
};

204
src/blocks/toolbox.js Normal file
View File

@ -0,0 +1,204 @@
export const toolbox = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Pin I/O',
colour: '230',
contents: [
{ kind: 'block', type: 'pin_set_mode' },
{ kind: 'block', type: 'pin_digital_write' },
{ kind: 'block', type: 'pin_digital_read' },
],
},
{
kind: 'category',
name: 'PWM',
colour: '160',
contents: [
{ kind: 'block', type: 'pwm_init' },
{ kind: 'block', type: 'pwm_set_duty' },
{ kind: 'block', type: 'pwm_set_freq' },
],
},
{
kind: 'category',
name: 'ADC',
colour: '30',
contents: [
{ kind: 'block', type: 'adc_read' },
],
},
{
kind: 'category',
name: 'Time',
colour: '120',
contents: [
{
kind: 'block',
type: 'sleep_seconds',
inputs: {
SECONDS: {
shadow: { type: 'math_number', fields: { NUM: 1 } },
},
},
},
{
kind: 'block',
type: 'sleep_ms',
inputs: {
MS: {
shadow: { type: 'math_number', fields: { NUM: 100 } },
},
},
},
{ kind: 'block', type: 'ticks_ms' },
],
},
{
kind: 'category',
name: 'WiFi',
colour: '290',
contents: [
{ kind: 'block', type: 'wifi_connect' },
{ kind: 'block', type: 'wifi_get_ip' },
],
},
{
kind: 'category',
name: 'NeoPixel',
colour: '10',
contents: [
{ kind: 'block', type: 'neopixel_init' },
{
kind: 'block',
type: 'neopixel_set_color',
inputs: {
R: { shadow: { type: 'math_number', fields: { NUM: 255 } } },
G: { shadow: { type: 'math_number', fields: { NUM: 0 } } },
B: { shadow: { type: 'math_number', fields: { NUM: 0 } } },
},
},
{ kind: 'block', type: 'neopixel_show' },
],
},
{
kind: 'category',
name: 'I2C',
colour: '200',
contents: [
{ kind: 'block', type: 'i2c_init' },
{ kind: 'block', type: 'i2c_scan' },
{ kind: 'block', type: 'i2c_writeto' },
{ kind: 'block', type: 'i2c_readfrom' },
],
},
{
kind: 'category',
name: 'Serial / Print',
colour: '60',
contents: [
{
kind: 'block',
type: 'print_text',
inputs: {
TEXT: {
shadow: { type: 'text', fields: { TEXT: 'Hello ESP32!' } },
},
},
},
],
},
{ kind: 'sep' },
{
kind: 'category',
name: 'Logic',
categorystyle: 'logic_category',
contents: [
{ kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' },
{ kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' },
{ kind: 'block', type: 'logic_null' },
{ kind: 'block', type: 'logic_ternary' },
],
},
{
kind: 'category',
name: 'Loops',
categorystyle: 'loop_category',
contents: [
{ kind: 'block', type: 'controls_repeat_ext',
inputs: {
TIMES: { shadow: { type: 'math_number', fields: { NUM: 10 } } },
},
},
{ kind: 'block', type: 'controls_whileUntil' },
{ kind: 'block', type: 'controls_for',
inputs: {
FROM: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
TO: { shadow: { type: 'math_number', fields: { NUM: 10 } } },
BY: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
},
},
{ kind: 'block', type: 'controls_forEach' },
{ kind: 'block', type: 'controls_flow_statements' },
],
},
{
kind: 'category',
name: 'Math',
categorystyle: 'math_category',
contents: [
{ kind: 'block', type: 'math_number', fields: { NUM: 0 } },
{ kind: 'block', type: 'math_arithmetic' },
{ kind: 'block', type: 'math_single' },
{ kind: 'block', type: 'math_trig' },
{ kind: 'block', type: 'math_constant' },
{ kind: 'block', type: 'math_number_property' },
{ kind: 'block', type: 'math_round' },
{ kind: 'block', type: 'math_modulo' },
{ kind: 'block', type: 'math_constrain',
inputs: {
LOW: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
HIGH: { shadow: { type: 'math_number', fields: { NUM: 100 } } },
},
},
{ kind: 'block', type: 'math_random_int',
inputs: {
FROM: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
TO: { shadow: { type: 'math_number', fields: { NUM: 100 } } },
},
},
{ kind: 'block', type: 'math_random_float' },
],
},
{
kind: 'category',
name: 'Text',
categorystyle: 'text_category',
contents: [
{ kind: 'block', type: 'text' },
{ kind: 'block', type: 'text_join' },
{ kind: 'block', type: 'text_append' },
{ kind: 'block', type: 'text_length' },
{ kind: 'block', type: 'text_isEmpty' },
{ kind: 'block', type: 'text_indexOf' },
{ kind: 'block', type: 'text_charAt' },
],
},
{
kind: 'category',
name: 'Variables',
categorystyle: 'variable_category',
custom: 'VARIABLE',
},
{
kind: 'category',
name: 'Functions',
categorystyle: 'procedure_category',
custom: 'PROCEDURE',
},
],
};

182
src/main.js Normal file
View File

@ -0,0 +1,182 @@
import * as Blockly from 'blockly';
import { pythonGenerator } from 'blockly/python';
import './blocks/esp32_blocks.js';
import './blocks/esp32_generators.js';
import { toolbox } from './blocks/toolbox.js';
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
import { executeCode, stopExecution, saveToDevice } from './serial/repl.js';
import { flashFirmware } from './serial/flasher.js';
import { appendToTerminal, clearTerminal } from './ui/terminal.js';
import { initResizablePanels } from './ui/panels.js';
import './style.css';
// ─── Blockly Workspace ───────────────────────────────────
const workspace = Blockly.inject('blockly-div', {
toolbox,
theme: Blockly.Themes.Dark,
grid: { spacing: 25, length: 3, colour: '#333', snap: true },
zoom: { controls: true, wheel: true, startScale: 0.9, maxScale: 3, minScale: 0.3, scaleSpeed: 1.2 },
trashcan: true,
renderer: 'zelos',
});
// ─── Live Code Preview ───────────────────────────────────
const codeOutput = document.getElementById('code-output');
function updateCodePreview() {
const code = pythonGenerator.workspaceToCode(workspace);
codeOutput.textContent = code || '# Drag blocks to generate MicroPython code';
}
workspace.addChangeListener((event) => {
if (event.isUiEvent) return;
updateCodePreview();
});
updateCodePreview();
// ─── Workspace Persistence (localStorage) ────────────────
const STORAGE_KEY = 'esp32block_workspace';
function saveWorkspace() {
const state = Blockly.serialization.workspaces.save(workspace);
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function loadWorkspace() {
const json = localStorage.getItem(STORAGE_KEY);
if (json) {
try {
const state = JSON.parse(json);
Blockly.serialization.workspaces.load(state, workspace);
} catch (_) {
/* corrupted state, ignore */
}
}
}
workspace.addChangeListener((event) => {
if (event.isUiEvent) return;
saveWorkspace();
});
loadWorkspace();
// ─── Resize Handling ─────────────────────────────────────
function onResize() {
const blocklyArea = document.getElementById('blockly-area');
const blocklyDiv = document.getElementById('blockly-div');
blocklyDiv.style.width = blocklyArea.offsetWidth + 'px';
blocklyDiv.style.height = blocklyArea.offsetHeight + 'px';
Blockly.svgResize(workspace);
}
window.addEventListener('resize', onResize);
onResize();
initResizablePanels();
// ─── UI State Helpers ────────────────────────────────────
const btnConnect = document.getElementById('btn-connect');
const btnFlash = document.getElementById('btn-flash');
const btnRun = document.getElementById('btn-run');
const btnStop = document.getElementById('btn-stop');
const btnSave = document.getElementById('btn-save');
const statusEl = document.getElementById('connection-status');
const terminalInput = document.getElementById('terminal-input');
function setConnectedUI(connected) {
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
btnFlash.disabled = !connected;
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 Event Listeners ──────────────────────────────
onData((text) => appendToTerminal(text));
// ─── Toolbar Buttons ─────────────────────────────────────
btnConnect.addEventListener('click', async () => {
try {
if (isConnected()) {
await disconnect();
setConnectedUI(false);
appendToTerminal('\n--- Disconnected ---\n');
} else {
await connect();
setConnectedUI(true);
appendToTerminal('--- Connected ---\n');
}
} catch (err) {
appendToTerminal(`\nConnection error: ${err.message}\n`);
}
});
btnFlash.addEventListener('click', async () => {
try {
clearTerminal();
appendToTerminal('Starting firmware flash...\n');
await disconnect();
const port = await navigator.serial.requestPort();
await flashFirmware(port, (msg) => appendToTerminal(msg + '\n'));
appendToTerminal('Flash complete! Reconnect to use the device.\n');
setConnectedUI(false);
} catch (err) {
appendToTerminal(`\nFlash error: ${err.message}\n`);
}
});
btnRun.addEventListener('click', async () => {
const code = pythonGenerator.workspaceToCode(workspace);
if (!code.trim()) {
appendToTerminal('\nNo code to run. Add some blocks!\n');
return;
}
appendToTerminal('\n>>> Running...\n');
try {
await executeCode(code);
} catch (err) {
appendToTerminal(`\nRun error: ${err.message}\n`);
}
});
btnStop.addEventListener('click', async () => {
try {
await stopExecution();
appendToTerminal('\n--- Stopped ---\n');
} catch (err) {
appendToTerminal(`\nStop error: ${err.message}\n`);
}
});
btnSave.addEventListener('click', async () => {
const code = pythonGenerator.workspaceToCode(workspace);
if (!code.trim()) {
appendToTerminal('\nNo code to save.\n');
return;
}
appendToTerminal('\nSaving to device as main.py...\n');
try {
await saveToDevice(code);
} catch (err) {
appendToTerminal(`\nSave error: ${err.message}\n`);
}
});
terminalInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const text = terminalInput.value;
terminalInput.value = '';
await writeString(text + '\r\n');
}
});

69
src/serial/connection.js Normal file
View File

@ -0,0 +1,69 @@
let port = null;
let reader = null;
let writer = null;
export function isConnected() {
return port !== null;
}
export function getWriter() {
return writer;
}
export async function connect(baudRate = 115200) {
if (port) return port;
port = await navigator.serial.requestPort();
await port.open({ baudRate });
const textDecoder = new TextDecoderStream();
const textEncoder = new TextEncoderStream();
port.readable.pipeTo(textDecoder.writable);
textEncoder.readable.pipeTo(port.writable);
reader = textDecoder.readable.getReader();
writer = textEncoder.writable.getWriter();
readLoop();
return port;
}
export async function disconnect() {
if (reader) {
try { await reader.cancel(); } catch (_) { /* already closed */ }
reader = null;
}
if (writer) {
try { await writer.close(); } catch (_) { /* already closed */ }
writer = null;
}
if (port) {
try { await port.close(); } catch (_) { /* already closed */ }
port = null;
}
}
export async function writeString(str) {
if (writer) await writer.write(str);
}
const listeners = new Set();
export function onData(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
}
async function readLoop() {
try {
while (reader) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
for (const cb of listeners) cb(value);
}
}
} catch (_) {
/* port disconnected */
}
}

51
src/serial/flasher.js Normal file
View File

@ -0,0 +1,51 @@
// esptool-js firmware flasher wrapper
// Actual flashing will use the esptool-js ESPLoader when the user triggers it.
export async function flashFirmware(port, onProgress) {
const { ESPLoader, Transport } = await import('esptool-js');
const transport = new Transport(port);
const loader = new ESPLoader({
transport,
baudrate: 115200,
terminal: {
clean() {},
writeLine(data) { onProgress?.(data); },
write(data) { onProgress?.(data); },
},
});
await loader.main();
await loader.eraseFlash();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.bin';
return new Promise((resolve, reject) => {
input.onchange = async () => {
try {
const file = input.files[0];
if (!file) return reject(new Error('No file selected'));
const data = await file.arrayBuffer();
const uint8 = new Uint8Array(data);
onProgress?.('Writing firmware...');
await loader.writeFlash({
fileArray: [{ data: uint8, address: 0x0 }],
flashSize: 'keep',
eraseAll: false,
compress: true,
});
onProgress?.('Firmware flashed successfully!');
await transport.disconnect();
resolve();
} catch (err) {
reject(err);
}
};
input.click();
});
}

32
src/serial/repl.js Normal file
View File

@ -0,0 +1,32 @@
import { writeString } from './connection.js';
export async function enterRawRepl() {
await writeString('\x03\x03');
await sleep(100);
await writeString('\x01');
await sleep(100);
}
export async function executeCode(code) {
await enterRawRepl();
await writeString(code);
await writeString('\x04');
}
export async function stopExecution() {
await writeString('\x03\x03');
}
export async function saveToDevice(code, filename = 'main.py') {
const script = [
`f = open('${filename}', 'w')`,
`f.write(${JSON.stringify(code)})`,
`f.close()`,
`print('Saved to ${filename}')`,
].join('\n');
await executeCode(script);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}

262
src/style.css Normal file
View File

@ -0,0 +1,262 @@
/* --- Reset & Base --- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #1e1e2e;
--bg-secondary: #181825;
--bg-surface: #252538;
--bg-toolbar: #11111b;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-muted: #6c7086;
--accent: #89b4fa;
--accent-hover: #74c7ec;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--border: #313244;
--radius: 6px;
--toolbar-height: 48px;
}
html, body {
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
/* --- Toolbar --- */
#toolbar {
height: var(--toolbar-height);
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 12px;
}
.app-title {
font-weight: 700;
font-size: 15px;
color: var(--accent);
letter-spacing: 0.5px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-actions button {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s;
}
.toolbar-actions button:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-toolbar);
border-color: var(--accent);
}
.toolbar-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolbar-actions button .icon {
font-size: 14px;
}
#connection-status {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
}
.status-disconnected {
background: rgba(243, 139, 168, 0.15);
color: var(--red);
}
.status-connected {
background: rgba(166, 227, 161, 0.15);
color: var(--green);
}
/* --- Main Layout --- */
#workspace-container {
display: flex;
flex-direction: column;
height: calc(100vh - var(--toolbar-height));
}
#blockly-area {
flex: 1 1 60%;
position: relative;
min-height: 200px;
overflow: hidden;
}
#blockly-div {
position: absolute;
inset: 0;
}
/* --- Bottom Panels --- */
#bottom-panels {
flex: 0 0 35%;
min-height: 120px;
display: flex;
position: relative;
border-top: 1px solid var(--border);
}
.resize-handle.horizontal {
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 6px;
cursor: ns-resize;
z-index: 10;
}
#code-panel, #terminal-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#code-panel {
border-right: 1px solid var(--border);
}
.panel-header {
background: var(--bg-secondary);
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* --- Code Preview --- */
#code-preview {
flex: 1;
overflow: auto;
background: var(--bg-secondary);
padding: 10px 14px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre;
tab-size: 4;
}
/* --- Terminal --- */
#terminal-panel {
background: var(--bg-secondary);
}
#terminal-output {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--green);
white-space: pre-wrap;
word-break: break-all;
}
#terminal-input-row {
display: flex;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
#terminal-input {
flex: 1;
background: var(--bg-toolbar);
color: var(--text-primary);
border: none;
padding: 8px 12px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
outline: none;
}
#terminal-input:disabled {
opacity: 0.5;
}
#terminal-input::placeholder {
color: var(--text-muted);
}
/* --- Blockly Overrides (dark theme) --- */
.blocklyMainBackground {
fill: var(--bg-primary) !important;
}
.blocklyToolboxDiv {
background: var(--bg-secondary) !important;
border-right: 1px solid var(--border) !important;
}
.blocklyFlyoutBackground {
fill: var(--bg-surface) !important;
}
.blocklyTreeRow:hover {
background: var(--bg-surface) !important;
}
.blocklyTreeSelected {
background: var(--accent) !important;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

32
src/ui/panels.js Normal file
View File

@ -0,0 +1,32 @@
export function initResizablePanels() {
const handle = document.getElementById('resize-handle-h');
const container = document.getElementById('workspace-container');
const blocklyArea = document.getElementById('blockly-area');
const bottomPanels = document.getElementById('bottom-panels');
if (!handle || !container || !blocklyArea || !bottomPanels) return;
let dragging = false;
handle.addEventListener('mousedown', (e) => {
dragging = true;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const containerRect = container.getBoundingClientRect();
const offset = e.clientY - containerRect.top;
const totalHeight = containerRect.height;
const topPercent = Math.max(20, Math.min(80, (offset / totalHeight) * 100));
blocklyArea.style.flex = `0 0 ${topPercent}%`;
bottomPanels.style.flex = `0 0 ${100 - topPercent}%`;
window.dispatchEvent(new Event('resize'));
});
document.addEventListener('mouseup', () => {
dragging = false;
});
}

11
src/ui/terminal.js Normal file
View File

@ -0,0 +1,11 @@
const outputEl = document.getElementById('terminal-output');
export function appendToTerminal(text) {
if (!outputEl) return;
outputEl.textContent += text;
outputEl.scrollTop = outputEl.scrollHeight;
}
export function clearTerminal() {
if (outputEl) outputEl.textContent = '';
}

11
vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
open: true,
},
build: {
outDir: 'dist',
},
});