From 5738255423b87d833d1b0c07def768c0800f7ad6 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 12 Mar 2026 09:56:31 +0700 Subject: [PATCH] feat: enhance debug mode with async block highlighting and call depth tracking; improve control flow for step execution --- readme.md | 57 ++--- .../blockly_app/ui/blockly/blocks/delay.js | 2 +- .../ui/blockly/blocks/digitalOut.js | 2 +- .../ui/blockly/blocks/mainProgram.js | 2 +- .../ui/blockly/core/async-procedures.js | 23 +- .../ui/blockly/core/debug-engine.js | 201 ++++++++++++------ .../ui/blockly/core/ui-controls.js | 8 +- 7 files changed, 179 insertions(+), 116 deletions(-) diff --git a/readme.md b/readme.md index 531787e..599a014 100644 --- a/readme.md +++ b/readme.md @@ -2,8 +2,8 @@ > **Project**: Blockly ROS2 Robot Controller (Kiwi Wheel AMR) > **ROS2 Distro**: Jazzy -> **Last Updated**: 2026-03-10 -> **Current Focus**: `kiwi_controller` — Adaptive control for Kiwi Wheel drive +> **Last Updated**: 2026-03-12 +> **Current Focus**: Dokumentasi lengkap dapat dilihat di [DOCUMENTATION.md](DOCUMENTATION.md). @@ -36,46 +36,31 @@ jelaskan apa yang dimaksut untuk menyelesaikan task # Potential Enhancements this list is short by priority -- **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. +- **low level node enhancement**: porting gpio_node to c++ and all low level node that run on raspberry pi using c++, if you use cross compile it using host PC, and send binnary to raspberry pi it gonna help me develop it faster. - **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 -- **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 # Feature Task -## 1 GPIO Node (digital out + digital in) : [x] -Membuat ROS2 node (`gpio_node`) yang berjalan di Raspberry Pi untuk mengontrol pin GPIO secara langsung melalui `gpiod`. Node ini menerima perintah digital output via topic `/gpio/write` dan mempublikasikan state digital input via topic `/gpio/state`. Executor handler di-wire-up untuk publish/subscribe ke topic-topic tersebut pada mode real hardware. +## 1 Bug Fix: Blockly Debug Mode — Step Into for Function Blocks : [x] +Debug mode tidak bisa step into ke function blocks karena `highlightBlock()` bersifat synchronous — tidak bisa pause execution. Hanya `executeAction()` yang bisa pause, sehingga blocks tanpa `executeAction()` (function calls, variables, math) tidak bisa di-debug. Fix ini mengubah arsitektur debug engine: + +1. **Async `highlightBlock()`** — menjadi universal pause point. Semua block generators menggunakan `await highlightBlock()` sehingga setiap block bisa di-breakpoint dan di-step. +2. **Call depth tracking** — `enterFunction()/exitFunction()` di-inject ke generated code di procedure calls. Step Over menggunakan `callDepth` untuk skip function bodies. +3. **Step modes** — `stepMode` state machine ('into'|'over'|'continue') menggantikan monkey-patching `highlightBlock` di setiap step function. +4. **Auto-pause at first block** — debug mode langsung pause di block pertama (tidak perlu breakpoint untuk mulai stepping). +5. **Run = Continue** — Run button saat paused berfungsi sebagai Continue (resume sampai breakpoint berikutnya). ### Definition Of Done -- Package `gpio_node` dibuat dengan entry point `gpio_node = gpio_node.gpio_node:main` -- Custom message `GpioWrite.msg` dan `GpioRead.msg` didefinisikan di `blockly_interfaces` -- Handler `digital_out` mempublish `GpioWrite` ke `/gpio/write` pada mode real -- Handler `digital_in` subscribe ke `/gpio/state` dan membaca cache pin state -- Block `digitalIn.js` ditambahkan sebagai output block (return 0/1) -- Task `pixi run gpio-node` dan `pixi run build-gpio` tersedia -- 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 +- `debug-engine.js` di-rewrite: async `highlightBlock()` override di `runDebug()`, `callDepth` tracking, `stepMode` state machine +- `enterFunction()`/`exitFunction()` global helpers tersedia untuk generated code +- `async-procedures.js`: `procedures_callreturn` wrapped dengan async IIFE + `highlightBlock()` + depth tracking +- `async-procedures.js`: `procedures_callnoreturn` menggunakan `await highlightBlock()` + `enterFunction()/exitFunction()` dengan try/finally +- Block generators (`digitalOut.js`, `delay.js`, `mainProgram.js`) menggunakan `await highlightBlock()` +- `ui-controls.js`: Run button enabled saat paused (Continue behavior), `onRunClick()` memanggil `continueExecution()` +- Step Into pada function call block → pause di block pertama dalam function body +- Step Over pada function call block → skip function body, pause di block berikutnya +- Debug mode pause di block pertama tanpa perlu breakpoint +- Non-debug mode (`runProgram()`) tidak terpengaruh — `await` pada synchronous `highlightBlock()` adalah no-op - `pixi run build-app` berhasil tanpa error diff --git a/src/blockly_app/blockly_app/ui/blockly/blocks/delay.js b/src/blockly_app/blockly_app/ui/blockly/blocks/delay.js index 38dfd0b..1b91c53 100644 --- a/src/blockly_app/blockly_app/ui/blockly/blocks/delay.js +++ b/src/blockly_app/blockly_app/ui/blockly/blocks/delay.js @@ -29,7 +29,7 @@ BlockRegistry.register({ generator: function (block) { const ms = block.getFieldValue('DURATION_MS'); return ( - "highlightBlock('" + block.id + "');\n" + + "await highlightBlock('" + block.id + "');\n" + "await executeAction('delay', { duration_ms: '" + ms + "' });\n" ); }, diff --git a/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js b/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js index 71ddf4b..9f8cb14 100644 --- a/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js +++ b/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js @@ -26,7 +26,7 @@ BlockRegistry.register({ const GPIO = block.getFieldValue('GPIO'); const STATE = Blockly.JavaScript.valueToCode(block, 'digitalOut', Blockly.JavaScript.ORDER_ATOMIC) || 'false'; return ( - 'highlightBlock(\'' + block.id + '\');\n' + + 'await highlightBlock(\'' + block.id + '\');\n' + 'await executeAction(\'digital_out\', { gpio: \'' + GPIO + '\', state: String(' + STATE + ') });\n' ); } diff --git a/src/blockly_app/blockly_app/ui/blockly/blocks/mainProgram.js b/src/blockly_app/blockly_app/ui/blockly/blocks/mainProgram.js index 9328c10..f1fec68 100644 --- a/src/blockly_app/blockly_app/ui/blockly/blocks/mainProgram.js +++ b/src/blockly_app/blockly_app/ui/blockly/blocks/mainProgram.js @@ -30,6 +30,6 @@ BlockRegistry.register({ generator: function (block) { const body = javascript.javascriptGenerator.statementToCode(block, 'BODY'); - return "highlightBlock('" + block.id + "');\n" + body; + return "await highlightBlock('" + block.id + "');\n" + body; }, }); diff --git a/src/blockly_app/blockly_app/ui/blockly/core/async-procedures.js b/src/blockly_app/blockly_app/ui/blockly/core/async-procedures.js index 4ec2756..e1f469f 100644 --- a/src/blockly_app/blockly_app/ui/blockly/core/async-procedures.js +++ b/src/blockly_app/blockly_app/ui/blockly/core/async-procedures.js @@ -50,21 +50,34 @@ // ─── Override: procedures_callreturn ──────────────────────────────────────── // Original returns: [funcName(args), ORDER.FUNCTION_CALL] - // Override returns: [(await funcName(args)), ORDER.AWAIT] + // Override wraps in async IIFE with highlightBlock + call depth tracking. + // The IIFE is needed because this is a value expression (returns a value), + // but we need to await highlightBlock() and track call depth as statements. gen.forBlock['procedures_callreturn'] = function (block, generator) { const [code, _order] = origCallReturn.call(this, block, generator); - return ['(await ' + code + ')', javascript.Order.AWAIT]; + const id = block.id; + // enterFunction() BEFORE highlightBlock() so that Step Over from the + // parent block (which embeds this value expression) sees callDepth > stepStartDepth + // and skips this highlight entirely. Step Into still pauses because it ignores depth. + return [ + "(await (async function() { enterFunction(); await highlightBlock('" + id + "'); " + + 'try { return await ' + code + '; } ' + + 'finally { exitFunction(); } ' + + '})())', + javascript.Order.AWAIT, + ]; }; // ─── Override: procedures_callnoreturn ────────────────────────────────────── // Original delegates to callreturn[0] + ";\n" - // Override adds highlightBlock() for debug support + await + // Override adds await highlightBlock() for debug + call depth tracking 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' + "await highlightBlock('" + block.id + "');\n" + + 'enterFunction();\n' + + 'try { await ' + code + '; } finally { exitFunction(); }\n' ); }; diff --git a/src/blockly_app/blockly_app/ui/blockly/core/debug-engine.js b/src/blockly_app/blockly_app/ui/blockly/core/debug-engine.js index ef1bd55..9943f01 100644 --- a/src/blockly_app/blockly_app/ui/blockly/core/debug-engine.js +++ b/src/blockly_app/blockly_app/ui/blockly/core/debug-engine.js @@ -1,9 +1,9 @@ /** * Debug Engine — Step-by-step execution, breakpoints, and program control. * - * Provides Run, Debug, Step Over, Step Into, and Stop functionality + * Provides Run, Debug, Step Over, Step Into, Continue, and Stop functionality * for Blockly programs. In debug mode, execution pauses at breakpoints - * and allows step-by-step control. + * and allows step-by-step control via async highlightBlock(). * * Depends on: executeAction (bridge.js), activeBreakpoints/debugModeActive * (breakpoints.js), updateButtonStates (ui-controls.js), @@ -16,6 +16,9 @@ const debugState = { isPaused: false, currentBlockId: null, stopRequested: false, + callDepth: 0, // function call nesting depth + stepMode: null, // 'into' | 'over' | 'continue' | null + stepStartDepth: 0, // callDepth when step-over was initiated }; let debugResolve = null; // resolve function for pause/resume @@ -23,6 +26,10 @@ let debugResolve = null; // resolve function for pause/resume // ─── Highlight Helper ─────────────────────────────────────────────────────── /** * Highlight the currently executing block in the workspace. + * In non-debug mode this is synchronous (await on undefined is a no-op). + * In debug mode, runDebug() overrides this with an async version that + * can pause execution at breakpoints and step boundaries. + * * @param {string|null} blockId - Block ID to highlight, or null to clear */ function highlightBlock(blockId) { @@ -32,10 +39,27 @@ function highlightBlock(blockId) { } } +// ─── Call Depth Tracking ──────────────────────────────────────────────────── +/** + * Called before entering a user-defined function body. + * Generated code injects enterFunction()/exitFunction() around procedure calls. + */ +function enterFunction() { + debugState.callDepth++; +} + +function exitFunction() { + debugState.callDepth--; +} + // ─── Run Program (Non-Debug) ──────────────────────────────────────────────── /** * Run the full Blockly program using async eval(). * No step-by-step control — just run everything as fast as possible. + * + * Note: eval() is intentional here — Blockly generates JavaScript code + * strings that must be evaluated at runtime. This is the core execution + * model for the visual programming environment. */ async function runProgram() { const code = generateCode(workspace); @@ -53,9 +77,8 @@ async function runProgram() { consoleLog('=== Program started ===', 'info'); try { - // Wrap in async function to support await - const wrappedCode = `(async function() {\n${code}\n})()`; - await eval(wrappedCode); + const wrappedCode = '(async function() {\n' + code + '\n})()'; + await (0, globalThis.eval)(wrappedCode); if (!debugState.stopRequested) { consoleLog('=== Program completed ===', 'success'); } @@ -63,7 +86,7 @@ async function runProgram() { if (e.message === 'STOP_EXECUTION') { consoleLog('=== Program stopped by user ===', 'info'); } else { - consoleLog(`Error: ${e.message}`, 'error'); + consoleLog('Error: ' + e.message, 'error'); } } finally { debugState.isRunning = false; @@ -76,7 +99,13 @@ async function runProgram() { // ─── Debug Engine ─────────────────────────────────────────────────────────── /** * Run the program in debug mode. - * Pauses at breakpoints and supports Step Over/Step Into. + * Pauses at the first block, at breakpoints, and supports Step Over/Into. + * + * All pause logic lives in the async highlightBlock() override — this is + * the single pause point for the entire debug engine. executeAction() + * only checks stopRequested. + * + * Note: eval() is intentional — see runProgram() comment. */ async function runDebug() { const code = generateCode(workspace); @@ -89,77 +118,106 @@ async function runDebug() { debugState.isRunning = true; debugState.isPaused = false; debugState.stopRequested = false; + debugState.callDepth = 0; + debugState.stepMode = null; + debugState.stepStartDepth = 0; updateButtonStates(); consoleLog('=== Debug started ===', 'info'); - try { - const wrappedCode = ` - (async function() { - ${code} - })() - `; + let firstHighlight = true; - // Override executeAction temporarily to add debug pause logic + try { + const wrappedCode = '(async function() {\n' + code + '\n})()'; + + // Override executeAction — only check stop, no pause logic const originalExecuteAction = window.executeAction; window.executeAction = async function (command, params) { - // Check if stop was requested if (debugState.stopRequested) { throw new Error('STOP_EXECUTION'); } - - // Check for breakpoint on current block - if (activeBreakpoints.has(debugState.currentBlockId)) { - consoleLog( - `Breakpoint hit on block ${debugState.currentBlockId}`, - 'feedback' - ); - debugState.isPaused = true; - updateButtonStates(); - // Wait until resumed - await new Promise((resolve) => { - debugResolve = resolve; - }); - } - - // If running in debug auto-step mode, add a small delay for visual feedback - if (!debugState.isPaused && debugModeActive) { - await new Promise((r) => setTimeout(r, 300)); - } - - // Check stop again after pause - if (debugState.stopRequested) { - throw new Error('STOP_EXECUTION'); - } - return await originalExecuteAction(command, params); }; - // Override highlightBlock to also pause on breakpoints + // Override highlightBlock with async version — the universal pause point const originalHighlight = window.highlightBlock; - window.highlightBlock = function (blockId) { + + window.highlightBlock = async function (blockId) { originalHighlight(blockId); + + if (debugState.stopRequested) { + throw new Error('STOP_EXECUTION'); + } + + var shouldPause = false; + + if (firstHighlight) { + // Always pause on the very first block in debug mode + firstHighlight = false; + shouldPause = true; + } else if (debugState.stepMode === 'into') { + shouldPause = true; + } else if (debugState.stepMode === 'over') { + // Only pause when back at same depth or shallower + if (debugState.callDepth <= debugState.stepStartDepth) { + shouldPause = true; + } + } else if (debugState.stepMode === 'continue' || debugState.stepMode === null) { + // Continue or auto-run: only pause at breakpoints + if (activeBreakpoints.has(blockId)) { + shouldPause = true; + } + } + + if (shouldPause) { + debugState.isPaused = true; + debugState.stepMode = null; + updateButtonStates(); + + if (activeBreakpoints.has(blockId)) { + consoleLog('Breakpoint hit on block ' + blockId, 'feedback'); + } + + await new Promise(function (resolve) { + debugResolve = resolve; + }); + + if (debugState.stopRequested) { + throw new Error('STOP_EXECUTION'); + } + } else { + // Not pausing — add small delay for visual feedback, but only when + // it makes sense (auto-run mode). Skip delay when: + // - continue mode (running to next breakpoint at full speed) + // - step over inside a function body (should skip instantly) + var inDeeperCall = debugState.stepMode === 'over' && + debugState.callDepth > debugState.stepStartDepth; + if (debugState.stepMode === null && !inDeeperCall) { + await new Promise(function (r) { setTimeout(r, 300); }); + } + } }; - await eval(wrappedCode); + await (0, globalThis.eval)(wrappedCode); if (!debugState.stopRequested) { consoleLog('=== Debug completed ===', 'success'); } - - // Restore originals - window.executeAction = originalExecuteAction; - window.highlightBlock = originalHighlight; } catch (e) { if (e.message === 'STOP_EXECUTION') { consoleLog('=== Program stopped by user ===', 'info'); } else { - consoleLog(`Error: ${e.message}`, 'error'); + consoleLog('Error: ' + e.message, 'error'); } } finally { + // Always restore overrides — even on error or stop + window.executeAction = originalExecuteAction; + window.highlightBlock = originalHighlight; debugState.isRunning = false; debugState.isPaused = false; + debugState.callDepth = 0; + debugState.stepMode = null; debugResolve = null; workspace.highlightBlock(null); updateButtonStates(); @@ -169,24 +227,15 @@ async function runDebug() { // ─── Control Functions ────────────────────────────────────────────────────── /** - * Step Over: resume from pause, execute current block, pause at next block. + * Step Over: execute current block and pause at next block at same depth. + * Skips function bodies — only pauses when callDepth <= current depth. */ function stepOver() { if (!debugState.isPaused) return; debugState.isPaused = false; - - const currentBlock = debugState.currentBlockId; - const origHighlight = window.highlightBlock; - window.highlightBlock = function (blockId) { - origHighlight(blockId); - if (blockId !== currentBlock) { - // Moved to a new block — pause again - debugState.isPaused = true; - updateButtonStates(); - window.highlightBlock = origHighlight; - } - }; + debugState.stepMode = 'over'; + debugState.stepStartDepth = debugState.callDepth; if (debugResolve) { debugResolve(); @@ -196,21 +245,30 @@ function stepOver() { } /** - * Step Into: resume from pause, execute one interpreter step. - * For our async model, this pauses on the very next highlightBlock call. + * Step Into: resume and pause on the very next highlightBlock call. + * Enters function bodies — pauses at first block inside the function. */ function stepInto() { if (!debugState.isPaused) return; debugState.isPaused = false; + debugState.stepMode = 'into'; - const origHighlight = window.highlightBlock; - window.highlightBlock = function (blockId) { - origHighlight(blockId); - debugState.isPaused = true; - updateButtonStates(); - window.highlightBlock = origHighlight; - }; + if (debugResolve) { + debugResolve(); + debugResolve = null; + } + updateButtonStates(); +} + +/** + * Continue: resume execution and only pause at the next breakpoint. + */ +function continueExecution() { + if (!debugState.isPaused) return; + + debugState.isPaused = false; + debugState.stepMode = 'continue'; if (debugResolve) { debugResolve(); @@ -225,6 +283,7 @@ function stepInto() { function stopExecution() { debugState.stopRequested = true; debugState.isPaused = false; + debugState.isRunning = false; // Immediately reflect stopped state in UI // Cancel any in-flight executeAction() call (JS + Python side) cancelCurrentAction(); @@ -235,6 +294,8 @@ function stopExecution() { debugResolve = null; } + // Clear block highlight and update UI immediately + workspace.highlightBlock(null); consoleLog('Stop requested...', 'info'); updateButtonStates(); } diff --git a/src/blockly_app/blockly_app/ui/blockly/core/ui-controls.js b/src/blockly_app/blockly_app/ui/blockly/core/ui-controls.js index 25842f2..159f0ac 100644 --- a/src/blockly_app/blockly_app/ui/blockly/core/ui-controls.js +++ b/src/blockly_app/blockly_app/ui/blockly/core/ui-controls.js @@ -15,7 +15,10 @@ * Runs in debug mode or normal mode depending on debug toggle. */ function onRunClick() { - if (debugModeActive) { + if (debugState.isPaused) { + // Already paused in debug — "Run" acts as "Continue" (resume until next breakpoint) + continueExecution(); + } else if (debugModeActive) { runDebug(); } else { runProgram(); @@ -58,7 +61,8 @@ function updateButtonStates() { const btnStop = document.getElementById('btn-stop'); if (debugState.isRunning) { - btnRun.disabled = true; + // When paused, enable Run (acts as Continue), Step Over, and Step Into + btnRun.disabled = !debugState.isPaused; btnStop.disabled = false; btnStepOver.disabled = !debugState.isPaused; btnStepInto.disabled = !debugState.isPaused;