From 2c00738b072ebdc7bddd0df6fc450c20c8d1d913 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 18 Feb 2026 23:24:00 +0800 Subject: [PATCH] basic blockly code editor, python generated, and upload system working --- .gitignore | 22 +++ index.html | 61 +++++++ package.json | 18 ++ src/blocks/esp32_blocks.js | 294 +++++++++++++++++++++++++++++++++ src/blocks/esp32_generators.js | 162 ++++++++++++++++++ src/blocks/toolbox.js | 204 +++++++++++++++++++++++ src/main.js | 182 ++++++++++++++++++++ src/serial/connection.js | 69 ++++++++ src/serial/flasher.js | 51 ++++++ src/serial/repl.js | 32 ++++ src/style.css | 262 +++++++++++++++++++++++++++++ src/ui/panels.js | 32 ++++ src/ui/terminal.js | 11 ++ vite.config.js | 11 ++ 14 files changed, 1411 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package.json create mode 100644 src/blocks/esp32_blocks.js create mode 100644 src/blocks/esp32_generators.js create mode 100644 src/blocks/toolbox.js create mode 100644 src/main.js create mode 100644 src/serial/connection.js create mode 100644 src/serial/flasher.js create mode 100644 src/serial/repl.js create mode 100644 src/style.css create mode 100644 src/ui/panels.js create mode 100644 src/ui/terminal.js create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..597b97a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..2f07108 --- /dev/null +++ b/index.html @@ -0,0 +1,61 @@ + + + + + + ESP32-S3 Blockly IDE + + + + +
+
+ ESP32-S3 Blockly +
+
+ + + + + + Disconnected +
+
+ + +
+ +
+
+
+ + +
+
+
+
Generated Code
+
+
+
+
Serial Terminal
+
+
+ +
+
+
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..95a08b3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/blocks/esp32_blocks.js b/src/blocks/esp32_blocks.js new file mode 100644 index 0000000..cf4bdc9 --- /dev/null +++ b/src/blocks/esp32_blocks.js @@ -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'); + }, +}; diff --git a/src/blocks/esp32_generators.js b/src/blocks/esp32_generators.js new file mode 100644 index 0000000..40c5464 --- /dev/null +++ b/src/blocks/esp32_generators.js @@ -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`; +}; diff --git a/src/blocks/toolbox.js b/src/blocks/toolbox.js new file mode 100644 index 0000000..fbf948f --- /dev/null +++ b/src/blocks/toolbox.js @@ -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', + }, + ], +}; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..fd4f76f --- /dev/null +++ b/src/main.js @@ -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'); + } +}); diff --git a/src/serial/connection.js b/src/serial/connection.js new file mode 100644 index 0000000..ca7e279 --- /dev/null +++ b/src/serial/connection.js @@ -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 */ + } +} diff --git a/src/serial/flasher.js b/src/serial/flasher.js new file mode 100644 index 0000000..c9cc322 --- /dev/null +++ b/src/serial/flasher.js @@ -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(); + }); +} diff --git a/src/serial/repl.js b/src/serial/repl.js new file mode 100644 index 0000000..3e775d8 --- /dev/null +++ b/src/serial/repl.js @@ -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)); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..7d1468c --- /dev/null +++ b/src/style.css @@ -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); +} diff --git a/src/ui/panels.js b/src/ui/panels.js new file mode 100644 index 0000000..dd99db8 --- /dev/null +++ b/src/ui/panels.js @@ -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; + }); +} diff --git a/src/ui/terminal.js b/src/ui/terminal.js new file mode 100644 index 0000000..87152ed --- /dev/null +++ b/src/ui/terminal.js @@ -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 = ''; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..4396fc4 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + open: true, + }, + build: { + outDir: 'dist', + }, +});