From 100cad47f02ca6ec2914553be4c8503a6928e16e Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 8 Mar 2026 21:55:50 +0700 Subject: [PATCH] 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. --- .gitignore | 4 + DOCUMENTATION.md | 342 +++++++-- PROJECT_MANAGEMENT.md | 726 +----------------- pixi.toml | 1 + .../blockly_executor/executor_node.py | 13 +- .../blockly_executor/handlers/__init__.py | 104 +-- .../blockly_executor/handlers/base.py | 39 - .../blockly_executor/handlers/gpio.py | 36 +- .../blockly_executor/handlers/timing.py | 25 +- .../blockly_executor/hardware/__init__.py | 3 +- .../hardware/real_hardware.py | 31 + 11 files changed, 410 insertions(+), 914 deletions(-) delete mode 100644 src/blockly_executor/blockly_executor/handlers/base.py create mode 100644 src/blockly_executor/blockly_executor/hardware/real_hardware.py diff --git a/.gitignore b/.gitignore index 820d276..ffaf300 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ Thumbs.db # Ruff cache .ruff_cache/ + +# Local project management & AI assistant notes (not for version control) +PROJECT_MANAGEMENT.md +CLAUDE.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1f5c109..6c6d190 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -195,17 +195,17 @@ amr-ros-k4/ # ROS2 Workspace root │ ├── blockly_executor/ # Python module │ │ ├── __init__.py │ │ ├── executor_node.py # ROS2 Action Server (thin wrapper) - │ │ ├── handlers/ # Modular command handler system - │ │ │ ├── __init__.py # HandlerRegistry + auto-discovery - │ │ │ ├── base.py # HandlerModule ABC — contract - │ │ │ ├── gpio.py # GpioHandlers: led_on, led_off - │ │ │ └── timing.py # TimingHandlers: delay + │ │ ├── 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 - │ │ └── 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 │ ├── __init__.py │ ├── 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 -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 +# 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 @@ -424,57 +430,46 @@ Each block file calls `BlockRegistry.register()` with all metadata, so the toolb | 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` | | [`_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)` | **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. - -**Architecture** mirrors the JS frontend's per-block file structure: +**Purpose:** Maps command names to handler functions using `@handler` decorator and auto-discovery. Mirrors the JS frontend's `BlockRegistry.register()` pattern. ``` handlers/ -├── __init__.py # HandlerRegistry + auto-imports HANDLER_MODULES -├── base.py # HandlerModule ABC — contract for all handler modules -├── gpio.py # GpioHandlers: led_on, led_off -└── timing.py # TimingHandlers: delay +├── __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`](src/blockly_executor/blockly_executor/handlers/__init__.py)): | Method | Description | |---|---| -| `HandlerRegistry.__init__(hardware)` | Auto-registers all `HANDLER_MODULES` with the hardware interface | -| `register(command, handler)` | Adds a new command→handler mapping | +| `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 | -**HandlerModule** ([`handlers/base.py`](src/blockly_executor/blockly_executor/handlers/base.py)) — the contract: - -| 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/.py` with a class extending `HandlerModule` -2. Import and add to `HANDLER_MODULES` list in `handlers/__init__.py` +**Adding a new handler:** Create `handlers/.py`, use `@handler("command")`. That's it — no other files to edit. #### [`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 +In-memory implementation for development and testing. No ROS2 communication, no real hardware. + | Attribute/Method | Description | |---|---| -| [`led_states: dict[int, bool]`](src/blockly_executor/blockly_executor/hardware/dummy_hardware.py:15) | 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 | +| `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`](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` @@ -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` | `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/` | — | @@ -557,19 +563,19 @@ Tests for the `delay` command including timing verification (±100ms tolerance). ## 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 -──── ──── ────── -1. JS src/blockly_app/blockly_app/ui/blockly/blocks/.js Create — block definition + code generator +Step File Action +──── ──── ────── +1. JS src/blockly_app/blockly_app/ui/blockly/blocks/.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.py Edit — add Python handler +2. Py src/blockly_executor/blockly_executor/handlers/.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) @@ -624,25 +630,24 @@ const BLOCK_FILES = [ ### 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`. - -Example — adding to an existing module or creating a new one: +Create a new file in `handlers/` or add to an existing one. Use the `@handler` decorator — auto-discovery handles the rest. ```python -# In HandlerRegistry._register_default_handlers(): -self.register("move_forward", self._handle_move_forward) +# src/blockly_executor/blockly_executor/handlers/movement.py +from . import handler -# New handler method: -def _handle_move_forward(self, params: dict[str, str]) -> tuple[bool, str]: - """Move robot forward with given speed and duration.""" +@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}") - 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") ``` +No imports to update, no registry list to edit. The file is auto-discovered on startup. + **Verify immediately** (no Blockly UI needed): ```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.xml +├── setup.py +├── setup.cfg +├── resource/ +│ └── # Empty file — ament index marker +├── / # Python module — same name as package +│ ├── __init__.py +│ └── .py +└── test/ + ├── __init__.py + └── test_.py +``` + +**setup.cfg:** +```ini +[develop] +script_dir=$base/lib/ +[install] +install_scripts=$base/lib/ +``` + +**package.xml:** +```xml + + + + PACKAGE_NAME + 0.1.0 + DESCRIPTION + developer + MIT + rclpy + pytest + + ament_python + + +``` + +**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//` +2. Create `package.xml`, `setup.py`, `setup.cfg` from templates above +3. `touch src//resource/` +4. Add `__init__.py` and node files +5. Add build/run tasks to `pixi.toml` +6. `colcon build --symlink-install --packages-select ` + +--- + +## 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` @@ -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. --- - -*This documentation was generated for the Blockly ROS2 Robot Controller project. For project management and task tracking, see [`PROJECT_MANAGEMENT.md`](PROJECT_MANAGEMENT.md).* diff --git a/PROJECT_MANAGEMENT.md b/PROJECT_MANAGEMENT.md index e70b854..0eddbaf 100644 --- a/PROJECT_MANAGEMENT.md +++ b/PROJECT_MANAGEMENT.md @@ -6,727 +6,25 @@ --- -## Table of Contents - -- [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. -``` - -**Adding a new block — 2 steps only**: -1. Create `blocks/.js` — call `BlockRegistry.register({...})` -2. Add `'.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.xml # ROS2 package manifest -├── setup.py # Python package setup -├── setup.cfg # ament install config -├── resource/ -│ └── # Empty file — ament index marker -├── / # Python module — same name as package -│ ├── __init__.py -│ └── .py -└── test/ # Package tests - ├── __init__.py - └── test_.py -``` - -**setup.cfg template:** -```ini -[develop] -script_dir=$base/lib/ -[install] -install_scripts=$base/lib/ -``` - -**package.xml template:** -```xml - - - - PACKAGE_NAME - 0.1.0 - DESCRIPTION - developer - MIT - - rclpy - - - pytest - - - ament_python - - -``` - -**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//` -2. **Copy templates**: Create `package.xml`, `setup.py`, `setup.cfg` from templates above -3. **Create ament marker**: `mkdir -p src//resource && touch src//resource/` -4. **Create Python module**: Add `__init__.py` and node files under `src///` -5. **Add dependencies**: Update `package.xml` with `` 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 ` -8. **Test**: `colcon test --packages-select ` - -### 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/.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_.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/.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 +## Planned Packages | Package | Purpose | Status | |---------|---------|--------| -| `blockly_app` | Desktop Blockly GUI + Action Client | ✅ Exists — restructuring | -| `blockly_executor` | Action Server — command handler registry | ✅ Exists — restructuring | -| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Exists — restructuring | +| `blockly_app` | Desktop Blockly GUI + Action Client | ✅ Done | +| `blockly_executor` | Action Server — command handler registry | ✅ Done | +| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Done | | `kiwi_controller` | Adaptive control for Kiwi Wheel drive | 📋 Planned | +## Future Tasks + + + ### 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**: Create a `blockly_bringup` package with ROS2 launch files to start all nodes with a single command -- **Parameter server**: Use ROS2 parameters for configurable values like GPIO pin mappings, control loop rates -- **Sensor integration**: Add subscriber nodes for sensor data that can feed back into Blockly's visual feedback +- **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) - **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 - **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. - ---- - -*This document is maintained as part of the project management process. Update task checkboxes as work progresses.* +- **Block categories**: Future blocks grouped into Robot, Sensors, Navigation categories diff --git a/pixi.toml b/pixi.toml index fe2e4a9..763a03f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -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 = { 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-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"] } 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/" diff --git a/src/blockly_executor/blockly_executor/executor_node.py b/src/blockly_executor/blockly_executor/executor_node.py index 4611e04..a915fba 100644 --- a/src/blockly_executor/blockly_executor/executor_node.py +++ b/src/blockly_executor/blockly_executor/executor_node.py @@ -6,8 +6,8 @@ from rclpy.action.server import ServerGoalHandle from rclpy.node import Node from .handlers import HandlerRegistry -from .hardware.interface import HardwareInterface from .hardware.dummy_hardware import DummyHardware +from .hardware.real_hardware import RealHardware from .utils import parse_params # Import will work after colcon build of interfaces package. @@ -24,12 +24,17 @@ class ExecutorNode(Node): 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") - 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() - self.get_logger().info("Using DummyHardware (no real GPIO)") + self.get_logger().info("Using DummyHardware (no real hardware)") self._registry = HandlerRegistry(hardware) diff --git a/src/blockly_executor/blockly_executor/handlers/__init__.py b/src/blockly_executor/blockly_executor/handlers/__init__.py index 77f0366..6adc322 100644 --- a/src/blockly_executor/blockly_executor/handlers/__init__.py +++ b/src/blockly_executor/blockly_executor/handlers/__init__.py @@ -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 -and registers its commands with the HandlerRegistry during initialization. +To add a new command handler: +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: -1. Create a new file (e.g., movement.py) with a class extending HandlerModule -2. Add an import and instantiation in HANDLER_MODULES below + from . import handler + + @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 ..hardware.interface import HardwareInterface -from .base import HandlerModule -# Import all handler modules -from .gpio import GpioHandlers -from .timing import TimingHandlers +# Global list that collects (command_name, function) pairs during import. +# Each @handler("name") call appends here. HandlerRegistry.__init__ +# 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. -HANDLER_MODULES: list[HandlerModule] = [ - GpioHandlers(), - TimingHandlers(), -] + +def handler(command: str) -> Callable: + """Decorator that registers a function as a command handler. + + 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: - """ - Registry of command handlers that maps command names to handler functions. + """Maps command names to handler functions. - Each handler receives a params dict and returns a tuple of - (success: bool, message: str). - - Handler modules are auto-registered from HANDLER_MODULES during init. + On init, auto-discovers all handler modules in this package and binds + the hardware interface to each registered handler. """ def __init__(self, hardware: HardwareInterface) -> None: self._hardware = hardware self._handlers: dict[str, Callable[[dict[str, str]], tuple[bool, str]]] = {} - self._register_all_modules() - def _register_all_modules(self) -> None: - """Auto-register all handler modules from HANDLER_MODULES.""" - for module in HANDLER_MODULES: - module.register(self, self._hardware) + _auto_discover_handlers() - def register( - self, - command: str, - handler: Callable[[dict[str, str]], tuple[bool, str]], - ) -> None: - """Register a new command handler.""" - self._handlers[command] = handler + for command, fn in _REGISTERED_HANDLERS: + self._handlers[command] = lambda params, _fn=fn: _fn(params, hardware) def execute(self, command: str, params: dict[str, str]) -> tuple[bool, str]: - """ - Execute a command by name with given parameters. - - 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: + handler_fn = self._handlers.get(command) + if handler_fn is None: return (False, f"Unknown command: '{command}'") - return handler(params) + return handler_fn(params) -__all__ = ["HandlerRegistry", "HandlerModule", "HANDLER_MODULES"] +__all__ = ["HandlerRegistry", "handler"] diff --git a/src/blockly_executor/blockly_executor/handlers/base.py b/src/blockly_executor/blockly_executor/handlers/base.py deleted file mode 100644 index ee3a561..0000000 --- a/src/blockly_executor/blockly_executor/handlers/base.py +++ /dev/null @@ -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. - """ - ... diff --git a/src/blockly_executor/blockly_executor/handlers/gpio.py b/src/blockly_executor/blockly_executor/handlers/gpio.py index 6722ae3..e513007 100644 --- a/src/blockly_executor/blockly_executor/handlers/gpio.py +++ b/src/blockly_executor/blockly_executor/handlers/gpio.py @@ -1,31 +1,17 @@ """GPIO command handlers — LED on/off and future GPIO operations.""" -from ..hardware.interface import HardwareInterface -from .base import HandlerModule +from . import handler -class GpioHandlers(HandlerModule): - """ - Handler module for GPIO-related commands. +@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") - Commands: - - led_on: Turn on LED at specified pin - - led_off: Turn off LED at specified pin - """ - def register(self, registry, hardware: HardwareInterface) -> None: - self._hardware = hardware - registry.register("led_on", self._handle_led_on) - registry.register("led_off", self._handle_led_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") +@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") diff --git a/src/blockly_executor/blockly_executor/handlers/timing.py b/src/blockly_executor/blockly_executor/handlers/timing.py index c00eb13..5950e39 100644 --- a/src/blockly_executor/blockly_executor/handlers/timing.py +++ b/src/blockly_executor/blockly_executor/handlers/timing.py @@ -2,24 +2,11 @@ import time -from ..hardware.interface import HardwareInterface -from .base import HandlerModule +from . import handler -class TimingHandlers(HandlerModule): - """ - Handler module for time-based commands. - - Commands: - - 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") +@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") diff --git a/src/blockly_executor/blockly_executor/hardware/__init__.py b/src/blockly_executor/blockly_executor/hardware/__init__.py index 7b6340b..629c973 100644 --- a/src/blockly_executor/blockly_executor/hardware/__init__.py +++ b/src/blockly_executor/blockly_executor/hardware/__init__.py @@ -2,5 +2,6 @@ from .interface import HardwareInterface from .dummy_hardware import DummyHardware +from .real_hardware import RealHardware -__all__ = ["HardwareInterface", "DummyHardware"] +__all__ = ["HardwareInterface", "DummyHardware", "RealHardware"] diff --git a/src/blockly_executor/blockly_executor/hardware/real_hardware.py b/src/blockly_executor/blockly_executor/hardware/real_hardware.py new file mode 100644 index 0000000..0034749 --- /dev/null +++ b/src/blockly_executor/blockly_executor/hardware/real_hardware.py @@ -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