esp32 firmware flashing good

main
Jake 2026-04-17 18:34:46 +08:00
parent 18ea585320
commit 63db4556c5
35 changed files with 45750 additions and 250 deletions

4
deploy.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
call npm run build
if %errorlevel% neq 0 exit /b %errorlevel%
scp -r dist\. jake@realrobots.net:~/blocks/

View File

@ -3,29 +3,27 @@
<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>Blockly IDE</title> <title>RealRobots 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">Blockly IDE</span> <span class="app-title">RealRobots IDE</span>
<label for="device-select" class="device-label">Device</label> <label for="device-select" class="device-label">Device</label>
<select id="device-select" title="Target device (changes code generator and firmware)"> <select id="device-select" title="Target device (changes code generator and firmware)">
<option value="esp32s3">ESP32-S3</option> <option value="esp32s3">ESP32</option>
<option value="microbit">micro:bit</option> <option value="microbit">micro:bit</option>
<option value="rp2040">RP2040 (Pico)</option> <option value="rp2040">RP2040 (Pico)</option>
<option value="arduino_uno">Arduino Uno/Nano</option> <option value="arduino_uno">Arduino Uno/Nano</option>
</select> </select>
</div>
<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">
<span class="icon">&#9654;</span> Connect <span class="icon">&#9654;</span> Connect
</button> </button>
<button id="btn-flash" title="Flash MicroPython firmware"> <span id="connection-status" class="status-disconnected">Disconnected</span>
<span class="icon">&#9889;</span> Flash FW </div>
</button> <div class="toolbar-actions">
<button id="btn-run" title="Upload and run code" disabled> <button id="btn-run" title="Upload and run code" disabled>
<span class="icon">&#9655;</span> Run <span class="icon">&#9655;</span> Run
</button> </button>
@ -35,16 +33,12 @@
<button id="btn-save" title="Save code to device as main.py" disabled> <button id="btn-save" title="Save code to device as main.py" disabled>
<span class="icon">&#128190;</span> Save <span class="icon">&#128190;</span> Save
</button> </button>
<button id="btn-projects" title="Toggle projects panel"> <button id="btn-load" title="Load block layout from device" disabled>
<span class="icon">&#128194;</span> Projects <span class="icon">&#128229;</span> Load
</button> </button>
<button id="btn-addons" title="Manage addons"> <button id="btn-flash" title="Flash MicroPython firmware">
<span class="icon">&#128268;</span> Addons <span class="icon">&#9889;</span> Flash Firmware
</button> </button>
<button id="btn-customize" title="Show/hide toolbox categories and blocks">
<span class="icon">&#9881;</span> Customize
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
</div> </div>
</header> </header>
@ -56,6 +50,31 @@
</div> </div>
<!-- Customize toolbox sidebar --> <!-- Customize toolbox sidebar -->
<div id="robot-panel" class="ide-panel hidden">
<div class="panel-header">
<span class="panel-title">Robot</span>
<button type="button" id="robot-panel-done" class="cust-done-btn" title="Close">Done</button>
</div>
<div class="panel-body robot-panel-body">
<div class="robot-toolbar">
<button type="button" id="robot-new" class="robot-tb-btn">New</button>
<button type="button" id="robot-open" class="robot-tb-btn">Open…</button>
<button type="button" id="robot-save" class="robot-tb-btn">Save…</button>
<button type="button" id="robot-apply" class="robot-tb-btn robot-tb-primary">Apply</button>
</div>
<button type="button" id="robot-import-ws" class="robot-full-btn">Import from workspace</button>
<div id="robot-device-note" class="robot-note"></div>
<div class="robot-add-row">
<select id="robot-add-select" class="robot-add-select" aria-label="Component to add"></select>
<button type="button" id="robot-add-btn" class="robot-tb-btn">Add</button>
</div>
<ul id="robot-component-list" class="robot-comp-list" aria-label="Components"></ul>
<div id="robot-editor" class="robot-editor"></div>
<div id="robot-status" class="robot-status" role="status"></div>
<input type="file" id="robot-file-input" accept=".json,application/json" class="robot-file-input-hidden" />
</div>
</div>
<div id="customizer-panel" class="ide-panel hidden"> <div id="customizer-panel" class="ide-panel hidden">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Customize Toolbox</span> <span class="panel-title">Customize Toolbox</span>
@ -84,19 +103,30 @@
</div> </div>
<!-- Projects right sidebar --> <!-- Projects right sidebar -->
<div id="projects-panel" class="ide-panel"> <div id="projects-panel" class="ide-panel collapsed">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Projects</span> <span class="panel-title">Projects</span>
<button class="panel-toggle" data-panel="projects-panel" title="Toggle panel">&#9666;</button> <button class="panel-toggle" data-panel="projects-panel" title="Toggle panel">&#9666;</button>
</div> </div>
<div id="side-tools-rail" aria-label="Tool tabs">
<button id="btn-projects" class="side-rail-btn" title="Toggle Projects panel">
<span class="side-rail-label">PROJECTS</span>
<span class="side-rail-arrow" aria-hidden="true">&#9666;</span>
</button>
<button id="btn-robot" class="side-rail-btn" title="Robot hardware (pins, apply init blocks)">
<span class="side-rail-label">ROBOT</span>
<span class="side-rail-arrow" aria-hidden="true">&#9666;</span>
</button>
<button id="btn-customize" class="side-rail-btn" title="Show/hide toolbox categories and blocks">
<span class="side-rail-label">CUSTOMIZE</span>
<span class="side-rail-arrow" aria-hidden="true">&#9666;</span>
</button>
<button id="btn-addons" class="side-rail-btn" title="Manage addons">
<span class="side-rail-label">ADDONS</span>
<span class="side-rail-arrow" aria-hidden="true">&#9666;</span>
</button>
</div>
<div class="panel-body"> <div class="panel-body">
<!-- Tabs: Browser / Device -->
<div class="proj-tabs">
<button class="proj-tab active" data-tab="browser">Browser</button>
<button class="proj-tab" data-tab="device">Device</button>
</div>
<!-- Browser tab -->
<div class="proj-tab-content" id="proj-tab-browser"> <div class="proj-tab-content" id="proj-tab-browser">
<ul class="projects-list" id="browser-list"></ul> <ul class="projects-list" id="browser-list"></ul>
<div class="projects-actions"> <div class="projects-actions">
@ -106,26 +136,11 @@
</div> </div>
<div class="projects-btn-row"> <div class="projects-btn-row">
<button id="browser-load-btn" disabled>Load</button> <button id="browser-load-btn" disabled>Load</button>
<button id="browser-download-btn" disabled>Download to computer</button>
<button id="browser-delete-btn" class="btn-danger" disabled>Delete</button> <button id="browser-delete-btn" class="btn-danger" disabled>Delete</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Device tab -->
<div class="proj-tab-content hidden" id="proj-tab-device">
<ul class="projects-list" id="device-list"></ul>
<div class="projects-actions">
<div class="projects-name-row">
<input type="text" id="device-save-name" placeholder="Project name..." />
<button id="device-save-btn">Save</button>
</div>
<div class="projects-btn-row">
<button id="device-load-btn" disabled>Load</button>
<button id="device-delete-btn" class="btn-danger" disabled>Delete</button>
</div>
</div>
<div id="device-status" class="projects-note"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -223,6 +238,30 @@
</div> </div>
</div> </div>
<!-- ESP32 firmware board picker overlay -->
<div id="esp32-flash-overlay" class="hidden">
<div id="esp32-flash-modal">
<div class="board-select-header">
<h3>Flash ESP32 MicroPython</h3>
<button id="esp32-flash-close" title="Close">&times;</button>
</div>
<p class="board-select-description">Choose your ESP32 chip family, then continue to flash firmware in-browser.</p>
<div class="hex-upload-fields">
<label class="hex-field-label" for="esp32-variant-select">Board family</label>
<select id="esp32-variant-select">
<option value="esp32s3">ESP32-S3</option>
<option value="esp32">ESP32 (Generic)</option>
<option value="esp32s2">ESP32-S2</option>
<option value="esp32c3">ESP32-C3</option>
</select>
</div>
<div class="board-select-actions">
<button id="esp32-flash-start">Flash</button>
</div>
<div id="esp32-flash-status" class="hex-upload-status"></div>
</div>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -105,26 +105,34 @@ api.pythonGenerator.forBlock['hbridge_motor_init'] = function (block) {
' p2.write_analog(0)', ' p2.write_analog(0)',
].join('\n'); ].join('\n');
} else { } else {
// ESP32 / RP2040: machine.PWM // ESP32 / RP2040: machine.PWM (pins stored as numbers, PWM created on demand)
api.pythonGenerator.definitions_['import_machine'] = api.pythonGenerator.definitions_['import_machine'] =
'from machine import Pin, PWM'; 'from machine import Pin, PWM';
api.pythonGenerator.definitions_['hb_motor_' + m + '_in1'] = api.pythonGenerator.definitions_['hb_motor_' + m + '_in1'] =
'motor_' + m + '_in1 = PWM(Pin(' + in1 + '), freq=1000, duty=0)'; 'motor_' + m + '_in1 = ' + in1;
api.pythonGenerator.definitions_['hb_motor_' + m + '_in2'] = api.pythonGenerator.definitions_['hb_motor_' + m + '_in2'] =
'motor_' + m + '_in2 = PWM(Pin(' + in2 + '), freq=1000, duty=0)'; 'motor_' + m + '_in2 = ' + in2;
api.pythonGenerator.definitions_['hb_set_motor'] = [ api.pythonGenerator.definitions_['hb_set_motor'] = [
'def _hb_set_motor(pwm1, pwm2, speed):', 'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))', ' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4', ' duty = abs(speed) * 4',
' if speed > 0:', ' if speed > 0:',
' pwm1.duty(duty)', ' try: PWM(Pin(p2)).deinit()',
' pwm2.duty(0)', ' except: pass',
' Pin(p2, Pin.OUT).value(0)',
' PWM(Pin(p1), freq=1000, duty=duty)',
' elif speed < 0:', ' elif speed < 0:',
' pwm1.duty(0)', ' try: PWM(Pin(p1)).deinit()',
' pwm2.duty(duty)', ' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' PWM(Pin(p2), freq=1000, duty=duty)',
' else:', ' else:',
' pwm1.duty(0)', ' try: PWM(Pin(p1)).deinit()',
' pwm2.duty(0)', ' except: pass',
' try: PWM(Pin(p2)).deinit()',
' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' Pin(p2, Pin.OUT).value(0)',
].join('\n'); ].join('\n');
} }
return ''; return '';
@ -145,6 +153,192 @@ api.pythonGenerator.forBlock['hbridge_motor_stop'] = function (block) {
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n'; return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n';
}; };
// --- Dual-motor block definitions ---
api.Blockly.Blocks['hbridge_dual_init'] = {
init() {
this.appendDummyInput().appendField('init dual motors');
this.appendDummyInput()
.appendField('left IN1')
.appendField(new api.Blockly.FieldNumber(2, 0, 48, 1), 'L_IN1')
.appendField('IN2')
.appendField(new api.Blockly.FieldNumber(3, 0, 48, 1), 'L_IN2');
this.appendDummyInput()
.appendField('right IN1')
.appendField(new api.Blockly.FieldNumber(4, 0, 48, 1), 'R_IN1')
.appendField('IN2')
.appendField(new api.Blockly.FieldNumber(5, 0, 48, 1), 'R_IN2');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Initialize two h-bridge motors (left and right) with 4 pins.');
},
};
api.Blockly.Blocks['hbridge_dual_speed'] = {
init() {
this.appendDummyInput().appendField('set motors');
this.appendValueInput('LEFT')
.setCheck('Number')
.appendField('left');
this.appendValueInput('RIGHT')
.setCheck('Number')
.appendField('right');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Set left and right motor speeds independently (-255 to 255).');
},
};
api.Blockly.Blocks['hbridge_dual_forward'] = {
init() {
this.appendDummyInput().appendField('drive forward');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive both motors forward using the speed from "set motors".');
},
};
api.Blockly.Blocks['hbridge_dual_backward'] = {
init() {
this.appendDummyInput().appendField('drive backward');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive both motors backward using the speed from "set motors".');
},
};
api.Blockly.Blocks['hbridge_dual_left'] = {
init() {
this.appendDummyInput().appendField('turn left');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn left (left motor backward, right motor forward).');
},
};
api.Blockly.Blocks['hbridge_dual_right'] = {
init() {
this.appendDummyInput().appendField('turn right');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn right (left motor forward, right motor backward).');
},
};
api.Blockly.Blocks['hbridge_dual_stop'] = {
init() {
this.appendDummyInput().appendField('stop motors');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Stop both motors.');
},
};
// --- Dual-motor generators ---
api.pythonGenerator.forBlock['hbridge_dual_init'] = function (block) {
var l1 = block.getFieldValue('L_IN1');
var l2 = block.getFieldValue('L_IN2');
var r1 = block.getFieldValue('R_IN1');
var r2 = block.getFieldValue('R_IN2');
if (isMicrobit()) {
api.pythonGenerator.definitions_['import_microbit'] = 'from microbit import *';
api.pythonGenerator.definitions_['hb_dual_pins'] =
'motor_l_in1 = ' + mbPin(l1) + '\n' +
'motor_l_in2 = ' + mbPin(l2) + '\n' +
'motor_r_in1 = ' + mbPin(r1) + '\n' +
'motor_r_in2 = ' + mbPin(r2);
api.pythonGenerator.definitions_['hb_set_motor_mb'] = [
'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' p1.write_analog(duty)',
' p2.write_analog(0)',
' elif speed < 0:',
' p1.write_analog(0)',
' p2.write_analog(duty)',
' else:',
' p1.write_analog(0)',
' p2.write_analog(0)',
].join('\n');
} else {
api.pythonGenerator.definitions_['import_machine'] =
'from machine import Pin, PWM';
api.pythonGenerator.definitions_['hb_motor_l_in1'] = 'motor_l_in1 = ' + l1;
api.pythonGenerator.definitions_['hb_motor_l_in2'] = 'motor_l_in2 = ' + l2;
api.pythonGenerator.definitions_['hb_motor_r_in1'] = 'motor_r_in1 = ' + r1;
api.pythonGenerator.definitions_['hb_motor_r_in2'] = 'motor_r_in2 = ' + r2;
api.pythonGenerator.definitions_['hb_set_motor'] = [
'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' try: PWM(Pin(p2)).deinit()',
' except: pass',
' Pin(p2, Pin.OUT).value(0)',
' PWM(Pin(p1), freq=1000, duty=duty)',
' elif speed < 0:',
' try: PWM(Pin(p1)).deinit()',
' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' PWM(Pin(p2), freq=1000, duty=duty)',
' else:',
' try: PWM(Pin(p1)).deinit()',
' except: pass',
' try: PWM(Pin(p2)).deinit()',
' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' Pin(p2, Pin.OUT).value(0)',
].join('\n');
}
return '';
};
api.pythonGenerator.forBlock['hbridge_dual_speed'] = function (block) {
var left = api.pythonGenerator.valueToCode(
block, 'LEFT', api.pythonGenerator.ORDER_NONE) || '0';
var right = api.pythonGenerator.valueToCode(
block, 'RIGHT', api.pythonGenerator.ORDER_NONE) || '0';
return '_hb_set_motor(motor_l_in1, motor_l_in2, ' + left + ')\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, ' + right + ')\n';
};
api.pythonGenerator.forBlock['hbridge_dual_forward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 255)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 255)\n';
};
api.pythonGenerator.forBlock['hbridge_dual_backward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -255)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -255)\n';
};
api.pythonGenerator.forBlock['hbridge_dual_left'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -255)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 255)\n';
};
api.pythonGenerator.forBlock['hbridge_dual_right'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 255)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -255)\n';
};
api.pythonGenerator.forBlock['hbridge_dual_stop'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
// --- Register toolbox category --- // --- Register toolbox category ---
api.registerCategories([ api.registerCategories([
@ -162,6 +356,20 @@ api.registerCategories([
}, },
}, },
{ kind: 'block', type: 'hbridge_motor_stop' }, { kind: 'block', type: 'hbridge_motor_stop' },
{ kind: 'block', type: 'hbridge_dual_init' },
{
kind: 'block',
type: 'hbridge_dual_speed',
inputs: {
LEFT: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
RIGHT: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
},
},
{ kind: 'block', type: 'hbridge_dual_forward' },
{ kind: 'block', type: 'hbridge_dual_backward' },
{ kind: 'block', type: 'hbridge_dual_left' },
{ kind: 'block', type: 'hbridge_dual_right' },
{ kind: 'block', type: 'hbridge_dual_stop' },
], ],
}, },
]); ]);

