micro:bit support added

main
Jake 2026-02-20 15:27:56 +08:00
parent 83c4988a2c
commit ef0f3e12d1
21 changed files with 725 additions and 222 deletions

View File

@ -3,14 +3,20 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32-S3 Blockly IDE</title> <title>Blockly IDE</title>
<link rel="stylesheet" href="/src/style.css" /> <link rel="stylesheet" href="/src/style.css" />
</head> </head>
<body> <body>
<!-- Top toolbar --> <!-- Top toolbar -->
<header id="toolbar"> <header id="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="app-title">ESP32-S3 Blockly</span> <span class="app-title">Blockly IDE</span>
<label for="device-select" class="device-label">Device</label>
<select id="device-select" title="Target device (changes code generator and firmware)">
<option value="esp32s3">ESP32-S3</option>
<option value="microbit">micro:bit</option>
<option value="rp2040">RP2040 (Pico)</option>
</select>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button id="btn-connect" title="Connect to ESP32 via Web Serial"> <button id="btn-connect" title="Connect to ESP32 via Web Serial">

View File

@ -0,0 +1,8 @@
export const adcCategory = {
kind: 'category',
name: 'ADC',
colour: '30',
contents: [
{ kind: 'block', type: 'adc_read' },
],
};

View File

@ -0,0 +1,94 @@
export const builtinCategories = [
{
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',
},
];

View File

@ -0,0 +1,11 @@
export const i2cCategory = {
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' },
],
};

View File

@ -0,0 +1,8 @@
export const microbitDisplayCategory = {
kind: 'category',
name: 'Display (micro:bit)',
colour: '10',
contents: [
{ kind: 'block', type: 'microbit_display_all' },
],
};

View File

@ -0,0 +1,18 @@
export const neopixelCategory = {
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' },
],
};

View File

@ -0,0 +1,10 @@
export const pinIoCategory = {
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' },
],
};

View File

@ -0,0 +1,10 @@
export const pwmCategory = {
kind: 'category',
name: 'PWM',
colour: '160',
contents: [
{ kind: 'block', type: 'pwm_init' },
{ kind: 'block', type: 'pwm_set_duty' },
{ kind: 'block', type: 'pwm_set_freq' },
],
};

View File

@ -0,0 +1,14 @@
export const serialPrintCategory = {
kind: 'category',
name: 'Serial / Print',
colour: '60',
contents: [
{
kind: 'block',
type: 'print_text',
inputs: {
TEXT: { shadow: { type: 'text', fields: { TEXT: 'Hello!' } } },
},
},
],
};

View File

