feat: add main program block and async procedure support in Blockly; enhance code generation and execution flow

master
a2nr 2026-03-11 16:09:46 +07:00
parent 0bdd3040e0
commit fc17d9e9e9
11 changed files with 322 additions and 57 deletions

View File

@ -36,11 +36,9 @@ jelaskan apa yang dimaksut untuk menyelesaikan task
# Potential Enhancements
this list is short by priority
- **Implement function and main function block in blockly**: we need that.
- **ROS Feature in generated block blocky**: currently, block blocky only generate action client, and there is sub/pub and other ROS feature need to implement to get/set value to node.
- **Launch files**: `blockly_bringup` package with ROS2 launch files to start all nodes with one command
- **Sensor integration**: Subscriber nodes for sensor data feeding back into Blockly visual feedback
- **RealHardware implementation**: Fill in remaining ROS2 publishers/service clients for Pi hardware nodes (GPIO done via `gpio_node`, motor/servo TBD)
- **ROS2 lifecycle nodes**: Migrate executor and controller to lifecycle nodes for managed state transitions
- **Simulation**: Integrate with Gazebo/Isaac Sim for testing Kiwi Wheel kinematics before deploying to hardware
- **Block categories**: Future blocks grouped into Robot, Sensors, Navigation categories
@ -60,3 +58,24 @@ Membuat ROS2 node (`gpio_node`) yang berjalan di Raspberry Pi untuk mengontrol p
- Dependency `gpiod` ditambahkan untuk `linux-aarch64`
- Integration test `test_block_gpio.py` mencakup digital_out dan digital_in
- `pixi run build` berhasil tanpa error
## 2 Function & Main Program Block in Blockly : [x]
Saat ini Blockly workspace hanya mendukung linear execution — semua top-level blocks dijalankan berurutan tanpa entry point yang jelas. Belum ada cara untuk mendefinisikan reusable functions. Enhancement ini menambahkan:
1. **Function blocks** — menggunakan built-in Blockly procedure blocks (`procedures_defnoreturn`, `procedures_defreturn`, `procedures_callnoreturn`, `procedures_callreturn`) dengan override code generator untuk menghasilkan `async function` + `await` calls agar kompatibel dengan `executeAction()`.
2. **Main Program block** — custom hat block sebagai entry point program. Ketika ada Main Program block, hanya function definitions + main block body yang di-execute (orphan blocks diabaikan). Tanpa Main Program block, behavior tetap backward compatible.
Perubahan ini murni di sisi JS/frontend. Tidak ada perubahan di Python side (executor, handlers, action interface).
### Definition Of Done
- File `async-procedures.js` dibuat di `blockly/core/` — override procedure generators ke async/await + fungsi `generateCode(ws)`
- File `mainProgram.js` dibuat di `blockly/blocks/` — Main Program hat block (no top/bottom connector, statement input BODY)
- Category "Functions" muncul di toolbox (menggunakan `custom: 'PROCEDURE'`)
- Category "Program" muncul di toolbox dengan Main Program block
- Hanya satu Main Program block diperbolehkan per workspace (enforced via change listener)
- `debug-engine.js`, `ui-tabs.js` menggunakan `generateCode()` sebagai pengganti `workspaceToCode()`
- Tanpa Main block: blocks lama tetap berjalan normal (backward compatible)
- Dengan Main block: hanya function definitions + main block body yang di-execute
- Code tab menampilkan generated code yang sesuai (async functions + main body)
- Debug mode (breakpoints, step over/into) bekerja di dalam function calls
- Export/Import workspace dengan main block & functions bisa di-save dan di-load
- `pixi run build-app` berhasil tanpa error

View File

@ -27,7 +27,7 @@ BlockRegistry.register({
const STATE = Blockly.JavaScript.valueToCode(block, 'digitalOut', Blockly.JavaScript.ORDER_ATOMIC) || 'false';
return (
'highlightBlock(\'' + block.id + '\');\n' +
'await executeAction(\'digital_out\', { gpio: \'' + GPIO + '\', state: \'' + STATE + '\' });\n'
'await executeAction(\'digital_out\', { gpio: \'' + GPIO + '\', state: String(' + STATE + ') });\n'
);
}
});

