12 KiB
System Architecture & Integration Flow
2. System Architecture
2.1 High-Level Architecture Diagram
┌────────────────────────────────────────────────────────────────┐
│ Desktop Application │
│ (pywebview) │
│ │
│ ┌──────────────────────┐ │
│ │ Blockly UI │ HTML/JS │
│ │ │ │
│ │ • User assembles │ Blockly is the EXECUTOR — │
│ │ blocks visually │ not just an editor. │
│ │ • if/else, loops, │ │
│ │ variables (native)│ When encountering a robot │
│ │ • Block highlighting│ action block, Blockly calls │
│ │ during execution │ Python and WAITS for result. │
│ │ │ │
│ │ [Run] [Stop] │ │
│ └──────────┬───────────┘ │
│ │ JS ↔ Python bridge (pywebview API) │
│ │ • execute_action(command, keys, values) │
│ │ ← return: {success, message} │
│ ┌──────────▼───────────┐ │
│ │ BlocklyAPI │ Python / rclpy │
│ │ (blockly_app/app.py)│ │
│ │ │ Runs in pywebview thread; │
│ │ • Receives command │ polls futures resolved by │
│ │ from Blockly │ background spin thread. │
│ │ • Sends Action Goal │ │
│ │ • Waits for Result │ │
│ │ • Returns to JS │ │
│ └──────────┬───────────┘ │
└─────────────┼──────────────────────────────────────────────────┘
│ ROS2 Action — BlocklyAction.action
│ (one action per call)
┌─────────────▼──────────────────────────────────────────────────┐
│ Executor Node │
│ (Action Server) │
│ │
│ Handles ONE action at a time. Has no concept of │
│ "program" — sequencing is controlled entirely by Blockly. │
│ │
│ ┌─────────────────────┐ │
│ │ HandlerRegistry │ Extensible command map │
│ │ • led_on │ │
│ │ • led_off │ │
│ │ • delay │ │
│ └──────────┬──────────┘ │
│ ┌──────────▼──────────┐ │
│ │ Hardware Interface │ Abstraction layer │
│ │ • DummyHardware │ for dev & test │
│ │ • GpioHardware │ for Raspberry Pi │
│ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
2.2 Threading Model
The application uses a carefully designed threading model to avoid rclpy's "Executor is already spinning" error:
┌─────────────────────────────────────────────────────┐
│ Process: app.py │
│ │
│ Main Thread (pywebview) │
│ ├── webview.start() ← blocks here │
│ ├── BlocklyAPI.execute_action() called from JS │
│ │ └── _wait_for_future() ← polls future.done() │
│ │ (does NOT call spin) │
│ │ │
│ Background Thread (daemon) │
│ └── MultiThreadedExecutor.spin() │
│ └── processes action client callbacks │
│ (goal response, result, feedback) │
└─────────────────────────────────────────────────────┘
Why MultiThreadedExecutor in the app but NOT in the executor node:
-
App (client side): Uses
MultiThreadedExecutorbecause the background spin thread must process action client callbacks while the main thread pollsfuture.done(). A single-threaded executor would work too, butMultiThreadedExecutorensures callbacks are processed promptly. -
Executor Node (server side): Uses simple
rclpy.spin(node)with the default single-threaded executor. UsingMultiThreadedExecutorwithReentrantCallbackGroupon the server side causes action result delivery failures withrmw_fastrtps_cpp— the client receives default-constructed results (success=False, message='') instead of the actual values.
2.3 ROS2 Interface Contract
Defined in BlocklyAction.action:
# GOAL — one instruction to execute
string command # e.g. "led_on", "delay", "move_forward"
string[] param_keys # e.g. ["pin"]
string[] param_values # e.g. ["3"]
---
# RESULT — sent after action completes or fails
bool success
string message # success message or informative error description
---
# FEEDBACK — sent during execution
string status # "executing" | "done" | "error"
This interface is generic by design — adding new commands never requires modifying the .action file. The command + param_keys/param_values pattern supports any instruction with any parameters.
8. Blockly–ROS2 Integration Flow
8.1 End-to-End Execution Flow
When the user presses Run, the following sequence occurs:
User presses [Run]
│
▼
① Blockly generates JavaScript code from workspace blocks
javascript.javascriptGenerator.workspaceToCode(workspace)
│
▼
② Generated code is wrapped in async function and eval()'d
(async function() {
highlightBlock('block_abc123');
await executeAction('led_on', { pin: '1' });
highlightBlock('block_def456');
await executeAction('delay', { duration_ms: '500' });
})()
│
▼
③ executeAction() calls Python via pywebview bridge
window.pywebview.api.execute_action("led_on", ["pin"], ["1"])
│
▼
④ BlocklyAPI.execute_action() builds ROS2 Action Goal
goal.command = "led_on"
goal.param_keys = ["pin"]
goal.param_values = ["1"]
│
▼
⑤ Action Client sends goal asynchronously
send_future = client.send_goal_async(goal)
│
▼
⑥ _wait_for_future() polls until goal is accepted
(background spin thread processes the callback)
│
▼
⑦ Executor Node receives goal, publishes "executing" feedback
│
▼
⑧ HandlerRegistry.execute("led_on", {"pin": "1"})
→ hardware.set_led(1, True)
→ returns (True, "LED on pin 1 turned ON")
│
▼
⑨ Executor Node calls goal_handle.succeed(), returns Result
│
▼
⑩ _wait_for_future() receives result, returns to BlocklyAPI
│
▼
⑪ BlocklyAPI returns {success: true, message: "..."} to JavaScript
│
▼
⑫ Blockly continues to next block (await resolves)
8.2 Code Generation Pipeline
Each custom block has a code generator defined in its block file (e.g., blocks/led_on.js) that produces JavaScript code. For example, the led_on block with pin=3 generates:
highlightBlock('block_abc123');
await executeAction('led_on', { pin: '3' });
Native Blockly blocks (loops, conditionals, variables) use Blockly's built-in JavaScript generators.
8.3 pywebview Bridge Mechanism
pywebview exposes Python objects to JavaScript through window.pywebview.api. In app.py:
window = webview.create_window(..., js_api=api)
This makes all public methods of BlocklyAPI callable from JavaScript:
const result = await window.pywebview.api.execute_action("led_on", ["pin"], ["3"]);
// result = { success: true, message: "LED on pin 3 turned ON" }
The call is synchronous from JavaScript's perspective — the await pauses Blockly's execution until Python returns.
8.4 Future Waiting Without Blocking
The _wait_for_future() function is the key to avoiding the "Executor is already spinning" error:
def _wait_for_future(future, timeout_sec=30.0):
deadline = time.monotonic() + timeout_sec
while not future.done():
if time.monotonic() > deadline:
raise TimeoutError(...)
time.sleep(0.01) # 10ms polling
return future.result()
Why this works: The background thread running MultiThreadedExecutor.spin() processes all ROS2 callbacks, including action client responses. When a response arrives, the executor's spin loop invokes the callback which marks the future as done. The _wait_for_future() function simply waits for this to happen.
8.5 Debug Mode Flow
When Debug Mode is enabled:
runDebug()wrapsexecuteActionwith breakpoint checking- Before each action, it checks if
debugState.currentBlockIdis inactiveBreakpoints - If a breakpoint is hit, execution pauses via a
Promisethat only resolves when the user clicks Step Over/Step Into - A 300ms delay is added between blocks for visual feedback
- Stop sets
stopRequested = trueand resolves any pending pause Promise, causing the nextexecuteActioncall to throw'STOP_EXECUTION'