feat: enhance debug mode with async block highlighting and call depth tracking; improve control flow for step execution

master
a2nr 2026-03-12 09:56:31 +07:00
parent fc17d9e9e9
commit 5738255423
7 changed files with 179 additions and 116 deletions

View File

@ -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

View File

@ -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"
);
},

View File

@ -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'
);
}

View File

@ -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;
},
});

View File

@ -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'
);
};

View File

@ -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();
}

View File

@ -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;