View File

@ -114,6 +114,120 @@ arduinoGenerator.forBlock['print_text'] = function (block) {
return `Serial.println(${text});\n`; return `Serial.println(${text});\n`;
}; };
// ─── H-Bridge Motor ─────────────────────────────────────
arduinoGenerator.definitions_['hb_set_motor_fn'] = [
'void _hb_set_motor(int p1, int p2, int speed) {',
' speed = constrain(speed, -255, 255);',
' if (speed > 0) { analogWrite(p1, abs(speed)); analogWrite(p2, 0); }',
' else if (speed < 0) { analogWrite(p1, 0); analogWrite(p2, abs(speed)); }',
' else { analogWrite(p1, 0); analogWrite(p2, 0); }',
'}',
].join('\n');
arduinoGenerator.forBlock['hbridge_motor_init'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
var in1 = block.getFieldValue('IN1');
var in2 = block.getFieldValue('IN2');
arduinoGenerator.definitions_['hb_motor_' + m + '_in1'] = 'const int motor_' + m + '_in1 = ' + in1 + ';';
arduinoGenerator.definitions_['hb_motor_' + m + '_in2'] = 'const int motor_' + m + '_in2 = ' + in2 + ';';
arduinoGenerator.addSetupCode('pinMode(' + in1 + ', OUTPUT);');
arduinoGenerator.addSetupCode('pinMode(' + in2 + ', OUTPUT);');
return '';
};
arduinoGenerator.forBlock['hbridge_motor_speed'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
var speed = arduinoGenerator.valueToCode(block, 'SPEED', Order.NONE) || '0';
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, ' + speed + ');\n';
};
arduinoGenerator.forBlock['hbridge_motor_stop'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0);\n';
};
arduinoGenerator.forBlock['hbridge_dual_init'] = function (block) {
var pins = [
['motor_l_in1', 'L_IN1'], ['motor_l_in2', 'L_IN2'],
['motor_r_in1', 'R_IN1'], ['motor_r_in2', 'R_IN2'],
];
for (var [name, field] of pins) {
var pin = block.getFieldValue(field);
arduinoGenerator.definitions_['hb_' + name] = 'const int ' + name + ' = ' + pin + ';';
arduinoGenerator.addSetupCode('pinMode(' + pin + ', OUTPUT);');
}
return '';
};
arduinoGenerator.forBlock['hbridge_dual_speed'] = function (block) {
var left = arduinoGenerator.valueToCode(block, 'LEFT', Order.NONE) || '0';
var right = arduinoGenerator.valueToCode(block, 'RIGHT', Order.NONE) || '0';
return '_hb_set_motor(motor_l_in1, motor_l_in2, ' + left + ');\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, ' + right + ');\n';
};
arduinoGenerator.forBlock['hbridge_dual_forward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128);\n';
};
arduinoGenerator.forBlock['hbridge_dual_backward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128);\n';
};
arduinoGenerator.forBlock['hbridge_dual_left'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128);\n';
};
arduinoGenerator.forBlock['hbridge_dual_right'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128);\n';
};
arduinoGenerator.forBlock['hbridge_dual_forward_x_seconds'] = function (block) {
var s = arduinoGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128);\n' +
'delay((' + s + ') * 1000);\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0);\n';
};
arduinoGenerator.forBlock['hbridge_dual_backward_x_seconds'] = function (block) {
var s = arduinoGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128);\n' +
'delay((' + s + ') * 1000);\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0);\n';
};
arduinoGenerator.forBlock['hbridge_dual_left_x_seconds'] = function (block) {
var s = arduinoGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128);\n' +
'delay((' + s + ') * 1000);\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0);\n';
};
arduinoGenerator.forBlock['hbridge_dual_right_x_seconds'] = function (block) {
var s = arduinoGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128);\n' +
'delay((' + s + ') * 1000);\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0);\n';
};
arduinoGenerator.forBlock['hbridge_dual_stop'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 0);\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0);\n';
};
// ─── Sound (shared blocks) ──────────────────────────────── // ─── Sound (shared blocks) ────────────────────────────────
arduinoGenerator.forBlock['sound_play_tone'] = function (block) { arduinoGenerator.forBlock['sound_play_tone'] = function (block) {
@ -136,6 +250,112 @@ arduinoGenerator.forBlock['sound_stop_speaker'] = function () {
return '// Built-in speaker not available on Arduino\n'; return '// Built-in speaker not available on Arduino\n';
}; };
arduinoGenerator.forBlock['sound_play_note'] = function (block) {
const pin = block.getFieldValue('PIN');
const freq = block.getFieldValue('NOTE');
const duration = arduinoGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500';
return `tone(${pin}, ${freq}, ${duration});\n`;
};
arduinoGenerator.forBlock['sound_play_note_speaker'] = function () {
return '// Built-in speaker not available on Arduino\n';
};
arduinoGenerator.forBlock['sound_play_staff_note'] =
arduinoGenerator.forBlock['sound_play_note'];
arduinoGenerator.forBlock['sound_play_staff_note_speaker'] =
arduinoGenerator.forBlock['sound_play_note_speaker'];
// ─── Sound buzzer (init + pin-free) ────────────────────
arduinoGenerator.forBlock['sound_buzzer_init'] = function (block) {
const pin = block.getFieldValue('PIN');
arduinoGenerator.definitions_['buzzer_pin'] = `const int _buzzerPin = ${pin};`;
arduinoGenerator.addSetupCode('noTone(_buzzerPin);');
return '';
};
arduinoGenerator.forBlock['sound_buzzer_tone'] = function (block) {
const freq = arduinoGenerator.valueToCode(block, 'FREQ', Order.NONE) || '440';
const duration = arduinoGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500';
return `tone(_buzzerPin, ${freq}, ${duration});\n`;
};
arduinoGenerator.forBlock['sound_buzzer_note'] = function (block) {
const freq = block.getFieldValue('NOTE');
const duration = arduinoGenerator.valueToCode(block, 'DURATION', Order.NONE) || '500';
return `tone(_buzzerPin, ${freq}, ${duration});\n`;
};
arduinoGenerator.forBlock['sound_buzzer_staff_note'] =
arduinoGenerator.forBlock['sound_buzzer_note'];
arduinoGenerator.forBlock['sound_buzzer_stop'] = function () {
return 'noTone(_buzzerPin);\n';
};
// ─── Sound melodies ────────────────────────────────────
const MELODIES = {
mario: [
[660,100],[660,100],[0,100],[660,100],[0,100],[520,100],[660,100],[0,100],
[784,100],[0,300],[392,100],[0,300],
[520,100],[0,200],[392,100],[0,200],[330,100],[0,200],
[440,100],[0,100],[494,100],[0,100],[466,100],[440,100],[0,100],
[392,100],[660,100],[784,100],[880,100],[0,100],[698,100],[784,100],
[0,100],[660,100],[0,100],[520,100],[587,100],[494,100],[0,100],
[520,100],[0,200],[392,100],[0,200],[330,100],[0,200],
[440,100],[0,100],[494,100],[0,100],[466,100],[440,100],[0,100],
[392,100],[660,100],[784,100],[880,100],[0,100],[698,100],[784,100],
[0,100],[660,100],[0,100],[520,100],[587,100],[494,100],[0,200],
],
happy: [[523,150],[0,30],[659,150],[0,30],[784,200]],
sad: [[392,250],[0,30],[330,250],[0,30],[262,300]],
};
function melodyArduino(name, pinExpr) {
const notes = MELODIES[name];
const freqArr = `_${name}Freqs`;
const durArr = `_${name}Durs`;
const lenVar = `_${name}Len`;
arduinoGenerator.definitions_[`${name}_freqs`] =
`const int ${freqArr}[] = {${notes.map(([f]) => f).join(',')}};`;
arduinoGenerator.definitions_[`${name}_durs`] =
`const int ${durArr}[] = {${notes.map(([, d]) => d).join(',')}};`;
arduinoGenerator.definitions_[`${name}_len`] =
`const int ${lenVar} = ${notes.length};`;
let code = '';
code += `for (int _i = 0; _i < ${lenVar}; _i++) {\n`;
code += ` if (${freqArr}[_i] == 0) {\n`;
code += ` noTone(${pinExpr});\n`;
code += ` } else {\n`;
code += ` tone(${pinExpr}, ${freqArr}[_i]);\n`;
code += ` }\n`;
code += ` delay(${durArr}[_i]);\n`;
code += `}\n`;
code += `noTone(${pinExpr});\n`;
return code;
}
function melodyPinArduino(name) {
return function (block) {
return melodyArduino(name, String(block.getFieldValue('PIN')));
};
}
function melodyBuzzerArduino(name) {
return function () {
return melodyArduino(name, '_buzzerPin');
};
}
arduinoGenerator.forBlock['sound_melody_mario'] = melodyPinArduino('mario');
arduinoGenerator.forBlock['sound_melody_mario_buzzer'] = melodyBuzzerArduino('mario');
arduinoGenerator.forBlock['sound_melody_happy'] = melodyPinArduino('happy');
arduinoGenerator.forBlock['sound_melody_happy_buzzer'] = melodyBuzzerArduino('happy');
arduinoGenerator.forBlock['sound_melody_sad'] = melodyPinArduino('sad');
arduinoGenerator.forBlock['sound_melody_sad_buzzer'] = melodyBuzzerArduino('sad');
// ─── Arduino-specific blocks ────────────────────────────── // ─── Arduino-specific blocks ──────────────────────────────
arduinoGenerator.forBlock['arduino_builtin_led'] = function (block) { arduinoGenerator.forBlock['arduino_builtin_led'] = function (block) {
@ -212,3 +432,10 @@ for (const blockType of unsupportedBlocks) {
return '// Block not supported on Arduino\n'; return '// Block not supported on Arduino\n';
}; };
} }
// ─── Loops ─────────────────────────────────────────────
arduinoGenerator.forBlock['controls_repeat_forever'] = function (block) {
let branch = arduinoGenerator.statementToCode(block, 'DO');
return `while (true) {\n${branch}}\n`;
};

View File