View File

@ -0,0 +1,35 @@
/**
* Block: main_program Main program entry point (hat block).
*
* Type: Hat block (no top/bottom connectors, statement input for body)
* Category: Program
*
* When present, only function definitions + this block's body are executed.
* Orphan blocks outside the main block are ignored.
* Only one main_program block is allowed per workspace.
*/
BlockRegistry.register({
name: 'main_program',
category: 'Program',
categoryColor: '#FF9800',
color: '#FF9800',
tooltip: 'Main program entry point. Only one allowed per workspace.',
definition: {
init: function () {
this.appendDummyInput()
.appendField('\u{1F3C1} Main Program');
this.appendStatementInput('BODY');
this.setPreviousStatement(false);
this.setNextStatement(false);
this.setColour('#FF9800');
this.setTooltip('Main program entry point. Only one allowed per workspace.');
},
},
generator: function (block) {
const body = javascript.javascriptGenerator.statementToCode(block, 'BODY');
return "highlightBlock('" + block.id + "');\n" + body;
},
});

View File

@ -9,6 +9,7 @@
// eslint-disable-next-line no-unused-vars
const BLOCK_FILES = [
'mainProgram.js',
'digitalOut.js',
'digitalIn.js',
'delay.js',

View File

@ -0,0 +1,122 @@
/**
* Async Procedures Override Blockly's built-in procedure generators
* for async/await support, and provide main-block-aware code generation.
*
* Since our custom blocks use `await executeAction()`, any user-defined
* function that contains those blocks must be declared as `async function`
* and called with `await`.
*
* Also provides `generateCode(ws)` which replaces `workspaceToCode()`
* when a main_program block exists, only function definitions + main body
* are included (orphan blocks are ignored).
*
* Depends on: Blockly (vendor), javascript (vendor), BlockRegistry (registry.js)
*/
(function () {
'use strict';
const gen = javascript.javascriptGenerator;
// ─── Save original generators ──────────────────────────────────────────────
const origDefReturn = gen.forBlock['procedures_defreturn'];
const origCallReturn = gen.forBlock['procedures_callreturn'];
// Note: procedures_defnoreturn === procedures_defreturn in Blockly vendor
// Note: procedures_callnoreturn delegates to callreturn dynamically
// ─── Override: procedures_defreturn & procedures_defnoreturn ────────────────
// Makes generated function definitions async.
// Original generates: function funcName(args) { ... }
// Override produces: async function funcName(args) { ... }
function asyncDefReturn(block, generator) {
// Let original populate definitions_
const result = origDefReturn.call(this, block, generator);
// Patch the definition: function → async function
// scrub_() may prepend comments (e.g. "// Describe this function...\n"),
// so we match by exact function name instead of start-of-string
const funcName = generator.getProcedureName(block.getFieldValue('NAME'));
const key = '%' + funcName;
if (generator.definitions_[key]) {
generator.definitions_[key] = generator.definitions_[key]
.replace('function ' + funcName, 'async function ' + funcName);
}
return result; // always null for def blocks
}
gen.forBlock['procedures_defreturn'] = asyncDefReturn;
gen.forBlock['procedures_defnoreturn'] = asyncDefReturn;
// ─── Override: procedures_callreturn ────────────────────────────────────────
// Original returns: [funcName(args), ORDER.FUNCTION_CALL]
// Override returns: [(await funcName(args)), ORDER.AWAIT]
gen.forBlock['procedures_callreturn'] = function (block, generator) {
const [code, _order] = origCallReturn.call(this, block, generator);
return ['(await ' + code + ')', javascript.Order.AWAIT];
};
// ─── Override: procedures_callnoreturn ──────────────────────────────────────
// Original delegates to callreturn[0] + ";\n"
// Override adds highlightBlock() for debug support + await
gen.forBlock['procedures_callnoreturn'] = function (block, generator) {
// Use original callreturn (pre-override) to get clean funcName(args)
const [code, _order] = origCallReturn.call(this, block, generator);
return (
"highlightBlock('" + block.id + "');\n" +
'await ' + code + ';\n'
);
};
// ─── generateCode(ws) ─────────────────────────────────────────────────────
/**
* Generate executable code from the workspace.
*
* If a main_program block exists:
* - Only function definitions + main block body are included
* - Orphan top-level blocks are ignored
* If no main_program block:
* - Falls back to standard workspaceToCode() behavior (backward compatible)
*
* @param {Blockly.Workspace} ws
* @returns {string} Generated JavaScript code
*/
window.generateCode = function (ws) {
const mainBlocks = ws.getBlocksByType('main_program');
if (mainBlocks.length === 0) {
// No main block — backward compatible
return gen.workspaceToCode(ws);
}
// Main block exists — selective code generation
gen.init(ws);
// 1. Process all procedure definition blocks to populate definitions_
const topBlocks = ws.getTopBlocks(true);
for (const block of topBlocks) {
if (
block.type === 'procedures_defnoreturn' ||
block.type === 'procedures_defreturn'
) {
gen.blockToCode(block);
}
}
// 2. Process the main block body
let mainCode = gen.blockToCode(mainBlocks[0]);
if (Array.isArray(mainCode)) {
mainCode = mainCode[0];
}
// 3. Assemble: definitions (async functions) + main body code
let code = gen.finish(mainCode);
// Clean up whitespace
code = code.replace(/^\s+\n/, '');
code = code.replace(/\n\s+$/, '\n');
code = code.replace(/[ \t]+\n/g, '\n');
return code;
};
})();

View File

@ -38,7 +38,7 @@ function highlightBlock(blockId) {
* No step-by-step control just run everything as fast as possible.
*/
async function runProgram() {
const code = javascript.javascriptGenerator.workspaceToCode(workspace);
const code = generateCode(workspace);
if (!code.trim()) {
consoleLog('No blocks to execute', 'error');
@ -79,7 +79,7 @@ async function runProgram() {
* Pauses at breakpoints and supports Step Over/Step Into.
*/
async function runDebug() {
const code = javascript.javascriptGenerator.workspaceToCode(workspace);
const code = generateCode(workspace);
if (!code.trim()) {
consoleLog('No blocks to execute', 'error');

View File

@ -127,6 +127,12 @@ const BlockRegistry = (() => {
name: 'Variables',
colour: '#a55b80',
custom: 'VARIABLE',
},
{
kind: 'category',
name: 'Functions',
colour: '#995ba5',
custom: 'PROCEDURE',
}
);

View File

@ -40,6 +40,6 @@ function switchTab(tab) {
*/
function refreshCodePanel() {
if (typeof workspace === 'undefined' || !workspace) return;
const code = javascript.javascriptGenerator.workspaceToCode(workspace);
const code = generateCode(workspace);
document.getElementById('code-output').textContent = code || '// (no blocks)';
}

View File

@ -62,6 +62,18 @@ function initWorkspace() {
}
});
// Enforce single main_program block per workspace
ws.addChangeListener(function (event) {
if (event.type === Blockly.Events.BLOCK_CREATE) {
const mainBlocks = ws.getBlocksByType('main_program');
if (mainBlocks.length > 1) {
const newest = mainBlocks[mainBlocks.length - 1];
newest.dispose();
consoleLog('Only one Main Program block is allowed', 'error');
}
}
});
// Refresh Code tab when blocks change
ws.addChangeListener(function () {
const codePanel = document.getElementById('code-panel');

View File

@ -242,6 +242,7 @@
<!-- Core infrastructure (load order matters) -->
<script src="blockly/core/registry.js"></script>
<script src="blockly/core/async-procedures.js"></script>
<script src="blockly/core/breakpoints.js"></script>
<script src="blockly/core/bridge.js"></script>
<script src="blockly/core/debug-engine.js"></script>

View File

@ -3,70 +3,66 @@
"languageVersion": 0,
"blocks": [
{
"type": "controls_repeat_ext",
"id": "j+usYB.X4%o``Le;Q,LA",
"x": 190,
"y": 150,
"type": "main_program",
"id": "COLVqmFP{j*XNMc.9rz+",
"x": 210,
"y": 190,
"inputs": {
"TIMES": {
"shadow": {
"type": "math_number",
"id": "cI`]|Us0+)Ol~XY0L`wE",
"fields": {
"NUM": 3
}
}
},
"DO": {
"BODY": {
"block": {
"type": "digitalOut",
"id": "3}G7M%~([nNy9YHlug!|",
"type": "controls_whileUntil",
"id": "UZH|b`4F=4X`zYjNW9zL",
"fields": {
"GPIO": 17
"MODE": "WHILE"
},
"inputs": {
"digitalOut": {
"BOOL": {
"block": {
"type": "logic_boolean",
"id": "Sw__`~LvxpKyo}./q]1/",
"id": "nsD(~e:hYj[+9m-)yu-4",
"fields": {
"BOOL": "TRUE"
}
}
}
},
"next": {
"block": {
"type": "delay",
"id": "?o-l1IBd(^YR,u[;k$-|",
"fields": {
"DURATION_MS": 500
},
"next": {
"block": {
"type": "digitalOut",
"id": "t}@.X|Ac7F?J;C4v`5ic",
"fields": {
"GPIO": 17
},
"inputs": {
"digitalOut": {
"block": {
"type": "logic_boolean",
"id": "]I]_@v6=ErAYdiolo|+n",
"fields": {
"BOOL": "FALSE"
},
"DO": {
"block": {
"type": "digitalOut",
"id": "Cz1MB5`}~cPRiZhh$/P9",
"fields": {
"GPIO": 17
},
"inputs": {
"digitalOut": {
"block": {
"type": "procedures_callreturn",
"id": "U|7!ynVp5Z.nwD_`4=Z,",
"extraState": {
"name": "foo",
"params": [
"logic"
]
},
"inputs": {
"ARG0": {
"block": {
"type": "logic_boolean",
"id": "HXRaHRaPE)[G3WGmOi-T",
"fields": {
"BOOL": "TRUE"
}
}
}
}
}
},
"next": {
"block": {
"type": "delay",
"id": "45BA([9g7/_tItSnUwB-",
"fields": {
"DURATION_MS": 500
}
}
},
"next": {
"block": {
"type": "delay",
"id": "IXp?_lac7+V*GG!lW{]0",
"fields": {
"DURATION_MS": 1000
}
}
}
@ -76,7 +72,80 @@
}
}
}
},
{
"type": "procedures_defreturn",
"id": "4W(2:w1NGV^I;j6@^_I|",
"x": 630,
"y": 210,
"extraState": {
"params": [
{
"name": "logic",
"id": "-HsGyh[-?q^.O;|%cRw="
}
]
},
"icons": {
"comment": {
"text": "Describe this function...",
"pinned": false,
"height": 80,
"width": 160
}
},
"fields": {
"NAME": "foo"
},
"inputs": {
"STACK": {
"block": {
"type": "digitalOut",
"id": "@.#O-pmBQ/iD*yw?nVpw",
"fields": {
"GPIO": 17
},
"inputs": {
"digitalOut": {
"block": {
"type": "variables_get",
"id": "H%L0RpA8^Wt+Y~*sY0wH",
"fields": {
"VAR": {
"id": "-HsGyh[-?q^.O;|%cRw="
}
}
}
}
},
"next": {
"block": {
"type": "delay",
"id": "GGjd9rTd!=`+-xt[nH-l",
"fields": {
"DURATION_MS": 1000
}
}
}
}
},
"RETURN": {
"block": {
"type": "logic_boolean",
"id": "xwUu7IW=*1qG5ae#*LzZ",
"fields": {
"BOOL": "FALSE"
}
}
}
}
}
]
}
},
"variables": [
{
"name": "logic",
"id": "-HsGyh[-?q^.O;|%cRw="
}
]
}