@ -0,0 +1,36 @@
export function soundCategory({ hasSpeaker = false } = {}) {
const contents = [];
if (hasSpeaker) {
contents.push(
{
kind: 'block',
type: 'sound_play_tone_speaker',
inputs: {
FREQ: { shadow: { type: 'math_number', fields: { NUM: 440 } } },
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{ kind: 'block', type: 'sound_stop_speaker' },
);
}
contents.push(
{
kind: 'block',
type: 'sound_play_tone',
inputs: {
FREQ: { shadow: { type: 'math_number', fields: { NUM: 440 } } },
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{ kind: 'block', type: 'sound_stop' },
);
return {
kind: 'category',
name: 'Sound',
colour: '300',
contents,
};
}

View File

@ -0,0 +1,22 @@
export const timeCategory = {
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' },
],
};

View File

@ -0,0 +1,9 @@
export const wifiCategory = {
kind: 'category',
name: 'WiFi',
colour: '290',
contents: [
{ kind: 'block', type: 'wifi_connect' },
{ kind: 'block', type: 'wifi_get_ip' },
],
};

View File

@ -292,3 +292,102 @@ Blockly.Blocks['print_text'] = {
this.setTooltip('Print to serial output'); this.setTooltip('Print to serial output');
}, },
}; };
// ─── Sound ───────────────────────────────────────────────
Blockly.Blocks['sound_play_tone_speaker'] = {
init() {
this.appendDummyInput().appendField('play tone on speaker');
this.appendValueInput('FREQ')
.setCheck('Number')
.appendField('freq');
this.appendDummyInput().appendField('Hz');
this.appendValueInput('DURATION')
.setCheck('Number')
.appendField('for');
this.appendDummyInput()
.appendField('ms')
.appendField(new Blockly.FieldDropdown([
['wait', 'TRUE'],
['continue', 'FALSE'],
]), 'WAIT');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a tone on the built-in speaker (micro:bit v2). "continue" runs code while tone plays.');
},
};
Blockly.Blocks['sound_stop_speaker'] = {
init() {
this.appendDummyInput().appendField('stop speaker');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Stop sound on the built-in speaker (micro:bit v2)');
},
};
Blockly.Blocks['sound_play_tone'] = {
init() {
this.appendDummyInput()
.appendField('play tone on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.appendValueInput('FREQ')
.setCheck('Number')
.appendField('freq');
this.appendDummyInput().appendField('Hz');
this.appendValueInput('DURATION')
.setCheck('Number')
.appendField('for');
this.appendDummyInput()
.appendField('ms')
.appendField(new Blockly.FieldDropdown([
['wait', 'TRUE'],
['continue', 'FALSE'],
]), 'WAIT');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a tone via PWM on a specific pin. "continue" runs code while tone plays.');
},
};
Blockly.Blocks['sound_stop'] = {
init() {
this.appendDummyInput()
.appendField('stop sound on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Stop sound on a specific pin');
},
};
// ─── micro:bit Display ────────────────────────────────────
// Helper to add one row of 5 checkboxes (Pxy = pixel at row x, col y)
function addMicrobitDisplayRow(block, row) {
const input = block.appendDummyInput(`ROW${row}`);
for (let col = 0; col < 5; col++) {
const name = `P${row}${col}`;
input.appendField(new Blockly.FieldCheckbox(false), name);
}
}
Blockly.Blocks['microbit_display_all'] = {
init() {
this.appendDummyInput()
.appendField('micro:bit display 5×5');
for (let row = 0; row < 5; row++) {
addMicrobitDisplayRow(this, row);
}
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(10);
this.setTooltip('Click LEDs to set the 5×5 display (micro:bit only). On = 9, off = 0.');
},
};

View File

@ -1,4 +1,13 @@
import { pythonGenerator, Order } from 'blockly/python'; import { pythonGenerator, Order } from 'blockly/python';
import { getDeviceId } from '../devices/registry.js';
const DEVICE = () => getDeviceId();
// micro:bit pin name: pin0..pin20 (clamp to valid range)
function mbPin(pin) {
const n = Math.max(0, Math.min(20, parseInt(pin, 10) || 0));
return `pin${n}`;
}
// ─── Pin I/O ────────────────────────────────────────────── // ─── Pin I/O ──────────────────────────────────────────────
@ -12,6 +21,9 @@ function ensurePinVar(pin, mode) {
pythonGenerator.forBlock['pin_set_mode'] = function (block) { pythonGenerator.forBlock['pin_set_mode'] = function (block) {
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
if (DEVICE() === 'microbit') {
return ''; // micro:bit has no explicit set_mode
}
const mode = block.getFieldValue('MODE'); const mode = block.getFieldValue('MODE');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
const varName = `pin_${pin}`; const varName = `pin_${pin}`;
@ -23,12 +35,18 @@ pythonGenerator.forBlock['pin_set_mode'] = function (block) {
pythonGenerator.forBlock['pin_digital_write'] = function (block) { pythonGenerator.forBlock['pin_digital_write'] = function (block) {
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
const value = block.getFieldValue('VALUE'); const value = block.getFieldValue('VALUE');
if (DEVICE() === 'microbit') {
return `${mbPin(pin)}.write_digital(${value})\n`;
}
const varName = ensurePinVar(pin, 'OUT'); const varName = ensurePinVar(pin, 'OUT');
return `${varName}.value(${value})\n`; return `${varName}.value(${value})\n`;
}; };
pythonGenerator.forBlock['pin_digital_read'] = function (block) { pythonGenerator.forBlock['pin_digital_read'] = function (block) {
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
if (DEVICE() === 'microbit') {
return [`${mbPin(pin)}.read_digital()`, Order.FUNCTION_CALL];
}
const varName = ensurePinVar(pin, 'IN'); const varName = ensurePinVar(pin, 'IN');
return [`${varName}.value()`, Order.FUNCTION_CALL]; return [`${varName}.value()`, Order.FUNCTION_CALL];
}; };
@ -36,6 +54,7 @@ pythonGenerator.forBlock['pin_digital_read'] = function (block) {
// ─── PWM ────────────────────────────────────────────────── // ─── PWM ──────────────────────────────────────────────────
pythonGenerator.forBlock['pwm_init'] = function (block) { pythonGenerator.forBlock['pwm_init'] = function (block) {
if (DEVICE() === 'microbit') return ''; // no separate init on micro:bit
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
const freq = block.getFieldValue('FREQ'); const freq = block.getFieldValue('FREQ');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
@ -45,11 +64,15 @@ pythonGenerator.forBlock['pwm_init'] = function (block) {
pythonGenerator.forBlock['pwm_set_duty'] = function (block) { pythonGenerator.forBlock['pwm_set_duty'] = function (block) {
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
const duty = pythonGenerator.valueToCode(block, 'DUTY', Order.NONE) || '0'; const duty = pythonGenerator.valueToCode(block, 'DUTY', Order.NONE) || '0';
if (DEVICE() === 'microbit') {
return `${mbPin(pin)}.write_analog(${duty})\n`;
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return `pwm_${pin}.duty(${duty})\n`; return `pwm_${pin}.duty(${duty})\n`;
}; };
pythonGenerator.forBlock['pwm_set_freq'] = function (block) { pythonGenerator.forBlock['pwm_set_freq'] = function (block) {
if (DEVICE() === 'microbit') return ''; // micro:bit doesn't support changing PWM freq per-pin
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
const freq = pythonGenerator.valueToCode(block, 'FREQ', Order.NONE) || '1000'; const freq = pythonGenerator.valueToCode(block, 'FREQ', Order.NONE) || '1000';
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
@ -60,6 +83,9 @@ pythonGenerator.forBlock['pwm_set_freq'] = function (block) {
pythonGenerator.forBlock['adc_read'] = function (block) { pythonGenerator.forBlock['adc_read'] = function (block) {
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
if (DEVICE() === 'microbit') {
return [`${mbPin(pin)}.read_analog()`, Order.FUNCTION_CALL];
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
pythonGenerator.definitions_[`adc_init_${pin}`] = `adc_${pin} = ADC(Pin(${pin}))`; pythonGenerator.definitions_[`adc_init_${pin}`] = `adc_${pin} = ADC(Pin(${pin}))`;
return [`adc_${pin}.read()`, Order.FUNCTION_CALL]; return [`adc_${pin}.read()`, Order.FUNCTION_CALL];
@ -69,17 +95,29 @@ pythonGenerator.forBlock['adc_read'] = function (block) {
pythonGenerator.forBlock['sleep_seconds'] = function (block) { pythonGenerator.forBlock['sleep_seconds'] = function (block) {
const seconds = pythonGenerator.valueToCode(block, 'SECONDS', Order.NONE) || '1'; const seconds = pythonGenerator.valueToCode(block, 'SECONDS', Order.NONE) || '1';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_microbit_sleep'] = 'from microbit import sleep';
return `sleep(${seconds} * 1000)\n`;
}
pythonGenerator.definitions_['import_time'] = 'import time'; pythonGenerator.definitions_['import_time'] = 'import time';
return `time.sleep(${seconds})\n`; return `time.sleep(${seconds})\n`;
}; };
pythonGenerator.forBlock['sleep_ms'] = function (block) { pythonGenerator.forBlock['sleep_ms'] = function (block) {
const ms = pythonGenerator.valueToCode(block, 'MS', Order.NONE) || '100'; const ms = pythonGenerator.valueToCode(block, 'MS', Order.NONE) || '100';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_microbit_sleep'] = 'from microbit import sleep';
return `sleep(${ms})\n`;
}
pythonGenerator.definitions_['import_time'] = 'import time'; pythonGenerator.definitions_['import_time'] = 'import time';
return `time.sleep_ms(${ms})\n`; return `time.sleep_ms(${ms})\n`;
}; };
pythonGenerator.forBlock['ticks_ms'] = function () { pythonGenerator.forBlock['ticks_ms'] = function () {
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_microbit_running_time'] = 'from microbit import running_time';
return ['running_time()', Order.FUNCTION_CALL];
}
pythonGenerator.definitions_['import_time'] = 'import time'; pythonGenerator.definitions_['import_time'] = 'import time';
return ['time.ticks_ms()', Order.FUNCTION_CALL]; return ['time.ticks_ms()', Order.FUNCTION_CALL];
}; };
@ -87,6 +125,9 @@ pythonGenerator.forBlock['ticks_ms'] = function () {
// ─── WiFi ───────────────────────────────────────────────── // ─── WiFi ─────────────────────────────────────────────────
pythonGenerator.forBlock['wifi_connect'] = function (block) { pythonGenerator.forBlock['wifi_connect'] = function (block) {
if (DEVICE() === 'microbit' || DEVICE() === 'rp2040') {
return '# WiFi not available on this device\n';
}
const ssid = block.getFieldValue('SSID'); const ssid = block.getFieldValue('SSID');
const password = block.getFieldValue('PASSWORD'); const password = block.getFieldValue('PASSWORD');
pythonGenerator.definitions_['import_network'] = 'import network'; pythonGenerator.definitions_['import_network'] = 'import network';
@ -100,7 +141,10 @@ pythonGenerator.forBlock['wifi_connect'] = function (block) {
].join(''); ].join('');
}; };
pythonGenerator.forBlock['wifi_get_ip'] = function () { pythonGenerator.forBlock['wifi_get_ip'] = function (block) {
if (DEVICE() === 'microbit' || DEVICE() === 'rp2040') {
return ["''", Order.ATOMIC];
}
pythonGenerator.definitions_['import_network'] = 'import network'; pythonGenerator.definitions_['import_network'] = 'import network';
return ['network.WLAN(network.STA_IF).ifconfig()[0]', Order.FUNCTION_CALL]; return ['network.WLAN(network.STA_IF).ifconfig()[0]', Order.FUNCTION_CALL];
}; };
@ -108,6 +152,9 @@ pythonGenerator.forBlock['wifi_get_ip'] = function () {
// ─── NeoPixel ───────────────────────────────────────────── // ─── NeoPixel ─────────────────────────────────────────────
pythonGenerator.forBlock['neopixel_init'] = function (block) { pythonGenerator.forBlock['neopixel_init'] = function (block) {
if (DEVICE() === 'microbit') {
return '# NeoPixel on micro:bit: use neopixel library with pin\n';
}
const pin = block.getFieldValue('PIN'); const pin = block.getFieldValue('PIN');
const num = block.getFieldValue('NUM'); const num = block.getFieldValue('NUM');
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
@ -116,6 +163,7 @@ pythonGenerator.forBlock['neopixel_init'] = function (block) {
}; };
pythonGenerator.forBlock['neopixel_set_color'] = function (block) { pythonGenerator.forBlock['neopixel_set_color'] = function (block) {
if (DEVICE() === 'microbit') return '';
const index = block.getFieldValue('INDEX'); const index = block.getFieldValue('INDEX');
const r = pythonGenerator.valueToCode(block, 'R', Order.NONE) || '0'; const r = pythonGenerator.valueToCode(block, 'R', Order.NONE) || '0';
const g = pythonGenerator.valueToCode(block, 'G', Order.NONE) || '0'; const g = pythonGenerator.valueToCode(block, 'G', Order.NONE) || '0';
@ -124,12 +172,14 @@ pythonGenerator.forBlock['neopixel_set_color'] = function (block) {
}; };
pythonGenerator.forBlock['neopixel_show'] = function () { pythonGenerator.forBlock['neopixel_show'] = function () {
if (DEVICE() === 'microbit') return '';
return 'np.write()\n'; return 'np.write()\n';
}; };
// ─── I2C ────────────────────────────────────────────────── // ─── I2C ──────────────────────────────────────────────────
pythonGenerator.forBlock['i2c_init'] = function (block) { pythonGenerator.forBlock['i2c_init'] = function (block) {
if (DEVICE() === 'microbit') return ''; // micro:bit has built-in i2c
const sda = block.getFieldValue('SDA'); const sda = block.getFieldValue('SDA');
const scl = block.getFieldValue('SCL'); const scl = block.getFieldValue('SCL');
const freq = block.getFieldValue('FREQ'); const freq = block.getFieldValue('FREQ');
@ -137,18 +187,23 @@ pythonGenerator.forBlock['i2c_init'] = function (block) {
return `i2c = I2C(0, sda=Pin(${sda}), scl=Pin(${scl}), freq=${freq})\n`; return `i2c = I2C(0, sda=Pin(${sda}), scl=Pin(${scl}), freq=${freq})\n`;
}; };
pythonGenerator.forBlock['i2c_scan'] = function () { pythonGenerator.forBlock['i2c_scan'] = function (block) {
if (DEVICE() === 'microbit') {
return ['[]', Order.ATOMIC]; // stub
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C'; pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
return ['i2c.scan()', Order.FUNCTION_CALL]; return ['i2c.scan()', Order.FUNCTION_CALL];
}; };
pythonGenerator.forBlock['i2c_writeto'] = function (block) { pythonGenerator.forBlock['i2c_writeto'] = function (block) {
if (DEVICE() === 'microbit') return '';
const addr = block.getFieldValue('ADDR'); const addr = block.getFieldValue('ADDR');
const data = pythonGenerator.valueToCode(block, 'DATA', Order.NONE) || 'b""'; const data = pythonGenerator.valueToCode(block, 'DATA', Order.NONE) || 'b""';
return `i2c.writeto(${addr}, ${data})\n`; return `i2c.writeto(${addr}, ${data})\n`;
}; };
pythonGenerator.forBlock['i2c_readfrom'] = function (block) { pythonGenerator.forBlock['i2c_readfrom'] = function (block) {
if (DEVICE() === 'microbit') return ['b""', Order.ATOMIC];
const addr = block.getFieldValue('ADDR'); const addr = block.getFieldValue('ADDR');
const nbytes = block.getFieldValue('NBYTES'); const nbytes = block.getFieldValue('NBYTES');
return [`i2c.readfrom(${addr}, ${nbytes})`, Order.FUNCTION_CALL]; return [`i2c.readfrom(${addr}, ${nbytes})`, Order.FUNCTION_CALL];
@ -160,3 +215,85 @@ pythonGenerator.forBlock['print_text'] = function (block) {
const text = pythonGenerator.valueToCode(block, 'TEXT', Order.NONE) || "''"; const text = pythonGenerator.valueToCode(block, 'TEXT', Order.NONE) || "''";
return `print(${text})\n`; return `print(${text})\n`;
}; };
// ─── Sound ───────────────────────────────────────────────
pythonGenerator.forBlock['sound_play_tone_speaker'] = function (block) {
const freq = pythonGenerator.valueToCode(block, 'FREQ', Order.NONE) || '440';
const duration = pythonGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500';
const wait = block.getFieldValue('WAIT') === 'TRUE';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
return `music.pitch(${freq}, ${duration}, wait=${wait ? 'True' : 'False'})\n`;
}
return '# Built-in speaker only available on micro:bit\n';
};
pythonGenerator.forBlock['sound_stop_speaker'] = function () {
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
return 'music.stop()\n';
}
return '# Built-in speaker only available on micro:bit\n';
};
pythonGenerator.forBlock['sound_play_tone'] = function (block) {
const pin = block.getFieldValue('PIN');
const freq = pythonGenerator.valueToCode(block, 'FREQ', Order.NONE) || '440';
const duration = pythonGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500';
const wait = block.getFieldValue('WAIT') === 'TRUE';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
pythonGenerator.definitions_['import_microbit_pins'] = 'from microbit import *';
return `music.pitch(${freq}, ${duration}, pin=${mbPin(pin)}, wait=${wait ? 'True' : 'False'})\n`;
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
const varName = `buzzer_${pin}`;
pythonGenerator.definitions_[`buzzer_init_${pin}`] = `${varName} = PWM(Pin(${pin}))`;
if (wait) {
pythonGenerator.definitions_['import_time'] = 'import time';
return `${varName}.freq(${freq})\n${varName}.duty(512)\ntime.sleep_ms(${duration})\n${varName}.duty(0)\n`;
}
pythonGenerator.definitions_['import_timer'] = 'from machine import Timer';
return `${varName}.freq(${freq})\n${varName}.duty(512)\nTimer(0).init(period=${duration}, mode=Timer.ONE_SHOT, callback=lambda t: ${varName}.duty(0))\n`;
};
pythonGenerator.forBlock['sound_stop'] = function (block) {
const pin = block.getFieldValue('PIN');
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
return 'music.stop()\n';
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
const varName = `buzzer_${pin}`;
pythonGenerator.definitions_[`buzzer_init_${pin}`] = `${varName} = PWM(Pin(${pin}))`;
return `${varName}.duty(0)\n`;
};
// ─── micro:bit Display ────────────────────────────────────
pythonGenerator.forBlock['microbit_display_all'] = function (block) {
if (DEVICE() !== 'microbit') {
return '# micro:bit display block (select micro:bit device)\n';
}
// Build 5x5 string from checkboxes P00..P44 (each row = 5 chars, rows joined by :)
const rows = [];
for (let r = 0; r < 5; r++) {
let row = '';
for (let c = 0; c < 5; c++) {
const val = block.getFieldValue(`P${r}${c}`);
row += val === 'TRUE' ? '9' : '0';
}
rows.push(row);
}
const imageStr = rows.join(':');
pythonGenerator.definitions_['import_microbit_display'] = 'from microbit import display, Image';
return `display.show(Image("${imageStr}"))\n`;
};

View File

@ -1,204 +0,0 @@
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',
},
],
};

31
src/devices/esp32s3.js Normal file
View File

@ -0,0 +1,31 @@
import { pinIoCategory } from '../blocks/categories/pinIo.js';
import { pwmCategory } from '../blocks/categories/pwm.js';
import { adcCategory } from '../blocks/categories/adc.js';
import { timeCategory } from '../blocks/categories/time.js';
import { wifiCategory } from '../blocks/categories/wifi.js';
import { neopixelCategory } from '../blocks/categories/neopixel.js';
import { i2cCategory } from '../blocks/categories/i2c.js';
import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { soundCategory } from '../blocks/categories/sound.js';
export const esp32s3 = {
id: 'esp32s3',
label: 'ESP32-S3',
firmware: {
label: 'MicroPython (ESP32-S3)',
url: '/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin',
canFlashInBrowser: true,
instructions: null,
},
categories: [
pinIoCategory,
pwmCategory,
adcCategory,
timeCategory,
wifiCategory,
neopixelCategory,
i2cCategory,
soundCategory(),
serialPrintCategory,
],
};

27
src/devices/microbit.js Normal file
View File

@ -0,0 +1,27 @@
import { pinIoCategory } from '../blocks/categories/pinIo.js';
import { pwmCategory } from '../blocks/categories/pwm.js';
import { adcCategory } from '../blocks/categories/adc.js';
import { timeCategory } from '../blocks/categories/time.js';
import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { microbitDisplayCategory } from '../blocks/categories/microbitDisplay.js';
import { soundCategory } from '../blocks/categories/sound.js';
export const microbit = {
id: 'microbit',
label: 'micro:bit',
firmware: {
label: 'MicroPython (micro:bit v2)',
url: 'https://micropython.org/resources/firmware/MICROBIT_V2.hex',
canFlashInBrowser: false,
instructions: 'Hold RESET, then drag the .hex file onto the MICROBIT drive.',
},
categories: [
pinIoCategory,
pwmCategory,
adcCategory,
timeCategory,
microbitDisplayCategory,
soundCategory({ hasSpeaker: true }),
serialPrintCategory,
],
};

75
src/devices/registry.js Normal file
View File

@ -0,0 +1,75 @@
import { esp32s3 } from './esp32s3.js';
import { microbit } from './microbit.js';
import { rp2040 } from './rp2040.js';
import { builtinCategories } from '../blocks/categories/builtins.js';
const devices = {};
function register(profile) {
devices[profile.id] = profile;
}
register(esp32s3);
register(microbit);
register(rp2040);
/**
* Register a new device at runtime (e.g. a sub-device like "ESP32 robot").
* Call refreshToolbox() in main.js after registering to pick up changes.
*/
export function registerDevice(profile) {
register(profile);
}
export function getDeviceIds() {
return Object.keys(devices);
}
export function getDeviceProfile(id) {
return devices[id] || null;
}
export function getAllDevices() {
return { ...devices };
}
// --- active device state ---
const STORAGE_KEY = 'esp32block_device';
let currentId = localStorage.getItem(STORAGE_KEY) || 'esp32s3';
if (!devices[currentId]) currentId = 'esp32s3';
export function getDeviceId() {
return currentId;
}
export function getDevice() {
return devices[currentId];
}
export function setDeviceId(id) {
if (!devices[id]) return;
currentId = id;
localStorage.setItem(STORAGE_KEY, id);
}
export function canFlashInBrowser() {
return getDevice().firmware?.canFlashInBrowser === true;
}
// --- toolbox builder ---
export function buildToolbox(deviceId) {
const profile = devices[deviceId || currentId];
if (!profile) return { kind: 'categoryToolbox', contents: [] };
return {
kind: 'categoryToolbox',
contents: [
...profile.categories,
{ kind: 'sep' },
...builtinCategories,
],
};
}

27
src/devices/rp2040.js Normal file
View File

@ -0,0 +1,27 @@
import { pinIoCategory } from '../blocks/categories/pinIo.js';
import { pwmCategory } from '../blocks/categories/pwm.js';
import { adcCategory } from '../blocks/categories/adc.js';
import { timeCategory } from '../blocks/categories/time.js';
import { i2cCategory } from '../blocks/categories/i2c.js';
import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { soundCategory } from '../blocks/categories/sound.js';
export const rp2040 = {
id: 'rp2040',
label: 'RP2040 (Pico)',
firmware: {
label: 'MicroPython (Raspberry Pi Pico)',
url: 'https://micropython.org/resources/firmware/RPI_PICO.uf2',
canFlashInBrowser: false,
instructions: 'Hold BOOTSEL while plugging in, then drag the .uf2 file onto the RPI-RP2 drive.',
},
categories: [
pinIoCategory,
pwmCategory,
adcCategory,
timeCategory,
i2cCategory,
soundCategory(),
serialPrintCategory,
],
};

View File

@ -2,7 +2,13 @@ import * as Blockly from 'blockly';
import { pythonGenerator } from 'blockly/python'; import { pythonGenerator } from 'blockly/python';
import './blocks/esp32_blocks.js'; import './blocks/esp32_blocks.js';
import './blocks/esp32_generators.js'; import './blocks/esp32_generators.js';
import { toolbox } from './blocks/toolbox.js'; import {
getDeviceId,
setDeviceId,
getDevice,
canFlashInBrowser,
buildToolbox,
} from './devices/registry.js';
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js'; import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js'; import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
import { flashFirmware } from './serial/flasher.js'; import { flashFirmware } from './serial/flasher.js';
@ -13,7 +19,7 @@ import './style.css';
// ─── Blockly Workspace ─────────────────────────────────── // ─── Blockly Workspace ───────────────────────────────────
const workspace = Blockly.inject('blockly-div', { const workspace = Blockly.inject('blockly-div', {
toolbox, toolbox: buildToolbox(getDeviceId()),
theme: Blockly.Themes.Dark, theme: Blockly.Themes.Dark,
grid: { spacing: 25, length: 3, colour: '#333', snap: true }, 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 }, zoom: { controls: true, wheel: true, startScale: 0.9, maxScale: 3, minScale: 0.3, scaleSpeed: 1.2 },
@ -81,6 +87,7 @@ initResizablePanels();
// ─── UI State Helpers ──────────────────────────────────── // ─── UI State Helpers ────────────────────────────────────
const deviceSelect = document.getElementById('device-select');
const btnConnect = document.getElementById('btn-connect'); const btnConnect = document.getElementById('btn-connect');
const btnFlash = document.getElementById('btn-flash'); const btnFlash = document.getElementById('btn-flash');
const btnRun = document.getElementById('btn-run'); const btnRun = document.getElementById('btn-run');
@ -91,6 +98,21 @@ const btnLoadWorkspace = document.getElementById('btn-load-workspace');
const statusEl = document.getElementById('connection-status'); const statusEl = document.getElementById('connection-status');
const terminalInput = document.getElementById('terminal-input'); const terminalInput = document.getElementById('terminal-input');
// Sync device dropdown with stored device
deviceSelect.value = getDeviceId();
deviceSelect.addEventListener('change', () => {
setDeviceId(deviceSelect.value);
workspace.updateToolbox(buildToolbox(getDeviceId()));
updateCodePreview();
// Update Flash button tooltip/label based on device
btnFlash.title = canFlashInBrowser()
? 'Flash MicroPython firmware'
: 'Download firmware (drag to device)';
});
btnFlash.title = canFlashInBrowser()
? 'Flash MicroPython firmware'
: 'Download firmware (drag to device)';
function setConnectedUI(connected) { function setConnectedUI(connected) {
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect'; btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
btnRun.disabled = !connected; btnRun.disabled = !connected;
@ -239,8 +261,11 @@ btnFlash.addEventListener('click', async () => {
setConnectedUI(false); setConnectedUI(false);
} }
showFlashOverlay(); const device = getDevice();
const fw = device.firmware;
if (canFlashInBrowser()) {
showFlashOverlay();
try { try {
await flashFirmware( await flashFirmware(
(msg) => appendFlashLog(msg), (msg) => appendFlashLog(msg),
@ -253,6 +278,16 @@ btnFlash.addEventListener('click', async () => {
} finally { } finally {
flashCloseBtn.classList.remove('hidden'); flashCloseBtn.classList.remove('hidden');
} }
} else {
// Download firmware: open URL and show instructions
window.open(fw.url, '_blank');
appendToTerminal(`\n--- Firmware: ${fw.label} ---\n`);
appendToTerminal(`Download opened in new tab: ${fw.url}\n`);
if (fw.instructions) {
appendToTerminal(`${fw.instructions}\n`);
}
appendToTerminal('After flashing, connect here to run code.\n');
}
}); });
btnRun.addEventListener('click', async () => { btnRun.addEventListener('click', async () => {

View File

@ -44,6 +44,12 @@ html, body {
gap: 12px; gap: 12px;
} }
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-title { .app-title {
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
@ -51,6 +57,30 @@ html, body {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.device-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
#device-select {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
min-width: 140px;
}
#device-select:hover,
#device-select:focus {
border-color: var(--accent);
outline: none;
}
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;