@ -5,7 +5,13 @@ export const builtinCategories = [
categorystyle: 'logic_category', categorystyle: 'logic_category',
contents: [ contents: [
{ kind: 'block', type: 'controls_if' }, { kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' }, {
kind: 'block', type: 'logic_compare',
inputs: {
A: { shadow: { type: 'math_number', fields: { NUM: 0 } } },
B: { shadow: { type: 'math_number', fields: { NUM: 0 } } },
},
},
{ kind: 'block', type: 'logic_operation' }, { kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' }, { kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' }, { kind: 'block', type: 'logic_boolean' },
@ -22,7 +28,11 @@ export const builtinCategories = [
kind: 'block', type: 'controls_repeat_ext', kind: 'block', type: 'controls_repeat_ext',
inputs: { TIMES: { shadow: { type: 'math_number', fields: { NUM: 10 } } } }, inputs: { TIMES: { shadow: { type: 'math_number', fields: { NUM: 10 } } } },
}, },
{ kind: 'block', type: 'controls_whileUntil' }, { kind: 'block', type: 'controls_repeat_forever' },
{
kind: 'block', type: 'controls_whileUntil',
inputs: { BOOL: { shadow: { type: 'logic_boolean', fields: { BOOL: 'TRUE' } } } },
},
{ {
kind: 'block', type: 'controls_for', kind: 'block', type: 'controls_for',
inputs: { inputs: {

View File

@ -0,0 +1,60 @@
export const hbridgeMotorCategory = {
kind: 'category',
name: 'H-Bridge Motor',
colour: '30',
contents: [
// Single motor
{ kind: 'block', type: 'hbridge_motor_init' },
{
kind: 'block',
type: 'hbridge_motor_speed',
inputs: {
SPEED: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
},
},
{ kind: 'block', type: 'hbridge_motor_stop' },
// Dual motor
{ kind: 'block', type: 'hbridge_dual_init' },
{
kind: 'block',
type: 'hbridge_dual_speed',
inputs: {
LEFT: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
RIGHT: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
},
},
{ kind: 'block', type: 'hbridge_dual_forward' },
{ kind: 'block', type: 'hbridge_dual_backward' },
{ kind: 'block', type: 'hbridge_dual_left' },
{ kind: 'block', type: 'hbridge_dual_right' },
{
kind: 'block',
type: 'hbridge_dual_forward_x_seconds',
inputs: {
SECS: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
},
},
{
kind: 'block',
type: 'hbridge_dual_backward_x_seconds',
inputs: {
SECS: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
},
},
{
kind: 'block',
type: 'hbridge_dual_left_x_seconds',
inputs: {
SECS: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
},
},
{
kind: 'block',
type: 'hbridge_dual_right_x_seconds',
inputs: {
SECS: { shadow: { type: 'math_number', fields: { NUM: 1 } } },
},
},
{ kind: 'block', type: 'hbridge_dual_stop' },
],
};

View File

@ -11,6 +11,20 @@ export function soundCategory({ hasSpeaker = false } = {}) {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } }, DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
}, },
}, },
{
kind: 'block',
type: 'sound_play_note_speaker',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{
kind: 'block',
type: 'sound_play_staff_note_speaker',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{ kind: 'block', type: 'sound_stop_speaker' }, { kind: 'block', type: 'sound_stop_speaker' },
); );
} }
@ -24,9 +38,61 @@ export function soundCategory({ hasSpeaker = false } = {}) {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } }, DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
}, },
}, },
{
kind: 'block',
type: 'sound_play_note',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{
kind: 'block',
type: 'sound_play_staff_note',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{ kind: 'block', type: 'sound_stop' }, { kind: 'block', type: 'sound_stop' },
); );
// Buzzer blocks (init once, play without specifying pin each time)
contents.push(
{ kind: 'block', type: 'sound_buzzer_init' },
{
kind: 'block',
type: 'sound_buzzer_tone',
inputs: {
FREQ: { shadow: { type: 'math_number', fields: { NUM: 440 } } },
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{
kind: 'block',
type: 'sound_buzzer_note',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{
kind: 'block',
type: 'sound_buzzer_staff_note',
inputs: {
DURATION: { shadow: { type: 'math_number', fields: { NUM: 500 } } },
},
},
{ kind: 'block', type: 'sound_buzzer_stop' },
);
// Melodies pin versions first, then buzzer versions
contents.push(
{ kind: 'block', type: 'sound_melody_mario' },
{ kind: 'block', type: 'sound_melody_happy' },
{ kind: 'block', type: 'sound_melody_sad' },
{ kind: 'block', type: 'sound_melody_mario_buzzer' },
{ kind: 'block', type: 'sound_melody_happy_buzzer' },
{ kind: 'block', type: 'sound_melody_sad_buzzer' },
);
return { return {
kind: 'category', kind: 'category',
name: 'Sound', name: 'Sound',

View File

@ -1,4 +1,5 @@
import * as Blockly from 'blockly'; import * as Blockly from 'blockly';
import { FieldPitch } from './field_pitch.js';
// ─── Pin I/O ────────────────────────────────────────────── // ─── Pin I/O ──────────────────────────────────────────────
@ -523,8 +524,225 @@ Blockly.Blocks['print_text'] = {
}, },
}; };
// ─── H-Bridge Motor ─────────────────────────────────────
Blockly.Blocks['hbridge_motor_init'] = {
init() {
this.appendDummyInput()
.appendField('init motor')
.appendField(new Blockly.FieldDropdown([['A', 'A'], ['B', 'B']]), 'MOTOR')
.appendField('IN1 pin')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'IN1')
.appendField('IN2 pin')
.appendField(new Blockly.FieldNumber(3, 0, 48, 1), 'IN2');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Initialize an h-bridge motor channel with two pins.');
},
};
Blockly.Blocks['hbridge_motor_speed'] = {
init() {
this.appendDummyInput()
.appendField('set motor')
.appendField(new Blockly.FieldDropdown([['A', 'A'], ['B', 'B']]), 'MOTOR');
this.appendValueInput('SPEED')
.setCheck('Number')
.appendField('speed');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Set motor speed: -255 (full reverse) to +255 (full forward).');
},
};
Blockly.Blocks['hbridge_motor_stop'] = {
init() {
this.appendDummyInput()
.appendField('stop motor')
.appendField(new Blockly.FieldDropdown([['A', 'A'], ['B', 'B']]), 'MOTOR');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Stop an h-bridge motor (brake).');
},
};
Blockly.Blocks['hbridge_dual_init'] = {
init() {
this.appendDummyInput().appendField('init dual motors');
this.appendDummyInput()
.appendField('left IN1')
.appendField(new Blockly.FieldNumber(2, 0, 48, 1), 'L_IN1')
.appendField('IN2')
.appendField(new Blockly.FieldNumber(3, 0, 48, 1), 'L_IN2');
this.appendDummyInput()
.appendField('right IN1')
.appendField(new Blockly.FieldNumber(4, 0, 48, 1), 'R_IN1')
.appendField('IN2')
.appendField(new Blockly.FieldNumber(5, 0, 48, 1), 'R_IN2');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Initialize two h-bridge motors (left and right) with 4 pins.');
},
};
Blockly.Blocks['hbridge_dual_speed'] = {
init() {
this.appendDummyInput().appendField('set motors');
this.appendValueInput('LEFT')
.setCheck('Number')
.appendField('left');
this.appendValueInput('RIGHT')
.setCheck('Number')
.appendField('right');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Set left and right motor speeds independently (-255 to 255).');
},
};
Blockly.Blocks['hbridge_dual_forward'] = {
init() {
this.appendDummyInput().appendField('drive forward');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive both motors forward using the speed from "set motors".');
},
};
Blockly.Blocks['hbridge_dual_backward'] = {
init() {
this.appendDummyInput().appendField('drive backward');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive both motors backward using the speed from "set motors".');
},
};
Blockly.Blocks['hbridge_dual_left'] = {
init() {
this.appendDummyInput().appendField('turn left');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn left (left motor backward, right motor forward).');
},
};
Blockly.Blocks['hbridge_dual_right'] = {
init() {
this.appendDummyInput().appendField('turn right');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn right (left motor forward, right motor backward).');
},
};
Blockly.Blocks['hbridge_dual_forward_x_seconds'] = {
init() {
this.appendValueInput('SECS')
.setCheck('Number')
.appendField('drive forward for');
this.appendDummyInput().appendField('seconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive forward for a number of seconds, then stop.');
},
};
Blockly.Blocks['hbridge_dual_backward_x_seconds'] = {
init() {
this.appendValueInput('SECS')
.setCheck('Number')
.appendField('drive backward for');
this.appendDummyInput().appendField('seconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Drive backward for a number of seconds, then stop.');
},
};
Blockly.Blocks['hbridge_dual_left_x_seconds'] = {
init() {
this.appendValueInput('SECS')
.setCheck('Number')
.appendField('turn left for');
this.appendDummyInput().appendField('seconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn left for a number of seconds, then stop.');
},
};
Blockly.Blocks['hbridge_dual_right_x_seconds'] = {
init() {
this.appendValueInput('SECS')
.setCheck('Number')
.appendField('turn right for');
this.appendDummyInput().appendField('seconds');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Turn right for a number of seconds, then stop.');
},
};
Blockly.Blocks['hbridge_dual_stop'] = {
init() {
this.appendDummyInput().appendField('stop motors');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Stop both motors.');
},
};
// ─── Sound ─────────────────────────────────────────────── // ─── Sound ───────────────────────────────────────────────
const NOTE_FREQUENCIES = [
['C4', '262'],
['C#4', '277'],
['D4', '294'],
['D#4', '311'],
['E4', '330'],
['F4', '349'],
['F#4', '370'],
['G4', '392'],
['G#4', '415'],
['A4', '440'],
['A#4', '466'],
['B4', '494'],
['C5', '523'],
['C#5', '554'],
['D5', '587'],
['D#5', '622'],
['E5', '659'],
['F5', '698'],
['F#5', '740'],
['G5', '784'],
['G#5', '831'],
['A5', '880'],
['A#5', '932'],
['B5', '988'],
];
Blockly.Blocks['sound_play_tone_speaker'] = { Blockly.Blocks['sound_play_tone_speaker'] = {
init() { init() {
this.appendDummyInput().appendField('play tone on speaker'); this.appendDummyInput().appendField('play tone on speaker');
@ -597,6 +815,266 @@ Blockly.Blocks['sound_stop'] = {
}, },
}; };
Blockly.Blocks['sound_play_note_speaker'] = {
init() {
this.appendDummyInput()
.appendField('play note on speaker');
this.appendDummyInput()
.appendField('note')
.appendField(new Blockly.FieldDropdown(NOTE_FREQUENCIES), 'NOTE');
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 musical note on the built-in speaker (micro:bit v2).');
},
};
Blockly.Blocks['sound_play_note'] = {
init() {
this.appendDummyInput()
.appendField('play note on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.appendDummyInput()
.appendField('note')
.appendField(new Blockly.FieldDropdown(NOTE_FREQUENCIES), 'NOTE');
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 musical note on a specific pin.');
},
};
Blockly.Blocks['sound_play_staff_note_speaker'] = {
init() {
this.appendDummyInput()
.appendField('play note on speaker');
this.appendDummyInput()
.appendField('note')
.appendField(new FieldPitch('440'), 'NOTE');
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 note on the built-in speaker, picked from a staff.');
},
};
Blockly.Blocks['sound_play_staff_note'] = {
init() {
this.appendDummyInput()
.appendField('play note on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.appendDummyInput()
.appendField('note')
.appendField(new FieldPitch('440'), 'NOTE');
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 note on a specific pin, picked from a staff.');
},
};
// ─── Sound buzzer (init + pin-free blocks) ─────────────
Blockly.Blocks['sound_buzzer_init'] = {
init() {
this.appendDummyInput()
.appendField('set up buzzer on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Initialise a buzzer on a pin. Use with the "buzzer" play / stop blocks.');
},
};
Blockly.Blocks['sound_buzzer_tone'] = {
init() {
this.appendDummyInput().appendField('play tone on buzzer');
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 buzzer (set pin with "set up buzzer" block).');
},
};
Blockly.Blocks['sound_buzzer_note'] = {
init() {
this.appendDummyInput()
.appendField('play note on buzzer')
.appendField('note')
.appendField(new Blockly.FieldDropdown(NOTE_FREQUENCIES), 'NOTE');
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 musical note on the buzzer (set pin with "set up buzzer" block).');
},
};
Blockly.Blocks['sound_buzzer_staff_note'] = {
init() {
this.appendDummyInput()
.appendField('play note on buzzer')
.appendField('note')
.appendField(new FieldPitch('440'), 'NOTE');
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 note (from staff) on the buzzer (set pin with "set up buzzer" block).');
},
};
Blockly.Blocks['sound_buzzer_stop'] = {
init() {
this.appendDummyInput().appendField('stop buzzer');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Stop the buzzer (set pin with "set up buzzer" block).');
},
};
// ─── Sound melodies ────────────────────────────────────
Blockly.Blocks['sound_melody_mario'] = {
init() {
this.appendDummyInput()
.appendField('play Mario theme on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play the Super Mario Bros theme on a specific pin.');
},
};
Blockly.Blocks['sound_melody_mario_buzzer'] = {
init() {
this.appendDummyInput().appendField('play Mario theme on buzzer');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play the Super Mario Bros theme on the buzzer (set pin with "set up buzzer" block).');
},
};
Blockly.Blocks['sound_melody_happy'] = {
init() {
this.appendDummyInput()
.appendField('play happy tune on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a cheerful melody on a specific pin.');
},
};
Blockly.Blocks['sound_melody_happy_buzzer'] = {
init() {
this.appendDummyInput().appendField('play happy tune on buzzer');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a cheerful melody on the buzzer (set pin with "set up buzzer" block).');
},
};
Blockly.Blocks['sound_melody_sad'] = {
init() {
this.appendDummyInput()
.appendField('play sad tune on pin')
.appendField(new Blockly.FieldNumber(0, 0, 48, 1), 'PIN');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a melancholy melody on a specific pin.');
},
};
Blockly.Blocks['sound_melody_sad_buzzer'] = {
init() {
this.appendDummyInput().appendField('play sad tune on buzzer');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(300);
this.setTooltip('Play a melancholy melody on the buzzer (set pin with "set up buzzer" block).');
},
};
// ─── micro:bit Display ──────────────────────────────────── // ─── micro:bit Display ────────────────────────────────────
// Helper to add one row of 5 checkboxes (Pxy = pixel at row x, col y) // Helper to add one row of 5 checkboxes (Pxy = pixel at row x, col y)
@ -790,6 +1268,19 @@ Blockly.Blocks['arduino_no_tone'] = {
}, },
}; };
// ─── Loops ─────────────────────────────────────────────
Blockly.Blocks['controls_repeat_forever'] = {
init() {
this.appendDummyInput().appendField('repeat forever');
this.appendStatementInput('DO').appendField('do');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(120);
this.setTooltip('Repeat the enclosed blocks forever.');
},
};
Blockly.Blocks['arduino_map'] = { Blockly.Blocks['arduino_map'] = {
init() { init() {
this.appendValueInput('VALUE').setCheck('Number').appendField('map'); this.appendValueInput('VALUE').setCheck('Number').appendField('map');

View File

@ -306,6 +306,160 @@ pythonGenerator.forBlock['print_text'] = function (block) {
return `print(${text})\n`; return `print(${text})\n`;
}; };
// ─── H-Bridge Motor ─────────────────────────────────────
function hbSetMotorDef() {
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_microbit'] = 'from microbit import *';
pythonGenerator.definitions_['hb_set_motor'] = [
'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' p1.write_analog(duty)',
' p2.write_analog(0)',
' elif speed < 0:',
' p1.write_analog(0)',
' p2.write_analog(duty)',
' else:',
' p1.write_analog(0)',
' p2.write_analog(0)',
].join('\n');
} else {
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
pythonGenerator.definitions_['hb_set_motor'] = [
'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' try: PWM(Pin(p2)).deinit()',
' except: pass',
' Pin(p2, Pin.OUT).value(0)',
' PWM(Pin(p1), freq=1000, duty=duty)',
' elif speed < 0:',
' try: PWM(Pin(p1)).deinit()',
' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' PWM(Pin(p2), freq=1000, duty=duty)',
' else:',
' try: PWM(Pin(p1)).deinit()',
' except: pass',
' try: PWM(Pin(p2)).deinit()',
' except: pass',
' Pin(p1, Pin.OUT).value(0)',
' Pin(p2, Pin.OUT).value(0)',
].join('\n');
}
}
function hbInitPin(label, pin) {
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['hb_' + label] = label + ' = ' + mbPin(pin);
} else {
pythonGenerator.definitions_['hb_' + label] = label + ' = ' + pin;
}
}
pythonGenerator.forBlock['hbridge_motor_init'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
hbSetMotorDef();
hbInitPin('motor_' + m + '_in1', block.getFieldValue('IN1'));
hbInitPin('motor_' + m + '_in2', block.getFieldValue('IN2'));
return '';
};
pythonGenerator.forBlock['hbridge_motor_speed'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
var speed = pythonGenerator.valueToCode(block, 'SPEED', Order.NONE) || '0';
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, ' + speed + ')\n';
};
pythonGenerator.forBlock['hbridge_motor_stop'] = function (block) {
var m = block.getFieldValue('MOTOR').toLowerCase();
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n';
};
pythonGenerator.forBlock['hbridge_dual_init'] = function (block) {
hbSetMotorDef();
hbInitPin('motor_l_in1', block.getFieldValue('L_IN1'));
hbInitPin('motor_l_in2', block.getFieldValue('L_IN2'));
hbInitPin('motor_r_in1', block.getFieldValue('R_IN1'));
hbInitPin('motor_r_in2', block.getFieldValue('R_IN2'));
return '';
};
pythonGenerator.forBlock['hbridge_dual_speed'] = function (block) {
var left = pythonGenerator.valueToCode(block, 'LEFT', Order.NONE) || '0';
var right = pythonGenerator.valueToCode(block, 'RIGHT', Order.NONE) || '0';
return '_hb_set_motor(motor_l_in1, motor_l_in2, ' + left + ')\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, ' + right + ')\n';
};
pythonGenerator.forBlock['hbridge_dual_forward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128)\n';
};
pythonGenerator.forBlock['hbridge_dual_backward'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128)\n';
};
pythonGenerator.forBlock['hbridge_dual_left'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128)\n';
};
pythonGenerator.forBlock['hbridge_dual_right'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128)\n';
};
pythonGenerator.forBlock['hbridge_dual_forward_x_seconds'] = function (block) {
var s = pythonGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
pythonGenerator.definitions_['import_time'] = 'import time';
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128)\n' +
'time.sleep(' + s + ')\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
pythonGenerator.forBlock['hbridge_dual_backward_x_seconds'] = function (block) {
var s = pythonGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
pythonGenerator.definitions_['import_time'] = 'import time';
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128)\n' +
'time.sleep(' + s + ')\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
pythonGenerator.forBlock['hbridge_dual_left_x_seconds'] = function (block) {
var s = pythonGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
pythonGenerator.definitions_['import_time'] = 'import time';
return '_hb_set_motor(motor_l_in1, motor_l_in2, -128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 128)\n' +
'time.sleep(' + s + ')\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
pythonGenerator.forBlock['hbridge_dual_right_x_seconds'] = function (block) {
var s = pythonGenerator.valueToCode(block, 'SECS', Order.NONE) || '1';
pythonGenerator.definitions_['import_time'] = 'import time';
return '_hb_set_motor(motor_l_in1, motor_l_in2, 128)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, -128)\n' +
'time.sleep(' + s + ')\n' +
'_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
pythonGenerator.forBlock['hbridge_dual_stop'] = function () {
return '_hb_set_motor(motor_l_in1, motor_l_in2, 0)\n' +
'_hb_set_motor(motor_r_in1, motor_r_in2, 0)\n';
};
// ─── Sound ─────────────────────────────────────────────── // ─── Sound ───────────────────────────────────────────────
pythonGenerator.forBlock['sound_play_tone_speaker'] = function (block) { pythonGenerator.forBlock['sound_play_tone_speaker'] = function (block) {
@ -367,6 +521,199 @@ pythonGenerator.forBlock['sound_stop'] = function (block) {
return `${varName}.duty(0)\n`; return `${varName}.duty(0)\n`;
}; };
pythonGenerator.forBlock['sound_play_note_speaker'] = function (block) {
const freq = block.getFieldValue('NOTE');
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_play_note'] = function (block) {
const pin = block.getFieldValue('PIN');
const freq = block.getFieldValue('NOTE');
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_play_staff_note_speaker'] =
pythonGenerator.forBlock['sound_play_note_speaker'];
pythonGenerator.forBlock['sound_play_staff_note'] =
pythonGenerator.forBlock['sound_play_note'];
// ─── Sound buzzer (init + pin-free) ────────────────────
pythonGenerator.forBlock['sound_buzzer_init'] = function (block) {
const pin = block.getFieldValue('PIN');
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
pythonGenerator.definitions_['import_microbit_pins'] = 'from microbit import *';
pythonGenerator.definitions_['buzzer_pin'] = `_buzzer_pin = ${mbPin(pin)}`;
return '';
}
pythonGenerator.definitions_['import_machine'] = 'from machine import Pin, PWM, ADC, I2C';
pythonGenerator.definitions_['buzzer_init'] = `_buzzer = PWM(Pin(${pin}))\n_buzzer.duty(0)`;
return '';
};
pythonGenerator.forBlock['sound_buzzer_tone'] = 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}, pin=_buzzer_pin, wait=${wait ? 'True' : 'False'})\n`;
}
if (wait) {
pythonGenerator.definitions_['import_time'] = 'import time';
return `_buzzer.freq(${freq})\n_buzzer.duty(512)\ntime.sleep_ms(${duration})\n_buzzer.duty(0)\n`;
}
pythonGenerator.definitions_['import_timer'] = 'from machine import Timer';
return `_buzzer.freq(${freq})\n_buzzer.duty(512)\nTimer(0).init(period=${duration}, mode=Timer.ONE_SHOT, callback=lambda t: _buzzer.duty(0))\n`;
};
pythonGenerator.forBlock['sound_buzzer_note'] = function (block) {
const freq = block.getFieldValue('NOTE');
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}, pin=_buzzer_pin, wait=${wait ? 'True' : 'False'})\n`;
}
if (wait) {
pythonGenerator.definitions_['import_time'] = 'import time';
return `_buzzer.freq(${freq})\n_buzzer.duty(512)\ntime.sleep_ms(${duration})\n_buzzer.duty(0)\n`;
}
pythonGenerator.definitions_['import_timer'] = 'from machine import Timer';
return `_buzzer.freq(${freq})\n_buzzer.duty(512)\nTimer(0).init(period=${duration}, mode=Timer.ONE_SHOT, callback=lambda t: _buzzer.duty(0))\n`;
};
pythonGenerator.forBlock['sound_buzzer_staff_note'] =
pythonGenerator.forBlock['sound_buzzer_note'];
pythonGenerator.forBlock['sound_buzzer_stop'] = function () {
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
return 'music.stop()\n';
}
return '_buzzer.duty(0)\n';
};
// ─── Sound melodies ────────────────────────────────────
const MELODIES = {
mario: [
[660,100],[660,100],[0,100],[660,100],[0,100],[520,100],[660,100],[0,100],
[784,100],[0,300],[392,100],[0,300],
[520,100],[0,200],[392,100],[0,200],[330,100],[0,200],
[440,100],[0,100],[494,100],[0,100],[466,100],[440,100],[0,100],
[392,100],[660,100],[784,100],[880,100],[0,100],[698,100],[784,100],
[0,100],[660,100],[0,100],[520,100],[587,100],[494,100],[0,100],
[520,100],[0,200],[392,100],[0,200],[330,100],[0,200],
[440,100],[0,100],[494,100],[0,100],[466,100],[440,100],[0,100],
[392,100],[660,100],[784,100],[880,100],[0,100],[698,100],[784,100],
[0,100],[660,100],[0,100],[520,100],[587,100],[494,100],[0,200],
],
happy: [[523,150],[0,30],[659,150],[0,30],[784,200]],
sad: [[392,250],[0,30],[330,250],[0,30],[262,300]],
};
function melodyPython(name, buzzerExpr) {
const notes = MELODIES[name];
let code = `_${name}_melody = [`;
code += notes.map(([f, d]) => `(${f},${d})`).join(',');
code += ']\n';
code += `for _freq, _dur in _${name}_melody:\n`;
code += ` if _freq == 0:\n`;
code += ` ${buzzerExpr}.duty(0)\n`;
code += ` else:\n`;
code += ` ${buzzerExpr}.freq(_freq)\n`;
code += ` ${buzzerExpr}.duty(512)\n`;
code += ` time.sleep_ms(_dur)\n`;
code += `${buzzerExpr}.duty(0)\n`;
return code;
}
function melodyMicrobit(name, pinExpr) {
const notes = MELODIES[name];
let code = `_${name}_melody = [`;
code += notes.map(([f, d]) => `(${f},${d})`).join(',');
code += ']\n';
code += `for _freq, _dur in _${name}_melody:\n`;
code += ` if _freq == 0:\n`;
code += ` sleep(_dur)\n`;
code += ` else:\n`;
code += ` music.pitch(_freq, _dur, pin=${pinExpr}, wait=True)\n`;
return code;
}
function melodyPinGenerator(name) {
return function (block) {
const pin = block.getFieldValue('PIN');
pythonGenerator.definitions_['import_time'] = 'import time';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
pythonGenerator.definitions_['import_microbit_pins'] = 'from microbit import *';
return melodyMicrobit(name, mbPin(pin));
}
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 melodyPython(name, varName);
};
}
function melodyBuzzerGenerator(name) {
return function () {
pythonGenerator.definitions_['import_time'] = 'import time';
if (DEVICE() === 'microbit') {
pythonGenerator.definitions_['import_music'] = 'import music';
pythonGenerator.definitions_['import_microbit_pins'] = 'from microbit import *';
return melodyMicrobit(name, '_buzzer_pin');
}
return melodyPython(name, '_buzzer');
};
}
pythonGenerator.forBlock['sound_melody_mario'] = melodyPinGenerator('mario');
pythonGenerator.forBlock['sound_melody_mario_buzzer'] = melodyBuzzerGenerator('mario');
pythonGenerator.forBlock['sound_melody_happy'] = melodyPinGenerator('happy');
pythonGenerator.forBlock['sound_melody_happy_buzzer'] = melodyBuzzerGenerator('happy');
pythonGenerator.forBlock['sound_melody_sad'] = melodyPinGenerator('sad');
pythonGenerator.forBlock['sound_melody_sad_buzzer'] = melodyBuzzerGenerator('sad');
// ─── micro:bit Display ──────────────────────────────────── // ─── micro:bit Display ────────────────────────────────────
pythonGenerator.forBlock['microbit_display_all'] = function (block) { pythonGenerator.forBlock['microbit_display_all'] = function (block) {
@ -617,3 +964,10 @@ pythonGenerator.forBlock['hid_gamepad_axis'] = function (block) {
const value = pythonGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0'; const value = pythonGenerator.valueToCode(block, 'VALUE', Order.NONE) || '0';
return `_hid_gamepad_axis(${axis}, ${value})\n`; return `_hid_gamepad_axis(${axis}, ${value})\n`;
}; };
// ─── Loops ─────────────────────────────────────────────
pythonGenerator.forBlock['controls_repeat_forever'] = function (block) {
let branch = pythonGenerator.statementToCode(block, 'DO') || ' pass\n';
return `while True:\n${branch}`;
};

208
src/blocks/field_pitch.js Normal file
View File

@ -0,0 +1,208 @@
import * as Blockly from 'blockly';
const NOTES = [
{ name: 'C4', freq: 262 },
{ name: 'D4', freq: 294 },
{ name: 'E4', freq: 330 },
{ name: 'F4', freq: 349 },
{ name: 'G4', freq: 392 },
{ name: 'A4', freq: 440 },
{ name: 'B4', freq: 494 },
{ name: 'C5', freq: 523 },
{ name: 'D5', freq: 587 },
{ name: 'E5', freq: 659 },
{ name: 'F5', freq: 698 },
{ name: 'G5', freq: 784 },
{ name: 'A5', freq: 880 },
];
const NOTE_STEP = 7;
const PADDING_Y = 16;
const NOTE_X = 82;
const PICKER_WIDTH = 134;
const PICKER_HEIGHT = PADDING_Y * 2 + 12 * NOTE_STEP;
const STAFF_LINE_POSITIONS = [2, 4, 6, 8, 10];
const NS = 'http://www.w3.org/2000/svg';
function noteY(index) {
return PADDING_Y + (12 - index) * NOTE_STEP;
}
function svgEl(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
return el;
}
export class FieldPitch extends Blockly.Field {
SERIALIZABLE = true;
CURSOR = 'pointer';
constructor(value, validator) {
super(String(value ?? '440'), validator);
}
static fromJson(options) {
return new FieldPitch(options['pitch']);
}
getText_() {
const note = NOTES.find(n => String(n.freq) === String(this.getValue()));
return note ? note.name : String(this.getValue());
}
noteIndex_() {
const v = String(this.getValue());
const i = NOTES.findIndex(n => String(n.freq) === v);
return i >= 0 ? i : 5;
}
doClassValidation_(newValue) {
if (newValue == null) return null;
return NOTES.find(n => String(n.freq) === String(newValue))
? String(newValue)
: null;
}
showEditor_() {
const picker = this.buildPicker_();
Blockly.DropDownDiv.getContentDiv().appendChild(picker);
Blockly.DropDownDiv.setColour(
this.sourceBlock_.style.colourPrimary,
this.sourceBlock_.style.colourTertiary,
);
Blockly.DropDownDiv.showPositionedByField(
this,
() => this.teardownPicker_(),
);
this.updatePicker_();
}
buildPicker_() {
const svg = (this.pickerSvg_ = svgEl('svg', {
width: PICKER_WIDTH,
height: PICKER_HEIGHT,
}));
svg.style.cursor = 'pointer';
svg.style.display = 'block';
svg.appendChild(
svgEl('rect', {
x: 0,
y: 0,
width: PICKER_WIDTH,
height: PICKER_HEIGHT,
fill: '#fff',
rx: 4,
ry: 4,
}),
);
for (const pos of STAFF_LINE_POSITIONS) {
svg.appendChild(
svgEl('line', {
x1: 10,
y1: noteY(pos),
x2: PICKER_WIDTH - 10,
y2: noteY(pos),
stroke: '#aaa',
'stroke-width': 1,
}),
);
}
const clef = svgEl('text', {
x: 14,
y: noteY(2) + 8,
'font-size': 52,
'font-family':
'"Noto Music","Noto Sans Symbols2","Segoe UI Symbol",serif',
fill: '#999',
'pointer-events': 'none',
});
clef.textContent = '\u{1D11E}';
svg.appendChild(clef);
this.ledgerGrp_ = svgEl('g', {});
svg.appendChild(this.ledgerGrp_);
this.noteEl_ = svgEl('ellipse', { rx: 7, ry: 5, fill: '#000' });
svg.appendChild(this.noteEl_);
this.labelEl_ = svgEl('text', {
'font-size': 11,
'font-family': 'sans-serif',
fill: '#333',
'font-weight': 'bold',
'pointer-events': 'none',
});
svg.appendChild(this.labelEl_);
this.handleMove_ = (e) => {
const rect = svg.getBoundingClientRect();
const y = e.clientY - rect.top;
const raw = (PADDING_Y + 12 * NOTE_STEP - y) / NOTE_STEP;
const idx = Math.max(0, Math.min(12, Math.round(raw)));
this.setValue(String(NOTES[idx].freq));
this.updatePicker_();
};
this.handleClick_ = (e) => {
this.handleMove_(e);
Blockly.DropDownDiv.hideWithoutAnimation();
};
svg.addEventListener('mousemove', this.handleMove_);
svg.addEventListener('click', this.handleClick_);
return svg;
}
updatePicker_() {
if (!this.noteEl_) return;
const idx = this.noteIndex_();
const y = noteY(idx);
this.noteEl_.setAttribute('cx', NOTE_X);
this.noteEl_.setAttribute('cy', y);
this.noteEl_.setAttribute('transform', `rotate(-15 ${NOTE_X} ${y})`);
while (this.ledgerGrp_.firstChild)
this.ledgerGrp_.removeChild(this.ledgerGrp_.firstChild);
if (idx <= 0) this.addLedger_(noteY(0));
if (idx >= 12) this.addLedger_(noteY(12));
this.labelEl_.textContent = NOTES[idx].name;
this.labelEl_.setAttribute('x', NOTE_X + 14);
this.labelEl_.setAttribute('y', y + 4);
}
addLedger_(y) {
this.ledgerGrp_.appendChild(
svgEl('line', {
x1: NOTE_X - 14,
y1: y,
x2: NOTE_X + 14,
y2: y,
stroke: '#aaa',
'stroke-width': 1,
}),
);
}
teardownPicker_() {
if (this.pickerSvg_) {
if (this.handleMove_)
this.pickerSvg_.removeEventListener('mousemove', this.handleMove_);
if (this.handleClick_)
this.pickerSvg_.removeEventListener('click', this.handleClick_);
}
this.pickerSvg_ = null;
this.noteEl_ = null;
this.labelEl_ = null;
this.ledgerGrp_ = null;
this.handleMove_ = null;
this.handleClick_ = null;
}
}
Blockly.fieldRegistry.register('field_pitch', FieldPitch);

View File

@ -3,6 +3,7 @@ import { sensorsCategory } from '../blocks/categories/sensors.js';
import { timeCategory } from '../blocks/categories/time.js'; import { timeCategory } from '../blocks/categories/time.js';
import { serialPrintCategory } from '../blocks/categories/serialPrint.js'; import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { randomCategory } from '../blocks/categories/random.js'; import { randomCategory } from '../blocks/categories/random.js';
import { hbridgeMotorCategory } from '../blocks/categories/hbridgeMotor.js';
const analogCategory = { const analogCategory = {
kind: 'category', kind: 'category',
@ -86,6 +87,7 @@ export const arduinoUno = {
arduinoSoundCategory, arduinoSoundCategory,
sensorsCategory, sensorsCategory,
timeCategory, timeCategory,
hbridgeMotorCategory,
serialPrintCategory, serialPrintCategory,
randomCategory, randomCategory,
], ],

View File

@ -10,13 +10,14 @@ import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { soundCategory } from '../blocks/categories/sound.js'; import { soundCategory } from '../blocks/categories/sound.js';
import { randomCategory } from '../blocks/categories/random.js'; import { randomCategory } from '../blocks/categories/random.js';
import { hidCategory } from '../blocks/categories/hid.js'; import { hidCategory } from '../blocks/categories/hid.js';
import { hbridgeMotorCategory } from '../blocks/categories/hbridgeMotor.js';
export const esp32s3 = { export const esp32s3 = {
id: 'esp32s3', id: 'esp32s3',
label: 'ESP32-S3', label: 'ESP32',
firmware: { firmware: {
label: 'MicroPython (ESP32-S3)', label: 'MicroPython (ESP32)',
url: '/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin', url: import.meta.env.BASE_URL + 'firmware/ESP32_GENERIC-20260406-v1.28.0.bin',
canFlashInBrowser: true, canFlashInBrowser: true,
instructions: null, instructions: null,
}, },
@ -31,6 +32,7 @@ export const esp32s3 = {
i2cCategory, i2cCategory,
hidCategory, hidCategory,
soundCategory(), soundCategory(),
hbridgeMotorCategory,
serialPrintCategory, serialPrintCategory,
randomCategory, randomCategory,
], ],

View File

@ -9,6 +9,7 @@ import { soundCategory } from '../blocks/categories/sound.js';
import { neopixelCategory } from '../blocks/categories/neopixel.js'; import { neopixelCategory } from '../blocks/categories/neopixel.js';
import { randomCategory } from '../blocks/categories/random.js'; import { randomCategory } from '../blocks/categories/random.js';
import { superbitCategory } from '../blocks/categories/superbit.js'; import { superbitCategory } from '../blocks/categories/superbit.js';
import { hbridgeMotorCategory } from '../blocks/categories/hbridgeMotor.js';
export const microbit = { export const microbit = {
id: 'microbit', id: 'microbit',
@ -28,6 +29,7 @@ export const microbit = {
microbitDisplayCategory, microbitDisplayCategory,
superbitCategory, superbitCategory,
soundCategory({ hasSpeaker: true }), soundCategory({ hasSpeaker: true }),
hbridgeMotorCategory,
serialPrintCategory, serialPrintCategory,
neopixelCategory, neopixelCategory,
randomCategory, randomCategory,

View File

@ -119,13 +119,41 @@ export function setFilter(deviceId, filter) {
// --- presets --- // --- presets ---
export function getSavedPresets() { const BUILTIN_PRESETS = [
{
name: 'Legobot',
builtin: true,
filter: {
hiddenCategories: [
'Pin I/O', 'PWM', 'ADC', 'WiFi', 'NeoPixel', 'I2C',
'HID (Keyboard / Mouse / Gamepad)',
],
hiddenBlocks: [
'sound_play_tone', 'sound_play_note', 'sound_play_staff_note',
'sound_stop', 'hbridge_motor_init', 'hbridge_motor_speed',
'hbridge_motor_stop',
],
},
},
{
name: 'Show All',
builtin: true,
filter: { hiddenCategories: [], hiddenBlocks: [] },
},
];
function getUserPresets() {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY)) || []; } try { return JSON.parse(localStorage.getItem(PRESETS_KEY)) || []; }
catch { return []; } catch { return []; }
} }
export function getSavedPresets() {
return [...BUILTIN_PRESETS, ...getUserPresets()];
}
export function savePreset(name, filter) { export function savePreset(name, filter) {
const presets = getSavedPresets(); if (BUILTIN_PRESETS.some(p => p.name === name)) return;
const presets = getUserPresets();
const idx = presets.findIndex(p => p.name === name); const idx = presets.findIndex(p => p.name === name);
if (idx >= 0) presets[idx] = { name, filter }; if (idx >= 0) presets[idx] = { name, filter };
else presets.push({ name, filter }); else presets.push({ name, filter });
@ -133,7 +161,8 @@ export function savePreset(name, filter) {
} }
export function deletePreset(name) { export function deletePreset(name) {
const presets = getSavedPresets().filter(p => p.name !== name); if (BUILTIN_PRESETS.some(p => p.name === name)) return;
const presets = getUserPresets().filter(p => p.name !== name);
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)); localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
} }

View File

@ -8,15 +8,17 @@ import { serialPrintCategory } from '../blocks/categories/serialPrint.js';
import { soundCategory } from '../blocks/categories/sound.js'; import { soundCategory } from '../blocks/categories/sound.js';
import { neopixelCategory } from '../blocks/categories/neopixel.js'; import { neopixelCategory } from '../blocks/categories/neopixel.js';
import { randomCategory } from '../blocks/categories/random.js'; import { randomCategory } from '../blocks/categories/random.js';
import { hbridgeMotorCategory } from '../blocks/categories/hbridgeMotor.js';
export const rp2040 = { export const rp2040 = {
id: 'rp2040', id: 'rp2040',
label: 'RP2040 (Pico)', label: 'RP2040 (Pico)',
firmware: { firmware: {
label: 'MicroPython (Raspberry Pi Pico)', label: 'MicroPython (Raspberry Pi Pico)',
url: 'https://micropython.org/resources/firmware/RPI_PICO.uf2', url: import.meta.env.BASE_URL + 'firmware/RPI_PICO-20251209-v1.27.0.uf2',
canFlashInBrowser: false, canFlashInBrowser: true,
instructions: 'Hold BOOTSEL while plugging in, then drag the .uf2 file onto the RPI-RP2 drive.', flashMethod: 'webusb',
instructions: 'Hold BOOTSEL while plugging in, then click Flash FW.',
}, },
categories: [ categories: [
pinIoCategory, pinIoCategory,
@ -26,6 +28,7 @@ export const rp2040 = {
timeCategory, timeCategory,
i2cCategory, i2cCategory,
soundCategory(), soundCategory(),
hbridgeMotorCategory,
serialPrintCategory, serialPrintCategory,
neopixelCategory, neopixelCategory,
randomCategory, randomCategory,

View File

@ -24,10 +24,17 @@ import {
import { connect, disconnect, isConnected, getPort, onData, writeString } from './serial/connection.js'; import { connect, disconnect, isConnected, getPort, onData, writeString } from './serial/connection.js';
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js'; import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
import { flashFirmware } from './serial/flasher.js'; import { flashFirmware } from './serial/flasher.js';
import { flashPicoFirmware } from './serial/picoFlasher.js';
import { appendToTerminal, clearTerminal } from './ui/terminal.js'; import { appendToTerminal, clearTerminal } from './ui/terminal.js';
import { initResizablePanels, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js'; import { initResizablePanels, initPanelToggles, initProjectTabs, setProjectsPanelCallbacks } from './ui/panels.js';
import { initToolboxCustomizer, toggleCustomizeMode, refreshCustomizer } from './ui/toolboxCustomizer.js'; import { initToolboxCustomizer, toggleCustomizeMode, refreshCustomizer } from './ui/toolboxCustomizer.js';
import { initProjectsDialog, refreshAll as refreshProjects, refreshDeviceList } from './ui/projectsDialog.js'; import {
initProjectsDialog,
refreshAll as refreshProjects,
loadWorkspaceFromDevice,
saveCurrentWorkspaceToDevice,
} from './ui/projectsDialog.js';
import { initRobotPanel, syncRobotPanelDevice } from './ui/robotPanel.js';
import { uploadHex, BOARDS } from './arduino/stk500.js'; import { uploadHex, BOARDS } from './arduino/stk500.js';
import './style.css'; import './style.css';
@ -69,7 +76,7 @@ setDeviceListRefreshCallback(rebuildDeviceSelect);
// Toolbox customizer (show/hide categories & blocks) // Toolbox customizer (show/hide categories & blocks)
initToolboxCustomizer(refreshToolbox); initToolboxCustomizer(refreshToolbox);
document.getElementById('btn-customize').addEventListener('click', toggleCustomizeMode); document.getElementById('btn-customize')?.addEventListener('click', toggleCustomizeMode);
// ─── Generator Selection ───────────────────────────────── // ─── Generator Selection ─────────────────────────────────
@ -151,7 +158,6 @@ initResizablePanels();
initPanelToggles(); initPanelToggles();
initProjectTabs(); initProjectTabs();
setProjectsPanelCallbacks({ setProjectsPanelCallbacks({
onDeviceTab: () => refreshDeviceList(),
onExpand: () => refreshProjects(), onExpand: () => refreshProjects(),
}); });
@ -163,7 +169,7 @@ const btnFlash = document.getElementById('btn-flash');
const btnRun = document.getElementById('btn-run'); const btnRun = document.getElementById('btn-run');
const btnStop = document.getElementById('btn-stop'); const btnStop = document.getElementById('btn-stop');
const btnSave = document.getElementById('btn-save'); const btnSave = document.getElementById('btn-save');
const btnProjects = document.getElementById('btn-projects'); const btnLoad = document.getElementById('btn-load');
const statusEl = document.getElementById('connection-status'); const statusEl = document.getElementById('connection-status');
const terminalInput = document.getElementById('terminal-input'); const terminalInput = document.getElementById('terminal-input');
const sendOverlayEl = document.getElementById('send-overlay'); const sendOverlayEl = document.getElementById('send-overlay');
@ -204,6 +210,10 @@ function updateDeviceUI() {
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Download .ino'; btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Download .ino';
btnSave.title = 'Download generated code as .ino file'; btnSave.title = 'Download generated code as .ino file';
btnSave.disabled = false; btnSave.disabled = false;
btnLoad.querySelector('.icon').innerHTML = '&#128229;';
btnLoad.childNodes[btnLoad.childNodes.length - 1].textContent = ' Load';
btnLoad.title = 'Load is unavailable for Arduino devices';
btnLoad.disabled = true;
btnConnect.querySelector('.icon').innerHTML = '&#9654;'; btnConnect.querySelector('.icon').innerHTML = '&#9654;';
btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = ' Serial Monitor'; btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = ' Serial Monitor';
@ -221,6 +231,10 @@ function updateDeviceUI() {
btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Save'; btnSave.childNodes[btnSave.childNodes.length - 1].textContent = ' Save';
btnSave.title = 'Save code to device as main.py'; btnSave.title = 'Save code to device as main.py';
btnSave.disabled = !isConnected(); btnSave.disabled = !isConnected();
btnLoad.querySelector('.icon').innerHTML = '&#128229;';
btnLoad.childNodes[btnLoad.childNodes.length - 1].textContent = ' Load';
btnLoad.title = 'Load block layout from main.blk';
btnLoad.disabled = !isConnected();
btnConnect.querySelector('.icon').innerHTML = '&#9654;'; btnConnect.querySelector('.icon').innerHTML = '&#9654;';
btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = isConnected() ? ' Disconnect' : ' Connect'; btnConnect.childNodes[btnConnect.childNodes.length - 1].textContent = isConnected() ? ' Disconnect' : ' Connect';
@ -241,6 +255,7 @@ deviceSelect.addEventListener('change', () => {
setDeviceId(deviceSelect.value); setDeviceId(deviceSelect.value);
refreshToolbox(); refreshToolbox();
refreshCustomizer(); refreshCustomizer();
syncRobotPanelDevice();
updateCodePreview(); updateCodePreview();
updateDeviceUI(); updateDeviceUI();
}); });
@ -265,6 +280,7 @@ function setConnectedUI(connected) {
btnRun.disabled = !connected; btnRun.disabled = !connected;
btnStop.disabled = !connected; btnStop.disabled = !connected;
btnSave.disabled = !connected; btnSave.disabled = !connected;
btnLoad.disabled = !connected;
terminalInput.disabled = !connected; terminalInput.disabled = !connected;
statusEl.textContent = connected ? 'Connected' : 'Disconnected'; statusEl.textContent = connected ? 'Connected' : 'Disconnected';
statusEl.className = connected ? 'status-connected' : 'status-disconnected'; statusEl.className = connected ? 'status-connected' : 'status-disconnected';
@ -373,6 +389,37 @@ const flashLog = document.getElementById('flash-log');
const flashFill = document.getElementById('flash-progress-fill'); const flashFill = document.getElementById('flash-progress-fill');
const flashPctText = document.getElementById('flash-progress-text'); const flashPctText = document.getElementById('flash-progress-text');
const flashCloseBtn = document.getElementById('flash-close'); const flashCloseBtn = document.getElementById('flash-close');
const esp32FlashOverlay = document.getElementById('esp32-flash-overlay');
const esp32FlashClose = document.getElementById('esp32-flash-close');
const esp32FlashStart = document.getElementById('esp32-flash-start');
const esp32FlashVariant = document.getElementById('esp32-variant-select');
const esp32FlashStatus = document.getElementById('esp32-flash-status');
const FW_BASE = import.meta.env.BASE_URL + 'firmware/';
const ESP32_FIRMWARE_OPTIONS = {
esp32: {
label: 'ESP32',
url: FW_BASE + 'ESP32_GENERIC-20260406-v1.28.0.bin',
flashAddress: 0x1000,
},
esp32s2: {
label: 'ESP32-S2',
url: FW_BASE + 'ESP32_GENERIC_S2-20260406-v1.28.0.bin',
flashAddress: 0x1000,
resetMode: 'no_reset',
},
esp32s3: {
label: 'ESP32-S3',
url: FW_BASE + 'ESP32_GENERIC_S3-20251209-v1.27.0.bin',
flashAddress: 0x0,
},
esp32c3: {
label: 'ESP32-C3',
url: FW_BASE + 'ESP32_GENERIC_C3-20260406-v1.28.0.bin',
flashAddress: 0x0,
},
};
function showFlashOverlay() { function showFlashOverlay() {
flashLog.textContent = ''; flashLog.textContent = '';
@ -396,24 +443,44 @@ flashCloseBtn.addEventListener('click', () => {
flashOverlay.classList.add('hidden'); flashOverlay.classList.add('hidden');
}); });
function openEsp32FlashChooser() {
if (!esp32FlashOverlay) return;
if (esp32FlashStatus) {
esp32FlashStatus.textContent = '';
esp32FlashStatus.className = 'hex-upload-status';
}
esp32FlashOverlay.classList.remove('hidden');
}
function closeEsp32FlashChooser() {
if (!esp32FlashOverlay) return;
esp32FlashOverlay.classList.add('hidden');
}
esp32FlashClose?.addEventListener('click', closeEsp32FlashChooser);
esp32FlashOverlay?.addEventListener('click', (event) => {
if (event.target === esp32FlashOverlay) closeEsp32FlashChooser();
});
btnFlash.addEventListener('click', async () => { btnFlash.addEventListener('click', async () => {
if (isArduinoDevice()) return; if (isArduinoDevice()) return;
if (isConnected()) {
await disconnect();
setConnectedUI(false);
}
const device = getDevice(); const device = getDevice();
const fw = device.firmware; const fw = device.firmware;
if (!fw) return; if (!fw) return;
if (canFlashInBrowser()) { if (fw.flashMethod === 'webusb') {
if (isConnected()) {
await disconnect();
setConnectedUI(false);
}
showFlashOverlay(); showFlashOverlay();
try { try {
await flashFirmware( await flashPicoFirmware(
(msg) => appendFlashLog(msg), (msg) => appendFlashLog(msg),
(pct) => setFlashProgress(pct), (pct) => setFlashProgress(pct),
{ firmwareUrl: fw.url },
); );
setFlashProgress(100); setFlashProgress(100);
appendFlashLog('\nFlash complete! You can now Connect to use the device.\n'); appendFlashLog('\nFlash complete! You can now Connect to use the device.\n');
@ -422,11 +489,28 @@ btnFlash.addEventListener('click', async () => {
} finally { } finally {
flashCloseBtn.classList.remove('hidden'); flashCloseBtn.classList.remove('hidden');
} }
} else { return;
// Download firmware: open URL and show instructions }
window.open(fw.url, '_blank');
if (canFlashInBrowser()) {
openEsp32FlashChooser();
return;
}
if (isConnected()) {
await disconnect();
setConnectedUI(false);
}
{
const a = document.createElement('a');
a.href = fw.url;
a.download = fw.url.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
appendToTerminal(`\n--- Firmware: ${fw.label} ---\n`); appendToTerminal(`\n--- Firmware: ${fw.label} ---\n`);
appendToTerminal(`Download opened in new tab: ${fw.url}\n`); appendToTerminal(`Downloading: ${a.download}\n`);
if (fw.instructions) { if (fw.instructions) {
appendToTerminal(`${fw.instructions}\n`); appendToTerminal(`${fw.instructions}\n`);
} }
@ -434,6 +518,47 @@ btnFlash.addEventListener('click', async () => {
} }
}); });
esp32FlashStart?.addEventListener('click', async () => {
if (isConnected()) {
await disconnect();
setConnectedUI(false);
appendToTerminal('\n--- Disconnected for firmware flash ---\n');
}
const variantKey = esp32FlashVariant?.value || 'esp32s3';
const variant = ESP32_FIRMWARE_OPTIONS[variantKey];
if (!variant) {
if (esp32FlashStatus) {
esp32FlashStatus.textContent = 'Invalid board family selected.';
esp32FlashStatus.className = 'hex-upload-status status-err';
}
return;
}
closeEsp32FlashChooser();
showFlashOverlay();
appendFlashLog(`Selected target: ${variant.label}\n`);
try {
await flashFirmware(
(msg) => appendFlashLog(msg),
(pct) => setFlashProgress(pct),
{
firmwareUrl: variant.url,
targetLabel: variant.label,
flashAddress: variant.flashAddress ?? 0x0,
resetMode: variant.resetMode || 'default_reset',
},
);
setFlashProgress(100);
appendFlashLog('\nFlash complete! You can now Connect to use the device.\n');
} catch (err) {
appendFlashLog(`\nFlash error: ${err.message}\n`);
} finally {
flashCloseBtn.classList.remove('hidden');
}
});
btnRun.addEventListener('click', async () => { btnRun.addEventListener('click', async () => {
if (isArduinoDevice()) { if (isArduinoDevice()) {
document.getElementById('hex-upload-overlay').classList.remove('hidden'); document.getElementById('hex-upload-overlay').classList.remove('hidden');
@ -499,6 +624,11 @@ btnSave.addEventListener('click', async () => {
await saveToDevice(code, 'main.py', { await saveToDevice(code, 'main.py', {
onProgress: (sent, total) => updateSendProgress(sent, total), onProgress: (sent, total) => updateSendProgress(sent, total),
}); });
const layoutFile = await saveCurrentWorkspaceToDevice('main');
if (layoutFile) {
appendToTerminal(`Saved Blockly layout to ${layoutFile}\n`);
refreshProjects();
}
} catch (err) { } catch (err) {
appendToTerminal(`\nSave error: ${err.message}\n`); appendToTerminal(`\nSave error: ${err.message}\n`);
} finally { } finally {
@ -506,6 +636,26 @@ btnSave.addEventListener('click', async () => {
} }
}); });
btnLoad.addEventListener('click', async () => {
if (isArduinoDevice()) {
appendToTerminal('\nLoad is not available for Arduino devices.\n');
return;
}
appendToTerminal('\nLoading Blockly layout from device...\n');
try {
const layoutFile = await loadWorkspaceFromDevice('main');
if (!layoutFile) {
appendToTerminal('Load skipped: no layout name available.\n');
return;
}
updateCodePreview();
appendToTerminal(`Loaded Blockly layout from ${layoutFile}\n`);
refreshProjects();
} catch (err) {
appendToTerminal(`\nLoad error: ${err.message}\n`);
}
});
// ─── Projects Dialog ──────────────────────────────────── // ─── Projects Dialog ────────────────────────────────────
initProjectsDialog({ initProjectsDialog({
@ -516,14 +666,7 @@ initProjectsDialog({
isConnected, isConnected,
}); });
btnProjects.addEventListener('click', () => { initRobotPanel({ workspace, getDeviceId });
const panel = document.getElementById('projects-panel');
panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.innerHTML = panel.classList.contains('collapsed') ? '&#9666;' : '&#9656;';
refreshProjects();
window.dispatchEvent(new Event('resize'));
});
terminalInput.addEventListener('keydown', async (e) => { terminalInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {

View File

@ -0,0 +1,104 @@
import * as Blockly from 'blockly';
import { getTemplate } from './robotComponents.js';
/**
* @param {Blockly.WorkspaceSvg} workspace
* @returns {{ x: number, y: number }}
*/
function suggestStatementStackOrigin(workspace) {
const tops = workspace.getTopBlocks(false);
if (!tops.length) return { x: 40, y: 40 };
let minY = Infinity;
for (const b of tops) {
const xy = b.getRelativeToSurfaceXY();
minY = Math.min(minY, xy.y);
}
return { x: 40, y: Math.max(24, minY - 24) };
}
/**
* @param {Blockly.Block} block
* @param {number} x
* @param {number} y
*/
function moveBlockTo(block, x, y) {
const xy = block.getRelativeToSurfaceXY();
block.moveBy(x - xy.x, y - xy.y);
}
/**
* @param {Blockly.WorkspaceSvg} workspace
* @param {{ kind: string, fields: Record<string, string> }[]} components
*/
export function applyRobotToWorkspace(workspace, components) {
Blockly.Events.setGroup(true);
try {
const statements = [];
const sonarRows = [];
for (const row of components) {
const t = getTemplate(row.kind);
if (!t) continue;
if (t.applyAs === 'statement') statements.push(row);
else if (t.applyAs === 'sonar_reporter') sonarRows.push(row);
}
const origin = suggestStatementStackOrigin(workspace);
let head = /** @type {Blockly.Block | null} */ (null);
let tail = /** @type {Blockly.Block | null} */ (null);
for (const row of statements) {
const t = getTemplate(row.kind);
if (!t || t.applyAs !== 'statement') continue;
const block = workspace.newBlock(t.blockType);
for (const [key, val] of Object.entries(row.fields)) {
if (block.getField(key)) {
block.setFieldValue(String(val), key);
}
}
block.initSvg();
block.render();
if (!head) {
head = block;
tail = block;
} else if (tail && tail.nextConnection && block.previousConnection) {
tail.nextConnection.connect(block.previousConnection);
tail = block;
}
}
if (head) {
moveBlockTo(head, origin.x, origin.y);
}
let sonarX = origin.x + 280;
let sonarY = origin.y;
for (const row of sonarRows) {
const t = getTemplate(row.kind);
if (!t || t.applyAs !== 'sonar_reporter') continue;
const main = workspace.newBlock(t.blockType);
const trig = workspace.newBlock('math_number');
const echo = workspace.newBlock('math_number');
trig.setFieldValue(String(row.fields.TRIG ?? '5'), 'NUM');
echo.setFieldValue(String(row.fields.ECHO ?? '18'), 'NUM');
trig.initSvg();
trig.render();
echo.initSvg();
echo.render();
main.initSvg();
main.render();
const trigIn = main.getInput('TRIG');
const echoIn = main.getInput('ECHO');
if (trigIn?.connection && trig.outputConnection) {
trigIn.connection.connect(trig.outputConnection);
}
if (echoIn?.connection && echo.outputConnection) {
echoIn.connection.connect(echo.outputConnection);
}
moveBlockTo(main, sonarX, sonarY);
sonarY += 56;
}
} finally {
Blockly.Events.setGroup(false);
}
}

View File

@ -0,0 +1,151 @@
/**
* Registry for the Robot configurator. Each template maps to Blockly block
* types/fields in esp32_blocks.js (keep keys in sync with block definitions).
*/
/** @typedef {{ key: string, label: string, type: 'number', min?: number, max?: number, step?: number, default: number } | { key: string, label: string, type: 'dropdown', options: [string, string][], default: string }} RobotField */
/**
* @typedef {{
* id: string,
* label: string,
* blockType: string,
* applyAs: 'statement' | 'sonar_reporter',
* fields: RobotField[],
* }} RobotTemplate
*/
/** @type {RobotTemplate[]} */
export const ROBOT_TEMPLATE_LIST = [
{
id: 'hbridge_dual',
label: 'Dual H-bridge (init)',
blockType: 'hbridge_dual_init',
applyAs: 'statement',
fields: [
{ key: 'L_IN1', label: 'Left IN1', type: 'number', min: 0, max: 48, default: 2 },
{ key: 'L_IN2', label: 'Left IN2', type: 'number', min: 0, max: 48, default: 3 },
{ key: 'R_IN1', label: 'Right IN1', type: 'number', min: 0, max: 48, default: 4 },
{ key: 'R_IN2', label: 'Right IN2', type: 'number', min: 0, max: 48, default: 5 },
],
},
{
id: 'hbridge_motor',
label: 'Single H-bridge motor (init)',
blockType: 'hbridge_motor_init',
applyAs: 'statement',
fields: [
{
key: 'MOTOR',
label: 'Motor',
type: 'dropdown',
options: [
['A', 'A'],
['B', 'B'],
],
default: 'A',
},
{ key: 'IN1', label: 'IN1 pin', type: 'number', min: 0, max: 48, default: 2 },
{ key: 'IN2', label: 'IN2 pin', type: 'number', min: 0, max: 48, default: 3 },
],
},
{
id: 'neopixel',
label: 'NeoPixel strip (init)',
blockType: 'neopixel_init',
applyAs: 'statement',
fields: [
{ key: 'PIN', label: 'Data pin', type: 'number', min: 0, max: 48, default: 48 },
{ key: 'NUM', label: 'LED count', type: 'number', min: 1, max: 1024, default: 8 },
],
},
{
id: 'i2c',
label: 'I2C bus (init)',
blockType: 'i2c_init',
applyAs: 'statement',
fields: [
{ key: 'SDA', label: 'SDA pin', type: 'number', min: 0, max: 48, default: 8 },
{ key: 'SCL', label: 'SCL pin', type: 'number', min: 0, max: 48, default: 9 },
{ key: 'FREQ', label: 'Frequency (Hz)', type: 'number', min: 1, max: 1_000_000, default: 400_000 },
],
},
{
id: 'pwm',
label: 'PWM (init)',
blockType: 'pwm_init',
applyAs: 'statement',
fields: [
{ key: 'PIN', label: 'Pin', type: 'number', min: 0, max: 48, default: 2 },
{ key: 'FREQ', label: 'Frequency (Hz)', type: 'number', min: 1, max: 40_000_000, default: 1000 },
],
},
{
id: 'buzzer',
label: 'Buzzer (init)',
blockType: 'sound_buzzer_init',
applyAs: 'statement',
fields: [
{ key: 'PIN', label: 'Pin', type: 'number', min: 0, max: 48, default: 0 },
],
},
{
id: 'sonar',
label: 'HC-SR04 sonar (distance block)',
blockType: 'sonar_distance',
applyAs: 'sonar_reporter',
fields: [
{ key: 'TRIG', label: 'Trigger pin', type: 'number', min: 0, max: 48, default: 5 },
{ key: 'ECHO', label: 'Echo pin', type: 'number', min: 0, max: 48, default: 18 },
],
},
];
const byId = new Map(ROBOT_TEMPLATE_LIST.map((t) => [t.id, t]));
/**
* @param {string} id
* @returns {RobotTemplate | undefined}
*/
export function getTemplate(id) {
return byId.get(id);
}
/**
* @param {RobotTemplate} template
* @returns {Record<string, string | number>}
*/
export function defaultFieldsFor(template) {
/** @type {Record<string, string | number>} */
const out = {};
for (const f of template.fields) {
out[f.key] = f.default;
}
return out;
}
/**
* @param {string} kind
* @param {Record<string, unknown>} fields
* @returns {Record<string, string>}
*/
export function normalizeFieldValues(kind, fields) {
const template = getTemplate(kind);
if (!template) return {};
/** @type {Record<string, string>} */
const out = {};
const raw = fields && typeof fields === 'object' ? fields : {};
for (const f of template.fields) {
let v = raw[f.key];
if (f.type === 'number') {
const n = Math.round(Number(v));
const clamped = Math.min(f.max ?? 48, Math.max(f.min ?? 0, Number.isFinite(n) ? n : f.default));
out[f.key] = String(clamped);
} else {
const allowed = new Set(f.options.map((o) => o[1]));
const s = String(v ?? f.default);
out[f.key] = allowed.has(s) ? s : String(f.default);
}
}
return out;
}

63
src/robot/robotFile.js Normal file
View File

@ -0,0 +1,63 @@
import { getTemplate, normalizeFieldValues } from './robotComponents.js';
export const ROBOT_SCHEMA_VERSION = 1;
/**
* @param {string} deviceId
* @returns {{ version: number, device: string, components: { kind: string, fields: Record<string, string> }[] }}
*/
export function createEmptyRobotFile(deviceId) {
return {
version: ROBOT_SCHEMA_VERSION,
device: deviceId,
components: [],
};
}
/**
* @param {unknown} data
* @returns {asserts data is { version: number, device: string, components: unknown[] }}
*/
function assertShape(data) {
if (!data || typeof data !== 'object') throw new Error('Robot file must be a JSON object');
const d = /** @type {{ version?: unknown, device?: unknown, components?: unknown }} */ (data);
if (d.version !== ROBOT_SCHEMA_VERSION) {
throw new Error(`Unsupported robot file version (expected ${ROBOT_SCHEMA_VERSION})`);
}
if (typeof d.device !== 'string' || !d.device) throw new Error('Robot file needs a string "device" id');
if (!Array.isArray(d.components)) throw new Error('Robot file needs a "components" array');
}
/**
* @param {unknown} raw
* @returns {{ version: number, device: string, components: { kind: string, fields: Record<string, string> }[] }}
*/
export function normalizeRobotFile(raw) {
assertShape(raw);
const data = /** @type {{ version: number, device: string, components: unknown[] }} */ (raw);
const components = [];
for (let i = 0; i < data.components.length; i++) {
const row = data.components[i];
if (!row || typeof row !== 'object') throw new Error(`Invalid component at index ${i}`);
const r = /** @type {{ kind?: unknown, fields?: unknown }} */ (row);
if (typeof r.kind !== 'string' || !getTemplate(r.kind)) {
throw new Error(`Unknown component kind: ${String(r.kind)}`);
}
const fields = normalizeFieldValues(r.kind, r.fields && typeof r.fields === 'object' ? r.fields : {});
components.push({ kind: r.kind, fields });
}
return { version: data.version, device: data.device, components };
}
/**
* @param {string} text
*/
export function parseRobotFileText(text) {
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error('Invalid JSON');
}
return normalizeRobotFile(data);
}

61
src/robot/robotImport.js Normal file
View File

@ -0,0 +1,61 @@
import * as Blockly from 'blockly';
import { ROBOT_TEMPLATE_LIST, normalizeFieldValues } from './robotComponents.js';
/**
* @param {Blockly.Block} block
* @param {string} inputName
* @returns {number | null}
*/
function getConnectedMathNumber(block, inputName) {
const inp = block.getInput(inputName);
const child = inp?.connection?.targetBlock();
if (child && child.type === 'math_number') {
const n = Number(child.getFieldValue('NUM'));
return Number.isFinite(n) ? n : null;
}
return null;
}
/**
* Scan the workspace for supported init / sonar blocks and build component rows.
*
* @param {Blockly.Workspace} workspace
* @returns {{ kind: string, fields: Record<string, string> }[]}
*/
export function importRobotFromWorkspace(workspace) {
/** @type {{ kind: string, fields: Record<string, string> }[]} */
const components = [];
for (const tmpl of ROBOT_TEMPLATE_LIST) {
if (tmpl.applyAs === 'statement') {
const blocks = workspace.getBlocksByType(tmpl.blockType, false);
for (const b of blocks) {
/** @type {Record<string, unknown>} */
const raw = {};
for (const f of tmpl.fields) {
if (b.getField(f.key)) {
raw[f.key] = b.getFieldValue(f.key);
}
}
components.push({
kind: tmpl.id,
fields: normalizeFieldValues(tmpl.id, raw),
});
}
} else if (tmpl.applyAs === 'sonar_reporter') {
const blocks = workspace.getBlocksByType(tmpl.blockType, false);
for (const b of blocks) {
const trig = getConnectedMathNumber(b, 'TRIG');
const echo = getConnectedMathNumber(b, 'ECHO');
if (trig != null && echo != null) {
components.push({
kind: tmpl.id,
fields: normalizeFieldValues(tmpl.id, { TRIG: trig, ECHO: echo }),
});
}
}
}
}
return components;
}

View File

@ -1,6 +1,8 @@
let port = null; let port = null;
let reader = null; let reader = null;
let writer = null; let writer = null;
let readableAbort = null;
let writableAbort = null;
export function isConnected() { export function isConnected() {
return port !== null; return port !== null;
@ -23,8 +25,13 @@ export async function connect(baudRate = 115200, existingPort = null) {
const textDecoder = new TextDecoderStream(); const textDecoder = new TextDecoderStream();
const textEncoder = new TextEncoderStream(); const textEncoder = new TextEncoderStream();
port.readable.pipeTo(textDecoder.writable); const readableController = new AbortController();
textEncoder.readable.pipeTo(port.writable); const writableController = new AbortController();
readableAbort = readableController;
writableAbort = writableController;
port.readable.pipeTo(textDecoder.writable, { signal: readableController.signal }).catch(() => {});
textEncoder.readable.pipeTo(port.writable, { signal: writableController.signal }).catch(() => {});
reader = textDecoder.readable.getReader(); reader = textDecoder.readable.getReader();
writer = textEncoder.writable.getWriter(); writer = textEncoder.writable.getWriter();
@ -42,7 +49,16 @@ export async function disconnect() {
try { await writer.close(); } catch (_) { /* already closed */ } try { await writer.close(); } catch (_) { /* already closed */ }
writer = null; writer = null;
} }
if (readableAbort) {
try { readableAbort.abort(); } catch (_) {}
readableAbort = null;
}
if (writableAbort) {
try { writableAbort.abort(); } catch (_) {}
writableAbort = null;
}
if (port) { if (port) {
await new Promise(r => setTimeout(r, 50));
try { await port.close(); } catch (_) { /* already closed */ } try { await port.close(); } catch (_) { /* already closed */ }
port = null; port = null;
} }

View File

@ -1,6 +1,6 @@
import { ESPLoader, Transport } from 'esptool-js'; import { ESPLoader, Transport } from 'esptool-js';
const FIRMWARE_URL = '/firmware/ESP32_GENERIC_S3-20251209-v1.27.0.bin'; const DEFAULT_FIRMWARE_URL = import.meta.env.BASE_URL + 'firmware/ESP32_GENERIC-20260406-v1.28.0.bin';
function arrayBufferToBinaryString(buffer) { function arrayBufferToBinaryString(buffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
@ -13,7 +13,11 @@ function arrayBufferToBinaryString(buffer) {
return chunks.join(''); return chunks.join('');
} }
export async function flashFirmware(onLog, onProgress) { export async function flashFirmware(onLog, onProgress, options = {}) {
const firmwareUrl = options.firmwareUrl || DEFAULT_FIRMWARE_URL;
const targetLabel = options.targetLabel || 'ESP32';
const flashAddress = options.flashAddress ?? 0x0;
const resetMode = options.resetMode || 'default_reset';
const port = await navigator.serial.requestPort(); const port = await navigator.serial.requestPort();
// Buffer to track message state for proper newline insertion // Buffer to track message state for proper newline insertion
@ -55,6 +59,22 @@ export async function flashFirmware(onLog, onProgress) {
} }
} }
// Some USB-native ESP32 ports (e.g. S2 CDC) don't support DTR/RTS control
// signals. Patch setSignals so failures are non-fatal — the reset sequence
// becomes a no-op and the chip must already be in bootloader mode.
const origSetSignals = port.setSignals.bind(port);
let signalWarningShown = false;
port.setSignals = async (signals) => {
try {
return await origSetSignals(signals);
} catch (_) {
if (!signalWarningShown) {
onLog?.('(DTR/RTS not supported on this port — skipping reset)\n');
signalWarningShown = true;
}
}
};
const transport = new Transport(port); const transport = new Transport(port);
const esploader = new ESPLoader({ const esploader = new ESPLoader({
transport, transport,
@ -62,10 +82,9 @@ export async function flashFirmware(onLog, onProgress) {
romBaudrate: 115200, romBaudrate: 115200,
terminal: { terminal: {
clean() {}, clean() {},
writeLine(data) { writeLine(data) {
// Flush any buffered content first
flushBuffer(); flushBuffer();
onLog?.(data + (data.endsWith('\n') ? '' : '\n')); onLog?.(data + (data.endsWith('\n') ? '' : '\n'));
}, },
write(data) { write(data) {
normalizeAndLog(data); normalizeAndLog(data);
@ -73,14 +92,17 @@ export async function flashFirmware(onLog, onProgress) {
}, },
}); });
onLog?.('Connecting to ESP32-S3...\n'); onLog?.(`Connecting to ${targetLabel}...\n`);
const chip = await esploader.main(); if (resetMode === 'no_reset') {
onLog?.('(no_reset mode — device must be in bootloader already)\n');
}
const chip = await esploader.main(resetMode);
flushBuffer(); flushBuffer();
onLog?.(`Detected: ${chip}\n`); onLog?.(`Detected: ${chip}\n`);
onLog?.('Fetching firmware...\n'); onLog?.('Fetching firmware...\n');
const resp = await fetch(FIRMWARE_URL); const resp = await fetch(firmwareUrl);
if (!resp.ok) throw new Error(`Failed to fetch firmware: ${resp.status}`); if (!resp.ok) throw new Error(`Failed to fetch firmware (${firmwareUrl}): ${resp.status}`);
const firmwareBuf = await resp.arrayBuffer(); const firmwareBuf = await resp.arrayBuffer();
const firmwareBin = arrayBufferToBinaryString(firmwareBuf); const firmwareBin = arrayBufferToBinaryString(firmwareBuf);
onLog?.(`Firmware size: ${(firmwareBuf.byteLength / 1024).toFixed(0)} KB\n`); onLog?.(`Firmware size: ${(firmwareBuf.byteLength / 1024).toFixed(0)} KB\n`);
@ -90,9 +112,10 @@ export async function flashFirmware(onLog, onProgress) {
flushBuffer(); flushBuffer();
onLog?.('Erase complete.\n'); onLog?.('Erase complete.\n');
onLog?.('Writing firmware at 0x0...\n'); const addrHex = '0x' + flashAddress.toString(16);
onLog?.(`Writing firmware at ${addrHex}...\n`);
await esploader.writeFlash({ await esploader.writeFlash({
fileArray: [{ data: firmwareBin, address: 0x0 }], fileArray: [{ data: firmwareBin, address: flashAddress }],
flashSize: 'keep', flashSize: 'keep',
flashMode: 'keep', flashMode: 'keep',
flashFreq: 'keep', flashFreq: 'keep',

55
src/serial/picoFlasher.js Normal file
View File

@ -0,0 +1,55 @@
/**
* Flash RP2040/RP2350 firmware by writing a UF2 file to the BOOTSEL
* mass-storage drive via the File System Access API.
*
* The device must be in BOOTSEL mode (hold BOOTSEL while plugging in).
* The user selects the RPI-RP2 drive in the browser's folder picker,
* and the UF2 bootloader handles the rest automatically.
*/
export async function flashPicoFirmware(onLog, onProgress, options = {}) {
const { firmwareUrl } = options;
if (!firmwareUrl) throw new Error('No firmware URL provided');
onLog?.('Fetching firmware...\n');
const resp = await fetch(firmwareUrl);
if (!resp.ok) throw new Error(`Firmware fetch failed: ${resp.status}`);
const uf2Data = new Uint8Array(await resp.arrayBuffer());
onLog?.(`Firmware: ${(uf2Data.length / 1024).toFixed(0)} KB\n\n`);
onLog?.('Select the RPI-RP2 drive in the folder picker.\n');
onLog?.('(Hold BOOTSEL while plugging in if the drive isn\'t visible.)\n\n');
let dirHandle;
try {
dirHandle = await window.showDirectoryPicker({ id: 'pico-flash', mode: 'readwrite' });
} catch (err) {
if (err.name === 'AbortError') throw new Error('Cancelled — no folder selected');
throw err;
}
onLog?.(`Writing firmware.uf2 to ${dirHandle.name} ...\n`);
const fileHandle = await dirHandle.getFileHandle('firmware.uf2', { create: true });
const writable = await fileHandle.createWritable();
const CHUNK = 64 * 1024;
let written = 0;
try {
while (written < uf2Data.length) {
const end = Math.min(written + CHUNK, uf2Data.length);
await writable.write({ type: 'write', position: written, data: uf2Data.slice(written, end) });
written = end;
onProgress?.(Math.round((written / uf2Data.length) * 95));
}
await writable.close();
} catch (err) {
if (written > 0 && written >= uf2Data.length) {
onLog?.('(Drive disconnected — Pico is rebooting with new firmware)\n');
} else {
throw err;
}
}
onProgress?.(100);
onLog?.('Done! The Pico will reboot automatically with MicroPython.\n');
}

View File

@ -51,6 +51,26 @@ html, body {
gap: 12px; gap: 12px;
} }
.toolbar-left 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-left button:hover {
background: var(--accent);
color: var(--bg-toolbar);
border-color: var(--accent);
}
.app-title { .app-title {
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
@ -163,6 +183,7 @@ html, body {
#top-area { #top-area {
flex: 1 1 65%; flex: 1 1 65%;
display: flex; display: flex;
position: relative;
min-height: 200px; min-height: 200px;
overflow: hidden; overflow: hidden;
} }
@ -337,6 +358,7 @@ html, body {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-secondary);
transition: width 0.15s ease; transition: width 0.15s ease;
position: relative;
} }
#projects-panel.collapsed { #projects-panel.collapsed {
@ -345,21 +367,65 @@ html, body {
} }
#projects-panel.collapsed .panel-header { #projects-panel.collapsed .panel-header {
writing-mode: vertical-rl; display: none;
text-orientation: mixed;
height: 100%;
width: 30px;
padding: 10px 0;
justify-content: flex-start;
gap: 8px;
border-bottom: none;
border-left: none;
} }
#projects-panel.collapsed .panel-toggle { #projects-panel.collapsed .panel-toggle {
writing-mode: horizontal-tb; writing-mode: horizontal-tb;
} }
#side-tools-rail {
display: flex;
flex-direction: column;
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 30px;
gap: 6px;
}
#projects-panel:not(.collapsed) .panel-header,
#projects-panel:not(.collapsed) .panel-body {
margin-right: 30px;
}
#projects-panel.collapsed #side-tools-rail {
top: 6px;
}
.side-rail-btn {
width: 30px;
min-height: 110px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-left: none;
border-right: none;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
writing-mode: vertical-rl;
text-orientation: mixed;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 10px 0;
cursor: pointer;
}
.side-rail-btn:hover {
color: var(--text-primary);
background: var(--bg-surface);
}
.side-rail-arrow {
writing-mode: horizontal-tb;
line-height: 1;
}
/* Projects tabs */ /* Projects tabs */
.proj-tabs { .proj-tabs {
display: flex; display: flex;
@ -504,6 +570,207 @@ html, body {
min-height: 16px; min-height: 16px;
} }
/* --- Robot hardware panel --- */
#robot-panel {
width: 300px;
min-width: 0;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg-secondary);
}
.robot-panel-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
overflow: hidden;
}
.robot-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.robot-tb-btn {
flex: 1 1 auto;
min-width: 0;
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 6px;
font-size: 11px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.robot-tb-btn:hover {
background: var(--accent);
color: var(--bg-toolbar);
border-color: var(--accent);
}
.robot-tb-primary {
font-weight: 600;
}
.robot-full-btn {
width: 100%;
margin-top: 4px;
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
font-size: 11px;
cursor: pointer;
}
.robot-full-btn:hover {
background: var(--bg-primary);
border-color: var(--accent);
}
.robot-note {
font-size: 11px;
color: var(--text-muted);
margin-top: 6px;
min-height: 14px;
}
.robot-note-warn {
color: var(--amber, #d4a017);
}
.robot-add-row {
display: flex;
gap: 4px;
margin-top: 8px;
}
.robot-add-select {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 6px;
font-size: 12px;
}
.robot-comp-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
max-height: 140px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-primary);
}
.robot-comp-item {
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.robot-comp-item:last-child { border-bottom: none; }
.robot-comp-item:hover { background: var(--bg-surface); }
.robot-comp-item.selected {
background: var(--accent);
color: var(--bg-toolbar);
}
.robot-editor {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
flex: 1;
min-height: 0;
overflow-y: auto;
}
.robot-editor-empty {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.robot-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.robot-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.robot-remove-btn {
flex-shrink: 0;
padding: 4px 8px;
font-size: 11px;
}
.robot-field-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.robot-field-row label {
font-size: 11px;
color: var(--text-secondary);
}
.robot-field-row input,
.robot-field-row select {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 8px;
font-size: 12px;
}
.robot-status {
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
min-height: 14px;
}
.robot-status-error {
color: var(--red);
}
.robot-file-input-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
opacity: 0;
}
/* --- Blockly Overrides (dark theme) --- */ /* --- Blockly Overrides (dark theme) --- */
.blocklyMainBackground { .blocklyMainBackground {
fill: var(--bg-primary) !important; fill: var(--bg-primary) !important;
@ -1019,7 +1286,8 @@ html, body {
} }
/* --- Hex Upload Overlay (Arduino) --- */ /* --- Hex Upload Overlay (Arduino) --- */
#hex-upload-overlay { #hex-upload-overlay,
#esp32-flash-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
@ -1030,7 +1298,8 @@ html, body {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
#hex-upload-modal { #hex-upload-modal,
#esp32-flash-modal {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;

View File

@ -30,27 +30,31 @@ export function initResizablePanels() {
export function initPanelToggles() { export function initPanelToggles() {
const bottomPanels = document.getElementById('bottom-panels'); const bottomPanels = document.getElementById('bottom-panels');
const projectsPanel = document.getElementById('projects-panel');
function togglePanel(panel, btn) {
panel.classList.toggle('collapsed');
if (btn) updateToggleIcon(btn, panel);
if (bottomPanels) {
const code = document.getElementById('code-panel');
const term = document.getElementById('terminal-panel');
const allCollapsed = code?.classList.contains('collapsed') && term?.classList.contains('collapsed');
bottomPanels.classList.toggle('all-collapsed', allCollapsed);
}
if (panel.id === 'projects-panel' && !panel.classList.contains('collapsed') && onProjectsPanelExpanded) {
onProjectsPanelExpanded();
}
window.dispatchEvent(new Event('resize'));
}
document.querySelectorAll('.panel-toggle').forEach(btn => { document.querySelectorAll('.panel-toggle').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const panel = document.getElementById(btn.dataset.panel); const panel = document.getElementById(btn.dataset.panel);
if (!panel) return; if (!panel) return;
togglePanel(panel, btn);
panel.classList.toggle('collapsed');
updateToggleIcon(btn, panel);
if (bottomPanels) {
const code = document.getElementById('code-panel');
const term = document.getElementById('terminal-panel');
const allCollapsed = code?.classList.contains('collapsed') && term?.classList.contains('collapsed');
bottomPanels.classList.toggle('all-collapsed', allCollapsed);
}
if (btn.dataset.panel === 'projects-panel' && !panel.classList.contains('collapsed') && onProjectsPanelExpanded) {
onProjectsPanelExpanded();
}
window.dispatchEvent(new Event('resize'));
}); });
}); });
@ -58,6 +62,25 @@ export function initPanelToggles() {
const panel = document.getElementById(btn.dataset.panel); const panel = document.getElementById(btn.dataset.panel);
if (panel) updateToggleIcon(btn, panel); if (panel) updateToggleIcon(btn, panel);
}); });
if (projectsPanel) {
const projectsHeader = projectsPanel.querySelector('.panel-header');
const projectsToggle = projectsPanel.querySelector('.panel-toggle');
const projectsRailBtn = document.getElementById('btn-projects');
projectsHeader?.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('.panel-toggle')) return;
togglePanel(projectsPanel, projectsToggle);
});
projectsRailBtn?.addEventListener('click', () => {
togglePanel(projectsPanel, projectsToggle);
});
projectsPanel.addEventListener('transitionend', (event) => {
if (event.propertyName === 'width') {
window.dispatchEvent(new Event('resize'));
}
});
}
} }
function updateToggleIcon(btn, panel) { function updateToggleIcon(btn, panel) {

View File

@ -4,52 +4,58 @@ const BROWSER_STORAGE_KEY = 'esp32block_projects';
let workspace = null; let workspace = null;
let captureOutput = null; let captureOutput = null;
let execCode = null;
let writeFile = null; let writeFile = null;
let checkConnected = null; let checkConnected = null;
let browserList, deviceList; let browserList;
let browserNameInput, deviceNameInput; let browserNameInput;
let browserSaveBtn, browserLoadBtn, browserDeleteBtn; let browserSaveBtn, browserLoadBtn, browserDownloadBtn, browserDeleteBtn;
let deviceSaveBtn, deviceLoadBtn, deviceDeleteBtn;
let deviceStatus;
let browserSelected = null; let browserSelected = null;
let deviceSelected = null;
export function initProjectsDialog(deps) { export function initProjectsDialog(deps) {
workspace = deps.workspace; workspace = deps.workspace;
captureOutput = deps.captureDeviceOutput; captureOutput = deps.captureDeviceOutput;
execCode = deps.executeCode;
writeFile = deps.writeFileToDevice; writeFile = deps.writeFileToDevice;
checkConnected = deps.isConnected; checkConnected = deps.isConnected;
browserList = document.getElementById('browser-list'); browserList = document.getElementById('browser-list');
deviceList = document.getElementById('device-list');
browserNameInput = document.getElementById('browser-save-name'); browserNameInput = document.getElementById('browser-save-name');
deviceNameInput = document.getElementById('device-save-name');
browserSaveBtn = document.getElementById('browser-save-btn'); browserSaveBtn = document.getElementById('browser-save-btn');
browserLoadBtn = document.getElementById('browser-load-btn'); browserLoadBtn = document.getElementById('browser-load-btn');
browserDownloadBtn = document.getElementById('browser-download-btn');
browserDeleteBtn = document.getElementById('browser-delete-btn'); browserDeleteBtn = document.getElementById('browser-delete-btn');
deviceSaveBtn = document.getElementById('device-save-btn');
deviceLoadBtn = document.getElementById('device-load-btn');
deviceDeleteBtn = document.getElementById('device-delete-btn');
deviceStatus = document.getElementById('device-status');
browserSaveBtn.addEventListener('click', saveBrowser); browserSaveBtn.addEventListener('click', saveBrowser);
browserLoadBtn.addEventListener('click', loadBrowser); browserLoadBtn.addEventListener('click', loadBrowser);
browserDownloadBtn.addEventListener('click', downloadBrowser);
browserDeleteBtn.addEventListener('click', deleteBrowser); browserDeleteBtn.addEventListener('click', deleteBrowser);
deviceSaveBtn.addEventListener('click', saveDevice);
deviceLoadBtn.addEventListener('click', loadDevice);
deviceDeleteBtn.addEventListener('click', deleteDevice);
refreshBrowserList(); refreshBrowserList();
refreshDeviceList();
} }
export function refreshAll() { export function refreshAll() {
refreshBrowserList(); refreshBrowserList();
refreshDeviceList(); }
export async function saveCurrentWorkspaceToDevice(preferredName = 'main') {
if (!workspace || !writeFile || !checkConnected || !checkConnected()) return null;
const filename = preferredName.endsWith('.blk') ? preferredName : preferredName + '.blk';
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state);
await writeFile(json, filename);
return filename;
}
export async function loadWorkspaceFromDevice(preferredName = 'main') {
if (!workspace || !captureOutput || !checkConnected || !checkConnected()) return null;
const filename = preferredName.endsWith('.blk') ? preferredName : preferredName + '.blk';
const raw = await captureOutput(
`f=open('${filename}','r')\nprint(f.read(),end='')\nf.close()`
);
const state = JSON.parse(raw.trim());
Blockly.serialization.workspaces.load(state, workspace);
return filename;
} }
// ─── Browser column ────────────────────────────────────── // ─── Browser column ──────────────────────────────────────
@ -95,6 +101,7 @@ function selectBrowserItem(name, li) {
function updateBrowserButtons() { function updateBrowserButtons() {
browserLoadBtn.disabled = !browserSelected; browserLoadBtn.disabled = !browserSelected;
browserDownloadBtn.disabled = !browserSelected;
browserDeleteBtn.disabled = !browserSelected; browserDeleteBtn.disabled = !browserSelected;
} }
@ -124,109 +131,19 @@ function deleteBrowser() {
refreshBrowserList(); refreshBrowserList();
} }
// ─── Device column ─────────────────────────────────────── function downloadBrowser() {
if (!browserSelected) return;
export async function refreshDeviceList() { const projects = getBrowserProjects();
deviceList.innerHTML = ''; const state = projects[browserSelected];
deviceSelected = null; if (!state) return;
updateDeviceButtons(); const json = JSON.stringify(state, null, 2);
const blob = new Blob([json], { type: 'application/json' });
if (!checkConnected()) { const url = URL.createObjectURL(blob);
deviceStatus.textContent = 'Connect a device to see its projects'; const a = document.createElement('a');
deviceSaveBtn.disabled = true; a.href = url;
deviceList.innerHTML = '<li class="empty-msg">Not connected</li>'; a.download = `${browserSelected}.blk`;
return; document.body.appendChild(a);
} a.click();
document.body.removeChild(a);
deviceStatus.textContent = 'Loading...'; URL.revokeObjectURL(url);
deviceSaveBtn.disabled = false;
try {
const raw = await captureOutput(
"import os\n" +
"for f in os.listdir('/'):\n" +
" if f.endswith('.blk'): print(f)"
);
const files = raw.trim().split('\n').filter(Boolean).map(f => f.trim());
deviceList.innerHTML = '';
if (files.length === 0) {
deviceList.innerHTML = '<li class="empty-msg">No saved projects</li>';
deviceStatus.textContent = '';
return;
}
for (const file of files) {
const displayName = file.replace(/\.blk$/, '');
const li = document.createElement('li');
li.textContent = displayName;
li.addEventListener('click', () => selectDeviceItem(displayName, file, li));
li.addEventListener('dblclick', () => { selectDeviceItem(displayName, file, li); loadDevice(); });
deviceList.appendChild(li);
}
deviceStatus.textContent = '';
} catch {
deviceList.innerHTML = '<li class="empty-msg">Error reading device</li>';
deviceStatus.textContent = 'Could not list files';
}
}
function selectDeviceItem(displayName, filename, li) {
deviceList.querySelectorAll('li').forEach(el => el.classList.remove('selected'));
li.classList.add('selected');
deviceSelected = filename;
deviceNameInput.value = displayName;
updateDeviceButtons();
}
function updateDeviceButtons() {
const connected = checkConnected();
deviceSaveBtn.disabled = !connected;
deviceLoadBtn.disabled = !deviceSelected || !connected;
deviceDeleteBtn.disabled = !deviceSelected || !connected;
}
async function saveDevice() {
const name = deviceNameInput.value.trim();
if (!name || !checkConnected()) return;
const filename = name.endsWith('.blk') ? name : name + '.blk';
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state);
deviceStatus.textContent = 'Saving...';
deviceSaveBtn.disabled = true;
try {
await writeFile(json, filename);
deviceNameInput.value = '';
await refreshDeviceList();
} catch {
deviceStatus.textContent = 'Save failed';
}
}
async function loadDevice() {
if (!deviceSelected || !checkConnected()) return;
deviceStatus.textContent = 'Loading...';
try {
const raw = await captureOutput(
`f=open('${deviceSelected}','r')\nprint(f.read(),end='')\nf.close()`
);
const state = JSON.parse(raw.trim());
Blockly.serialization.workspaces.load(state, workspace);
deviceStatus.textContent = '';
} catch {
deviceStatus.textContent = 'Load failed';
}
}
async function deleteDevice() {
if (!deviceSelected || !checkConnected()) return;
deviceStatus.textContent = 'Deleting...';
try {
await execCode(`import os\nos.remove('${deviceSelected}')`);
await new Promise(r => setTimeout(r, 300));
await refreshDeviceList();
} catch {
deviceStatus.textContent = 'Delete failed';
}
} }

312
src/ui/robotPanel.js Normal file
View File

@ -0,0 +1,312 @@
import { ROBOT_TEMPLATE_LIST, getTemplate, defaultFieldsFor } from '../robot/robotComponents.js';
import { createEmptyRobotFile, parseRobotFileText } from '../robot/robotFile.js';
import { applyRobotToWorkspace } from '../robot/applyRobotToWorkspace.js';
import { importRobotFromWorkspace } from '../robot/robotImport.js';
/** @typedef {{ kind: string, fields: Record<string, string> }} RobotComponentRow */
/** @type {import('blockly').WorkspaceSvg | null} */
let workspace = null;
/** @type {() => string} */
let getDeviceId = () => 'esp32s3';
/** @type {{ version: number, device: string, components: RobotComponentRow[] }} */
let robotDocument = createEmptyRobotFile('esp32s3');
let selectedIndex = -1;
let listEl;
let editorEl;
let statusEl;
let deviceNoteEl;
let addSelectEl;
let fileInputEl;
let panelEl;
function setStatus(msg, isError = false) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.classList.toggle('robot-status-error', !!isError);
}
function updateDeviceNote() {
if (!deviceNoteEl) return;
const cur = getDeviceId();
if (robotDocument.device === cur) {
deviceNoteEl.textContent = `Target device: ${cur}`;
deviceNoteEl.classList.remove('robot-note-warn');
} else {
deviceNoteEl.textContent = `File device: ${robotDocument.device} (editor: ${cur})`;
deviceNoteEl.classList.add('robot-note-warn');
}
}
function renderAddSelect() {
if (!addSelectEl) return;
addSelectEl.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = 'Add component…';
addSelectEl.appendChild(ph);
for (const t of ROBOT_TEMPLATE_LIST) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
addSelectEl.appendChild(opt);
}
}
function renderList() {
if (!listEl) return;
listEl.innerHTML = '';
robotDocument.components.forEach((row, idx) => {
const tmpl = getTemplate(row.kind);
const li = document.createElement('li');
li.className = 'robot-comp-item' + (idx === selectedIndex ? ' selected' : '');
li.textContent = tmpl ? tmpl.label : row.kind;
li.title = row.kind;
li.addEventListener('click', () => {
selectedIndex = idx;
renderList();
renderEditor();
});
listEl.appendChild(li);
});
if (selectedIndex >= robotDocument.components.length) {
selectedIndex = robotDocument.components.length - 1;
}
}
function renderEditor() {
if (!editorEl) return;
editorEl.innerHTML = '';
if (selectedIndex < 0 || selectedIndex >= robotDocument.components.length) {
const p = document.createElement('p');
p.className = 'robot-editor-empty';
p.textContent = 'Select a component above, or add one from the list.';
editorEl.appendChild(p);
return;
}
const row = robotDocument.components[selectedIndex];
const tmpl = getTemplate(row.kind);
if (!tmpl) return;
const title = document.createElement('div');
title.className = 'robot-editor-title';
title.textContent = tmpl.label;
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn-danger robot-remove-btn';
rm.textContent = 'Remove';
rm.addEventListener('click', () => {
robotDocument.components.splice(selectedIndex, 1);
selectedIndex = Math.min(selectedIndex, robotDocument.components.length - 1);
setStatus('');
renderList();
renderEditor();
updateDeviceNote();
});
const head = document.createElement('div');
head.className = 'robot-editor-head';
head.appendChild(title);
head.appendChild(rm);
editorEl.appendChild(head);
for (const f of tmpl.fields) {
const wrap = document.createElement('div');
wrap.className = 'robot-field-row';
const lab = document.createElement('label');
lab.textContent = f.label;
lab.htmlFor = `rf-${selectedIndex}-${f.key}`;
if (f.type === 'number') {
const inp = document.createElement('input');
inp.id = `rf-${selectedIndex}-${f.key}`;
inp.type = 'number';
if (f.min != null) inp.min = String(f.min);
if (f.max != null) inp.max = String(f.max);
if (f.step != null) inp.step = String(f.step);
inp.value = String(row.fields[f.key] ?? f.default);
inp.addEventListener('change', () => {
row.fields[f.key] = String(inp.value);
setStatus('');
});
wrap.appendChild(lab);
wrap.appendChild(inp);
} else {
const sel = document.createElement('select');
sel.id = `rf-${selectedIndex}-${f.key}`;
for (const [label, val] of f.options) {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label;
sel.appendChild(opt);
}
sel.value = row.fields[f.key] ?? String(f.default);
sel.addEventListener('change', () => {
row.fields[f.key] = sel.value;
setStatus('');
});
wrap.appendChild(lab);
wrap.appendChild(sel);
}
editorEl.appendChild(wrap);
}
}
function renderAll() {
updateDeviceNote();
renderList();
renderEditor();
}
function addComponent(kind) {
const tmpl = getTemplate(kind);
if (!tmpl) return;
robotDocument.components.push({
kind,
fields: /** @type {Record<string, string>} */ (
Object.fromEntries(
Object.entries(defaultFieldsFor(tmpl)).map(([k, v]) => [k, String(v)]),
)
),
});
selectedIndex = robotDocument.components.length - 1;
setStatus('');
renderAll();
}
function newDocument() {
robotDocument = createEmptyRobotFile(getDeviceId());
selectedIndex = robotDocument.components.length ? 0 : -1;
setStatus('New robot file.');
renderAll();
}
function downloadJson() {
const text = JSON.stringify(robotDocument, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'robot.json';
a.click();
URL.revokeObjectURL(a.href);
setStatus('Download started.');
}
/**
* @param {{ workspace: import('blockly').WorkspaceSvg, getDeviceId: () => string }} deps
*/
export function initRobotPanel(deps) {
workspace = deps.workspace;
getDeviceId = deps.getDeviceId;
panelEl = document.getElementById('robot-panel');
listEl = document.getElementById('robot-component-list');
editorEl = document.getElementById('robot-editor');
statusEl = document.getElementById('robot-status');
deviceNoteEl = document.getElementById('robot-device-note');
addSelectEl = document.getElementById('robot-add-select');
fileInputEl = document.getElementById('robot-file-input');
const btnNew = document.getElementById('robot-new');
const btnOpen = document.getElementById('robot-open');
const btnSave = document.getElementById('robot-save');
const btnApply = document.getElementById('robot-apply');
const btnImport = document.getElementById('robot-import-ws');
const btnAdd = document.getElementById('robot-add-btn');
const btnClose = document.getElementById('robot-panel-done');
const btnToolbar = document.getElementById('btn-robot');
robotDocument = createEmptyRobotFile(getDeviceId());
renderAddSelect();
renderAll();
btnNew?.addEventListener('click', () => newDocument());
btnOpen?.addEventListener('click', () => fileInputEl?.click());
fileInputEl?.addEventListener('change', () => {
const f = fileInputEl.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
try {
const text = String(reader.result ?? '');
robotDocument = parseRobotFileText(text);
selectedIndex = robotDocument.components.length ? 0 : -1;
setStatus(`Loaded ${f.name}`);
renderAll();
} catch (e) {
setStatus(e instanceof Error ? e.message : 'Could not read robot file', true);
}
fileInputEl.value = '';
};
reader.readAsText(f);
});
btnSave?.addEventListener('click', () => downloadJson());
btnApply?.addEventListener('click', () => {
if (!workspace) return;
try {
applyRobotToWorkspace(workspace, robotDocument.components);
setStatus('Applied init blocks to the workspace.');
} catch (e) {
setStatus(e instanceof Error ? e.message : 'Apply failed', true);
}
});
btnImport?.addEventListener('click', () => {
if (!workspace) return;
try {
const rows = importRobotFromWorkspace(workspace);
if (!rows.length) {
setStatus('No matching blocks found on the workspace.', true);
return;
}
robotDocument.components = rows;
robotDocument.device = getDeviceId();
selectedIndex = 0;
setStatus(`Imported ${rows.length} component(s).`);
renderAll();
} catch (e) {
setStatus(e instanceof Error ? e.message : 'Import failed', true);
}
});
btnAdd?.addEventListener('click', () => {
const kind = addSelectEl?.value;
if (!kind) return;
addComponent(kind);
if (addSelectEl) addSelectEl.value = '';
});
btnClose?.addEventListener('click', () => {
panelEl?.classList.add('hidden');
window.dispatchEvent(new Event('resize'));
});
btnToolbar?.addEventListener('click', () => {
panelEl?.classList.toggle('hidden');
if (!panelEl?.classList.contains('hidden')) {
robotDocument.device = getDeviceId();
updateDeviceNote();
}
window.dispatchEvent(new Event('resize'));
});
}
/** Call when the device dropdown changes so new files default correctly. */
export function syncRobotPanelDevice() {
if (!robotDocument.components.length) {
robotDocument.device = getDeviceId();
updateDeviceNote();
} else {
updateDeviceNote();
}
}

View File

@ -84,7 +84,8 @@ function renderPresetDropdown() {
for (const p of presets) { for (const p of presets) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.name; opt.value = p.name;
opt.textContent = p.name; opt.textContent = p.builtin ? `${p.name}` : p.name;
if (p.builtin) opt.style.fontWeight = 'bold';
presetSelect.appendChild(opt); presetSelect.appendChild(opt);
} }
} }
@ -102,6 +103,8 @@ function loadSelectedPreset() {
function saveCurrentAsPreset() { function saveCurrentAsPreset() {
const name = presetNameInput.value.trim(); const name = presetNameInput.value.trim();
if (!name) { presetNameInput.focus(); return; } if (!name) { presetNameInput.focus(); return; }
const presets = getSavedPresets();
if (presets.find(p => p.name === name && p.builtin)) return;
const f = getFilter(getDeviceId()); const f = getFilter(getDeviceId());
savePreset(name, f); savePreset(name, f);
presetNameInput.value = ''; presetNameInput.value = '';
@ -112,6 +115,9 @@ function saveCurrentAsPreset() {
function deleteSelectedPreset() { function deleteSelectedPreset() {
const name = presetSelect.value; const name = presetSelect.value;
if (!name) return; if (!name) return;
const presets = getSavedPresets();
const preset = presets.find(p => p.name === name);
if (preset && preset.builtin) return;
deletePreset(name); deletePreset(name);
renderPresetDropdown(); renderPresetDropdown();
} }
@ -223,9 +229,19 @@ function renderTree() {
// Expand toggle // Expand toggle
if (hasBlocks) { if (hasBlocks) {
toggle.addEventListener('click', () => { const toggleExpanded = () => {
blockList.classList.toggle('hidden'); blockList.classList.toggle('hidden');
toggle.innerHTML = blockList.classList.contains('hidden') ? '&#9656;' : '&#9662;'; toggle.innerHTML = blockList.classList.contains('hidden') ? '&#9656;' : '&#9662;';
};
toggle.addEventListener('click', (event) => {
event.stopPropagation();
toggleExpanded();
});
row.addEventListener('click', (event) => {
if (event.target instanceof Element && event.target.closest('.cust-cb')) return;
toggleExpanded();
}); });
} }

View File

@ -5,6 +5,7 @@ export default defineConfig({
port: 3000, port: 3000,
open: true, open: true,
}, },
base: '/blocks/',
build: { build: {
outDir: 'dist', outDir: 'dist',
}, },