Add README files for blockly_app and blockly_executor; fix typo in digitalOut block
parent
dced16a00b
commit
3a081a6fd9
1908
DOCUMENTATION.md
1908
DOCUMENTATION.md
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,227 @@
|
|||
# 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 [`MultiThreadedExecutor`](../src/blockly_app/blockly_app/app.py:162) because the background spin thread must process action client callbacks while the main thread polls `future.done()`. A single-threaded executor would work too, but `MultiThreadedExecutor` ensures callbacks are processed promptly.
|
||||
|
||||
- **Executor Node (server side):** Uses simple [`rclpy.spin(node)`](../src/blockly_executor/blockly_executor/executor_node.py:123) with the default single-threaded executor. Using `MultiThreadedExecutor` with `ReentrantCallbackGroup` on the server side causes action result delivery failures with `rmw_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`](../src/blockly_interfaces/action/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`](../src/blockly_app/blockly_app/ui/blockly/blocks/led_on.js)) that produces JavaScript code. For example, the `led_on` block with pin=3 generates:
|
||||
|
||||
```javascript
|
||||
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`](../src/blockly_app/blockly_app/app.py:181):
|
||||
|
||||
```python
|
||||
window = webview.create_window(..., js_api=api)
|
||||
```
|
||||
|
||||
This makes all public methods of `BlocklyAPI` callable from JavaScript:
|
||||
|
||||
```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()`](../src/blockly_app/blockly_app/app.py:26) function is the key to avoiding the "Executor is already spinning" error:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
1. [`runDebug()`](../src/blockly_app/blockly_app/ui/blockly/core/debug-engine.js:87) wraps `executeAction` with breakpoint checking
|
||||
2. Before each action, it checks if `debugState.currentBlockId` is in `activeBreakpoints`
|
||||
3. If a breakpoint is hit, execution pauses via a `Promise` that only resolves when the user clicks Step Over/Step Into
|
||||
4. A 300ms delay is added between blocks for visual feedback
|
||||
5. Stop sets `stopRequested = true` and resolves any pending pause Promise, causing the next `executeAction` call to throw `'STOP_EXECUTION'`
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# Installation & Running the Project
|
||||
|
||||
## 3. Directory Structure
|
||||
|
||||
```
|
||||
amr-ros-k4/ # ROS2 Workspace root
|
||||
│
|
||||
├── pixi.toml # Environment & task definitions
|
||||
├── pixi.lock # Locked dependency versions
|
||||
├── DOCUMENTATION.md # This file
|
||||
├── PROJECT_MANAGEMENT.md # Project tracking & guides
|
||||
│
|
||||
└── src/ # All ROS2 packages
|
||||
├── blockly_interfaces/ # ROS2 custom interface package (ament_cmake)
|
||||
│ ├── package.xml
|
||||
│ ├── CMakeLists.txt
|
||||
│ └── action/
|
||||
│ └── BlocklyAction.action # Single action for all instructions
|
||||
│
|
||||
├── blockly_executor/ # ROS2 Executor Node package (ament_python)
|
||||
│ ├── package.xml
|
||||
│ ├── setup.py
|
||||
│ ├── setup.cfg
|
||||
│ ├── resource/blockly_executor # Ament index marker
|
||||
│ ├── blockly_executor/ # Python module
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── executor_node.py # ROS2 Action Server (thin wrapper)
|
||||
│ │ ├── handlers/ # @handler decorator + auto-discovery
|
||||
│ │ │ ├── __init__.py # HandlerRegistry, @handler, auto-discover
|
||||
│ │ │ ├── gpio.py # @handler("led_on"), @handler("led_off")
|
||||
│ │ │ └── timing.py # @handler("delay")
|
||||
│ │ ├── utils.py # parse_params and helpers
|
||||
│ │ └── hardware/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── interface.py # HardwareInterface abstract class
|
||||
│ │ ├── dummy_hardware.py # In-memory impl for dev & test
|
||||
│ │ ├── real_hardware.py # ROS2 topics/services to Pi nodes
|
||||
│ │ └── gpio_hardware.py # Direct RPi.GPIO (legacy)
|
||||
│ └── test/ # Integration test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Shared fixtures: ros_context, exe_action
|
||||
│ ├── test_block_led_on.py
|
||||
│ ├── test_block_led_off.py
|
||||
│ └── test_block_delay.py
|
||||
│
|
||||
└── blockly_app/ # pywebview desktop application (ament_python)
|
||||
├── package.xml
|
||||
├── setup.py
|
||||
├── setup.cfg
|
||||
├── resource/blockly_app # Ament index marker
|
||||
└── blockly_app/ # Python module
|
||||
├── __init__.py
|
||||
├── app.py # Entry point: pywebview + Action Client
|
||||
└── ui/ # Frontend assets
|
||||
├── index.html # Main UI with toolbar & workspace
|
||||
├── vendor/ # Local Blockly JS files (no CDN)
|
||||
│ ├── blockly.min.js
|
||||
│ ├── blocks_compressed.js
|
||||
│ ├── javascript_compressed.js
|
||||
│ └── en.js
|
||||
└── blockly/ # Modular block system
|
||||
├── core/ # Shared infrastructure
|
||||
│ ├── registry.js # BlockRegistry — auto-register + toolbox
|
||||
│ ├── breakpoints.js # Debug breakpoint management
|
||||
│ ├── bridge.js # executeAction — pywebview bridge
|
||||
│ ├── debug-engine.js # Run, debug, step, stop logic
|
||||
│ ├── ui-controls.js # Button states and callbacks
|
||||
│ ├── ui-tabs.js # switchTab(), refreshCodePanel()
|
||||
│ └── workspace-io.js # exportWorkspace(), importWorkspace()
|
||||
├── blocks/ # One file per block (auto-discovered)
|
||||
│ ├── manifest.js # BLOCK_FILES array
|
||||
│ ├── led_on.js
|
||||
│ ├── led_off.js
|
||||
│ └── delay.js
|
||||
├── loader.js # Dynamic script loader from manifest
|
||||
└── workspace-init.js # Auto-toolbox + workspace setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
|
||||
### 4.1 Prerequisites
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|---|---|---|
|
||||
| **OS** | Ubuntu 22.04+ or Raspberry Pi OS (64-bit) | `linux-64` or `linux-aarch64` |
|
||||
| **Pixi** | Latest | Package manager — [install guide](https://pixi.sh) |
|
||||
| **Node.js** | ≥18 | Only needed once for `setup-ui` task |
|
||||
|
||||
**No system-level ROS2 installation is required.** Pixi installs ROS2 Jazzy from the RoboStack channel in an isolated environment.
|
||||
|
||||
### 4.2 Step-by-Step Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repository-url>
|
||||
cd amr-ros-k4
|
||||
|
||||
# 2. Install all dependencies (ROS2, Python, Qt, etc.)
|
||||
pixi install
|
||||
|
||||
# 3. Build the ROS2 custom interfaces (required once)
|
||||
pixi run build-interfaces
|
||||
|
||||
# 4. Build all packages
|
||||
pixi run build
|
||||
|
||||
# 5. Verify ROS2 is working
|
||||
pixi run python -c "import rclpy; print('rclpy OK')"
|
||||
```
|
||||
|
||||
### 4.3 Building the Blockly Vendor Files
|
||||
|
||||
Blockly is loaded from local files (no CDN) to support offline operation on robots in the field:
|
||||
|
||||
```bash
|
||||
# Download Blockly and copy to vendor/ (requires internet, run once)
|
||||
pixi run setup-ui
|
||||
```
|
||||
|
||||
This runs `npm install blockly` and copies the built files to `src/blockly_app/blockly_app/ui/vendor/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running the Project
|
||||
|
||||
### 5.1 Running the Desktop Application
|
||||
|
||||
Requires two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start the Executor Node (Action Server)
|
||||
pixi run executor
|
||||
|
||||
# Terminal 2: Start the desktop application (Action Client + UI)
|
||||
pixi run app
|
||||
```
|
||||
|
||||
The app window opens with the Blockly workspace. Drag blocks from the toolbox, connect them, and press **Run**.
|
||||
|
||||
### 5.2 Running the Executor Node Standalone
|
||||
|
||||
The executor has two hardware modes controlled by the ROS2 parameter `use_real_hardware`:
|
||||
|
||||
```bash
|
||||
# Dummy mode (default) — in-memory hardware, no real GPIO/motor access
|
||||
pixi run executor
|
||||
|
||||
# Real hardware mode — communicates with hardware nodes on Raspberry Pi via ROS2 topics/services
|
||||
pixi run executor-hw
|
||||
```
|
||||
|
||||
The executor does NOT run on the Raspberry Pi directly. In real hardware mode, `RealHardware` creates ROS2 publishers/service clients that talk to hardware nodes running on the Pi.
|
||||
|
||||
The executor logs all received goals and their results to the terminal.
|
||||
|
||||
### 5.3 Running the Test Suite
|
||||
|
||||
The executor must be running in a separate terminal before starting tests:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start executor
|
||||
pixi run executor
|
||||
|
||||
# Terminal 2: Run all tests
|
||||
pixi run test
|
||||
|
||||
# Run a specific test file
|
||||
pixi run test -- src/blockly_executor/test/test_block_led_on.py -v
|
||||
|
||||
# Run a single test function
|
||||
pixi run test -- src/blockly_executor/test/test_block_led_on.py::test_block_led_on_returns_success -v
|
||||
```
|
||||
|
||||
If the executor is not running, tests are **skipped** with an informative message rather than failing with a cryptic timeout error.
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# Guide: Adding a New ROS2 Package
|
||||
|
||||
## 10. Guide: Adding a New ROS2 Package
|
||||
|
||||
Every new `ament_python` package under `src/` must follow this structure:
|
||||
|
||||
```
|
||||
src/<package_name>/
|
||||
├── package.xml
|
||||
├── setup.py
|
||||
├── setup.cfg
|
||||
├── resource/
|
||||
│ └── <package_name> # Empty file — ament index marker
|
||||
├── <package_name>/ # Python module — same name as package
|
||||
│ ├── __init__.py
|
||||
│ └── <your_node>.py
|
||||
└── test/
|
||||
├── __init__.py
|
||||
└── test_<feature>.py
|
||||
```
|
||||
|
||||
**setup.cfg:**
|
||||
```ini
|
||||
[develop]
|
||||
script_dir=$base/lib/<package_name>
|
||||
[install]
|
||||
install_scripts=$base/lib/<package_name>
|
||||
```
|
||||
|
||||
**package.xml:**
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd"
|
||||
schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>PACKAGE_NAME</name>
|
||||
<version>0.1.0</version>
|
||||
<description>DESCRIPTION</description>
|
||||
<maintainer email="dev@example.com">developer</maintainer>
|
||||
<license>MIT</license>
|
||||
<depend>rclpy</depend>
|
||||
<test_depend>pytest</test_depend>
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
```
|
||||
|
||||
**setup.py:**
|
||||
```python
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
package_name = "PACKAGE_NAME"
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version="0.1.0",
|
||||
packages=find_packages(exclude=["test"]),
|
||||
data_files=[
|
||||
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
|
||||
("share/" + package_name, ["package.xml"]),
|
||||
],
|
||||
install_requires=["setuptools"],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"node_name = PACKAGE_NAME.module:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. `mkdir -p src/<package_name>/<package_name>`
|
||||
2. Create `package.xml`, `setup.py`, `setup.cfg` from templates above
|
||||
3. `touch src/<package_name>/resource/<package_name>`
|
||||
4. Add `__init__.py` and node files
|
||||
5. Add build/run tasks to `pixi.toml`
|
||||
6. `colcon build --symlink-install --packages-select <package_name>`
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Troubleshooting & Known Issues
|
||||
|
||||
## 11. Troubleshooting & Known Issues
|
||||
|
||||
### "Executor is already spinning" in `app.py`
|
||||
|
||||
**Symptom:** `RuntimeError: Executor is already spinning` when Blockly calls `execute_action()`.
|
||||
|
||||
**Cause:** Code calls `rclpy.spin_until_future_complete()` while a background thread is already spinning the same node.
|
||||
|
||||
**Solution:** Use [`_wait_for_future()`](src/blockly_app/blockly_app/app.py:26) which polls `future.done()` instead of calling spin. The background thread's spin loop resolves the futures.
|
||||
|
||||
### "Ignoring unexpected goal response" warnings
|
||||
|
||||
**Symptom:** Warning messages about unexpected goal responses.
|
||||
|
||||
**Cause:** Two executor nodes are running simultaneously on the same action topic.
|
||||
|
||||
**Solution:** Ensure only one executor is running:
|
||||
```bash
|
||||
pkill -f "executor_node"
|
||||
pixi run executor
|
||||
```
|
||||
|
||||
### Action result always `success=False, message=''`
|
||||
|
||||
**Symptom:** Executor logs show successful execution, but the client receives default-constructed results.
|
||||
|
||||
**Cause:** Using `MultiThreadedExecutor` with `ReentrantCallbackGroup` on the **server** side causes result delivery failures with `rmw_fastrtps_cpp`.
|
||||
|
||||
**Solution:** The executor node uses simple `rclpy.spin(node)` with the default single-threaded executor. Do not add `MultiThreadedExecutor` or `ReentrantCallbackGroup` to [`executor_node.py`](src/blockly_executor/blockly_executor/executor_node.py).
|
||||
|
||||
### `goal_handle.abort()` causes empty results
|
||||
|
||||
**Symptom:** When the executor calls `goal_handle.abort()` for failed commands, the client receives empty result fields.
|
||||
|
||||
**Solution:** Always call `goal_handle.succeed()`. The `result.success` field communicates command-level success/failure.
|
||||
|
||||
### Tests skipped with "Executor Node tidak ditemukan"
|
||||
|
||||
**Symptom:** All tests show `SKIPPED` with message about executor not found.
|
||||
|
||||
**Cause:** The executor node is not running in a separate terminal.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Terminal 1
|
||||
pixi run executor
|
||||
|
||||
# Terminal 2
|
||||
pixi run test
|
||||
```
|
||||
|
||||
### Export/Import button has no effect (force close or nothing happens)
|
||||
|
||||
**Symptom:** Clicking Export or Import either force-closes the app or does nothing.
|
||||
|
||||
**Cause:** Qt file dialogs (`QFileDialog`, `pywebview.create_file_dialog`) must run on the Qt main thread. pywebview calls Python API methods from a background thread. Attempting to open a Qt dialog from there causes:
|
||||
- `pywebview.create_file_dialog` → deadlock via `BlockingQueuedConnection` → force close
|
||||
- `QFileDialog` via `QTimer.singleShot` → no effect, because non-QThread background threads have no Qt event loop
|
||||
|
||||
**Solution:** Use `tkinter.filedialog` — tkinter uses its own Tcl/Tk interpreter, completely separate from Qt. `filedialog.asksaveasfilename()` blocks the calling background thread until the user responds. Already available in the pixi environment (no extra dependency needed).
|
||||
See [`_native_save_dialog()`](src/blockly_app/blockly_app/app.py:29) in `app.py`.
|
||||
|
||||
### pywebview shows "GTK cannot be loaded"
|
||||
|
||||
**Symptom:** Warning about `ModuleNotFoundError: No module named 'gi'` followed by "Using Qt 5.15".
|
||||
|
||||
**Impact:** This is **informational only**. pywebview tries GTK first, falls back to Qt (which is installed via `pyqtwebengine`). The application works correctly with the Qt backend.
|
||||
|
||||
---
|
||||
|
|
@ -19,7 +19,8 @@ Feature Task : Penjabaran Pekerjaan yang ready untuk dikerjakan. Ta
|
|||
# Potential Enhancements
|
||||
this list is short by priority
|
||||
- **Potensial inefective development**: in handlers/hardware use interface.py to all hardware (dummy, ros2, and hardware) class that posibly haavily change.
|
||||
- ** UI bug **: stop button not actualy stop execution. tried with long delay with loop and press stop button, program still continue
|
||||
- **UI bug stop button**: stop button not actualy stop execution. tried with long delay with loop and press stop button, program still continue
|
||||
- **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 ROS2 publishers/service clients for actual Pi hardware nodes (topics TBD)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,948 @@
|
|||
# Creating Custom Blocks in Blockly
|
||||
|
||||
## 7. Creating Custom Blocks in Blockly
|
||||
|
||||
### 7.1 Overview: Auto-Discovery on Both Sides
|
||||
|
||||
Both JS and Python use the same pattern: **decorator/register + auto-discovery**. Adding a new block:
|
||||
|
||||
```
|
||||
Step File Action
|
||||
──── ──── ──────
|
||||
1. JS src/blockly_app/blockly_app/ui/blockly/blocks/<name>.js Create — BlockRegistry.register({...})
|
||||
src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js Edit — add filename to BLOCK_FILES array
|
||||
2. Py src/blockly_executor/blockly_executor/handlers/<name>.py Create — @handler("command") function
|
||||
```
|
||||
|
||||
**Files that do NOT need changes:** [`index.html`](blockly_app/ui/index.html), [`conftest.py`](../blockly_executor/test/conftest.py), [`executor_node.py`](../blockly_executor/blockly_executor/executor_node.py), [`BlocklyAction.action`](../blockly_interfaces/action/BlocklyAction.action), [`handlers/__init__.py`](../blockly_executor/blockly_executor/handlers/__init__.py). Both toolbox and handler registry are auto-generated.
|
||||
|
||||
### 7.2 Step 1 — Create Block File (JS)
|
||||
|
||||
Create `src/blockly_app/blockly_app/ui/blockly/blocks/move_forward.js`:
|
||||
|
||||
```javascript
|
||||
BlockRegistry.register({
|
||||
name: 'move_forward',
|
||||
category: 'Robot',
|
||||
categoryColor: '#5b80a5',
|
||||
color: '#7B1FA2',
|
||||
tooltip: 'Move robot forward with given speed and duration',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('Move forward speed')
|
||||
.appendField(new Blockly.FieldNumber(50, 0, 100, 1), 'SPEED')
|
||||
.appendField('for')
|
||||
.appendField(new Blockly.FieldNumber(1000, 0), 'DURATION_MS')
|
||||
.appendField('ms');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#7B1FA2');
|
||||
this.setTooltip('Move robot forward with given speed and duration');
|
||||
}
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const speed = block.getFieldValue('SPEED');
|
||||
const durationMs = block.getFieldValue('DURATION_MS');
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('move_forward', { speed: '" + speed + "', duration_ms: '" + durationMs + "' });\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Then add to [`manifest.js`](blockly_app/ui/blockly/blocks/manifest.js):
|
||||
|
||||
```javascript
|
||||
const BLOCK_FILES = [
|
||||
'led_on.js',
|
||||
'led_off.js',
|
||||
'delay.js',
|
||||
'move_forward.js', // ← add here
|
||||
];
|
||||
```
|
||||
|
||||
**No `index.html` changes needed.** No toolbox XML editing. The block appears automatically in the "Robot" category.
|
||||
|
||||
### 7.3 Step 2 — Register Handler in Python
|
||||
|
||||
Create a new file in `handlers/` or add to an existing one. Use the `@handler` decorator — auto-discovery handles the rest.
|
||||
|
||||
```python
|
||||
# src/blockly_executor/blockly_executor/handlers/movement.py
|
||||
from . import handler
|
||||
|
||||
@handler("move_forward")
|
||||
def handle_move_forward(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
speed = int(params["speed"])
|
||||
duration_ms = int(params["duration_ms"])
|
||||
if not (0 <= speed <= 100):
|
||||
raise ValueError(f"speed must be 0-100, got: {speed}")
|
||||
hardware.move(direction="forward", speed=speed, duration_ms=duration_ms)
|
||||
return (True, f"Moved forward at speed {speed} for {duration_ms}ms")
|
||||
```
|
||||
|
||||
No imports to update, no registry list to edit. The file is auto-discovered on startup.
|
||||
|
||||
**Verify immediately** (no Blockly UI needed):
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
pixi run executor
|
||||
|
||||
# Terminal 2 — send goal manually
|
||||
pixi run bash -c 'source install/setup.bash && ros2 action send_goal /execute_blockly_action blockly_interfaces/action/BlocklyAction "{command: move_forward, param_keys: [speed, duration_ms], param_values: [50, 1000]}"'
|
||||
```
|
||||
|
||||
### 7.4 Step 3 — Write Integration Test (Optional)
|
||||
|
||||
Create `src/blockly_executor/test/test_block_move_forward.py`:
|
||||
|
||||
```python
|
||||
"""Integration test for Blockly instruction: move_forward"""
|
||||
|
||||
|
||||
def test_block_move_forward_returns_success(exe_action):
|
||||
result = exe_action("move_forward", speed="50", duration_ms="200")
|
||||
assert result.result.success is True
|
||||
|
||||
|
||||
def test_block_move_forward_sends_executing_feedback(exe_action):
|
||||
result = exe_action("move_forward", speed="50", duration_ms="200")
|
||||
assert len(result.feedbacks) > 0
|
||||
assert result.feedbacks[0].status == "executing"
|
||||
|
||||
|
||||
def test_block_move_forward_missing_speed_returns_failure(exe_action):
|
||||
result = exe_action("move_forward", duration_ms="200")
|
||||
assert result.result.success is False
|
||||
assert "speed" in result.result.message.lower()
|
||||
```
|
||||
|
||||
### 7.5 Name Consistency Reference Table
|
||||
|
||||
```
|
||||
handlers.py blocks/<name>.js blocks/<name>.js
|
||||
─────────── ──────────────── ─────────────────
|
||||
"move_forward" == name: 'move_forward' == 'move_forward' in executeAction
|
||||
params["speed"] == 'SPEED' in FieldNumber == getFieldValue('SPEED')
|
||||
params["duration_ms"] == 'DURATION_MS' == getFieldValue('DURATION_MS')
|
||||
```
|
||||
|
||||
|
||||
### 7.6 Block Type Overview
|
||||
|
||||
There are three fundamental block shapes. Choose based on what the block **does**:
|
||||
|
||||
| Type | Shape | Returns | Use case | Examples |
|
||||
|------|-------|---------|----------|---------|
|
||||
| **Statement** | Top + bottom notches | Nothing (side effect) | Execute an action on the robot | `led_on`, `led_off`, `delay` |
|
||||
| **Output** | Left plug, no notches | A value | Read a sensor, compute something | `read_distance`, `read_temperature` |
|
||||
| **Container** | Statement input socket | Nothing | Wrap a block stack (loop body, condition body) | Custom `repeat_until_clear` |
|
||||
|
||||
> Output blocks can be plugged into `appendValueInput()` sockets of other blocks (e.g., plug `read_distance` into `move_to(X=..., Y=...)`)
|
||||
|
||||
---
|
||||
|
||||
### 7.7 `BlockRegistry.register()` — All Fields
|
||||
|
||||
```js
|
||||
BlockRegistry.register({
|
||||
// ── Required ────────────────────────────────────────────────────────────
|
||||
name: 'my_command', // Unique block ID. Must match Python @handler("my_command")
|
||||
category: 'Robot', // Toolbox category label. New names auto-create a category.
|
||||
color: '#4CAF50', // Block body hex color
|
||||
definition: { init: function() { … } }, // Blockly visual definition
|
||||
generator: function(block) { … }, // JS code generator
|
||||
|
||||
// ── Optional ────────────────────────────────────────────────────────────
|
||||
categoryColor: '#5b80a5', // Category sidebar color (default: '#5b80a5')
|
||||
tooltip: 'Description', // Hover text (also set inside init via setTooltip)
|
||||
outputType: 'Number', // Set ONLY for output blocks ('Number', 'String', 'Boolean')
|
||||
// When present, generator must return [code, Order] array
|
||||
});
|
||||
```
|
||||
|
||||
**`name` is the contract** — it must exactly match the `@handler("name")` string in Python. A mismatch causes `Unknown command: 'name'` at runtime.
|
||||
|
||||
---
|
||||
|
||||
### 7.8 `definition.init` — Complete Reference
|
||||
|
||||
The `definition` field takes a plain object with one required key: `init`. This function runs once when Blockly creates the block. Inside `init`, `this` refers to the block instance.
|
||||
|
||||
**Call order inside `init`:**
|
||||
1. `appendXxxInput()` rows — build the visual layout top-to-bottom
|
||||
2. Connection methods — define how the block connects to others
|
||||
3. Style methods — set color and tooltip
|
||||
|
||||
---
|
||||
|
||||
#### Input Rows — Visual Layout
|
||||
|
||||
Every input row is a horizontal strip. Rows stack top-to-bottom. Fields chain left-to-right within a row.
|
||||
|
||||
```
|
||||
appendDummyInput() appendValueInput('X') appendStatementInput('DO')
|
||||
┌───────────────────────────┐ ┌─────────────────────┬──┐ ┌──────────────────────────┐
|
||||
│ [label] [field] [label]│ │ [label] [field] │◄─┤socket │ do │
|
||||
└───────────────────────────┘ └─────────────────────┴──┘ │ ┌────────────────────┐ │
|
||||
no sockets, no plug right side has an input socket │ │ (block stack here) │ │
|
||||
that accepts output block plugs │ └────────────────────┘ │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `appendDummyInput()` — Label / field row (no sockets)
|
||||
|
||||
Use when the block only needs static inline fields. No output block can connect here.
|
||||
|
||||
```js
|
||||
init: function () {
|
||||
this.appendDummyInput() // creates one horizontal row
|
||||
.appendField('Speed') // text label (no key needed)
|
||||
.appendField(
|
||||
new Blockly.FieldNumber(50, 0, 100, 1), 'SPEED'
|
||||
) // interactive field — key 'SPEED'
|
||||
.appendField('%'); // another text label after the field
|
||||
|
||||
// Visual: ┌─ Speed [50] % ─┐
|
||||
}
|
||||
```
|
||||
|
||||
Multiple `appendDummyInput()` calls stack as separate rows:
|
||||
|
||||
```js
|
||||
init: function () {
|
||||
this.appendDummyInput().appendField('Motor Config'); // header label row
|
||||
this.appendDummyInput()
|
||||
.appendField('Left ')
|
||||
.appendField(new Blockly.FieldNumber(50, -100, 100, 1), 'LEFT');
|
||||
this.appendDummyInput()
|
||||
.appendField('Right')
|
||||
.appendField(new Blockly.FieldNumber(50, -100, 100, 1), 'RIGHT');
|
||||
|
||||
// Visual:
|
||||
// ┌──────────────────────────┐
|
||||
// │ Motor Config │
|
||||
// │ Left [-100..100] │
|
||||
// │ Right [-100..100] │
|
||||
// └──────────────────────────┘
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `appendValueInput('KEY')` — Socket row (accepts output blocks)
|
||||
|
||||
Use when you want the user to plug in a sensor or value block. The socket appears on the **right side** of the row. The label (if any) appears on the left.
|
||||
|
||||
```js
|
||||
init: function () {
|
||||
this.appendValueInput('SPEED') // 'SPEED' is the key used in valueToCode()
|
||||
.setCheck('Number') // only accept Number-type output blocks
|
||||
// use setCheck(null) or omit to accept any type
|
||||
.appendField('Drive at'); // label to the left of the socket
|
||||
|
||||
// Visual: ┌─ Drive at ◄──(output block plugs here) ─┐
|
||||
}
|
||||
```
|
||||
|
||||
Multiple `appendValueInput` rows, collapsed inline with `setInputsInline(true)`:
|
||||
|
||||
```js
|
||||
init: function () {
|
||||
this.appendValueInput('X').setCheck('Number').appendField('X');
|
||||
this.appendValueInput('Y').setCheck('Number').appendField('Y');
|
||||
this.setInputsInline(true); // ← collapses rows side-by-side
|
||||
|
||||
// setInputsInline(false) (default): rows stacked vertically
|
||||
// setInputsInline(true): rows placed side-by-side on one line
|
||||
|
||||
// Visual (inline): ┌─ X ◄─ Y ◄─ ─┐
|
||||
}
|
||||
```
|
||||
|
||||
Reading the plugged-in block value in the generator:
|
||||
```js
|
||||
generator: function (block) {
|
||||
// valueToCode returns the generated expression for the plugged-in block
|
||||
// Falls back to the default ('0', '', etc.) if the socket is empty
|
||||
const x = javascript.javascriptGenerator.valueToCode(
|
||||
block, 'X', javascript.Order.ATOMIC // Order.ATOMIC: value is a safe atom (no parens needed)
|
||||
) || '0'; // || '0': fallback if socket is empty
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `appendStatementInput('KEY')` — Indented block stack slot
|
||||
|
||||
Use for container blocks (loops, conditionals) where the user places a stack of blocks inside.
|
||||
|
||||
```js
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('While obstacle');
|
||||
|
||||
this.appendStatementInput('BODY') // 'BODY' is the key for statementToCode()
|
||||
.appendField('do'); // optional label next to the slot opening
|
||||
|
||||
// Visual:
|
||||
// ┌────────────────────────────────┐
|
||||
// │ While obstacle │
|
||||
// │ do │
|
||||
// │ ┌──────────────────────┐ │
|
||||
// │ │ (blocks stack here) │ │
|
||||
// │ └──────────────────────┘ │
|
||||
// └────────────────────────────────┘
|
||||
}
|
||||
```
|
||||
|
||||
Reading the inner stack in the generator:
|
||||
```js
|
||||
generator: function (block) {
|
||||
const inner = javascript.javascriptGenerator.statementToCode(block, 'BODY');
|
||||
// statementToCode returns the generated code for ALL blocks stacked inside 'BODY'
|
||||
// Returns '' if the slot is empty
|
||||
return "while (true) {\n" + inner + "}\n";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Connection / Shape Methods
|
||||
|
||||
These define the block's shape. Call them **after** all `appendXxx` rows.
|
||||
|
||||
```js
|
||||
// ── Statement block (stackable in a sequence) ─────────────────────────────
|
||||
this.setPreviousStatement(true, null);
|
||||
// ↑ ↑── type filter: null = accept any block above
|
||||
// └── true = show top notch
|
||||
this.setNextStatement(true, null);
|
||||
// same params — shows bottom notch so another block can connect below
|
||||
|
||||
// ── Output block (value-returning, plugs into sockets) ────────────────────
|
||||
this.setOutput(true, 'Number');
|
||||
// ↑── output type: 'Number' | 'String' | 'Boolean' | null (any)
|
||||
// ⚠ setOutput is MUTUALLY EXCLUSIVE with setPreviousStatement / setNextStatement
|
||||
|
||||
// ── Standalone (no connections — rare) ───────────────────────────────────
|
||||
// Omit all three. Block floats freely, cannot connect to anything.
|
||||
```
|
||||
|
||||
Type filter (second argument) restricts which blocks can connect:
|
||||
|
||||
```js
|
||||
this.setPreviousStatement(true, 'RobotAction'); // only connects below RobotAction blocks
|
||||
this.setNextStatement(true, 'RobotAction');
|
||||
// Matching blocks must also declare:
|
||||
// this.setOutput(true, 'RobotAction') ← on the output side
|
||||
// Rarely needed in this project — use null for unrestricted.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Style Methods
|
||||
|
||||
```js
|
||||
this.setColour('#4CAF50'); // block body color — hex string OR hue int (0–360)
|
||||
this.setTooltip('Hover text'); // shown when user hovers over the block
|
||||
this.setHelpUrl('https://...'); // optional: opens URL when user clicks '?' on block
|
||||
```
|
||||
|
||||
> Always use **hex strings** (e.g. `'#4CAF50'`) to match the project's color scheme. The `color` field in `BlockRegistry.register()` and `setColour()` inside `init` should use the same value.
|
||||
|
||||
---
|
||||
|
||||
#### Layout Control — `setInputsInline`
|
||||
|
||||
```js
|
||||
this.setInputsInline(true); // compact: all appendValueInput rows on ONE line
|
||||
this.setInputsInline(false); // (default) each appendValueInput row on its OWN line
|
||||
```
|
||||
|
||||
`setInputsInline` only affects `appendValueInput` rows — `appendDummyInput` rows are always inline.
|
||||
|
||||
---
|
||||
|
||||
#### Complete Multi-Row Example
|
||||
|
||||
A block combining all input types:
|
||||
|
||||
```js
|
||||
definition: {
|
||||
init: function () {
|
||||
// ── Row 1: DummyInput — header label + FieldNumber ──────────────────
|
||||
this.appendDummyInput()
|
||||
.appendField('Drive until <')
|
||||
.appendField(new Blockly.FieldNumber(20, 1, 500, 1), 'THRESHOLD')
|
||||
.appendField('cm');
|
||||
|
||||
// ── Row 2+3: ValueInput — two sockets, collapsed inline ─────────────
|
||||
this.appendValueInput('SPEED_L')
|
||||
.setCheck('Number')
|
||||
.appendField('L');
|
||||
this.appendValueInput('SPEED_R')
|
||||
.setCheck('Number')
|
||||
.appendField('R');
|
||||
this.setInputsInline(true); // collapse rows 2+3 side-by-side
|
||||
|
||||
// ── Row 4: StatementInput — block stack slot ─────────────────────────
|
||||
this.appendStatementInput('ON_ARRIVAL')
|
||||
.appendField('then');
|
||||
|
||||
// ── Connection shape ─────────────────────────────────────────────────
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
|
||||
// ── Style ────────────────────────────────────────────────────────────
|
||||
this.setColour('#FF5722');
|
||||
this.setTooltip('Drive at given speeds until obstacle is closer than threshold');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const threshold = block.getFieldValue('THRESHOLD');
|
||||
const speedL = javascript.javascriptGenerator.valueToCode(block, 'SPEED_L', javascript.Order.ATOMIC) || '50';
|
||||
const speedR = javascript.javascriptGenerator.valueToCode(block, 'SPEED_R', javascript.Order.ATOMIC) || '50';
|
||||
const body = javascript.javascriptGenerator.statementToCode(block, 'ON_ARRIVAL');
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('drive_until', {" +
|
||||
" threshold: '" + threshold + "'," +
|
||||
" speed_l: String(" + speedL + ")," +
|
||||
" speed_r: String(" + speedR + ")" +
|
||||
" });\n" +
|
||||
body
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
Visual result:
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Drive until < [20] cm │
|
||||
│ L ◄─(speed) R ◄─(speed) │
|
||||
│ then │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ (on-arrival blocks stack here) │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.9 Data Flow: JS Block → Python Handler → Hardware
|
||||
|
||||
```
|
||||
User drags block into workspace
|
||||
│
|
||||
▼
|
||||
[Run button pressed]
|
||||
│
|
||||
▼
|
||||
Blockly generates JS code from block.generator()
|
||||
─ Statement: string code ending with \n
|
||||
─ Output: [expression_string, Order]
|
||||
│
|
||||
▼
|
||||
debug-engine.js eval()s the generated JS
|
||||
│
|
||||
▼
|
||||
await executeAction('my_command', { key: 'value', … })
|
||||
→ bridge.js calls window.pywebview.api.execute_action(command, keys, values)
|
||||
│
|
||||
▼
|
||||
BlocklyAPI.execute_action() in app.py
|
||||
→ ROS2 Action Client sends Goal { command, param_keys, param_values }
|
||||
│
|
||||
▼
|
||||
ExecutorNode receives goal, calls HandlerRegistry.execute(command, params)
|
||||
│
|
||||
▼
|
||||
@handler("my_command") function(params, hardware)
|
||||
→ hardware.set_led(pin, True) ← DummyHardware or RealHardware
|
||||
│
|
||||
▼
|
||||
Returns (True, "LED on pin 3 turned ON")
|
||||
→ goal_handle.succeed() → result.success=True, result.message=...
|
||||
│
|
||||
▼
|
||||
await executeAction() resolves → JS continues to next block
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.10 Real Block Examples from This Project
|
||||
|
||||
#### `led_on` — Statement block with FieldNumber
|
||||
|
||||
**JS** ([blocks/led_on.js](blockly_app/ui/blockly/blocks/led_on.js)):
|
||||
```js
|
||||
BlockRegistry.register({
|
||||
name: 'led_on',
|
||||
category: 'Robot',
|
||||
categoryColor: '#5b80a5',
|
||||
color: '#4CAF50',
|
||||
tooltip: 'Turn on LED at the specified GPIO pin',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('LED ON pin')
|
||||
.appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN');
|
||||
// ↑ ↑ ↑ ↑
|
||||
// default min max step
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#4CAF50');
|
||||
this.setTooltip('Turn on LED at the specified GPIO pin');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const pin = block.getFieldValue('PIN'); // reads the PIN field value
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" + // visual debugger highlight
|
||||
"await executeAction('led_on', { pin: '" + pin + "' });\n"
|
||||
// ↑ command name (must match @handler)
|
||||
// ↑ param key ↑ param value (always string)
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Python** ([handlers/gpio.py](../blockly_executor/blockly_executor/handlers/gpio.py)):
|
||||
```python
|
||||
from . import handler
|
||||
|
||||
@handler("led_on") # ← must match JS name
|
||||
def handle_led_on(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
pin = int(params["pin"]) # params values are always strings — cast as needed
|
||||
hardware.set_led(pin, True)
|
||||
return (True, f"LED on pin {pin} turned ON") # (success: bool, message: str)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `led_off` — Statement block (same pattern, different color)
|
||||
|
||||
**JS** ([blocks/led_off.js](blockly_app/ui/blockly/blocks/led_off.js)):
|
||||
```js
|
||||
BlockRegistry.register({
|
||||
name: 'led_off',
|
||||
category: 'Robot',
|
||||
categoryColor: '#5b80a5',
|
||||
color: '#FF9800', // orange — visually distinct from led_on
|
||||
tooltip: 'Turn off LED at the specified GPIO pin',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('LED OFF pin')
|
||||
.appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#FF9800');
|
||||
this.setTooltip('Turn off LED at the specified GPIO pin');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const pin = block.getFieldValue('PIN');
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('led_off', { pin: '" + pin + "' });\n"
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Python** ([handlers/gpio.py](../blockly_executor/blockly_executor/handlers/gpio.py)):
|
||||
```python
|
||||
@handler("led_off")
|
||||
def handle_led_off(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
pin = int(params["pin"])
|
||||
hardware.set_led(pin, False)
|
||||
return (True, f"LED on pin {pin} turned OFF")
|
||||
```
|
||||
|
||||
> Multiple handlers can live in the same `.py` file — they are auto-discovered by `pkgutil.iter_modules`.
|
||||
|
||||
---
|
||||
|
||||
#### `delay` — Statement block with multiple field decorators
|
||||
|
||||
**JS** ([blocks/delay.js](blockly_app/ui/blockly/blocks/delay.js)):
|
||||
```js
|
||||
BlockRegistry.register({
|
||||
name: 'delay',
|
||||
category: 'Robot',
|
||||
categoryColor: '#5b80a5',
|
||||
color: '#2196F3',
|
||||
tooltip: 'Wait for the specified duration in milliseconds',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('Delay')
|
||||
.appendField(new Blockly.FieldNumber(500, 0, 60000, 100), 'DURATION_MS')
|
||||
.appendField('ms'); // ← plain text label appended after the field
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#2196F3');
|
||||
this.setTooltip('Wait for the specified duration in milliseconds');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const ms = block.getFieldValue('DURATION_MS');
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('delay', { duration_ms: '" + ms + "' });\n"
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Python** ([handlers/timing.py](../blockly_executor/blockly_executor/handlers/timing.py)):
|
||||
```python
|
||||
import time
|
||||
from . import handler
|
||||
|
||||
@handler("delay")
|
||||
def handle_delay(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
duration_ms = int(params["duration_ms"])
|
||||
time.sleep(duration_ms / 1000.0)
|
||||
return (True, f"Delayed {duration_ms}ms")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.11 Template A — Statement Block (action command)
|
||||
|
||||
Copy-paste starting point for any new action block:
|
||||
|
||||
```js
|
||||
// src/blockly_app/blockly_app/ui/blockly/blocks/MY_BLOCK.js
|
||||
|
||||
BlockRegistry.register({
|
||||
name: 'MY_COMMAND', // ← change this (must match @handler)
|
||||
category: 'Robot', // ← choose or create a category
|
||||
categoryColor: '#5b80a5',
|
||||
color: '#9C27B0', // ← pick a hex color
|
||||
tooltip: 'Short description of what this block does',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('Label text')
|
||||
.appendField(new Blockly.FieldNumber(0, 0, 100, 1), 'PARAM1');
|
||||
// add more .appendField(...) calls for more parameters
|
||||
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#9C27B0');
|
||||
this.setTooltip('Short description of what this block does');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const param1 = block.getFieldValue('PARAM1');
|
||||
// Statement generators return a string (not an array)
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('MY_COMMAND', { param1: '" + param1 + "' });\n"
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Corresponding Python handler:
|
||||
```python
|
||||
# src/blockly_executor/blockly_executor/handlers/MY_FILE.py
|
||||
from . import handler
|
||||
|
||||
@handler("MY_COMMAND")
|
||||
def handle_my_command(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
param1 = params["param1"] # always str — cast as needed (int, float, etc.)
|
||||
# ... do something with hardware ...
|
||||
return (True, f"Done: {param1}")
|
||||
```
|
||||
|
||||
After creating both files, add the JS filename to [manifest.js](blockly_app/ui/blockly/blocks/manifest.js):
|
||||
```js
|
||||
const BLOCK_FILES = [
|
||||
'led_on.js',
|
||||
'led_off.js',
|
||||
'delay.js',
|
||||
'MY_BLOCK.js', // ← add here
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.12 Template B — Output Block (sensor / computed value)
|
||||
|
||||
Output blocks expose a **left plug** and plug into input sockets of other blocks. They have **no** top/bottom notches and cannot stand alone in a stack.
|
||||
|
||||
```js
|
||||
// src/blockly_app/blockly_app/ui/blockly/blocks/read_distance.js
|
||||
|
||||
BlockRegistry.register({
|
||||
name: 'read_distance',
|
||||
category: 'Sensors',
|
||||
categoryColor: '#a5745b',
|
||||
color: '#E91E63',
|
||||
tooltip: 'Read distance from ultrasonic sensor in cm',
|
||||
outputType: 'Number', // ← required for output blocks; determines socket type-check
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('Distance')
|
||||
.appendField(new Blockly.FieldDropdown([
|
||||
['Front', 'front'],
|
||||
['Left', 'left'],
|
||||
['Right', 'right'],
|
||||
]), 'SENSOR_ID');
|
||||
|
||||
this.setOutput(true, 'Number'); // ← left plug; NO setPreviousStatement / setNextStatement
|
||||
this.setColour('#E91E63');
|
||||
this.setTooltip('Read distance from ultrasonic sensor in cm');
|
||||
},
|
||||
},
|
||||
|
||||
// Output block generators MUST return [expression_string, Order] — NOT a plain string
|
||||
generator: function (block) {
|
||||
const sensorId = block.getFieldValue('SENSOR_ID');
|
||||
// executeAction returns { success, message } — use .message to extract the value
|
||||
const code = "(await executeAction('read_distance', { sensor_id: '" + sensorId + "' })).message";
|
||||
return [code, javascript.Order.AWAIT];
|
||||
// ↑ expression ↑ operator precedence (Order.AWAIT for async expressions)
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Python handler:
|
||||
```python
|
||||
@handler("read_distance")
|
||||
def handle_read_distance(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
sensor_id = params["sensor_id"]
|
||||
distance = hardware.read_distance(sensor_id) # returns float in cm
|
||||
return (True, str(distance)) # message becomes the expression value in JS
|
||||
```
|
||||
|
||||
> The JS expression `.message` retrieves the string from the ROS2 result. If the value needs to be numeric, wrap it: `parseFloat((await executeAction(…)).message)`.
|
||||
|
||||
---
|
||||
|
||||
### 7.13 Template C — Block with Value Input Sockets (accepts other blocks)
|
||||
|
||||
Value input sockets let output blocks plug into a statement block as dynamic parameters.
|
||||
|
||||
```js
|
||||
// src/blockly_app/blockly_app/ui/blockly/blocks/move_to.js
|
||||
|
||||
BlockRegistry.register({
|
||||
name: 'move_to',
|
||||
category: 'Navigation',
|
||||
categoryColor: '#5ba55b',
|
||||
color: '#00BCD4',
|
||||
tooltip: 'Move robot to target X and Y coordinates',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
// appendValueInput creates a socket where output blocks plug in
|
||||
this.appendValueInput('X')
|
||||
.setCheck('Number') // only accept Number-type output blocks
|
||||
.appendField('Move to X');
|
||||
this.appendValueInput('Y')
|
||||
.setCheck('Number')
|
||||
.appendField('Y');
|
||||
this.setInputsInline(true); // place inputs side-by-side instead of stacked
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#00BCD4');
|
||||
this.setTooltip('Move robot to target X and Y coordinates');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
// valueToCode generates code for the plugged-in block; falls back to '0' if empty
|
||||
const x = javascript.javascriptGenerator.valueToCode(block, 'X', javascript.Order.ATOMIC) || '0';
|
||||
const y = javascript.javascriptGenerator.valueToCode(block, 'Y', javascript.Order.ATOMIC) || '0';
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"await executeAction('move_to', { x: String(" + x + "), y: String(" + y + ") });\n"
|
||||
// ↑ wrap in String() — params must be strings
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Python handler:
|
||||
```python
|
||||
@handler("move_to")
|
||||
def handle_move_to(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
x = float(params["x"])
|
||||
y = float(params["y"])
|
||||
hardware.move_to(x, y)
|
||||
return (True, f"Moved to ({x}, {y})")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.14 Template D — Container Block (statement input socket)
|
||||
|
||||
Container blocks hold a **stack of other blocks** inside them, like a loop or conditional. They use `appendStatementInput()`.
|
||||
|
||||
```js
|
||||
BlockRegistry.register({
|
||||
name: 'repeat_n_times',
|
||||
category: 'Control',
|
||||
categoryColor: '#5ba55b',
|
||||
color: '#FF5722',
|
||||
tooltip: 'Repeat the inner blocks N times',
|
||||
|
||||
definition: {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('Repeat')
|
||||
.appendField(new Blockly.FieldNumber(3, 1, 100, 1), 'TIMES')
|
||||
.appendField('times');
|
||||
this.appendStatementInput('DO') // ← creates an indented slot for block stacks
|
||||
.appendField('do');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour('#FF5722');
|
||||
this.setTooltip('Repeat the inner blocks N times');
|
||||
},
|
||||
},
|
||||
|
||||
generator: function (block) {
|
||||
const times = block.getFieldValue('TIMES');
|
||||
// statementToCode generates code for all blocks stacked inside DO
|
||||
const inner = javascript.javascriptGenerator.statementToCode(block, 'DO');
|
||||
return (
|
||||
"highlightBlock('" + block.id + "');\n" +
|
||||
"for (let _i = 0; _i < " + times + "; _i++) {\n" +
|
||||
inner +
|
||||
"}\n"
|
||||
);
|
||||
// Note: no executeAction needed — this block is pure JS control flow
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> Container blocks that implement pure JS control flow (loops, if/else) do **not** need a Python handler. Only blocks that call `executeAction()` need one.
|
||||
|
||||
---
|
||||
|
||||
### 7.15 `executeAction()` and `highlightBlock()` Reference
|
||||
|
||||
Both functions are provided by [bridge.js](blockly_app/ui/blockly/core/bridge.js) and injected into the eval context by [debug-engine.js](blockly_app/ui/blockly/core/debug-engine.js).
|
||||
|
||||
#### `await executeAction(command, params)`
|
||||
|
||||
```js
|
||||
// Sends a ROS2 action goal and waits for the result.
|
||||
// Returns: { success: bool, message: string }
|
||||
const result = await executeAction('my_command', {
|
||||
key1: 'value1', // all values must be strings
|
||||
key2: String(someNumber),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.message);
|
||||
}
|
||||
```
|
||||
|
||||
- **Always `await`** — omitting `await` means the next block runs immediately before the hardware finishes.
|
||||
- **All param values must be strings** — the ROS2 action interface uses `string[]` arrays. Use `String(n)` or template literals for numbers.
|
||||
- Returns after the Python handler calls `goal_handle.succeed()` (which is always called, even for logical failures).
|
||||
|
||||
#### `highlightBlock(blockId)`
|
||||
|
||||
```js
|
||||
highlightBlock(block.id);
|
||||
```
|
||||
|
||||
Visually highlights the currently executing block in the workspace. Call it **first** in every statement generator so the user can see which block is running. For output blocks it is optional (they have no visual position in the stack).
|
||||
|
||||
---
|
||||
|
||||
### 7.16 Quick Reference: Blockly Field Types
|
||||
|
||||
| Field | Constructor | Returns | Use Case |
|
||||
|-------|-------------|---------|----------|
|
||||
| **Number** | `new Blockly.FieldNumber(default, min, max, step)` | number string | Pin, duration, speed, PWM |
|
||||
| **Text** | `new Blockly.FieldTextInput('default')` | string | Topic name, label |
|
||||
| **Dropdown** | `new Blockly.FieldDropdown([['Label','value'], …])` | value string | Direction, sensor ID, mode |
|
||||
| **Checkbox** | `new Blockly.FieldCheckbox('TRUE')` | `'TRUE'` / `'FALSE'` | On/off toggle, enable flag |
|
||||
| **Colour** | `new Blockly.FieldColour('#ff0000')` | hex string | LED RGB color |
|
||||
| **Angle** | `new Blockly.FieldAngle(90)` | angle string | Rotation, steering |
|
||||
| **Image** | `new Blockly.FieldImage('url', w, h)` | _(no value)_ | Icon decoration on block |
|
||||
|
||||
All field values retrieved via `block.getFieldValue('FIELD_NAME')` are **strings**. Cast with `parseInt()`, `parseFloat()`, or `Number()` in JS, or `int()` / `float()` in Python, as needed.
|
||||
|
||||
---
|
||||
|
||||
### 7.17 Quick Reference: Input and Connection Types
|
||||
|
||||
| Method | What it creates | When to use |
|
||||
|--------|----------------|-------------|
|
||||
| `setPreviousStatement(true, null)` | Top notch | Block can connect below another block |
|
||||
| `setNextStatement(true, null)` | Bottom notch | Block can connect above another block |
|
||||
| `setOutput(true, 'Type')` | Left plug | Block returns a value (output block) — mutually exclusive with previous/next |
|
||||
| `appendDummyInput()` | Horizontal row | Inline fields only, no sockets |
|
||||
| `appendValueInput('NAME')` | Input socket (right side) | Accept an output block plug |
|
||||
| `appendStatementInput('NAME')` | Statement socket (indented slot) | Accept a block stack (loop body, etc.) |
|
||||
| `setInputsInline(true)` | Collapse multi-input rows into one line | When `appendValueInput` rows should be side-by-side |
|
||||
| `.setCheck('Type')` | Type constraint on socket | Restrict which output blocks can plug in (`'Number'`, `'String'`, `'Boolean'`) |
|
||||
|
||||
---
|
||||
|
||||
### 7.18 Naming Conventions
|
||||
|
||||
| Item | Convention | Example |
|
||||
|------|-----------|---------|
|
||||
| Block `name` / `@handler` string | `snake_case` | `led_on`, `read_distance`, `move_to` |
|
||||
| JS file | `<name>.js` or `camelCase.js` | `led_on.js`, `digitalOut.js` |
|
||||
| Python handler file | `<topic>.py` (group related handlers) | `gpio.py`, `timing.py`, `motors.py` |
|
||||
| Field key in `getFieldValue` | `UPPER_SNAKE` | `'PIN'`, `'DURATION_MS'`, `'SENSOR_ID'` |
|
||||
| param key in `executeAction` | `snake_case` | `{ pin: '3' }`, `{ duration_ms: '500' }` |
|
||||
| param key in Python `params["…"]` | `snake_case` | `params["duration_ms"]` |
|
||||
|
||||
---
|
||||
|
||||
### 7.19 Step-by-Step Checklist — Adding a New Block
|
||||
|
||||
```
|
||||
1. Create src/blockly_app/…/ui/blockly/blocks/<name>.js
|
||||
└─ Call BlockRegistry.register({ name, category, color, definition, generator })
|
||||
└─ name must exactly match the Python @handler string
|
||||
|
||||
2. Edit src/blockly_app/…/ui/blockly/blocks/manifest.js
|
||||
└─ Add '<name>.js' to the BLOCK_FILES array
|
||||
|
||||
3. Create src/blockly_executor/…/handlers/<name>.py (or add to existing file)
|
||||
└─ from . import handler
|
||||
└─ @handler("name")
|
||||
└─ def handle_name(params, hardware): ... → return (bool, str)
|
||||
|
||||
4. Test pixi run executor (Terminal 1)
|
||||
pixi run app (Terminal 2) — drag block, click Run
|
||||
pixi run test -- src/blockly_executor/test/test_block_<name>.py -v
|
||||
```
|
||||
|
||||
No changes needed to `index.html`, `executor_node.py`, `handlers/__init__.py`, or `BlocklyAction.action`.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
# blockly_app — File Reference
|
||||
|
||||
### 6.1 Application Layer — `blockly_app`
|
||||
|
||||
#### [`blockly_app/app.py`](blockly_app/app.py) — Application Entry Point
|
||||
|
||||
**Purpose:** Combines pywebview (desktop UI) with a ROS2 Action Client. This is the bridge between the JavaScript Blockly runtime and the ROS2 ecosystem.
|
||||
|
||||
**Key components:**
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| [`_native_save_dialog()` / `_native_open_dialog()`](blockly_app/app.py:29) | Opens native OS file dialogs via `tkinter.filedialog`. Uses Tcl/Tk interpreter separate from Qt — no thread conflict. Available in pixi environment without extra dependencies. |
|
||||
| [`_wait_for_future(future, timeout_sec)`](blockly_app/app.py:63) | Polls `future.done()` without calling `rclpy.spin()`. Used because the node is already being spun by a background thread. |
|
||||
| [`BlocklyAPI`](blockly_app/app.py:80) | Python class exposed to JavaScript via pywebview. Its methods are callable as `window.pywebview.api.<method>()`. |
|
||||
| [`BlocklyAPI.execute_action()`](blockly_app/app.py:95) | Sends a ROS2 Action Goal and blocks until the result arrives. Returns `{success: bool, message: str}` to JavaScript. |
|
||||
| [`BlocklyAPI.save_workspace(json_string)`](blockly_app/app.py:145) | Opens native "Save As" dialog via tkinter, writes workspace JSON to chosen file. Returns `{success, path}` directly to JS. |
|
||||
| [`BlocklyAPI.load_workspace()`](blockly_app/app.py:168) | Opens native "Open" dialog via tkinter, reads and validates JSON, returns `{success, data, path}` to JS. |
|
||||
| [`main()`](blockly_app/app.py:190) | Initializes rclpy, creates the Action Client, starts the background spin thread with `MultiThreadedExecutor`, creates the pywebview window, and handles cleanup on exit. |
|
||||
|
||||
**Threading design:** The `main()` function starts `MultiThreadedExecutor.spin()` in a daemon thread. When JavaScript calls `execute_action()`, the method uses `_wait_for_future()` to poll for completion — it never calls `rclpy.spin_until_future_complete()`, which would conflict with the background spin.
|
||||
|
||||
#### [`blockly_app/ui/index.html`](blockly_app/ui/index.html) — Main UI
|
||||
|
||||
**Purpose:** The single HTML page that hosts the Blockly workspace, toolbar buttons, and console panel.
|
||||
|
||||
**Structure:**
|
||||
- **Toolbar**: Run, Step Over, Step Into, Stop buttons, and Debug Mode toggle
|
||||
- **Blockly Workspace**: The drag-and-drop canvas
|
||||
- **Console Panel**: Scrollable log output showing execution progress
|
||||
- **Script loading**: Loads Blockly vendor files, then core infrastructure, then blocks via auto-loader
|
||||
|
||||
**Script loading order** (fixed — never needs changing when adding blocks):
|
||||
1. `vendor/blockly.min.js` + other Blockly libs
|
||||
2. `blockly/core/registry.js` → `breakpoints.js` → `bridge.js` → `debug-engine.js` → `ui-controls.js` → `ui-tabs.js` → `workspace-io.js`
|
||||
3. `blockly/blocks/manifest.js` + `blockly/loader.js`
|
||||
4. `blockly/workspace-init.js`
|
||||
5. Inline script: `loadAllBlocks().then(() => initWorkspace())`
|
||||
|
||||
#### [`blockly_app/ui/blockly/core/registry.js`](blockly_app/ui/blockly/core/registry.js) — Block Registry
|
||||
|
||||
**Purpose:** Central registration system for custom blocks. Each block file calls `BlockRegistry.register()` to self-register its visual definition, code generator, and toolbox metadata.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `BlockRegistry.register(config)` | Register a block with name, category, color, definition, and generator |
|
||||
| `BlockRegistry.getBlocks()` | Get all registered blocks |
|
||||
| `BlockRegistry.getToolboxJSON()` | Build Blockly toolbox JSON from registered blocks + built-in categories |
|
||||
|
||||
#### [`blockly_app/ui/blockly/core/ui-tabs.js`](blockly_app/ui/blockly/core/ui-tabs.js) — Tab Switching
|
||||
|
||||
**Purpose:** Manages switching between the **Blocks** editor tab and the **Code** preview tab.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `switchTab(tab)` | Shows/hides `#blockly-area` and `#code-panel`. Calls `Blockly.svgResize()` when returning to Blocks tab (canvas dimensions are zeroed while hidden). |
|
||||
| `refreshCodePanel()` | Regenerates JS code from workspace via `javascript.javascriptGenerator.workspaceToCode()` and displays in `#code-output`. |
|
||||
|
||||
The Code tab updates automatically on every workspace change (via a change listener in `workspace-init.js`).
|
||||
|
||||
#### [`blockly_app/ui/blockly/core/workspace-io.js`](blockly_app/ui/blockly/core/workspace-io.js) — Workspace Export/Import
|
||||
|
||||
**Purpose:** Exports workspace to JSON (Save As dialog) and imports from JSON (Open dialog) via the Python bridge.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `exportWorkspace()` | Serializes workspace with `Blockly.serialization.workspaces.save()`, calls `window.pywebview.api.save_workspace(json)`, logs result path. |
|
||||
| `importWorkspace()` | Calls `window.pywebview.api.load_workspace()`, clears workspace, loads returned JSON with `Blockly.serialization.workspaces.load()`. |
|
||||
|
||||
Both functions use `async/await` — they return after the file dialog closes and the file has been read/written.
|
||||
|
||||
#### [`blockly_app/ui/blockly/core/bridge.js`](blockly_app/ui/blockly/core/bridge.js) — pywebview Bridge
|
||||
|
||||
**Purpose:** Provides `executeAction(command, params)` which calls Python via the pywebview JS-to-Python bridge. Falls back to a mock when running outside pywebview (browser dev).
|
||||
|
||||
#### [`blockly_app/ui/blockly/core/debug-engine.js`](blockly_app/ui/blockly/core/debug-engine.js) — Debug Engine
|
||||
|
||||
**Purpose:** Implements Run, Debug, Step Over, Step Into, and Stop functionality.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `runProgram()` | Non-debug execution: wraps generated code in async function and eval()s it |
|
||||
| `runDebug()` | Debug execution: wraps executeAction to check breakpoints and add delays |
|
||||
| `stepOver()` | Resumes from pause, executes current block, pauses at next block |
|
||||
| `stepInto()` | Resumes from pause, pauses at very next highlightBlock call |
|
||||
| `stopExecution()` | Sets stopRequested flag, resolves any pending pause Promise |
|
||||
| `highlightBlock(blockId)` | Highlights the currently executing block in the workspace |
|
||||
|
||||
#### [`blockly_app/ui/blockly/blocks/manifest.js`](blockly_app/ui/blockly/blocks/manifest.js) — Block Manifest
|
||||
|
||||
**Purpose:** Lists all block files to auto-load. This is the **only file you edit** when adding a new block (besides creating the block file itself).
|
||||
|
||||
```js
|
||||
const BLOCK_FILES = ['led_on.js', 'led_off.js', 'delay.js'];
|
||||
```
|
||||
|
||||
#### [`blockly_app/ui/blockly/blocks/led_on.js`](blockly_app/ui/blockly/blocks/led_on.js) — Example Block
|
||||
|
||||
**Purpose:** Self-contained block definition. Contains both the visual appearance AND the code generator.
|
||||
|
||||
Each block file calls `BlockRegistry.register()` with all metadata, so the toolbox is automatically generated.
|
||||
|
|
@ -11,7 +11,7 @@ BlockRegistry.register({
|
|||
this.appendValueInput('digitalOut')
|
||||
.appendField(' gpio:')
|
||||
// FieldNumber(default, min, max, step)
|
||||
.appendField(new Blockly.FieldNumbint(er(1, 0, 27, 1), 'GPIO')
|
||||
.appendField(new Blockly.FieldNumber(1, 0, 27, 1), 'GPIO')
|
||||
.setCheck('Boolean')
|
||||
.appendField(' state:');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
# blockly_executor — File Reference & Testing Guide
|
||||
|
||||
### 6.2 Executor Layer — `blockly_executor`
|
||||
|
||||
#### [`blockly_executor/executor_node.py`](blockly_executor/executor_node.py) — ROS2 Action Server
|
||||
|
||||
**Purpose:** Thin ROS2 wrapper that receives `BlocklyAction` goals, delegates to `HandlerRegistry`, and returns results.
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| [`ExecutorNode.__init__()`](blockly_executor/executor_node.py:27) | Creates the Action Server on topic `execute_blockly_action`. Reads ROS2 parameter `use_real_hardware` (bool, default `False`) to select `DummyHardware` or `RealHardware`. |
|
||||
| [`_goal_callback()`](blockly_executor/executor_node.py:47) | Always returns `GoalResponse.ACCEPT` |
|
||||
| [`_execute_callback(goal_handle)`](blockly_executor/executor_node.py:57) | Publishes "executing" feedback, calls `HandlerRegistry.execute()`, catches exceptions, always calls `goal_handle.succeed()` |
|
||||
| [`main()`](blockly_executor/executor_node.py:117) | Entry point: `rclpy.init()` → `ExecutorNode()` → `rclpy.spin(node)` |
|
||||
|
||||
**Important design decision:** The execute callback always calls `goal_handle.succeed()` regardless of whether the command succeeded or failed. The `result.success` and `result.message` fields communicate command-level outcome. Using `goal_handle.abort()` causes result delivery failures with `rmw_fastrtps_cpp`.
|
||||
|
||||
#### [`blockly_executor/handlers/`](blockly_executor/handlers/__init__.py) — Decorator-Based Command Handlers
|
||||
|
||||
**Purpose:** Maps command names to handler functions using `@handler` decorator and auto-discovery. Mirrors the JS frontend's `BlockRegistry.register()` pattern.
|
||||
|
||||
```
|
||||
handlers/
|
||||
├── __init__.py # @handler decorator, auto-discovery, HandlerRegistry
|
||||
├── gpio.py # @handler("led_on"), @handler("led_off")
|
||||
└── timing.py # @handler("delay")
|
||||
```
|
||||
|
||||
**`@handler` decorator** — each handler is a plain function:
|
||||
|
||||
```python
|
||||
from . import handler
|
||||
|
||||
@handler("led_on")
|
||||
def handle_led_on(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||
pin = int(params["pin"])
|
||||
hardware.set_led(pin, True)
|
||||
return (True, f"LED on pin {pin} turned ON")
|
||||
```
|
||||
|
||||
**Auto-discovery:** On `HandlerRegistry.__init__`, all `.py` files in `handlers/` are imported automatically. The `@handler` decorator collects `(command, function)` pairs, and the registry binds `hardware` to each function. No manual imports or module lists needed.
|
||||
|
||||
**HandlerRegistry** ([`handlers/__init__.py`](blockly_executor/handlers/__init__.py)):
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `HandlerRegistry.__init__(hardware)` | Auto-discovers handler modules, binds hardware to all `@handler` functions |
|
||||
| `execute(command, params)` | Looks up handler by name, returns `(False, "Unknown command: ...")` if not found |
|
||||
|
||||
**Adding a new handler:** Create `handlers/<name>.py`, use `@handler("command")`. That's it — no other files to edit.
|
||||
|
||||
#### [`blockly_executor/utils.py`](blockly_executor/utils.py) — Utility Functions
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| [`parse_params(keys, values)`](blockly_executor/utils.py:4) | Converts two parallel arrays into a `dict`. Raises `ValueError` if lengths differ. |
|
||||
|
||||
#### [`blockly_executor/hardware/interface.py`](blockly_executor/hardware/interface.py) — Hardware Abstract Class
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| [`set_led(pin, state)`](blockly_executor/hardware/interface.py:16) | Abstract. Set LED on/off at given GPIO pin. |
|
||||
| [`is_ready()`](blockly_executor/hardware/interface.py:27) | Abstract. Check if hardware is initialized. |
|
||||
|
||||
#### [`blockly_executor/hardware/dummy_hardware.py`](blockly_executor/hardware/dummy_hardware.py) — Test/Dev Hardware
|
||||
|
||||
In-memory implementation for development and testing. No ROS2 communication, no real hardware.
|
||||
|
||||
| Attribute/Method | Description |
|
||||
|---|---|
|
||||
| `led_states: dict[int, bool]` | In-memory LED state storage |
|
||||
| `call_log: list[str]` | Log of all method calls for test inspection |
|
||||
|
||||
#### [`blockly_executor/hardware/real_hardware.py`](blockly_executor/hardware/real_hardware.py) — Real Hardware via ROS2
|
||||
|
||||
Communicates with hardware nodes running on the Raspberry Pi via ROS2 topics/services. Requires a `Node` reference to create publishers and service clients. The executor does NOT run on the Pi — it sends commands over the ROS2 network.
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `node: Node` | ROS2 node used to create publishers/clients for Pi hardware nodes |
|
||||
|
||||
### 6.3 ROS2 Interfaces — `blockly_interfaces`
|
||||
|
||||
#### [`blockly_interfaces/action/BlocklyAction.action`](../blockly_interfaces/action/BlocklyAction.action)
|
||||
|
||||
The single ROS2 action interface used for all commands. See [Section 2.3](../../docs/architecture.md#23-ros2-interface-contract) for the full definition.
|
||||
|
||||
Built by `pixi run build-interfaces` using colcon. The generated Python module is importable as:
|
||||
|
||||
```python
|
||||
from blockly_interfaces.action import BlocklyAction
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Test Suite
|
||||
|
||||
Tests are located at [`src/blockly_executor/test/`](test/conftest.py).
|
||||
|
||||
#### [`test/conftest.py`](test/conftest.py) — Shared Test Fixtures
|
||||
|
||||
See [Section 9.2](#92-conftestpy--shared-fixtures) for detailed explanation.
|
||||
|
||||
#### [`test/test_block_led_on.py`](test/test_block_led_on.py)
|
||||
|
||||
Tests for the `led_on` command: happy path (success with valid pin), feedback verification, missing parameter failure, and error message content.
|
||||
|
||||
#### [`test/test_block_led_off.py`](test/test_block_led_off.py)
|
||||
|
||||
Tests for the `led_off` command with equivalent coverage to `led_on`.
|
||||
|
||||
#### [`test/test_block_delay.py`](test/test_block_delay.py)
|
||||
|
||||
Tests for the `delay` command including timing verification (±100ms tolerance).
|
||||
|
||||
### 6.5 Configuration Files
|
||||
|
||||
#### [`pixi.toml`](../../pixi.toml) — Environment & Task Definitions
|
||||
|
||||
| Section | Purpose |
|
||||
|---|---|
|
||||
| `[workspace]` | Project name, version, channels (`conda-forge`, `robostack-jazzy`), platforms |
|
||||
| `[dependencies]` | Shared deps: `python >=3.11`, `ros-jazzy-base`, `ros-jazzy-rclpy`, `pytest`, `colcon-common-extensions` |
|
||||
| `[target.linux-64.dependencies]` | Desktop-only: `nodejs`, `pyqtwebengine`, `qtpy` |
|
||||
| `[target.linux-64.pypi-dependencies]` | `pywebview` (PyPI only, not on conda-forge) |
|
||||
| `[tasks]` | Shortcut commands — see below |
|
||||
|
||||
**Task definitions:**
|
||||
|
||||
| Task | Command | Depends On |
|
||||
|---|---|---|
|
||||
| `build-interfaces` | `colcon build --symlink-install --packages-select blockly_interfaces` | — |
|
||||
| `build-executor` | `colcon build --symlink-install --packages-select blockly_executor` | `build-interfaces` |
|
||||
| `build-app` | `colcon build --symlink-install --packages-select blockly_app` | `build-interfaces` |
|
||||
| `build` | `colcon build --symlink-install` | `build-interfaces` |
|
||||
| `executor` | `source install/setup.bash && ros2 run blockly_executor executor_node` | `build-executor` |
|
||||
| `executor-hw` | `... executor_node --ros-args -p use_real_hardware:=true` | `build-executor` |
|
||||
| `app` | `source install/setup.bash && python -m blockly_app.app` | `build-app` |
|
||||
| `test` | `source install/setup.bash && pytest src/blockly_executor/test/ -v` | `build-interfaces` |
|
||||
| `setup-ui` | Downloads Blockly via npm and copies to `src/blockly_app/blockly_app/ui/vendor/` | — |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
### 9.1 Testing Philosophy
|
||||
|
||||
All tests are **integration tests** that communicate through the real ROS2 Action interface — not unit tests that call internal functions directly. This provides high confidence because the test exercises the exact same communication path as the real application.
|
||||
|
||||
**Key architectural decisions:**
|
||||
|
||||
- **Executor runs as a separate process** — eliminates race conditions from two threads competing for rclpy's global context
|
||||
- **`DummyHardware`** isolates physical hardware — tests run on any laptop without a Raspberry Pi
|
||||
- **Real ROS2 nodes** are used during tests — ROS2 code is verified, not just Python logic
|
||||
- **One file per block** — all scenarios for `led_on` live in `test_block_led_on.py`
|
||||
|
||||
### 9.2 `conftest.py` — Shared Fixtures
|
||||
|
||||
[`test/conftest.py`](test/conftest.py) provides two fixtures:
|
||||
|
||||
#### `ros_context` (session-scoped)
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
def ros_context():
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
```
|
||||
|
||||
Initializes ROS2 exactly once for the entire test session.
|
||||
|
||||
#### `exe_action` (function-scoped)
|
||||
|
||||
Each test gets a clean `Node("test_action_client")` to prevent state leakage. The fixture:
|
||||
|
||||
1. Creates an `ActionClient` on topic `execute_blockly_action`
|
||||
2. Waits 5 seconds for the server — **skips** (not fails) if not found
|
||||
3. Returns a `_send()` function that builds a Goal, sends it, collects feedback, and returns the result
|
||||
4. Destroys the node after the test
|
||||
|
||||
### 9.3 Test File Structure
|
||||
|
||||
Every test file follows this pattern:
|
||||
|
||||
```python
|
||||
# src/blockly_executor/test/test_block_<name>.py
|
||||
"""Integration test for Blockly instruction: <name>"""
|
||||
|
||||
# -- HAPPY PATH --
|
||||
def test_block_<name>_returns_success(exe_action):
|
||||
result = exe_action("<name>", param="value")
|
||||
assert result.result.success is True
|
||||
|
||||
def test_block_<name>_sends_executing_feedback(exe_action):
|
||||
result = exe_action("<name>", param="value")
|
||||
assert len(result.feedbacks) > 0
|
||||
assert result.feedbacks[0].status == "executing"
|
||||
|
||||
# -- SAD PATH --
|
||||
def test_block_<name>_missing_<param>_returns_failure(exe_action):
|
||||
result = exe_action("<name>") # intentionally missing param
|
||||
assert result.result.success is False
|
||||
```
|
||||
|
||||
### 9.4 Adding a New Test File
|
||||
|
||||
1. Create `src/blockly_executor/test/test_block_<name>.py`
|
||||
2. Write test functions using `exe_action` fixture
|
||||
3. No changes needed to `conftest.py` or any other test file
|
||||
4. Run: `pixi run test`
|
||||
|
||||
### 9.5 Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pixi run test
|
||||
|
||||
# Single file
|
||||
pixi run test -- src/blockly_executor/test/test_block_led_on.py -v
|
||||
|
||||
# Single test
|
||||
pixi run test -- src/blockly_executor/test/test_block_led_on.py::test_block_led_on_returns_success -v
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# blockly_interfaces — ROS2 Action Interface
|
||||
|
||||
Provides the single custom ROS2 action definition used for all Blockly commands.
|
||||
|
||||
## BlocklyAction.action
|
||||
|
||||
Defined in [`action/BlocklyAction.action`](action/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.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
pixi run build-interfaces
|
||||
```
|
||||
|
||||
This must be run before building any other package. The generated Python module is then importable as:
|
||||
|
||||
```python
|
||||
from blockly_interfaces.action import BlocklyAction
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
See [`src/blockly_executor/README.md`](../blockly_executor/README.md) for how the executor uses this interface, and [`src/blockly_app/BLOCKS.md`](../blockly_app/BLOCKS.md#79-data-flow-js-block--python-handler--hardware) for the full data flow from JS block to hardware.
|
||||
Loading…
Reference in New Issue