feat: add support for real hardware in blockly_executor
- Introduced RealHardware class for communication with hardware nodes via ROS2. - Updated ExecutorNode to select between RealHardware and DummyHardware based on a parameter. - Refactored command handlers to use a decorator for registration, simplifying the handler registration process. - Removed the base HandlerModule class and integrated handler registration directly in the handler functions. - Updated GPIO and timing command handlers to use the new registration method. - Added new executor command for running with real hardware.master
parent
a998ff13b4
commit
100cad47f0
|
|
@ -31,3 +31,7 @@ Thumbs.db
|
||||||
|
|
||||||
# Ruff cache
|
# Ruff cache
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Local project management & AI assistant notes (not for version control)
|
||||||
|
PROJECT_MANAGEMENT.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
|
||||||
342
DOCUMENTATION.md
342
DOCUMENTATION.md
|
|
@ -195,17 +195,17 @@ amr-ros-k4/ # ROS2 Workspace root
|
||||||
│ ├── blockly_executor/ # Python module
|
│ ├── blockly_executor/ # Python module
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── executor_node.py # ROS2 Action Server (thin wrapper)
|
│ │ ├── executor_node.py # ROS2 Action Server (thin wrapper)
|
||||||
│ │ ├── handlers/ # Modular command handler system
|
│ │ ├── handlers/ # @handler decorator + auto-discovery
|
||||||
│ │ │ ├── __init__.py # HandlerRegistry + auto-discovery
|
│ │ │ ├── __init__.py # HandlerRegistry, @handler, auto-discover
|
||||||
│ │ │ ├── base.py # HandlerModule ABC — contract
|
│ │ │ ├── gpio.py # @handler("led_on"), @handler("led_off")
|
||||||
│ │ │ ├── gpio.py # GpioHandlers: led_on, led_off
|
│ │ │ └── timing.py # @handler("delay")
|
||||||
│ │ │ └── timing.py # TimingHandlers: delay
|
|
||||||
│ │ ├── utils.py # parse_params and helpers
|
│ │ ├── utils.py # parse_params and helpers
|
||||||
│ │ └── hardware/
|
│ │ └── hardware/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── interface.py # HardwareInterface abstract class
|
│ │ ├── interface.py # HardwareInterface abstract class
|
||||||
│ │ ├── dummy_hardware.py # In-memory impl for dev & test
|
│ │ ├── dummy_hardware.py # In-memory impl for dev & test
|
||||||
│ │ └── gpio_hardware.py # Raspberry Pi GPIO impl
|
│ │ ├── real_hardware.py # ROS2 topics/services to Pi nodes
|
||||||
|
│ │ └── gpio_hardware.py # Direct RPi.GPIO (legacy)
|
||||||
│ └── test/ # Integration test suite
|
│ └── test/ # Integration test suite
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── conftest.py # Shared fixtures: ros_context, exe_action
|
│ ├── conftest.py # Shared fixtures: ros_context, exe_action
|
||||||
|
|
@ -309,12 +309,18 @@ The app window opens with the Blockly workspace. Drag blocks from the toolbox, c
|
||||||
|
|
||||||
### 5.2 Running the Executor Node Standalone
|
### 5.2 Running the Executor Node Standalone
|
||||||
|
|
||||||
Useful for testing with `ros2 action send_goal` or custom clients:
|
The executor has two hardware modes controlled by the ROS2 parameter `use_real_hardware`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Dummy mode (default) — in-memory hardware, no real GPIO/motor access
|
||||||
pixi run executor
|
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.
|
The executor logs all received goals and their results to the terminal.
|
||||||
|
|
||||||
### 5.3 Running the Test Suite
|
### 5.3 Running the Test Suite
|
||||||
|
|
@ -424,57 +430,46 @@ Each block file calls `BlockRegistry.register()` with all metadata, so the toolb
|
||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`ExecutorNode.__init__(hardware)`](src/blockly_executor/blockly_executor/executor_node.py:27) | Creates the Action Server on topic `execute_blockly_action`. Accepts optional `HardwareInterface`; defaults to `DummyHardware`. |
|
| [`ExecutorNode.__init__()`](src/blockly_executor/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()`](src/blockly_executor/blockly_executor/executor_node.py:47) | Always returns `GoalResponse.ACCEPT` |
|
| [`_goal_callback()`](src/blockly_executor/blockly_executor/executor_node.py:47) | Always returns `GoalResponse.ACCEPT` |
|
||||||
| [`_execute_callback(goal_handle)`](src/blockly_executor/blockly_executor/executor_node.py:57) | Publishes "executing" feedback, calls `HandlerRegistry.execute()`, catches exceptions, always calls `goal_handle.succeed()` |
|
| [`_execute_callback(goal_handle)`](src/blockly_executor/blockly_executor/executor_node.py:57) | Publishes "executing" feedback, calls `HandlerRegistry.execute()`, catches exceptions, always calls `goal_handle.succeed()` |
|
||||||
| [`main()`](src/blockly_executor/blockly_executor/executor_node.py:117) | Entry point: `rclpy.init()` → `ExecutorNode()` → `rclpy.spin(node)` |
|
| [`main()`](src/blockly_executor/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`.
|
**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/`](src/blockly_executor/blockly_executor/handlers/__init__.py) — Modular Command Handler System
|
#### [`blockly_executor/handlers/`](src/blockly_executor/blockly_executor/handlers/__init__.py) — Decorator-Based Command Handlers
|
||||||
|
|
||||||
**Purpose:** Maps command names to handler functions using a modular, auto-discovery pattern. Each handler category lives in its own module.
|
**Purpose:** Maps command names to handler functions using `@handler` decorator and auto-discovery. Mirrors the JS frontend's `BlockRegistry.register()` pattern.
|
||||||
|
|
||||||
**Architecture** mirrors the JS frontend's per-block file structure:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
handlers/
|
handlers/
|
||||||
├── __init__.py # HandlerRegistry + auto-imports HANDLER_MODULES
|
├── __init__.py # @handler decorator, auto-discovery, HandlerRegistry
|
||||||
├── base.py # HandlerModule ABC — contract for all handler modules
|
├── gpio.py # @handler("led_on"), @handler("led_off")
|
||||||
├── gpio.py # GpioHandlers: led_on, led_off
|
└── timing.py # @handler("delay")
|
||||||
└── timing.py # TimingHandlers: 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`](src/blockly_executor/blockly_executor/handlers/__init__.py)):
|
**HandlerRegistry** ([`handlers/__init__.py`](src/blockly_executor/blockly_executor/handlers/__init__.py)):
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `HandlerRegistry.__init__(hardware)` | Auto-registers all `HANDLER_MODULES` with the hardware interface |
|
| `HandlerRegistry.__init__(hardware)` | Auto-discovers handler modules, binds hardware to all `@handler` functions |
|
||||||
| `register(command, handler)` | Adds a new command→handler mapping |
|
|
||||||
| `execute(command, params)` | Looks up handler by name, returns `(False, "Unknown command: ...")` if not found |
|
| `execute(command, params)` | Looks up handler by name, returns `(False, "Unknown command: ...")` if not found |
|
||||||
|
|
||||||
**HandlerModule** ([`handlers/base.py`](src/blockly_executor/blockly_executor/handlers/base.py)) — the contract:
|
**Adding a new handler:** Create `handlers/<name>.py`, use `@handler("command")`. That's it — no other files to edit.
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|---|---|
|
|
||||||
| `register(registry, hardware)` | Abstract. Must register all commands this module provides. |
|
|
||||||
|
|
||||||
**GpioHandlers** ([`handlers/gpio.py`](src/blockly_executor/blockly_executor/handlers/gpio.py)):
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `led_on` | Reads `params["pin"]`, calls `hardware.set_led(pin, True)` |
|
|
||||||
| `led_off` | Reads `params["pin"]`, calls `hardware.set_led(pin, False)` |
|
|
||||||
|
|
||||||
**TimingHandlers** ([`handlers/timing.py`](src/blockly_executor/blockly_executor/handlers/timing.py)):
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `delay` | Reads `params["duration_ms"]`, calls `time.sleep()` |
|
|
||||||
|
|
||||||
**Adding a new handler category:**
|
|
||||||
1. Create `handlers/<category>.py` with a class extending `HandlerModule`
|
|
||||||
2. Import and add to `HANDLER_MODULES` list in `handlers/__init__.py`
|
|
||||||
|
|
||||||
#### [`blockly_executor/utils.py`](src/blockly_executor/blockly_executor/utils.py) — Utility Functions
|
#### [`blockly_executor/utils.py`](src/blockly_executor/blockly_executor/utils.py) — Utility Functions
|
||||||
|
|
||||||
|
|
@ -491,10 +486,20 @@ handlers/
|
||||||
|
|
||||||
#### [`blockly_executor/hardware/dummy_hardware.py`](src/blockly_executor/blockly_executor/hardware/dummy_hardware.py) — Test/Dev Hardware
|
#### [`blockly_executor/hardware/dummy_hardware.py`](src/blockly_executor/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 |
|
| Attribute/Method | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`led_states: dict[int, bool]`](src/blockly_executor/blockly_executor/hardware/dummy_hardware.py:15) | In-memory LED state storage |
|
| `led_states: dict[int, bool]` | In-memory LED state storage |
|
||||||
| [`call_log: list[str]`](src/blockly_executor/blockly_executor/hardware/dummy_hardware.py:16) | Log of all method calls for test inspection |
|
| `call_log: list[str]` | Log of all method calls for test inspection |
|
||||||
|
|
||||||
|
#### [`blockly_executor/hardware/real_hardware.py`](src/blockly_executor/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`
|
### 6.3 ROS2 Interfaces — `blockly_interfaces`
|
||||||
|
|
||||||
|
|
@ -549,6 +554,7 @@ Tests for the `delay` command including timing verification (±100ms tolerance).
|
||||||
| `build-app` | `colcon build --symlink-install --packages-select blockly_app` | `build-interfaces` |
|
| `build-app` | `colcon build --symlink-install --packages-select blockly_app` | `build-interfaces` |
|
||||||
| `build` | `colcon build --symlink-install` | `build-interfaces` |
|
| `build` | `colcon build --symlink-install` | `build-interfaces` |
|
||||||
| `executor` | `source install/setup.bash && ros2 run blockly_executor executor_node` | `build-executor` |
|
| `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` |
|
| `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` |
|
| `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/` | — |
|
| `setup-ui` | Downloads Blockly via npm and copies to `src/blockly_app/blockly_app/ui/vendor/` | — |
|
||||||
|
|
@ -557,19 +563,19 @@ Tests for the `delay` command including timing verification (±100ms tolerance).
|
||||||
|
|
||||||
## 7. Creating Custom Blocks in Blockly
|
## 7. Creating Custom Blocks in Blockly
|
||||||
|
|
||||||
### 7.1 Overview: 2 Steps with Auto-Discovery
|
### 7.1 Overview: Auto-Discovery on Both Sides
|
||||||
|
|
||||||
The project uses a **BlockRegistry** pattern with auto-discovery. Adding a new block requires only **2 files**:
|
Both JS and Python use the same pattern: **decorator/register + auto-discovery**. Adding a new block:
|
||||||
|
|
||||||
```
|
```
|
||||||
Step File Action
|
Step File Action
|
||||||
──── ──── ──────
|
──── ──── ──────
|
||||||
1. JS src/blockly_app/blockly_app/ui/blockly/blocks/<name>.js Create — block definition + code generator
|
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
|
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.py Edit — add Python handler
|
2. Py src/blockly_executor/blockly_executor/handlers/<name>.py Create — @handler("command") function
|
||||||
```
|
```
|
||||||
|
|
||||||
**Files that do NOT need changes:** [`index.html`](src/blockly_app/blockly_app/ui/index.html), [`conftest.py`](src/blockly_executor/test/conftest.py), [`executor_node.py`](src/blockly_executor/blockly_executor/executor_node.py), [`BlocklyAction.action`](src/blockly_interfaces/action/BlocklyAction.action). The toolbox is auto-generated from the registry.
|
**Files that do NOT need changes:** [`index.html`](src/blockly_app/blockly_app/ui/index.html), [`conftest.py`](src/blockly_executor/test/conftest.py), [`executor_node.py`](src/blockly_executor/blockly_executor/executor_node.py), [`BlocklyAction.action`](src/blockly_interfaces/action/BlocklyAction.action), [`handlers/__init__.py`](src/blockly_executor/blockly_executor/handlers/__init__.py). Both toolbox and handler registry are auto-generated.
|
||||||
|
|
||||||
### 7.2 Step 1 — Create Block File (JS)
|
### 7.2 Step 1 — Create Block File (JS)
|
||||||
|
|
||||||
|
|
@ -624,25 +630,24 @@ const BLOCK_FILES = [
|
||||||
|
|
||||||
### 7.3 Step 2 — Register Handler in Python
|
### 7.3 Step 2 — Register Handler in Python
|
||||||
|
|
||||||
Add the handler to an existing module (e.g., [`handlers/gpio.py`](src/blockly_executor/blockly_executor/handlers/gpio.py)), or create a new handler module under [`handlers/`](src/blockly_executor/blockly_executor/handlers/__init__.py). For a new category, create a file and add it to `HANDLER_MODULES` in `handlers/__init__.py`.
|
Create a new file in `handlers/` or add to an existing one. Use the `@handler` decorator — auto-discovery handles the rest.
|
||||||
|
|
||||||
Example — adding to an existing module or creating a new one:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# In HandlerRegistry._register_default_handlers():
|
# src/blockly_executor/blockly_executor/handlers/movement.py
|
||||||
self.register("move_forward", self._handle_move_forward)
|
from . import handler
|
||||||
|
|
||||||
# New handler method:
|
@handler("move_forward")
|
||||||
def _handle_move_forward(self, params: dict[str, str]) -> tuple[bool, str]:
|
def handle_move_forward(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||||
"""Move robot forward with given speed and duration."""
|
|
||||||
speed = int(params["speed"])
|
speed = int(params["speed"])
|
||||||
duration_ms = int(params["duration_ms"])
|
duration_ms = int(params["duration_ms"])
|
||||||
if not (0 <= speed <= 100):
|
if not (0 <= speed <= 100):
|
||||||
raise ValueError(f"speed must be 0-100, got: {speed}")
|
raise ValueError(f"speed must be 0-100, got: {speed}")
|
||||||
self._hardware.move(direction="forward", speed=speed, duration_ms=duration_ms)
|
hardware.move(direction="forward", speed=speed, duration_ms=duration_ms)
|
||||||
return (True, f"Moved forward at speed {speed} for {duration_ms}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):
|
**Verify immediately** (no Blockly UI needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -891,7 +896,224 @@ pixi run test -- src/blockly_executor/test/test_block_led_on.py::test_block_led_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Troubleshooting & Known Issues
|
## 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>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Blockly Block Types — Templates & Quick Reference
|
||||||
|
|
||||||
|
### Block Type Overview
|
||||||
|
|
||||||
|
| Pattern | Shape | Use Case | Example |
|
||||||
|
|---------|-------|----------|---------|
|
||||||
|
| **Statement block** | Top/bottom connectors | Perform an action | `led_on`, `delay`, `motor_stop` |
|
||||||
|
| **Output block** | Left plug only | Return a sensor value | `read_distance`, `read_temperature` |
|
||||||
|
| **Input block** | Input sockets | Accept values from other blocks | `move_to(x, y)` |
|
||||||
|
|
||||||
|
### Template A — Statement Block (action command)
|
||||||
|
|
||||||
|
```js
|
||||||
|
BlockRegistry.register({
|
||||||
|
name: 'led_on', // Must match Python handler name
|
||||||
|
category: 'Robot',
|
||||||
|
categoryColor: '#5b80a5',
|
||||||
|
color: '#4CAF50',
|
||||||
|
tooltip: 'Turn on LED at the specified GPIO pin',
|
||||||
|
|
||||||
|
definition: {
|
||||||
|
init: function () {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('LED ON pin')
|
||||||
|
// FieldNumber(default, min, max, step)
|
||||||
|
.appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN');
|
||||||
|
|
||||||
|
this.setPreviousStatement(true, null); // connect above
|
||||||
|
this.setNextStatement(true, null); // connect below
|
||||||
|
this.setColour('#4CAF50');
|
||||||
|
this.setTooltip('Turn on LED at the specified GPIO pin');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generator: function (block) {
|
||||||
|
const pin = block.getFieldValue('PIN');
|
||||||
|
return (
|
||||||
|
'highlightBlock(\'' + block.id + '\');\n' +
|
||||||
|
'await executeAction(\'led_on\', { pin: \'' + pin + '\' });\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template B — Output Block (sensor value)
|
||||||
|
|
||||||
|
```js
|
||||||
|
BlockRegistry.register({
|
||||||
|
name: 'read_distance',
|
||||||
|
category: 'Sensors',
|
||||||
|
categoryColor: '#a5745b',
|
||||||
|
color: '#E91E63',
|
||||||
|
tooltip: 'Read distance from ultrasonic sensor in cm',
|
||||||
|
outputType: 'Number',
|
||||||
|
|
||||||
|
definition: {
|
||||||
|
init: function () {
|
||||||
|
this.appendDummyInput()
|
||||||
|
.appendField('Distance sensor')
|
||||||
|
.appendField(new Blockly.FieldDropdown([
|
||||||
|
['Front', 'front'], ['Left', 'left'], ['Right', 'right'],
|
||||||
|
]), 'SENSOR_ID');
|
||||||
|
|
||||||
|
this.setOutput(true, 'Number'); // left plug, no top/bottom connectors
|
||||||
|
this.setColour('#E91E63');
|
||||||
|
this.setTooltip('Read distance from ultrasonic sensor in cm');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Output blocks return [code, order] ARRAY, not a string
|
||||||
|
generator: function (block) {
|
||||||
|
const sensorId = block.getFieldValue('SENSOR_ID');
|
||||||
|
const code =
|
||||||
|
'(await executeAction(\'read_distance\', { sensor_id: \'' + sensorId + '\' })).message';
|
||||||
|
return [code, javascript.Order.AWAIT];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template C — Block with Value Inputs (accepts other blocks)
|
||||||
|
|
||||||
|
```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').appendField('Move to X');
|
||||||
|
this.appendValueInput('Y').setCheck('Number').appendField('Y');
|
||||||
|
this.setInputsInline(true);
|
||||||
|
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 reads the plugged-in block, returns '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: \'' + x + '\', y: \'' + y + '\' });\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Reference: Blockly Field Types
|
||||||
|
|
||||||
|
| Field Type | Code | Use Case |
|
||||||
|
|------------|------|----------|
|
||||||
|
| Number | `new Blockly.FieldNumber(default, min, max, step)` | Pin number, duration, speed |
|
||||||
|
| Text | `new Blockly.FieldTextInput('default')` | Custom labels, names |
|
||||||
|
| Dropdown | `new Blockly.FieldDropdown([['Label','value'], ...])` | Sensor selection, direction |
|
||||||
|
| Checkbox | `new Blockly.FieldCheckbox('TRUE')` | On/off toggle |
|
||||||
|
| Color | `new Blockly.FieldColour('#ff0000')` | LED color picker |
|
||||||
|
| Angle | `new Blockly.FieldAngle(90)` | Rotation angle with dial |
|
||||||
|
| Image | `new Blockly.FieldImage('url', width, height)` | Icon on block |
|
||||||
|
|
||||||
|
### Quick Reference: Block Connection Types
|
||||||
|
|
||||||
|
| Method | Effect | When to use |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `setPreviousStatement(true)` + `setNextStatement(true)` | Stackable block | Action/command blocks |
|
||||||
|
| `setOutput(true, type)` | Output plug — returns a value | Sensor/calculation blocks |
|
||||||
|
| `appendDummyInput()` | Row with only inline fields | Simple blocks with fixed fields |
|
||||||
|
| `appendValueInput('NAME')` | Input socket — accepts output blocks | Blocks that take dynamic values |
|
||||||
|
| `appendStatementInput('DO')` | Statement socket — accepts block stack | Container blocks like loops, if-then |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Troubleshooting & Known Issues
|
||||||
|
|
||||||
### "Executor is already spinning" in `app.py`
|
### "Executor is already spinning" in `app.py`
|
||||||
|
|
||||||
|
|
@ -949,5 +1171,3 @@ pixi run test
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This documentation was generated for the Blockly ROS2 Robot Controller project. For project management and task tracking, see [`PROJECT_MANAGEMENT.md`](PROJECT_MANAGEMENT.md).*
|
|
||||||
|
|
|
||||||
|
|
@ -6,727 +6,25 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Planned Packages
|
||||||
|
|
||||||
- [1. Project Overview](#1-project-overview)
|
|
||||||
- [2. Workspace Restructuring Plan](#2-workspace-restructuring-plan)
|
|
||||||
- [2.1 Current Structure — Issues](#21-current-structure--issues)
|
|
||||||
- [2.2 Target Structure — ROS2 Standard](#22-target-structure--ros2-standard)
|
|
||||||
- [2.3 Package Mapping — Old to New](#23-package-mapping--old-to-new)
|
|
||||||
- [2.4 File-Level Migration Map](#24-file-level-migration-map)
|
|
||||||
- [3. Task Progress](#3-task-progress)
|
|
||||||
- [3.1 Phase 1 — Restructure Workspace Layout](#31-phase-1--restructure-workspace-layout)
|
|
||||||
- [3.2 Phase 2 — Add Missing ROS2 Scaffolding](#32-phase-2--add-missing-ros2-scaffolding)
|
|
||||||
- [3.3 Phase 3 — Update Imports and References](#33-phase-3--update-imports-and-references)
|
|
||||||
- [3.4 Phase 4 — Verification](#34-phase-4--verification)
|
|
||||||
- [4. Guide: Adding a New ROS2 Package](#4-guide-adding-a-new-ros2-package)
|
|
||||||
- [4.1 Python Package Template](#41-python-package-template)
|
|
||||||
- [4.2 Steps to Add a New Package](#42-steps-to-add-a-new-package)
|
|
||||||
- [4.3 Example: Adding a Controller Package](#43-example-adding-a-controller-package)
|
|
||||||
- [5. Development Potential — Future Roadmap](#5-development-potential--future-roadmap)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Project Overview
|
|
||||||
|
|
||||||
This workspace implements a **Blockly-based visual programming interface** for controlling a ROS2-powered robot. The system architecture has three layers:
|
|
||||||
|
|
||||||
- **blockly_app** — Desktop GUI with Blockly editor, connected to ROS2 via pywebview bridge
|
|
||||||
- **blockly_executor** — ROS2 Action Server that receives commands and executes them through a handler registry
|
|
||||||
- **blockly_interfaces** — Custom ROS2 action message definitions
|
|
||||||
|
|
||||||
The robot platform target is a **Kiwi Wheel drive AMR** (Autonomous Mobile Robot) with plans for adaptive control.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Workspace Restructuring Plan
|
|
||||||
|
|
||||||
### 2.1 Current Structure — Issues
|
|
||||||
|
|
||||||
```
|
|
||||||
amr-ros-k4/ # CURRENT — NOT ROS2 standard
|
|
||||||
├── app/ # ⚠ Not a ROS2 package, no package.xml
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── app.py
|
|
||||||
│ └── ui/
|
|
||||||
├── executor/ # ⚠ Missing setup.cfg, resource/
|
|
||||||
│ ├── __init__.py # ⚠ Misleading root-level __init__.py
|
|
||||||
│ ├── package.xml
|
|
||||||
│ ├── setup.py
|
|
||||||
│ └── executor/
|
|
||||||
├── interfaces/ # ⚠ Generic name, no project prefix
|
|
||||||
│ ├── CMakeLists.txt
|
|
||||||
│ ├── package.xml
|
|
||||||
│ └── action/
|
|
||||||
├── tests/ # ⚠ Outside any package
|
|
||||||
├── pixi.toml
|
|
||||||
└── DOCUMENTATION.md
|
|
||||||
```
|
|
||||||
|
|
||||||
| # | Issue | Impact |
|
|
||||||
|---|-------|--------|
|
|
||||||
| 1 | No `src/` directory | colcon cannot cleanly isolate source from build artifacts |
|
|
||||||
| 2 | Missing `setup.cfg` in executor | ament_python install destinations not configured |
|
|
||||||
| 3 | Missing `resource/` marker in executor | ament index cannot discover the package |
|
|
||||||
| 4 | Generic package names `executor`, `interfaces` | Name collision risk, not descriptive |
|
|
||||||
| 5 | Misleading `executor/__init__.py` at root | Confuses Python import resolution |
|
|
||||||
| 6 | `app/` lacks ROS2 package structure | Not managed by colcon, manual path handling |
|
|
||||||
| 7 | `tests/` at workspace root | Not associated with any package, not run by colcon test |
|
|
||||||
|
|
||||||
### 2.2 Target Structure — ROS2 Standard
|
|
||||||
|
|
||||||
```
|
|
||||||
amr-ros-k4/ # Workspace root
|
|
||||||
├── .gitignore
|
|
||||||
├── DOCUMENTATION.md
|
|
||||||
├── PROJECT_MANAGEMENT.md
|
|
||||||
├── pixi.toml
|
|
||||||
├── pixi.lock
|
|
||||||
│
|
|
||||||
└── src/ # All ROS2 packages
|
|
||||||
├── blockly_app/ # Desktop GUI — ament_python
|
|
||||||
│ ├── package.xml
|
|
||||||
│ ├── setup.py
|
|
||||||
│ ├── setup.cfg
|
|
||||||
│ ├── resource/
|
|
||||||
│ │ └── blockly_app # Empty ament index marker
|
|
||||||
│ ├── blockly_app/ # Python module
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── app.py
|
|
||||||
│ │ └── ui/ # Frontend assets (inside module for build inclusion)
|
|
||||||
│ │ ├── index.html
|
|
||||||
│ │ └── blockly/
|
|
||||||
│ │ ├── core/
|
|
||||||
│ │ │ ├── registry.js # BlockRegistry + toolbox builder
|
|
||||||
│ │ │ ├── breakpoints.js # Breakpoint state and toggle logic
|
|
||||||
│ │ │ ├── bridge.js # executeAction — pywebview bridge
|
|
||||||
│ │ │ ├── debug-engine.js # Run, debug, step, stop logic
|
|
||||||
│ │ │ └── ui-controls.js # Button states, highlight, callbacks
|
|
||||||
│ │ ├── blocks/
|
|
||||||
│ │ │ ├── manifest.js # BLOCK_FILES array — only file to edit
|
|
||||||
│ │ │ ├── led_on.js # Self-contained block: definition + generator
|
|
||||||
│ │ │ ├── led_off.js
|
|
||||||
│ │ │ └── delay.js
|
|
||||||
│ │ ├── loader.js # Dynamic script loader from manifest
|
|
||||||
│ │ └── workspace-init.js # Builds toolbox from registry, injects workspace
|
|
||||||
│
|
|
||||||
├── blockly_executor/ # Action Server — ament_python
|
|
||||||
│ ├── package.xml
|
|
||||||
│ ├── setup.py
|
|
||||||
│ ├── setup.cfg
|
|
||||||
│ ├── resource/
|
|
||||||
│ │ └── blockly_executor # Empty ament index marker
|
|
||||||
│ ├── blockly_executor/ # Python module
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── executor_node.py
|
|
||||||
│ │ ├── handlers.py
|
|
||||||
│ │ ├── utils.py
|
|
||||||
│ │ └── hardware/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── dummy_hardware.py
|
|
||||||
│ │ ├── gpio_hardware.py
|
|
||||||
│ │ └── interface.py
|
|
||||||
│ └── test/ # Package tests
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── conftest.py
|
|
||||||
│ ├── test_block_delay.py
|
|
||||||
│ ├── test_block_led_off.py
|
|
||||||
│ └── test_block_led_on.py
|
|
||||||
│
|
|
||||||
└── blockly_interfaces/ # Custom messages — ament_cmake
|
|
||||||
├── CMakeLists.txt
|
|
||||||
├── package.xml
|
|
||||||
└── action/
|
|
||||||
└── BlocklyAction.action
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Package Mapping — Old to New
|
|
||||||
|
|
||||||
| Old Name | New Name | Type | Notes |
|
|
||||||
|----------|----------|------|-------|
|
|
||||||
| `app/` | `src/blockly_app/` | ament_python | New ROS2 package with full scaffolding |
|
|
||||||
| `executor/` | `src/blockly_executor/` | ament_python | Renamed + added setup.cfg, resource/ |
|
|
||||||
| `interfaces/` | `src/blockly_interfaces/` | ament_cmake | Renamed, update CMakeLists project name |
|
|
||||||
| `tests/` | `src/blockly_executor/test/` | — | Moved into executor package |
|
|
||||||
|
|
||||||
### 2.4 Blockly JS Restructuring — Auto-Discovery Architecture
|
|
||||||
|
|
||||||
The monolithic `blocks.js` and `executor.js` will be split into per-block files with auto-loading and auto-generated toolbox.
|
|
||||||
|
|
||||||
**Current** — 2 monolithic files, each block split across both:
|
|
||||||
```
|
|
||||||
ui/blockly/
|
|
||||||
├── blocks.js # All block visual definitions + breakpoint logic
|
|
||||||
└── executor.js # All code generators + debug engine + UI controls
|
|
||||||
```
|
|
||||||
|
|
||||||
**Target** — modular, auto-discovered:
|
|
||||||
```
|
|
||||||
ui/blockly/
|
|
||||||
├── core/ # Shared infrastructure — rarely touched
|
|
||||||
│ ├── registry.js # BlockRegistry — register + auto-toolbox builder
|
|
||||||
│ ├── breakpoints.js # Breakpoint state, colors, toggle
|
|
||||||
│ ├── bridge.js # executeAction — pywebview bridge
|
|
||||||
│ ├── debug-engine.js # debugState, runProgram, runDebug, step*, stop
|
|
||||||
│ └── ui-controls.js # Button states, onRunClick, onDebugToggle
|
|
||||||
│
|
|
||||||
├── blocks/ # One file per block — self-contained
|
|
||||||
│ ├── manifest.js # BLOCK_FILES array — the ONLY file to edit
|
|
||||||
│ ├── led_on.js # BlockRegistry.register — definition + generator
|
|
||||||
│ ├── led_off.js
|
|
||||||
│ └── delay.js
|
|
||||||
│
|
|
||||||
├── loader.js # Dynamic script loader — reads manifest
|
|
||||||
└── workspace-init.js # Builds toolbox from registry, injects workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
**BlockRegistry pattern** — each block file calls:
|
|
||||||
```js
|
|
||||||
BlockRegistry.register({
|
|
||||||
name: 'led_on',
|
|
||||||
category: 'Robot',
|
|
||||||
categoryColor: '#5b80a5',
|
|
||||||
color: '#4CAF50',
|
|
||||||
tooltip: 'Turn on LED at the specified GPIO pin',
|
|
||||||
definition: { init: function() { /* Blockly visual definition */ } },
|
|
||||||
generator: function(block) { /* code generator */ }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Auto-toolbox flow**:
|
|
||||||
1. Each block file calls `BlockRegistry.register()` with `name`, `category`, `color`, `definition`, `generator`
|
|
||||||
2. `workspace-init.js` calls `BlockRegistry.getToolboxJSON()` to build the toolbox config
|
|
||||||
3. Built-in Blockly categories — Logic, Loops, Math, Variables — are appended as static config
|
|
||||||
4. Workspace is injected with the combined toolbox — no XML in `index.html`
|
|
||||||
|
|
||||||
**Script loading order** in `index.html` — fixed, never needs changing:
|
|
||||||
```
|
|
||||||
1. Blockly vendor libs
|
|
||||||
2. core/registry.js → core/breakpoints.js → core/bridge.js → core/debug-engine.js → core/ui-controls.js
|
|
||||||
3. blocks/manifest.js
|
|
||||||
4. loader.js
|
|
||||||
5. <script> loadAllBlocks().then(() => initWorkspace()) </script>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Adding a new block — 2 steps only**:
|
|
||||||
1. Create `blocks/<name>.js` — call `BlockRegistry.register({...})`
|
|
||||||
2. Add `'<name>.js'` to `blocks/manifest.js`
|
|
||||||
|
|
||||||
No `index.html` changes. No core file changes. No toolbox XML editing.
|
|
||||||
|
|
||||||
### 2.5 File-Level Migration Map
|
|
||||||
|
|
||||||
| Old Path | New Path | Action |
|
|
||||||
|----------|----------|--------|
|
|
||||||
| `app/__init__.py` | `src/blockly_app/blockly_app/__init__.py` | Move |
|
|
||||||
| `app/app.py` | `src/blockly_app/blockly_app/app.py` | Move + update imports |
|
|
||||||
| `app/ui/index.html` | `src/blockly_app/ui/index.html` | Move + rewrite script tags |
|
|
||||||
| `app/ui/blockly/blocks.js` | Split → `ui/blockly/core/*.js` + `ui/blockly/blocks/*.js` | Split + rewrite |
|
|
||||||
| `app/ui/blockly/executor.js` | Split → `ui/blockly/core/*.js` + `ui/blockly/blocks/*.js` | Split + rewrite |
|
|
||||||
| — | `src/blockly_app/ui/blockly/core/registry.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/core/breakpoints.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/core/bridge.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/core/debug-engine.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/core/ui-controls.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/blocks/manifest.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/blocks/led_on.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/blocks/led_off.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/blocks/delay.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/loader.js` | Create new |
|
|
||||||
| — | `src/blockly_app/ui/blockly/workspace-init.js` | Create new |
|
|
||||||
| — | `src/blockly_app/package.xml` | Create new |
|
|
||||||
| — | `src/blockly_app/setup.py` | Create new |
|
|
||||||
| — | `src/blockly_app/setup.cfg` | Create new |
|
|
||||||
| — | `src/blockly_app/resource/blockly_app` | Create new empty file |
|
|
||||||
| `executor/executor/**` | `src/blockly_executor/blockly_executor/**` | Move + update imports |
|
|
||||||
| `executor/package.xml` | `src/blockly_executor/package.xml` | Move + update name |
|
|
||||||
| `executor/setup.py` | `src/blockly_executor/setup.py` | Move + update name/entry_points |
|
|
||||||
| — | `src/blockly_executor/setup.cfg` | Create new |
|
|
||||||
| — | `src/blockly_executor/resource/blockly_executor` | Create new empty file |
|
|
||||||
| `executor/__init__.py` | — | **Delete** — no longer needed |
|
|
||||||
| `interfaces/CMakeLists.txt` | `src/blockly_interfaces/CMakeLists.txt` | Move + update project name |
|
|
||||||
| `interfaces/package.xml` | `src/blockly_interfaces/package.xml` | Move + update name |
|
|
||||||
| `interfaces/action/**` | `src/blockly_interfaces/action/**` | Move |
|
|
||||||
| `tests/**` | `src/blockly_executor/test/**` | Move + update imports |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Task Progress
|
|
||||||
|
|
||||||
### 3.1 Phase 1 — Restructure Workspace Layout ✅
|
|
||||||
|
|
||||||
- [x] Create `src/` directory
|
|
||||||
- [x] Create `src/blockly_app/`, `src/blockly_executor/`, `src/blockly_interfaces/` directories
|
|
||||||
- [x] Move `app/` contents → `src/blockly_app/`
|
|
||||||
- [x] Move `executor/executor/` contents → `src/blockly_executor/blockly_executor/`
|
|
||||||
- [x] Move `interfaces/` contents → `src/blockly_interfaces/`
|
|
||||||
- [x] Move `tests/` → `src/blockly_executor/test/`
|
|
||||||
- [x] Delete old empty directories: `app/`, `executor/`, `interfaces/`, `tests/`
|
|
||||||
|
|
||||||
### 3.2 Phase 2 — Add Missing ROS2 Scaffolding ✅
|
|
||||||
|
|
||||||
- [x] Create `src/blockly_app/package.xml`
|
|
||||||
- [x] Create `src/blockly_app/setup.py`
|
|
||||||
- [x] Create `src/blockly_app/setup.cfg`
|
|
||||||
- [x] Create `src/blockly_app/resource/blockly_app` (empty marker)
|
|
||||||
- [x] Create `src/blockly_executor/setup.cfg`
|
|
||||||
- [x] Create `src/blockly_executor/resource/blockly_executor` (empty marker)
|
|
||||||
- [x] Update `src/blockly_executor/package.xml` — change name to `blockly_executor`
|
|
||||||
- [x] Update `src/blockly_executor/setup.py` — change package name, entry_points, find_packages
|
|
||||||
- [x] Update `src/blockly_interfaces/CMakeLists.txt` — change project name to `blockly_interfaces`
|
|
||||||
- [x] Update `src/blockly_interfaces/package.xml` — change name to `blockly_interfaces`
|
|
||||||
|
|
||||||
### 3.3 Phase 3 — Blockly JS Modularization ✅
|
|
||||||
|
|
||||||
- [x] Create `core/registry.js` — BlockRegistry with register(), getToolboxJSON()
|
|
||||||
- [x] Create `core/breakpoints.js` — extract from blocks.js
|
|
||||||
- [x] Create `core/bridge.js` — extract executeAction() from executor.js
|
|
||||||
- [x] Create `core/debug-engine.js` — extract debug logic from executor.js
|
|
||||||
- [x] Create `core/ui-controls.js` — extract UI control functions from executor.js
|
|
||||||
- [x] Create `blocks/manifest.js` — BLOCK_FILES array
|
|
||||||
- [x] Create `blocks/led_on.js` — self-contained block with BlockRegistry.register()
|
|
||||||
- [x] Create `blocks/led_off.js` — self-contained block with BlockRegistry.register()
|
|
||||||
- [x] Create `blocks/delay.js` — self-contained block with BlockRegistry.register()
|
|
||||||
- [x] Create `loader.js` — dynamic script loader
|
|
||||||
- [x] Create `workspace-init.js` — workspace initialization with auto-toolbox
|
|
||||||
- [x] Rewrite `index.html` — remove toolbox XML, update script tags, use loadAllBlocks()
|
|
||||||
- [x] Delete old `blocks.js` and `executor.js`
|
|
||||||
|
|
||||||
### 3.4 Phase 4 — Update Imports and References ✅
|
|
||||||
|
|
||||||
- [x] Update `blockly_executor/executor_node.py` — `from interfaces.action` → `from blockly_interfaces.action`
|
|
||||||
- [x] Update `blockly_app/app.py` — `from interfaces.action` → `from blockly_interfaces.action`
|
|
||||||
- [x] Update `blockly_app/app.py` — UI path resolution for new directory layout
|
|
||||||
- [x] Update `blockly_executor/handlers.py` — internal imports (unchanged, relative imports still work)
|
|
||||||
- [x] Update `blockly_executor/hardware/__init__.py` — internal imports (unchanged, relative imports still work)
|
|
||||||
- [x] Update test files — `from interfaces.action` → `from blockly_interfaces.action`
|
|
||||||
- [x] Update `pixi.toml` — all task commands to use new paths and package names
|
|
||||||
- [x] Update `.gitignore` — added `.pytest_cache/`
|
|
||||||
|
|
||||||
### 3.5 Phase 5 — Verification ✅
|
|
||||||
|
|
||||||
- [x] Run `colcon build` — all three packages build successfully (blockly_interfaces, blockly_executor, blockly_app)
|
|
||||||
- [x] Verify Python imports work — `from blockly_interfaces.action import BlocklyAction` ✓
|
|
||||||
- [x] Verify Python imports work — `from blockly_executor.executor_node import ExecutorNode` ✓
|
|
||||||
- [x] `ros2 pkg list` — all 3 packages discovered: blockly_app, blockly_executor, blockly_interfaces ✓
|
|
||||||
- [x] `ros2 interface show blockly_interfaces/action/BlocklyAction` — action definition correct ✓
|
|
||||||
- [x] `ros2 pkg executables blockly_executor` → executor_node ✓
|
|
||||||
- [x] `ros2 pkg executables blockly_app` → blockly_app ✓
|
|
||||||
- [x] Run `pixi run setup-ui` to install Blockly vendor files into new location
|
|
||||||
- [x] Verify Blockly UI loads correctly — blocks appear in toolbox, run/debug works
|
|
||||||
- [ ] Update `DOCUMENTATION.md` — reflect new directory structure, package names, and block auto-discovery
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Guide: Adding a New ROS2 Package
|
|
||||||
|
|
||||||
This section documents the standard process for adding new packages to this workspace, ensuring consistency and ROS2 compliance.
|
|
||||||
|
|
||||||
### 4.1 Python Package Template
|
|
||||||
|
|
||||||
Every new `ament_python` package under `src/` must have this minimal structure:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/<package_name>/
|
|
||||||
├── package.xml # ROS2 package manifest
|
|
||||||
├── setup.py # Python package setup
|
|
||||||
├── setup.cfg # ament install config
|
|
||||||
├── resource/
|
|
||||||
│ └── <package_name> # Empty file — ament index marker
|
|
||||||
├── <package_name>/ # Python module — same name as package
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── <your_node>.py
|
|
||||||
└── test/ # Package tests
|
|
||||||
├── __init__.py
|
|
||||||
└── test_<feature>.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**setup.cfg template:**
|
|
||||||
```ini
|
|
||||||
[develop]
|
|
||||||
script_dir=$base/lib/<package_name>
|
|
||||||
[install]
|
|
||||||
install_scripts=$base/lib/<package_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
**package.xml template:**
|
|
||||||
```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>
|
|
||||||
<!-- Add other dependencies here -->
|
|
||||||
|
|
||||||
<test_depend>pytest</test_depend>
|
|
||||||
|
|
||||||
<export>
|
|
||||||
<build_type>ament_python</build_type>
|
|
||||||
</export>
|
|
||||||
</package>
|
|
||||||
```
|
|
||||||
|
|
||||||
**setup.py template:**
|
|
||||||
```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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Steps to Add a New Package
|
|
||||||
|
|
||||||
1. **Create directory**: `mkdir -p src/<package_name>/<package_name>`
|
|
||||||
2. **Copy templates**: Create `package.xml`, `setup.py`, `setup.cfg` from templates above
|
|
||||||
3. **Create ament marker**: `mkdir -p src/<package_name>/resource && touch src/<package_name>/resource/<package_name>`
|
|
||||||
4. **Create Python module**: Add `__init__.py` and node files under `src/<package_name>/<package_name>/`
|
|
||||||
5. **Add dependencies**: Update `package.xml` with `<depend>` tags for any packages this node needs
|
|
||||||
6. **Register in pixi.toml**: Add build/run tasks for the new package
|
|
||||||
7. **Build**: `colcon build --symlink-install --packages-select <package_name>`
|
|
||||||
8. **Test**: `colcon test --packages-select <package_name>`
|
|
||||||
|
|
||||||
### 4.3 Example: Adding a Controller Package
|
|
||||||
|
|
||||||
When the time comes to add the Kiwi Wheel controller, the structure would be:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/kiwi_controller/
|
|
||||||
├── package.xml # depends on rclpy, geometry_msgs, etc.
|
|
||||||
├── setup.py
|
|
||||||
├── setup.cfg
|
|
||||||
├── resource/
|
|
||||||
│ └── kiwi_controller
|
|
||||||
├── kiwi_controller/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── controller_node.py # Adaptive controller logic
|
|
||||||
│ ├── kinematics.py # Kiwi wheel inverse/forward kinematics
|
|
||||||
│ └── hardware/
|
|
||||||
│ └── motor_driver.py # Motor communication
|
|
||||||
└── test/
|
|
||||||
├── __init__.py
|
|
||||||
└── test_kinematics.py
|
|
||||||
```
|
|
||||||
|
|
||||||
And a corresponding `pixi.toml` task:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[tasks]
|
|
||||||
controller = { cmd = "bash -c 'source install/setup.bash && ros2 run kiwi_controller controller_node'", depends-on = ["build-interfaces"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Guide: Adding a New Blockly Block
|
|
||||||
|
|
||||||
After the restructuring, adding a new robot command block follows a streamlined 3-file pattern.
|
|
||||||
|
|
||||||
### 5.1 Overview — 3 Files to Touch
|
|
||||||
|
|
||||||
| # | File | Purpose |
|
|
||||||
|---|------|---------|
|
|
||||||
| 1 | `ui/blockly/blocks/<name>.js` | **Create** — block visual definition + code generator |
|
|
||||||
| 2 | `ui/blockly/blocks/manifest.js` | **Edit** — add filename to BLOCK_FILES array |
|
|
||||||
| 3 | `blockly_executor/handlers.py` | **Edit** — add Python handler for the command |
|
|
||||||
|
|
||||||
Optionally:
|
|
||||||
- Add a test file in `blockly_executor/test/test_block_<name>.py`
|
|
||||||
|
|
||||||
### 5.2 Block Types and Templates
|
|
||||||
|
|
||||||
There are 3 main block patterns for different use cases:
|
|
||||||
|
|
||||||
| Pattern | Shape | Use Case | Example |
|
|
||||||
|---------|-------|----------|---------|
|
|
||||||
| **Statement block** | Flat with top/bottom connectors | Perform an action/command | `led_on`, `delay`, `motor_stop` |
|
|
||||||
| **Output block** | Left plug, no top/bottom | Return a value from sensor | `read_distance`, `read_temperature` |
|
|
||||||
| **Input block** | Has sockets for other blocks | Accept dynamic values | `move_to(x, y)` where x/y come from sensors |
|
|
||||||
|
|
||||||
#### Template A — Statement Block (action command)
|
|
||||||
|
|
||||||
Use for blocks that **do something** and don't return a value.
|
|
||||||
Shape: connects vertically, top-to-bottom.
|
|
||||||
|
|
||||||
Create `ui/blockly/blocks/<name>.js`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
BlockRegistry.register({
|
|
||||||
// ─── Identity ────────────────────────────────────────────
|
|
||||||
name: 'led_on', // Unique ID — must match Python handler name
|
|
||||||
|
|
||||||
// ─── Toolbox Placement ───────────────────────────────────
|
|
||||||
category: 'Robot', // Toolbox category — same name = same group
|
|
||||||
categoryColor: '#5b80a5', // Sidebar color of the category tab
|
|
||||||
|
|
||||||
// ─── Appearance ──────────────────────────────────────────
|
|
||||||
color: '#4CAF50', // Block body color
|
|
||||||
tooltip: 'Turn on LED at the specified GPIO pin',
|
|
||||||
|
|
||||||
// ─── Visual Definition ───────────────────────────────────
|
|
||||||
// Defines how the block looks in the Blockly editor.
|
|
||||||
definition: {
|
|
||||||
init: function () {
|
|
||||||
// appendDummyInput: adds a row with NO pluggable input socket.
|
|
||||||
// Good for blocks where all values are inline fields.
|
|
||||||
this.appendDummyInput()
|
|
||||||
.appendField('LED ON pin') // Static text label
|
|
||||||
|
|
||||||
// FieldNumber(default, min, max, step) — editable number.
|
|
||||||
// 'PIN' is the key used by generator to read the value.
|
|
||||||
//
|
|
||||||
// Other field types:
|
|
||||||
// new Blockly.FieldTextInput('hello') — free text
|
|
||||||
// new Blockly.FieldDropdown([['High','1'],['Low','0']]) — dropdown
|
|
||||||
// new Blockly.FieldCheckbox('TRUE') — checkbox
|
|
||||||
// new Blockly.FieldColour('#ff0000') — color picker
|
|
||||||
// new Blockly.FieldAngle(90) — angle dial
|
|
||||||
.appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN');
|
|
||||||
|
|
||||||
// setPreviousStatement(true): allows blocks to connect ABOVE this one.
|
|
||||||
// setNextStatement(true): allows blocks to connect BELOW this one.
|
|
||||||
// Together: this block chains vertically like LEGO bricks.
|
|
||||||
this.setPreviousStatement(true, null);
|
|
||||||
this.setNextStatement(true, null);
|
|
||||||
|
|
||||||
this.setColour('#4CAF50');
|
|
||||||
this.setTooltip('Turn on LED at the specified GPIO pin');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ─── Code Generator ──────────────────────────────────────
|
|
||||||
// Returns a STRING of JavaScript code.
|
|
||||||
// This code runs inside an async eval() at runtime.
|
|
||||||
generator: function (block) {
|
|
||||||
const pin = block.getFieldValue('PIN'); // Read value from editor
|
|
||||||
|
|
||||||
// highlightBlock — visually highlights current block during execution
|
|
||||||
// executeAction — sends ROS2 action goal via pywebview bridge, awaits result
|
|
||||||
// arg1 = command name (must match Python handler)
|
|
||||||
// arg2 = params dict (keys must match what handler expects)
|
|
||||||
return (
|
|
||||||
'highlightBlock(\'' + block.id + '\');\n' +
|
|
||||||
'await executeAction(\'led_on\', { pin: \'' + pin + '\' });\n'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Template B — Output Block (sensor value)
|
|
||||||
|
|
||||||
Use for blocks that **return a value** — e.g., reading a sensor.
|
|
||||||
Shape: has a left plug (output connector), no top/bottom connectors.
|
|
||||||
Can be plugged into other blocks' input sockets.
|
|
||||||
|
|
||||||
```js
|
|
||||||
BlockRegistry.register({
|
|
||||||
name: 'read_distance',
|
|
||||||
category: 'Sensors',
|
|
||||||
categoryColor: '#a5745b',
|
|
||||||
color: '#E91E63',
|
|
||||||
tooltip: 'Read distance from ultrasonic sensor in cm',
|
|
||||||
|
|
||||||
// ─── outputType tells the registry what type this block produces.
|
|
||||||
// This is used by Blockly to validate connections.
|
|
||||||
// Common types: 'Number', 'String', 'Boolean', null (any type)
|
|
||||||
outputType: 'Number',
|
|
||||||
|
|
||||||
definition: {
|
|
||||||
init: function () {
|
|
||||||
this.appendDummyInput()
|
|
||||||
.appendField('Distance sensor')
|
|
||||||
.appendField(new Blockly.FieldDropdown([
|
|
||||||
['Front', 'front'],
|
|
||||||
['Left', 'left'],
|
|
||||||
['Right', 'right'],
|
|
||||||
]), 'SENSOR_ID');
|
|
||||||
|
|
||||||
// setOutput(true, type) — makes this block produce a VALUE.
|
|
||||||
// The block gets a left-side plug instead of top/bottom connectors.
|
|
||||||
// 'Number' means it can only connect to inputs expecting a number.
|
|
||||||
// Use null to allow connecting to any input type.
|
|
||||||
this.setOutput(true, 'Number');
|
|
||||||
|
|
||||||
// NOTE: Do NOT set setPreviousStatement/setNextStatement
|
|
||||||
// on output blocks — they are value producers, not action steps.
|
|
||||||
|
|
||||||
this.setColour('#E91E63');
|
|
||||||
this.setTooltip('Read distance from ultrasonic sensor in cm');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ─── Code Generator for Output Blocks ────────────────────
|
|
||||||
// IMPORTANT: Returns an ARRAY [code, order] instead of a string.
|
|
||||||
// code — JavaScript expression that evaluates to a value
|
|
||||||
// order — operator precedence (prevents incorrect parenthesization)
|
|
||||||
//
|
|
||||||
// Common ORDER values:
|
|
||||||
// javascript.Order.ATOMIC — tightest binding (literals, function calls)
|
|
||||||
// javascript.Order.FUNCTION_CALL — function call
|
|
||||||
// javascript.Order.NONE — loosest binding
|
|
||||||
generator: function (block) {
|
|
||||||
const sensorId = block.getFieldValue('SENSOR_ID');
|
|
||||||
|
|
||||||
// The generated code is an EXPRESSION, not a statement.
|
|
||||||
// It will be inserted wherever this block is plugged in.
|
|
||||||
// executeAction returns {success, message} — we parse the numeric value.
|
|
||||||
const code =
|
|
||||||
'(await executeAction(\'read_distance\', { sensor_id: \'' + sensorId + '\' })).message';
|
|
||||||
|
|
||||||
// Return [code, ORDER] — ARRAY, not just a string.
|
|
||||||
return [code, javascript.Order.AWAIT];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Template C — Block with Value Inputs (accepts other blocks)
|
|
||||||
|
|
||||||
Use when a block's parameter should come from **another block's output**
|
|
||||||
— e.g., `move_to(x, y)` where x/y come from sensor blocks or math blocks.
|
|
||||||
Shape: has input sockets where output blocks can plug in.
|
|
||||||
|
|
||||||
```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('X') — creates an INPUT SOCKET named 'X'.
|
|
||||||
// Other blocks with setOutput() can plug into this socket.
|
|
||||||
// .setCheck('Number') restricts to number-type blocks only.
|
|
||||||
this.appendValueInput('X')
|
|
||||||
.setCheck('Number') // Only accept Number blocks
|
|
||||||
.appendField('Move to X');
|
|
||||||
|
|
||||||
this.appendValueInput('Y')
|
|
||||||
.setCheck('Number')
|
|
||||||
.appendField('Y');
|
|
||||||
|
|
||||||
// setInputsInline(true) — makes inputs appear side-by-side
|
|
||||||
// instead of stacked vertically. Better for short inputs.
|
|
||||||
this.setInputsInline(true);
|
|
||||||
|
|
||||||
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() reads the code generated by the block plugged into
|
|
||||||
// the 'X' input socket. If nothing is plugged in, returns '0'.
|
|
||||||
// The third argument is the operator precedence for parenthesization.
|
|
||||||
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: \'' + x + '\', y: \'' + y + '\' });\n'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Quick Reference: Blockly Field Types
|
|
||||||
|
|
||||||
| Field Type | Code | Use Case |
|
|
||||||
|------------|------|----------|
|
|
||||||
| Number | `new Blockly.FieldNumber(default, min, max, step)` | Pin number, duration, speed |
|
|
||||||
| Text | `new Blockly.FieldTextInput('default')` | Custom labels, names |
|
|
||||||
| Dropdown | `new Blockly.FieldDropdown([['Label','value'], ...])` | Sensor selection, direction |
|
|
||||||
| Checkbox | `new Blockly.FieldCheckbox('TRUE')` | On/off toggle |
|
|
||||||
| Color | `new Blockly.FieldColour('#ff0000')` | LED color picker |
|
|
||||||
| Angle | `new Blockly.FieldAngle(90)` | Rotation angle with dial |
|
|
||||||
| Image | `new Blockly.FieldImage('url', width, height)` | Icon on block |
|
|
||||||
|
|
||||||
#### Quick Reference: Block Connection Types
|
|
||||||
|
|
||||||
| Method | Effect | When to use |
|
|
||||||
|--------|--------|-------------|
|
|
||||||
| `setPreviousStatement(true)` + `setNextStatement(true)` | Stackable block — chains vertically | Action/command blocks |
|
|
||||||
| `setOutput(true, type)` | Output plug — returns a value | Sensor/calculation blocks |
|
|
||||||
| `appendDummyInput()` | Row with only inline fields | Simple blocks with fixed fields |
|
|
||||||
| `appendValueInput('NAME')` | Input socket — accepts output blocks | Blocks that take dynamic values |
|
|
||||||
| `appendStatementInput('DO')` | Statement socket — accepts block stack | Container blocks like loops, if-then |
|
|
||||||
|
|
||||||
### 5.3 Register in Manifest
|
|
||||||
|
|
||||||
Add to `ui/blockly/blocks/manifest.js`:
|
|
||||||
```js
|
|
||||||
const BLOCK_FILES = [
|
|
||||||
'led_on.js',
|
|
||||||
'led_off.js',
|
|
||||||
'delay.js',
|
|
||||||
'NEW_BLOCK.js', // ← add here
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 Add Python Handler
|
|
||||||
|
|
||||||
In `blockly_executor/handlers.py`, register the handler:
|
|
||||||
```python
|
|
||||||
def _register_default_handlers(self) -> None:
|
|
||||||
# ... existing handlers ...
|
|
||||||
self.register('BLOCK_NAME', self._handle_block_name)
|
|
||||||
|
|
||||||
def _handle_block_name(self, params: dict[str, str]) -> tuple[bool, str]:
|
|
||||||
value = params['param_key']
|
|
||||||
# Implement hardware interaction
|
|
||||||
return (True, f'Block executed with {value}')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Development Potential — Future Roadmap
|
|
||||||
|
|
||||||
### Planned Packages
|
|
||||||
|
|
||||||
| Package | Purpose | Status |
|
| Package | Purpose | Status |
|
||||||
|---------|---------|--------|
|
|---------|---------|--------|
|
||||||
| `blockly_app` | Desktop Blockly GUI + Action Client | ✅ Exists — restructuring |
|
| `blockly_app` | Desktop Blockly GUI + Action Client | ✅ Done |
|
||||||
| `blockly_executor` | Action Server — command handler registry | ✅ Exists — restructuring |
|
| `blockly_executor` | Action Server — command handler registry | ✅ Done |
|
||||||
| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Exists — restructuring |
|
| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Done |
|
||||||
| `kiwi_controller` | Adaptive control for Kiwi Wheel drive | 📋 Planned |
|
| `kiwi_controller` | Adaptive control for Kiwi Wheel drive | 📋 Planned |
|
||||||
|
|
||||||
|
## Future Tasks
|
||||||
|
|
||||||
|
<!-- Add new phases / features here -->
|
||||||
|
|
||||||
### Potential Enhancements
|
### Potential Enhancements
|
||||||
|
|
||||||
- **New Blockly blocks**: With auto-discovery architecture, each new block = 1 JS file + 1 manifest entry + 1 Python handler. No core file changes needed.
|
- **Launch files**: `blockly_bringup` package with ROS2 launch files to start all nodes with one command
|
||||||
- **Launch files**: Create a `blockly_bringup` package with ROS2 launch files to start all nodes with a single command
|
- **Sensor integration**: Subscriber nodes for sensor data feeding back into Blockly visual feedback
|
||||||
- **Parameter server**: Use ROS2 parameters for configurable values like GPIO pin mappings, control loop rates
|
- **RealHardware implementation**: Fill in ROS2 publishers/service clients for actual Pi hardware nodes (topics TBD)
|
||||||
- **Sensor integration**: Add subscriber nodes for sensor data that can feed back into Blockly's visual feedback
|
|
||||||
- **Web-based UI**: Replace pywebview with a web server mode for remote access from any browser
|
- **Web-based UI**: Replace pywebview with a web server mode for remote access from any browser
|
||||||
- **ROS2 lifecycle nodes**: Migrate executor and controller to lifecycle nodes for managed state transitions
|
- **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
|
- **Simulation**: Integrate with Gazebo/Isaac Sim for testing Kiwi Wheel kinematics before deploying to hardware
|
||||||
- **Block categories**: The auto-toolbox supports multiple categories — future blocks can be grouped into Robot, Sensors, Navigation, etc.
|
- **Block categories**: Future blocks grouped into Robot, Sensors, Navigation categories
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This document is maintained as part of the project management process. Update task checkboxes as work progresses.*
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ build-executor = { cmd = "colcon build --symlink-install --packages-select blo
|
||||||
build-app = { cmd = "colcon build --symlink-install --packages-select blockly_app", depends-on = ["build-interfaces"] }
|
build-app = { cmd = "colcon build --symlink-install --packages-select blockly_app", depends-on = ["build-interfaces"] }
|
||||||
build = { cmd = "colcon build --symlink-install", depends-on = ["build-interfaces"] }
|
build = { cmd = "colcon build --symlink-install", depends-on = ["build-interfaces"] }
|
||||||
executor = { cmd = "bash -c 'source install/setup.bash && ros2 run blockly_executor executor_node'", depends-on = ["build-executor"] }
|
executor = { cmd = "bash -c 'source install/setup.bash && ros2 run blockly_executor executor_node'", depends-on = ["build-executor"] }
|
||||||
|
executor-hw = { cmd = "bash -c 'source install/setup.bash && ros2 run blockly_executor executor_node --ros-args -p use_real_hardware:=true'", depends-on = ["build-executor"] }
|
||||||
app = { cmd = "bash -c 'source install/setup.bash && python -m blockly_app.app'", depends-on = ["build-app"] }
|
app = { cmd = "bash -c 'source install/setup.bash && python -m blockly_app.app'", depends-on = ["build-app"] }
|
||||||
test = { cmd = "bash -c 'source install/setup.bash && PYTHONPATH=$PYTHONPATH:src/blockly_executor pytest src/blockly_executor/test/ -v'", depends-on = ["build-interfaces"] }
|
test = { cmd = "bash -c 'source install/setup.bash && PYTHONPATH=$PYTHONPATH:src/blockly_executor pytest src/blockly_executor/test/ -v'", depends-on = ["build-interfaces"] }
|
||||||
setup-ui = "npm install blockly && mkdir -p src/blockly_app/blockly_app/ui/vendor && cp node_modules/blockly/blockly.min.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/javascript_compressed.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/blocks_compressed.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/msg/en.js src/blockly_app/blockly_app/ui/vendor/"
|
setup-ui = "npm install blockly && mkdir -p src/blockly_app/blockly_app/ui/vendor && cp node_modules/blockly/blockly.min.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/javascript_compressed.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/blocks_compressed.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/blockly/msg/en.js src/blockly_app/blockly_app/ui/vendor/"
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ from rclpy.action.server import ServerGoalHandle
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
|
|
||||||
from .handlers import HandlerRegistry
|
from .handlers import HandlerRegistry
|
||||||
from .hardware.interface import HardwareInterface
|
|
||||||
from .hardware.dummy_hardware import DummyHardware
|
from .hardware.dummy_hardware import DummyHardware
|
||||||
|
from .hardware.real_hardware import RealHardware
|
||||||
from .utils import parse_params
|
from .utils import parse_params
|
||||||
|
|
||||||
# Import will work after colcon build of interfaces package.
|
# Import will work after colcon build of interfaces package.
|
||||||
|
|
@ -24,12 +24,17 @@ class ExecutorNode(Node):
|
||||||
commands. Program sequencing is entirely controlled by Blockly.
|
commands. Program sequencing is entirely controlled by Blockly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hardware: HardwareInterface | None = None) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("blockly_executor_node")
|
super().__init__("blockly_executor_node")
|
||||||
|
|
||||||
if hardware is None:
|
use_real = self.declare_parameter("use_real_hardware", False).value
|
||||||
|
|
||||||
|
if use_real:
|
||||||
|
hardware = RealHardware(self)
|
||||||
|
self.get_logger().info("Using RealHardware (ROS2 topics/services to Pi)")
|
||||||
|
else:
|
||||||
hardware = DummyHardware()
|
hardware = DummyHardware()
|
||||||
self.get_logger().info("Using DummyHardware (no real GPIO)")
|
self.get_logger().info("Using DummyHardware (no real hardware)")
|
||||||
|
|
||||||
self._registry = HandlerRegistry(hardware)
|
self._registry = HandlerRegistry(hardware)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,79 @@
|
||||||
"""
|
"""
|
||||||
Handler registry and auto-discovery for command handler modules.
|
Handler registry with @handler decorator and auto-discovery.
|
||||||
|
|
||||||
Each handler module in this package implements the HandlerModule interface
|
To add a new command handler:
|
||||||
and registers its commands with the HandlerRegistry during initialization.
|
1. Create a .py file in this directory (or add to an existing one)
|
||||||
|
2. Use the @handler decorator:
|
||||||
|
|
||||||
To add a new handler category:
|
from . import handler
|
||||||
1. Create a new file (e.g., movement.py) with a class extending HandlerModule
|
|
||||||
2. Add an import and instantiation in HANDLER_MODULES below
|
@handler("my_command")
|
||||||
|
def handle_my_command(params, hardware):
|
||||||
|
value = params["key"]
|
||||||
|
return (True, f"Done: {value}")
|
||||||
|
|
||||||
|
That's it. The file is auto-discovered, the function is auto-registered.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from ..hardware.interface import HardwareInterface
|
from ..hardware.interface import HardwareInterface
|
||||||
from .base import HandlerModule
|
|
||||||
|
|
||||||
# Import all handler modules
|
# Global list that collects (command_name, function) pairs during import.
|
||||||
from .gpio import GpioHandlers
|
# Each @handler("name") call appends here. HandlerRegistry.__init__
|
||||||
from .timing import TimingHandlers
|
# consumes this list and binds hardware to each function.
|
||||||
|
_REGISTERED_HANDLERS: list[tuple[str, Callable]] = []
|
||||||
|
|
||||||
# All handler modules to be auto-registered.
|
|
||||||
# Add new handler module instances here when creating new categories.
|
def handler(command: str) -> Callable:
|
||||||
HANDLER_MODULES: list[HandlerModule] = [
|
"""Decorator that registers a function as a command handler.
|
||||||
GpioHandlers(),
|
|
||||||
TimingHandlers(),
|
The decorated function must accept (params: dict[str,str], hardware) and
|
||||||
]
|
return (bool, str).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(fn: Callable) -> Callable:
|
||||||
|
_REGISTERED_HANDLERS.append((command, fn))
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_discover_handlers() -> None:
|
||||||
|
"""Import all .py modules in this package to trigger @handler decorators."""
|
||||||
|
package_dir = Path(__file__).parent
|
||||||
|
for module_info in pkgutil.iter_modules([str(package_dir)]):
|
||||||
|
if module_info.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
importlib.import_module(f".{module_info.name}", __package__)
|
||||||
|
|
||||||
|
|
||||||
class HandlerRegistry:
|
class HandlerRegistry:
|
||||||
"""
|
"""Maps command names to handler functions.
|
||||||
Registry of command handlers that maps command names to handler functions.
|
|
||||||
|
|
||||||
Each handler receives a params dict and returns a tuple of
|
On init, auto-discovers all handler modules in this package and binds
|
||||||
(success: bool, message: str).
|
the hardware interface to each registered handler.
|
||||||
|
|
||||||
Handler modules are auto-registered from HANDLER_MODULES during init.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hardware: HardwareInterface) -> None:
|
def __init__(self, hardware: HardwareInterface) -> None:
|
||||||
self._hardware = hardware
|
self._hardware = hardware
|
||||||
self._handlers: dict[str, Callable[[dict[str, str]], tuple[bool, str]]] = {}
|
self._handlers: dict[str, Callable[[dict[str, str]], tuple[bool, str]]] = {}
|
||||||
self._register_all_modules()
|
|
||||||
|
|
||||||
def _register_all_modules(self) -> None:
|
_auto_discover_handlers()
|
||||||
"""Auto-register all handler modules from HANDLER_MODULES."""
|
|
||||||
for module in HANDLER_MODULES:
|
|
||||||
module.register(self, self._hardware)
|
|
||||||
|
|
||||||
def register(
|
for command, fn in _REGISTERED_HANDLERS:
|
||||||
self,
|
self._handlers[command] = lambda params, _fn=fn: _fn(params, hardware)
|
||||||
command: str,
|
|
||||||
handler: Callable[[dict[str, str]], tuple[bool, str]],
|
|
||||||
) -> None:
|
|
||||||
"""Register a new command handler."""
|
|
||||||
self._handlers[command] = handler
|
|
||||||
|
|
||||||
def execute(self, command: str, params: dict[str, str]) -> tuple[bool, str]:
|
def execute(self, command: str, params: dict[str, str]) -> tuple[bool, str]:
|
||||||
"""
|
handler_fn = self._handlers.get(command)
|
||||||
Execute a command by name with given parameters.
|
if handler_fn is None:
|
||||||
|
|
||||||
Args:
|
|
||||||
command: The command name (e.g. "led_on", "delay").
|
|
||||||
params: Dictionary of parameters for the command.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (success, message).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If a required parameter is missing from params.
|
|
||||||
"""
|
|
||||||
handler = self._handlers.get(command)
|
|
||||||
if handler is None:
|
|
||||||
return (False, f"Unknown command: '{command}'")
|
return (False, f"Unknown command: '{command}'")
|
||||||
return handler(params)
|
return handler_fn(params)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["HandlerRegistry", "HandlerModule", "HANDLER_MODULES"]
|
__all__ = ["HandlerRegistry", "handler"]
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"""Base class for handler modules."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..hardware.interface import HardwareInterface
|
|
||||||
|
|
||||||
|
|
||||||
class HandlerModule(ABC):
|
|
||||||
"""
|
|
||||||
Abstract base class for handler modules.
|
|
||||||
|
|
||||||
Each handler module groups related commands (e.g., GPIO commands,
|
|
||||||
timing commands, navigation commands). Subclasses must implement
|
|
||||||
register() to bind their commands to the registry.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
class GpioHandlers(HandlerModule):
|
|
||||||
def register(self, registry, hardware):
|
|
||||||
registry.register("led_on", lambda p: self._led_on(p, hardware))
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
registry: "HandlerRegistry",
|
|
||||||
hardware: "HardwareInterface",
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Register all commands provided by this handler module.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
registry: The HandlerRegistry to register commands with.
|
|
||||||
hardware: The hardware interface for hardware operations.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
@ -1,31 +1,17 @@
|
||||||
"""GPIO command handlers — LED on/off and future GPIO operations."""
|
"""GPIO command handlers — LED on/off and future GPIO operations."""
|
||||||
|
|
||||||
from ..hardware.interface import HardwareInterface
|
from . import handler
|
||||||
from .base import HandlerModule
|
|
||||||
|
|
||||||
|
|
||||||
class GpioHandlers(HandlerModule):
|
@handler("led_on")
|
||||||
"""
|
def handle_led_on(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||||
Handler module for GPIO-related commands.
|
pin = int(params["pin"])
|
||||||
|
hardware.set_led(pin, True)
|
||||||
|
return (True, f"LED on pin {pin} turned ON")
|
||||||
|
|
||||||
Commands:
|
|
||||||
- led_on: Turn on LED at specified pin
|
|
||||||
- led_off: Turn off LED at specified pin
|
|
||||||
"""
|
|
||||||
|
|
||||||
def register(self, registry, hardware: HardwareInterface) -> None:
|
@handler("led_off")
|
||||||
self._hardware = hardware
|
def handle_led_off(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||||
registry.register("led_on", self._handle_led_on)
|
pin = int(params["pin"])
|
||||||
registry.register("led_off", self._handle_led_off)
|
hardware.set_led(pin, False)
|
||||||
|
return (True, f"LED on pin {pin} turned OFF")
|
||||||
def _handle_led_on(self, params: dict[str, str]) -> tuple[bool, str]:
|
|
||||||
"""Turn on LED at specified pin."""
|
|
||||||
pin = int(params["pin"])
|
|
||||||
self._hardware.set_led(pin, True)
|
|
||||||
return (True, f"LED on pin {pin} turned ON")
|
|
||||||
|
|
||||||
def _handle_led_off(self, params: dict[str, str]) -> tuple[bool, str]:
|
|
||||||
"""Turn off LED at specified pin."""
|
|
||||||
pin = int(params["pin"])
|
|
||||||
self._hardware.set_led(pin, False)
|
|
||||||
return (True, f"LED on pin {pin} turned OFF")
|
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,11 @@
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..hardware.interface import HardwareInterface
|
from . import handler
|
||||||
from .base import HandlerModule
|
|
||||||
|
|
||||||
|
|
||||||
class TimingHandlers(HandlerModule):
|
@handler("delay")
|
||||||
"""
|
def handle_delay(params: dict[str, str], hardware) -> tuple[bool, str]:
|
||||||
Handler module for time-based commands.
|
duration_ms = int(params["duration_ms"])
|
||||||
|
time.sleep(duration_ms / 1000.0)
|
||||||
Commands:
|
return (True, f"Delayed {duration_ms}ms")
|
||||||
- delay: Wait for specified duration in milliseconds
|
|
||||||
"""
|
|
||||||
|
|
||||||
def register(self, registry, hardware: HardwareInterface) -> None:
|
|
||||||
self._hardware = hardware
|
|
||||||
registry.register("delay", self._handle_delay)
|
|
||||||
|
|
||||||
def _handle_delay(self, params: dict[str, str]) -> tuple[bool, str]:
|
|
||||||
"""Wait for specified duration in milliseconds."""
|
|
||||||
duration_ms = int(params["duration_ms"])
|
|
||||||
time.sleep(duration_ms / 1000.0)
|
|
||||||
return (True, f"Delayed {duration_ms}ms")
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
|
|
||||||
from .interface import HardwareInterface
|
from .interface import HardwareInterface
|
||||||
from .dummy_hardware import DummyHardware
|
from .dummy_hardware import DummyHardware
|
||||||
|
from .real_hardware import RealHardware
|
||||||
|
|
||||||
__all__ = ["HardwareInterface", "DummyHardware"]
|
__all__ = ["HardwareInterface", "DummyHardware", "RealHardware"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Real hardware implementation — communicates with hardware nodes via ROS2."""
|
||||||
|
|
||||||
|
from rclpy.node import Node
|
||||||
|
|
||||||
|
from .interface import HardwareInterface
|
||||||
|
|
||||||
|
|
||||||
|
class RealHardware(HardwareInterface):
|
||||||
|
"""Hardware interface that talks to ROS2 nodes running on the Raspberry Pi.
|
||||||
|
|
||||||
|
Each hardware operation publishes to a topic or calls a service on the
|
||||||
|
Pi-side hardware nodes. The executor does NOT run on the Pi directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: The ROS2 node to create publishers/clients on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, node: Node) -> None:
|
||||||
|
self._node = node
|
||||||
|
self._logger = node.get_logger()
|
||||||
|
# TODO: create publishers/service clients for Pi hardware nodes
|
||||||
|
# e.g. self._led_pub = node.create_publisher(...)
|
||||||
|
self._logger.info("RealHardware initialized (stub — publishers TBD)")
|
||||||
|
|
||||||
|
def set_led(self, pin: int, state: bool) -> None:
|
||||||
|
# TODO: publish to Pi hardware node
|
||||||
|
self._logger.info(f"RealHardware.set_led(pin={pin}, state={state}) — stub")
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
# TODO: check if Pi hardware nodes are reachable
|
||||||
|
return True
|
||||||
Loading…
Reference in New Issue