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/
|
||||
|
||||
# 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
|
||||
│ │ ├── __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/<category>.py` with a class extending `HandlerModule`
|
||||
2. Import and add to `HANDLER_MODULES` list in `handlers/__init__.py`
|
||||
**Adding a new handler:** Create `handlers/<name>.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/<name>.js Create — block definition + code generator
|
||||
Step File Action
|
||||
──── ──── ──────
|
||||
1. JS src/blockly_app/blockly_app/ui/blockly/blocks/<name>.js Create — BlockRegistry.register({...})
|
||||
src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js Edit — add filename to BLOCK_FILES array
|
||||
2. Py src/blockly_executor/blockly_executor/handlers.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)
|
||||
|
||||
|
|
@ -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_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`
|
||||
|
||||
|
|
@ -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).*
|
||||
|
|
|
|||
|
|
@ -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. <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
|
||||
## 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
|
||||
|
||||
<!-- Add new phases / features here -->
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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