feat: add amr_bringup launch files and split BLOCKS.md into docs/

- Create amr_bringup package with blockly.launch.py (dummy) and
  blockly_hw.launch.py (real) for one-command desktop bringup
- Add pixi tasks: build-bringup, launch-blockly, launch-blockly-hw
- Split 1225-line BLOCKS.md into 4 focused files under docs/
- Guard rclpy.shutdown() double-call in executor_node.py
- Update DOCUMENTATION.md with amr_bringup entry and launch command
master
a2nr 2026-03-20 15:13:07 +07:00
parent c19f6656b8
commit cfc390e1d6
20 changed files with 1453 additions and 1254 deletions

View File

@ -14,12 +14,13 @@ See [readme.md](readme.md) for project overview and status.
| Troubleshooting & known issues | [docs/troubleshooting.md](docs/troubleshooting.md) |
| Guide: adding a new ROS2 package | [docs/ros2-package-guide.md](docs/ros2-package-guide.md) |
| `blockly_app` — file reference (incl. HMI core modules) | [src/blockly_app/README.md](src/blockly_app/README.md) |
| `blockly_app` — creating blocks (ROS2 action + client-side + HMI) | [src/blockly_app/BLOCKS.md](src/blockly_app/BLOCKS.md) |
| `blockly_app` — creating blocks (ROS2 action + client-side + HMI) | [src/blockly_app/docs/BLOCKS.md](src/blockly_app/docs/BLOCKS.md) |
| `blockly_executor` — file reference, handlers & testing guide | [src/blockly_executor/README.md](src/blockly_executor/README.md) |
| `blockly_interfaces` — ROS2 action & message interfaces | [src/blockly_interfaces/README.md](src/blockly_interfaces/README.md) |
| `gpio_node` — Raspberry Pi GPIO node (C++, libgpiod) | [src/gpio_node/README.md](src/gpio_node/README.md) |
| `pca9685_node` — PCA9685 16-channel PWM controller (C++, I2C) | [src/pca9685_node/README.md](src/pca9685_node/README.md) |
| `as5600_node` — AS5600 12-bit magnetic encoder (C++, I2C) | [src/as5600_node/README.md](src/as5600_node/README.md) |
| `amr_bringup` — ROS2 launch files (desktop bringup) | [src/amr_bringup/launch/](src/amr_bringup/launch/) |
---
@ -33,6 +34,8 @@ pixi run build-interfaces # must build interfaces first
pixi run build # build all packages
pixi run setup-ui # download Blockly JS vendor files (first time, needs internet)
pixi run launch-blockly # one command — executor (dummy) + app
# or separately:
pixi run executor # Terminal 1 — start Action Server
pixi run app # Terminal 2 — start desktop GUI
```

View File

@ -43,6 +43,9 @@ executor = { cmd = "bash -c 'source install/setup.bash && ros2 run block
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"] }
build-bringup = { cmd = "colcon build --symlink-install --packages-select amr_bringup", depends-on = ["build-interfaces"] }
launch-blockly = { cmd = "bash -c 'source install/setup.bash && ros2 launch amr_bringup blockly.launch.py'", depends-on = ["build-bringup", "build-executor", "build-app"] }
launch-blockly-hw = { cmd = "bash -c 'source install/setup.bash && ros2 launch amr_bringup blockly_hw.launch.py'", depends-on = ["build-bringup", "build-executor", "build-app"] }
setup-ui = "npm install blockly gridstack && 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/ && cp node_modules/gridstack/dist/gridstack-all.js src/blockly_app/blockly_app/ui/vendor/ && cp node_modules/gridstack/dist/gridstack.min.css src/blockly_app/blockly_app/ui/vendor/"
[target.linux-aarch64.tasks]

View File

@ -44,18 +44,6 @@ bab pada dokumen merepresentasikan alur rencana pengembangan.
this sub title list is short by priority
## **Launch files**
ROS2 launch files to start all nodes with one command includ node in raspberry pi. composite blockly dan executor yang memiliki composit 2 jenis yaitu menggunakan executor dummy dan executor-hw.
## **overwhelm BLOCKS.md**
please analyse and split BLOCKS.md into some file. sarankan padaku apa saja yang perlu di pindah sebelum melakukannya. this my purpose path
```
src/blockly_app
├── docs
| ├── BLOCKS.md # table of sub content blocks
│ └── <sub bab>.md # content
```
## **Feasibility Study to implement Vision Sensor**
coba kamu lakukan penelitian literatur untuk penerapan vision sensor dengan kemampuan berikut ini
@ -350,19 +338,54 @@ Fields: `X (cm)`, `Y (cm)`, `Heading (rad)`, `Vel X (cm/s)`, `Vel Y (cm/s)`, `An
- [ ] End-to-end: Blockly → executor (real) → cache `odometry_encoder/odom` → return JSON
- [x] Integration test `test_block_odometry.py` passes di dummy mode
## 6 Enhancement: HMI Interactive Widgets — Button, Slider, Switch : [x]
Menambahkan widget **control** (dua arah: user input ↔ code): Button, Slider, dan Switch — mengikuti konsep LabVIEW "Controls vs Indicators". Setiap control memiliki SET block (statement) dan GET block (value). Semua client-side JS, tidak ada perubahan Python/ROS2.
Detail implementasi: [docs/architecture.md](docs/architecture.md) §2.4, [BLOCKS.md](src/blockly_app/BLOCKS.md) §7.16, [troubleshooting.md](docs/troubleshooting.md).
## 6 Enhancement: Split BLOCKS.md Documentation : [x]
BLOCKS.md (1225 baris) dipecah menjadi 4 file terpisah + index di `src/blockly_app/docs/` agar lebih mudah dinavigasi.
### Definition Of Done
- [x] 6 block file (SET + GET untuk button, slider, switch)
- [x] `hmi-manager.js` — render, setter, getter, user interaction tracking, layout serialization
- [x] `hmi-preview.js` — design-time preview + auto-increment widget names
- [x] `manifest.js` — 6 entry baru
- [x] CSS untuk button, slider, switch di `index.html`
- [x] Bug fix: `highlightBlock` periodic macrotask yield (`debug-engine.js`)
- [x] Bug fix: `pointerdown` instead of `click` for button/switch (`hmi-manager.js`)
- [x] Bug fix: `_userState` / `_userHasInteracted` tracking for switch/slider (`hmi-manager.js`)
- [x] Bug fix: auto-increment duplicate widget names (`hmi-preview.js`)
- [x] `docs/BLOCKS.md` — index / table of contents (~40 baris)
- [x] `docs/01-quickstart.md` — walkthrough end-to-end menambah block baru
- [x] `docs/02-block-api-ref.md` — API reference `BlockRegistry.register()`, `definition.init`
- [x] `docs/03-templates.md` — 6 template copy-paste (AF)
- [x] `docs/04-examples.md` — contoh real (`digitalOut`, `digitalIn`, `delay`) + tabel kategori
- [x] Semua referensi ke BLOCKS.md di-update (README.md, DOCUMENTATION.md, blockly_interfaces/README.md, readme.md)
- [x] Original `BLOCKS.md` dihapus
## 8 Enhancement: Launch Files — Desktop Bringup : [ ]
ROS2 launch files untuk menjalankan executor + blockly_app dengan satu command. Package `amr_bringup` (ament_python, launch-only).
### Implementasi
#### A. Package Structure
```
src/amr_bringup/
├── package.xml
├── setup.py / setup.cfg
├── resource/amr_bringup
├── amr_bringup/__init__.py
├── launch/
│ ├── blockly.launch.py # executor (dummy) + blockly_app
│ └── blockly_hw.launch.py # executor (real) + blockly_app
└── config/ # reserved untuk Pi nodes YAML nanti
```
#### B. Pixi Tasks
```bash
pixi run launch-blockly # executor dummy + app (development)
pixi run launch-blockly-hw # executor real + app (Pi nodes harus sudah jalan)
```
#### C. Catatan
- `blockly_app` diluncurkan via `ExecuteProcess` (bukan `Node()`) karena pywebview memerlukan main thread ownership
- Pi nodes (gpio, pca9685, as5600) akan dibahas terpisah — kemungkinan menggunakan C++ composition (`rclcpp_components`) untuk optimasi resource di Raspberry Pi
- Ctrl+C menghentikan semua node sekaligus
### Definition Of Done
- [x] `src/amr_bringup/` package scaffolding (package.xml, setup.py, setup.cfg, resource)
- [x] `launch/blockly.launch.py` — executor dummy + app
- [x] `launch/blockly_hw.launch.py` — executor real + app
- [x] Pixi tasks: `build-bringup`, `launch-blockly`, `launch-blockly-hw`
- [x] `pixi run launch-blockly` berhasil menjalankan executor + app
- [x] `pixi run launch-blockly-hw` berhasil menjalankan executor (real) + app
- [x] Ctrl+C menghentikan semua proses

View File

View File

@ -0,0 +1,37 @@
"""Launch blockly_executor (dummy mode) + blockly_app in one command.
When blockly_app window is closed, the entire launch is shut down.
"""
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import ExecuteProcess, RegisterEventHandler, Shutdown
from launch.event_handlers import OnProcessExit
def generate_launch_description():
executor_node = Node(
package='blockly_executor',
executable='executor_node',
name='blockly_executor_node',
parameters=[{'hardware_mode': 'dummy'}],
output='screen',
emulate_tty=True,
)
# blockly_app uses pywebview which requires main thread ownership,
# so we launch it as a process rather than a Node.
blockly_app = ExecuteProcess(
cmd=['python', '-m', 'blockly_app.app'],
output='screen',
)
return LaunchDescription([
executor_node,
blockly_app,
# When blockly_app is closed, shut down everything
RegisterEventHandler(OnProcessExit(
target_action=blockly_app,
on_exit=[Shutdown(reason='blockly_app closed')],
)),
])

View File

@ -0,0 +1,38 @@
"""Launch blockly_executor (real hardware mode) + blockly_app in one command.
Pi hardware nodes (gpio_node, pca9685_node, as5600_node) must already be
running on the Raspberry Pi on the same ROS2 network (same ROS_DOMAIN_ID).
When blockly_app window is closed, the entire launch is shut down.
"""
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import ExecuteProcess, RegisterEventHandler, Shutdown
from launch.event_handlers import OnProcessExit
def generate_launch_description():
executor_node = Node(
package='blockly_executor',
executable='executor_node',
name='blockly_executor_node',
parameters=[{'hardware_mode': 'real'}],
output='screen',
emulate_tty=True,
)
blockly_app = ExecuteProcess(
cmd=['python', '-m', 'blockly_app.app'],
output='screen',
)
return LaunchDescription([
executor_node,
blockly_app,
# When blockly_app is closed, shut down everything
RegisterEventHandler(OnProcessExit(
target_action=blockly_app,
on_exit=[Shutdown(reason='blockly_app closed')],
)),
])

View File

@ -0,0 +1,16 @@
<?xml version="1.0"?>
<package format="3">
<name>amr_bringup</name>
<version>0.1.0</version>
<description>Launch files for AMR ROS2 Kiwi Wheel system bringup</description>
<maintainer email="dev@example.com">developer</maintainer>
<license>MIT</license>
<exec_depend>blockly_executor</exec_depend>
<exec_depend>blockly_app</exec_depend>
<exec_depend>ros2launch</exec_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

View File

@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/amr_bringup
[install]
install_scripts=$base/lib/amr_bringup

19
src/amr_bringup/setup.py Normal file
View File

@ -0,0 +1,19 @@
import os
from glob import glob
from setuptools import setup
package_name = 'amr_bringup'
setup(
name=package_name,
version='0.1.0',
packages=[package_name],
data_files=[
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
entry_points={},
)

File diff suppressed because it is too large Load Diff

View File

@ -182,4 +182,4 @@ All block files registered in [`manifest.js`](blockly_app/ui/blockly/blocks/mani
| `hmiSetSwitch.js` | HMI | Statement | Client-side (`HMI.setSwitch()`) |
| `hmiGetSwitch.js` | HMI | Output | Client-side (`HMI.getSwitch()`) |
Each block file calls `BlockRegistry.register()` with all metadata, so the toolbox is automatically generated. See [BLOCKS.md](BLOCKS.md) for the full guide to creating blocks.
Each block file calls `BlockRegistry.register()` with all metadata, so the toolbox is automatically generated. See [BLOCKS.md](docs/BLOCKS.md) for the full guide to creating blocks.

View File

@ -0,0 +1,235 @@
# Quickstart — Adding a New Block
## Overview: Auto-Discovery on Both Sides
Both JS and Python use the same pattern: **decorator/register + auto-discovery**. Adding a new block:
```
Step File Action
──── ──── ──────
1. JS src/blockly_app/blockly_app/ui/blockly/blocks/<name>.js Create — BlockRegistry.register({...})
src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js Edit — add filename to BLOCK_FILES array
2. Py src/blockly_executor/blockly_executor/handlers/<name>.py Create — @handler("command") function
```
**Files that do NOT need changes:** [`index.html`](../blockly_app/ui/index.html), [`conftest.py`](../../blockly_executor/test/conftest.py), [`executor_node.py`](../../blockly_executor/blockly_executor/executor_node.py), [`BlocklyAction.action`](../../blockly_interfaces/action/BlocklyAction.action), [`handlers/__init__.py`](../../blockly_executor/blockly_executor/handlers/__init__.py). Both toolbox and handler registry are auto-generated.
---
## Step 1 — Create Block File (JS)
Create `src/blockly_app/blockly_app/ui/blockly/blocks/move_forward.js`:
```javascript
BlockRegistry.register({
name: 'move_forward',
category: 'Robot',
categoryColor: '#5b80a5',
color: '#7B1FA2',
tooltip: 'Move robot forward with given speed and duration',
definition: {
init: function () {
this.appendDummyInput()
.appendField('Move forward speed')
.appendField(new Blockly.FieldNumber(50, 0, 100, 1), 'SPEED')
.appendField('for')
.appendField(new Blockly.FieldNumber(1000, 0), 'DURATION_MS')
.appendField('ms');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#7B1FA2');
this.setTooltip('Move robot forward with given speed and duration');
}
},
generator: function (block) {
const speed = block.getFieldValue('SPEED');
const durationMs = block.getFieldValue('DURATION_MS');
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('move_forward', { speed: '" + speed + "', duration_ms: '" + durationMs + "' });\n"
);
}
});
```
Then add to [`manifest.js`](../blockly_app/ui/blockly/blocks/manifest.js):
```javascript
const BLOCK_FILES = [
'digitalOut.js',
'digitalIn.js',
'delay.js',
'move_forward.js', // ← add here
];
```
**No `index.html` changes needed.** No toolbox XML editing. The block appears automatically in the "Robot" category.
---
## Step 2 — Register Handler in Python
Create a new file in `handlers/` or add to an existing one. Use the `@handler` decorator — auto-discovery handles the rest.
Each handler contains **both dummy and real hardware logic**. Use `hardware.is_real()` to branch, and `hardware.log()` to record actions for testing.
```python
# src/blockly_executor/blockly_executor/handlers/movement.py
from . import handler
from .hardware import Hardware
@handler("move_forward")
def handle_move_forward(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
speed = int(params["speed"])
duration_ms = int(params["duration_ms"])
if not (0 <= speed <= 100):
raise ValueError(f"speed must be 0-100, got: {speed}")
hardware.log(f"move_forward(speed={speed}, duration_ms={duration_ms})")
if hardware.is_real():
# Real hardware — lazy import, publish to ROS2 topic
# TODO: hardware.node.create_publisher(...) etc.
hardware.node.get_logger().info(f"Moving forward at {speed} for {duration_ms}ms")
return (True, f"Moved forward at speed {speed} for {duration_ms}ms")
```
No imports to update, no registry list to edit. The file is auto-discovered on startup.
**Verify immediately** (no Blockly UI needed):
```bash
# Terminal 1
pixi run executor
# Terminal 2 — send goal manually
pixi run bash -c 'source install/setup.bash && ros2 action send_goal /execute_blockly_action blockly_interfaces/action/BlocklyAction "{command: move_forward, param_keys: [speed, duration_ms], param_values: [50, 1000]}"'
```
---
## Step 3 — Write Integration Test (Optional)
Create `src/blockly_executor/test/test_block_move_forward.py`:
```python
"""Integration test for Blockly instruction: move_forward"""
def test_block_move_forward_returns_success(exe_action):
result = exe_action("move_forward", speed="50", duration_ms="200")
assert result.result.success is True
def test_block_move_forward_sends_executing_feedback(exe_action):
result = exe_action("move_forward", speed="50", duration_ms="200")
assert len(result.feedbacks) > 0
assert result.feedbacks[0].status == "executing"
def test_block_move_forward_missing_speed_returns_failure(exe_action):
result = exe_action("move_forward", duration_ms="200")
assert result.result.success is False
assert "speed" in result.result.message.lower()
```
---
## Name Consistency Reference Table
```
handlers.py blocks/<name>.js blocks/<name>.js
─────────── ──────────────── ─────────────────
"move_forward" == name: 'move_forward' == 'move_forward' in executeAction
params["speed"] == 'SPEED' in FieldNumber == getFieldValue('SPEED')
params["duration_ms"] == 'DURATION_MS' == getFieldValue('DURATION_MS')
```
---
## Data Flow: JS Block → Python Handler → Hardware
```
User drags block into workspace
[Run button pressed]
Blockly generates JS code from block.generator()
─ Statement: string code ending with \n
─ Output: [expression_string, Order]
debug-engine.js executes the generated JS
await executeAction('my_command', { key: 'value', … })
→ bridge.js calls window.pywebview.api.execute_action(command, keys, values)
BlocklyAPI.execute_action() in app.py
→ ROS2 Action Client sends Goal { command, param_keys, param_values }
ExecutorNode receives goal, calls HandlerRegistry.execute(command, params)
@handler("my_command") function(params, hardware)
→ hardware.log(...) ← always logged for testing
→ if hardware.is_real(): ... ← real hardware (ROS2 publish/GPIO)
Returns (True, "LED on pin 3 turned ON")
→ goal_handle.succeed() → result.success=True, result.message=...
await executeAction() resolves → JS continues to next block
```
---
## Step-by-Step Checklist — Adding a New Block
**Path A — ROS2 Action Block** (robot commands, sensor reads):
```
1. Create src/blockly_app/…/ui/blockly/blocks/<name>.js
└─ Call BlockRegistry.register({ name, category, color, definition, generator })
└─ generator calls await executeAction('name', { key: 'value' })
└─ name must exactly match the Python @handler string
2. Edit src/blockly_app/…/ui/blockly/blocks/manifest.js
└─ Add '<name>.js' to the BLOCK_FILES array
3. Create src/blockly_executor/…/handlers/<name>.py (or add to existing file)
└─ from . import handler
└─ from .hardware import Hardware
└─ @handler("name")
└─ def handle_name(params, hardware: Hardware): ... → return (bool, str)
└─ hardware.log(...) for testing + hardware.is_real() for real logic
4. Test pixi run executor (Terminal 1)
pixi run app (Terminal 2) — drag block, click Run
pixi run test -- src/blockly_executor/test/test_block_<name>.py -v
```
**Path B — Client-side Block** (print, HMI, pure JS):
```
1. Create src/blockly_app/…/ui/blockly/blocks/<name>.js
└─ Call BlockRegistry.register({ name, category, color, definition, generator })
└─ generator calls JS function directly (consoleLog(), HMI.set*(), etc.)
└─ NO executeAction(), NO Python handler needed
2. Edit src/blockly_app/…/ui/blockly/blocks/manifest.js
└─ Add '<name>.js' to the BLOCK_FILES array
3. Test pixi run app — drag block, click Run (no executor needed for client-side blocks)
```
No changes needed to `index.html`, `executor_node.py`, `handlers/__init__.py`, or `BlocklyAction.action`.
---

View File

@ -0,0 +1,383 @@
# Block API Reference
## Block Type Overview
There are three fundamental block shapes. Choose based on what the block **does**:
| Type | Shape | Returns | Use case | Examples |
|------|-------|---------|----------|---------|
| **Statement** | Top + bottom notches | Nothing (side effect) | Execute an action on the robot | `digital_out`, `delay` |
| **Output** | Left plug, no notches | A value | Read a sensor, compute something | `read_distance`, `read_temperature` |
| **Container** | Statement input socket | Nothing | Wrap a block stack (loop body, condition body) | Custom `repeat_until_clear` |
> Output blocks can be plugged into `appendValueInput()` sockets of other blocks (e.g., plug `read_distance` into `move_to(X=..., Y=...)`)
---
## `BlockRegistry.register()` — All Fields
```js
BlockRegistry.register({
// ── Required ────────────────────────────────────────────────────────────
name: 'my_command', // Unique block ID. Must match Python @handler("my_command")
category: 'Robot', // Toolbox category label. New names auto-create a category.
color: '#4CAF50', // Block body hex color
definition: { init: function() { … } }, // Blockly visual definition
generator: function(block) { … }, // JS code generator
// ── Optional ────────────────────────────────────────────────────────────
categoryColor: '#5b80a5', // Category sidebar color (default: '#5b80a5')
tooltip: 'Description', // Hover text (also set inside init via setTooltip)
outputType: 'Number', // Set ONLY for output blocks ('Number', 'String', 'Boolean')
// When present, generator must return [code, Order] array
});
```
**`name` is the contract** — it must exactly match the `@handler("name")` string in Python. A mismatch causes `Unknown command: 'name'` at runtime.
---
## `definition.init` — Complete Reference
The `definition` field takes a plain object with one required key: `init`. This function runs once when Blockly creates the block. Inside `init`, `this` refers to the block instance.
**Call order inside `init`:**
1. `appendXxxInput()` rows — build the visual layout top-to-bottom
2. Connection methods — define how the block connects to others
3. Style methods — set color and tooltip
---
### Input Rows — Visual Layout
Every input row is a horizontal strip. Rows stack top-to-bottom. Fields chain left-to-right within a row.
```
appendDummyInput() appendValueInput('X') appendStatementInput('DO')
┌───────────────────────────┐ ┌─────────────────────┬──┐ ┌──────────────────────────┐
│ [label] [field] [label]│ │ [label] [field] │◄─┤socket │ do │
└───────────────────────────┘ └─────────────────────┴──┘ │ ┌────────────────────┐ │
no sockets, no plug right side has an input socket │ │ (block stack here) │ │
that accepts output block plugs │ └────────────────────┘ │
└──────────────────────────┘
```
---
### `appendDummyInput()` — Label / field row (no sockets)
Use when the block only needs static inline fields. No output block can connect here.
```js
init: function () {
this.appendDummyInput() // creates one horizontal row
.appendField('Speed') // text label (no key needed)
.appendField(
new Blockly.FieldNumber(50, 0, 100, 1), 'SPEED'
) // interactive field — key 'SPEED'
.appendField('%'); // another text label after the field
// Visual: ┌─ Speed [50] % ─┐
}
```
Multiple `appendDummyInput()` calls stack as separate rows:
```js
init: function () {
this.appendDummyInput().appendField('Motor Config'); // header label row
this.appendDummyInput()
.appendField('Left ')
.appendField(new Blockly.FieldNumber(50, -100, 100, 1), 'LEFT');
this.appendDummyInput()
.appendField('Right')
.appendField(new Blockly.FieldNumber(50, -100, 100, 1), 'RIGHT');
// Visual:
// ┌──────────────────────────┐
// │ Motor Config │
// │ Left [-100..100] │
// │ Right [-100..100] │
// └──────────────────────────┘
}
```
---
### `appendValueInput('KEY')` — Socket row (accepts output blocks)
Use when you want the user to plug in a sensor or value block. The socket appears on the **right side** of the row. The label (if any) appears on the left.
```js
init: function () {
this.appendValueInput('SPEED') // 'SPEED' is the key used in valueToCode()
.setCheck('Number') // only accept Number-type output blocks
// use setCheck(null) or omit to accept any type
.appendField('Drive at'); // label to the left of the socket
// Visual: ┌─ Drive at ◄──(output block plugs here) ─┐
}
```
Multiple `appendValueInput` rows, collapsed inline with `setInputsInline(true)`:
```js
init: function () {
this.appendValueInput('X').setCheck('Number').appendField('X');
this.appendValueInput('Y').setCheck('Number').appendField('Y');
this.setInputsInline(true); // ← collapses rows side-by-side
// setInputsInline(false) (default): rows stacked vertically
// setInputsInline(true): rows placed side-by-side on one line
// Visual (inline): ┌─ X ◄─ Y ◄─ ─┐
}
```
Reading the plugged-in block value in the generator:
```js
generator: function (block) {
// valueToCode returns the generated expression for the plugged-in block
// Falls back to the default ('0', '', etc.) if the socket is empty
const x = javascript.javascriptGenerator.valueToCode(
block, 'X', javascript.Order.ATOMIC // Order.ATOMIC: value is a safe atom (no parens needed)
) || '0'; // || '0': fallback if socket is empty
}
```
---
### `appendStatementInput('KEY')` — Indented block stack slot
Use for container blocks (loops, conditionals) where the user places a stack of blocks inside.
```js
init: function () {
this.appendDummyInput()
.appendField('While obstacle');
this.appendStatementInput('BODY') // 'BODY' is the key for statementToCode()
.appendField('do'); // optional label next to the slot opening
// Visual:
// ┌────────────────────────────────┐
// │ While obstacle │
// │ do │
// │ ┌──────────────────────┐ │
// │ │ (blocks stack here) │ │
// │ └──────────────────────┘ │
// └────────────────────────────────┘
}
```
Reading the inner stack in the generator:
```js
generator: function (block) {
const inner = javascript.javascriptGenerator.statementToCode(block, 'BODY');
// statementToCode returns the generated code for ALL blocks stacked inside 'BODY'
// Returns '' if the slot is empty
return "while (true) {\n" + inner + "}\n";
}
```
---
### Connection / Shape Methods
These define the block's shape. Call them **after** all `appendXxx` rows.
```js
// ── Statement block (stackable in a sequence) ─────────────────────────────
this.setPreviousStatement(true, null);
// ↑ ↑── type filter: null = accept any block above
// └── true = show top notch
this.setNextStatement(true, null);
// same params — shows bottom notch so another block can connect below
// ── Output block (value-returning, plugs into sockets) ────────────────────
this.setOutput(true, 'Number');
// ↑── output type: 'Number' | 'String' | 'Boolean' | null (any)
// ⚠ setOutput is MUTUALLY EXCLUSIVE with setPreviousStatement / setNextStatement
// ── Standalone (no connections — rare) ───────────────────────────────────
// Omit all three. Block floats freely, cannot connect to anything.
```
Type filter (second argument) restricts which blocks can connect:
```js
this.setPreviousStatement(true, 'RobotAction'); // only connects below RobotAction blocks
this.setNextStatement(true, 'RobotAction');
// Matching blocks must also declare:
// this.setOutput(true, 'RobotAction') ← on the output side
// Rarely needed in this project — use null for unrestricted.
```
---
### Style Methods
```js
this.setColour('#4CAF50'); // block body color — hex string OR hue int (0360)
this.setTooltip('Hover text'); // shown when user hovers over the block
this.setHelpUrl('https://...'); // optional: opens URL when user clicks '?' on block
```
> Always use **hex strings** (e.g. `'#4CAF50'`) to match the project's color scheme. The `color` field in `BlockRegistry.register()` and `setColour()` inside `init` should use the same value.
---
### Layout Control — `setInputsInline`
```js
this.setInputsInline(true); // compact: all appendValueInput rows on ONE line
this.setInputsInline(false); // (default) each appendValueInput row on its OWN line
```
`setInputsInline` only affects `appendValueInput` rows — `appendDummyInput` rows are always inline.
---
### Complete Multi-Row Example
A block combining all input types:
```js
definition: {
init: function () {
// ── Row 1: DummyInput — header label + FieldNumber ──────────────────
this.appendDummyInput()
.appendField('Drive until <')
.appendField(new Blockly.FieldNumber(20, 1, 500, 1), 'THRESHOLD')
.appendField('cm');
// ── Row 2+3: ValueInput — two sockets, collapsed inline ─────────────
this.appendValueInput('SPEED_L')
.setCheck('Number')
.appendField('L');
this.appendValueInput('SPEED_R')
.setCheck('Number')
.appendField('R');
this.setInputsInline(true); // collapse rows 2+3 side-by-side
// ── Row 4: StatementInput — block stack slot ─────────────────────────
this.appendStatementInput('ON_ARRIVAL')
.appendField('then');
// ── Connection shape ─────────────────────────────────────────────────
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
// ── Style ────────────────────────────────────────────────────────────
this.setColour('#FF5722');
this.setTooltip('Drive at given speeds until obstacle is closer than threshold');
},
},
generator: function (block) {
const threshold = block.getFieldValue('THRESHOLD');
const speedL = javascript.javascriptGenerator.valueToCode(block, 'SPEED_L', javascript.Order.ATOMIC) || '50';
const speedR = javascript.javascriptGenerator.valueToCode(block, 'SPEED_R', javascript.Order.ATOMIC) || '50';
const body = javascript.javascriptGenerator.statementToCode(block, 'ON_ARRIVAL');
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('drive_until', {" +
" threshold: '" + threshold + "'," +
" speed_l: String(" + speedL + ")," +
" speed_r: String(" + speedR + ")" +
" });\n" +
body
);
},
```
Visual result:
```
┌──────────────────────────────────────────────────┐
│ Drive until < [20] cm │
│ L ◄─(speed) R ◄─(speed) │
│ then │
│ ┌────────────────────────────────────────┐ │
│ │ (on-arrival blocks stack here) │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
---
## `executeAction()` and `highlightBlock()` Reference
Both functions are provided by [bridge.js](../blockly_app/ui/blockly/core/bridge.js) and injected into the eval context by [debug-engine.js](../blockly_app/ui/blockly/core/debug-engine.js).
### `await executeAction(command, params)`
```js
// Sends a ROS2 action goal and waits for the result.
// Returns: { success: bool, message: string }
const result = await executeAction('my_command', {
key1: 'value1', // all values must be strings
key2: String(someNumber),
});
if (!result.success) {
console.error(result.message);
}
```
- **Always `await`** — omitting `await` means the next block runs immediately before the hardware finishes.
- **All param values must be strings** — the ROS2 action interface uses `string[]` arrays. Use `String(n)` or template literals for numbers.
- Returns after the Python handler calls `goal_handle.succeed()` (which is always called, even for logical failures).
### `highlightBlock(blockId)`
```js
highlightBlock(block.id);
```
Visually highlights the currently executing block in the workspace. Call it **first** in every statement generator so the user can see which block is running. For output blocks it is optional (they have no visual position in the stack).
---
## Quick Reference: Blockly Field Types
| Field | Constructor | Returns | Use Case |
|-------|-------------|---------|----------|
| **Number** | `new Blockly.FieldNumber(default, min, max, step)` | number string | Pin, duration, speed, PWM |
| **Text** | `new Blockly.FieldTextInput('default')` | string | Topic name, label |
| **Dropdown** | `new Blockly.FieldDropdown([['Label','value'], …])` | value string | Direction, sensor ID, mode |
| **Checkbox** | `new Blockly.FieldCheckbox('TRUE')` | `'TRUE'` / `'FALSE'` | On/off toggle, enable flag |
| **Colour** | `new Blockly.FieldColour('#ff0000')` | hex string | LED RGB color |
| **Angle** | `new Blockly.FieldAngle(90)` | angle string | Rotation, steering |
| **Image** | `new Blockly.FieldImage('url', w, h)` | _(no value)_ | Icon decoration on block |
All field values retrieved via `block.getFieldValue('FIELD_NAME')` are **strings**. Cast with `parseInt()`, `parseFloat()`, or `Number()` in JS, or `int()` / `float()` in Python, as needed.
---
## Quick Reference: Input and Connection Types
| Method | What it creates | When to use |
|--------|----------------|-------------|
| `setPreviousStatement(true, null)` | Top notch | Block can connect below another block |
| `setNextStatement(true, null)` | Bottom notch | Block can connect above another block |
| `setOutput(true, 'Type')` | Left plug | Block returns a value (output block) — mutually exclusive with previous/next |
| `appendDummyInput()` | Horizontal row | Inline fields only, no sockets |
| `appendValueInput('NAME')` | Input socket (right side) | Accept an output block plug |
| `appendStatementInput('NAME')` | Statement socket (indented slot) | Accept a block stack (loop body, etc.) |
| `setInputsInline(true)` | Collapse multi-input rows into one line | When `appendValueInput` rows should be side-by-side |
| `.setCheck('Type')` | Type constraint on socket | Restrict which output blocks can plug in (`'Number'`, `'String'`, `'Boolean'`) |
---
## Naming Conventions
| Item | Convention | Example |
|------|-----------|---------|
| Block `name` / `@handler` string | `snake_case` | `digital_out`, `read_distance`, `move_to` |
| JS file | `<name>.js` or `camelCase.js` | `digitalOut.js`, `digitalIn.js` |
| Python handler file | `<topic>.py` (group related handlers) | `gpio.py`, `timing.py`, `motors.py` |
| Field key in `getFieldValue` | `UPPER_SNAKE` | `'PIN'`, `'DURATION_MS'`, `'SENSOR_ID'` |
| param key in `executeAction` | `snake_case` | `{ pin: '3' }`, `{ duration_ms: '500' }` |
| param key in Python `params["…"]` | `snake_case` | `params["duration_ms"]` |
---

View File

@ -0,0 +1,432 @@
# Block Templates
Copy-paste starting points for all block shapes. Choose the template that matches your block type, then customize.
---
## Template A — Statement Block (action command)
Copy-paste starting point for any new action block:
```js
// src/blockly_app/blockly_app/ui/blockly/blocks/MY_BLOCK.js
BlockRegistry.register({
name: 'MY_COMMAND', // ← change this (must match @handler)
category: 'Robot', // ← choose or create a category
categoryColor: '#5b80a5',
color: '#9C27B0', // ← pick a hex color
tooltip: 'Short description of what this block does',
definition: {
init: function () {
this.appendDummyInput()
.appendField('Label text')
.appendField(new Blockly.FieldNumber(0, 0, 100, 1), 'PARAM1');
// add more .appendField(...) calls for more parameters
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#9C27B0');
this.setTooltip('Short description of what this block does');
},
},
generator: function (block) {
const param1 = block.getFieldValue('PARAM1');
// Statement generators return a string (not an array)
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('MY_COMMAND', { param1: '" + param1 + "' });\n"
);
},
});
```
Corresponding Python handler:
```python
# src/blockly_executor/blockly_executor/handlers/MY_FILE.py
from . import handler
from .hardware import Hardware
@handler("MY_COMMAND")
def handle_my_command(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
param1 = params["param1"] # always str — cast as needed (int, float, etc.)
hardware.log(f"my_command(param1={param1})")
if hardware.is_real():
# Real hardware logic — ROS2 publish, GPIO, etc.
# Use lazy imports here for dependencies not available in dev
hardware.node.get_logger().info(f"my_command: {param1}")
return (True, f"Done: {param1}")
```
After creating both files, add the JS filename to [`manifest.js`](../blockly_app/ui/blockly/blocks/manifest.js):
```js
const BLOCK_FILES = [
'digitalOut.js',
'digitalIn.js',
'delay.js',
'MY_BLOCK.js', // ← add here
];
```
---
## Template B — Output Block (sensor / computed value)
Output blocks expose a **left plug** and plug into input sockets of other blocks. They have **no** top/bottom notches and cannot stand alone in a stack.
```js
// src/blockly_app/blockly_app/ui/blockly/blocks/read_distance.js
BlockRegistry.register({
name: 'read_distance',
category: 'Sensors',
categoryColor: '#a5745b',
color: '#E91E63',
tooltip: 'Read distance from ultrasonic sensor in cm',
outputType: 'Number', // ← required for output blocks; determines socket type-check
definition: {
init: function () {
this.appendDummyInput()
.appendField('Distance')
.appendField(new Blockly.FieldDropdown([
['Front', 'front'],
['Left', 'left'],
['Right', 'right'],
]), 'SENSOR_ID');
this.setOutput(true, 'Number'); // ← left plug; NO setPreviousStatement / setNextStatement
this.setColour('#E91E63');
this.setTooltip('Read distance from ultrasonic sensor in cm');
},
},
// Output block generators MUST return [expression_string, Order] — NOT a plain string
generator: function (block) {
const sensorId = block.getFieldValue('SENSOR_ID');
// executeAction returns { success, message } — use .message to extract the value
const code = "(await executeAction('read_distance', { sensor_id: '" + sensorId + "' })).message";
return [code, javascript.Order.AWAIT];
// ↑ expression ↑ operator precedence (Order.AWAIT for async expressions)
},
});
```
Python handler:
```python
@handler("read_distance")
def handle_read_distance(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
sensor_id = params["sensor_id"]
hardware.log(f"read_distance(sensor_id={sensor_id})")
if hardware.is_real():
# TODO: subscribe to sensor topic, return actual reading
distance = 0.0 # placeholder
else:
distance = 42.0 # dummy test value
return (True, str(distance)) # message becomes the expression value in JS
```
> The JS expression `.message` retrieves the string from the ROS2 result. If the value needs to be numeric, wrap it: `parseFloat((await executeAction(…)).message)`.
---
## Template C — Block with Value Input Sockets (accepts other blocks)
Value input sockets let output blocks plug into a statement block as dynamic parameters.
```js
// src/blockly_app/blockly_app/ui/blockly/blocks/move_to.js
BlockRegistry.register({
name: 'move_to',
category: 'Navigation',
categoryColor: '#5ba55b',
color: '#00BCD4',
tooltip: 'Move robot to target X and Y coordinates',
definition: {
init: function () {
// appendValueInput creates a socket where output blocks plug in
this.appendValueInput('X')
.setCheck('Number') // only accept Number-type output blocks
.appendField('Move to X');
this.appendValueInput('Y')
.setCheck('Number')
.appendField('Y');
this.setInputsInline(true); // place inputs side-by-side instead of stacked
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#00BCD4');
this.setTooltip('Move robot to target X and Y coordinates');
},
},
generator: function (block) {
// valueToCode generates code for the plugged-in block; falls back to '0' if empty
const x = javascript.javascriptGenerator.valueToCode(block, 'X', javascript.Order.ATOMIC) || '0';
const y = javascript.javascriptGenerator.valueToCode(block, 'Y', javascript.Order.ATOMIC) || '0';
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('move_to', { x: String(" + x + "), y: String(" + y + ") });\n"
// ↑ wrap in String() — params must be strings
);
},
});
```
Python handler:
```python
@handler("move_to")
def handle_move_to(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
x = float(params["x"])
y = float(params["y"])
hardware.log(f"move_to(x={x}, y={y})")
if hardware.is_real():
# TODO: publish to cmd_vel or navigation topic
hardware.node.get_logger().info(f"Moving to ({x}, {y})")
return (True, f"Moved to ({x}, {y})")
```
---
## Template D — Container Block (statement input socket)
Container blocks hold a **stack of other blocks** inside them, like a loop or conditional. They use `appendStatementInput()`.
```js
BlockRegistry.register({
name: 'repeat_n_times',
category: 'Control',
categoryColor: '#5ba55b',
color: '#FF5722',
tooltip: 'Repeat the inner blocks N times',
definition: {
init: function () {
this.appendDummyInput()
.appendField('Repeat')
.appendField(new Blockly.FieldNumber(3, 1, 100, 1), 'TIMES')
.appendField('times');
this.appendStatementInput('DO') // ← creates an indented slot for block stacks
.appendField('do');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#FF5722');
this.setTooltip('Repeat the inner blocks N times');
},
},
generator: function (block) {
const times = block.getFieldValue('TIMES');
// statementToCode generates code for all blocks stacked inside DO
const inner = javascript.javascriptGenerator.statementToCode(block, 'DO');
return (
"highlightBlock('" + block.id + "');\n" +
"for (let _i = 0; _i < " + times + "; _i++) {\n" +
inner +
"}\n"
);
// Note: no executeAction needed — this block is pure JS control flow
},
});
```
> Container blocks that implement pure JS control flow (loops, if/else) do **not** need a Python handler. Only blocks that call `executeAction()` need one.
---
## Template E — Client-side Statement Block (no Python handler)
Client-side blocks call JavaScript functions directly — no `executeAction()`, no Python handler, no ROS2 round-trip. Used for **print** (console output) and **HMI widgets** (LED, Number, Text, Gauge).
**Print block** — calls `consoleLog()` directly:
```js
// src/blockly_app/blockly_app/ui/blockly/blocks/print.js
BlockRegistry.register({
name: 'print',
category: 'Program',
categoryColor: '#FF9800',
color: '#FFCA28',
tooltip: 'Print a value to the console for debugging',
definition: {
init: function () {
this.appendValueInput('TEXT')
.appendField('Print');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#FFCA28');
this.setTooltip('Print a value to the console for debugging');
},
},
generator: function (block) {
var value = Blockly.JavaScript.valueToCode(
block, 'TEXT', Blockly.JavaScript.ORDER_ATOMIC
) || "''";
return (
"await highlightBlock('" + block.id + "');\n" +
"consoleLog(String( ' USER LOG >>> ' + " + value + "), 'print');\n"
// ^^^^^^^^^^^^^^^^ direct JS function call — no executeAction!
);
},
});
```
**No Python handler needed.** The block works entirely in the browser. Add the JS filename to `manifest.js` and it's done.
---
## Template F — HMI Widget Block (client-side, design-time preview)
HMI blocks create/update widgets in the HMI Panel. They call `HMI.set*()` functions directly (client-side) and support design-time preview — widgets appear when blocks are placed, not just at runtime.
**HMI LED block:**
```js
// src/blockly_app/blockly_app/ui/blockly/blocks/hmiSetLed.js
BlockRegistry.register({
name: 'hmiSetLed',
category: 'HMI',
categoryColor: '#00BCD4',
color: '#00BCD4',
tooltip: 'Set an LED indicator on/off in the HMI panel',
definition: {
init: function () {
this.appendValueInput('STATE')
.appendField('HMI LED')
.appendField(new Blockly.FieldTextInput('LED1'), 'NAME')
.appendField('color')
.appendField(new Blockly.FieldDropdown([
['green', '#4caf50'],
['red', '#f44336'],
['yellow', '#ffeb3b'],
['blue', '#2196f3'],
['orange', '#ff9800'],
['white', '#ffffff'],
]), 'COLOR')
.setCheck('Boolean')
.appendField('state:');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#00BCD4');
this.setTooltip('Set an LED indicator on/off in the HMI panel');
},
},
generator: function (block) {
var name = block.getFieldValue('NAME');
var color = block.getFieldValue('COLOR');
var state = Blockly.JavaScript.valueToCode(
block, 'STATE', Blockly.JavaScript.ORDER_ATOMIC
) || 'false';
return (
"await highlightBlock('" + block.id + "');\n" +
"HMI.setLED('" + name + "', Boolean(" + state + "), '" + color + "');\n"
// ^^^^^^^^^^ direct call to HMI manager — no executeAction!
);
},
});
```
**HMI Number block** — numeric display with unit:
```js
generator: function (block) {
var name = block.getFieldValue('NAME');
var unit = block.getFieldValue('UNIT');
var value = Blockly.JavaScript.valueToCode(
block, 'VALUE', Blockly.JavaScript.ORDER_ATOMIC
) || '0';
return (
"await highlightBlock('" + block.id + "');\n" +
"HMI.setNumber('" + name + "', Number(" + value + "), '" + unit + "');\n"
);
},
```
**HMI Gauge block** — bar gauge with min/max:
```js
generator: function (block) {
var name = block.getFieldValue('NAME');
var min = block.getFieldValue('MIN');
var max = block.getFieldValue('MAX');
var value = Blockly.JavaScript.valueToCode(
block, 'VALUE', Blockly.JavaScript.ORDER_ATOMIC
) || '0';
return (
"await highlightBlock('" + block.id + "');\n" +
"HMI.setGauge('" + name + "', Number(" + value + "), " + min + ", " + max + ");\n"
);
},
```
**Key differences from ROS2 action blocks:**
- Generator calls `HMI.set*()` instead of `executeAction()`
- No Python handler file needed
- Widgets get design-time preview via `hmi-preview.js` (auto-detected by block type)
- All HMI blocks use `FieldTextInput('Name')` for widget identifier (`NAME` field)
- Category: `HMI`, color: `#00BCD4`
**Adding a new HMI widget type:**
1. Add render function `_renderFoo(body, widget)` in `core/hmi-manager.js`
2. Add public API method `setFoo(name, ...)` that stores state and calls `_scheduleRender(name)`
3. Add `case 'foo':` to the `_render()` switch
4. Create block file `blocks/hmiFoo.js` — generator calls `HMI.setFoo(...)`
5. Add to `manifest.js`
6. Add entry to `HMI_BLOCK_TYPES` in `core/hmi-preview.js` for design-time preview
### HMI Control Blocks (interactive — SET + GET pair)
Control widgets (Button, Slider, Switch) have both a **SET block** (statement, create/configure) and a **GET block** (value, read user interaction). User interaction state is tracked separately from programmatic state to prevent HMI loop overwrites.
**HMI Button GET block** (value, latch-until-read):
```js
generator: function (block) {
var name = block.getFieldValue('NAME');
return ['HMI.getButton(\'' + name + '\')', Blockly.JavaScript.ORDER_FUNCTION_CALL];
// Returns Boolean — true once per click, then auto-resets to false
},
```
**HMI Slider GET block** (value, user-drag tracking):
```js
generator: function (block) {
var name = block.getFieldValue('NAME');
return ['HMI.getSlider(\'' + name + '\')', Blockly.JavaScript.ORDER_FUNCTION_CALL];
// Returns Number — user-dragged value takes priority over programmatic setSlider()
},
```
**HMI Switch GET block** (value, user-toggle tracking):
```js
generator: function (block) {
var name = block.getFieldValue('NAME');
return ['HMI.getSwitch(\'' + name + '\')', Blockly.JavaScript.ORDER_FUNCTION_CALL];
// Returns Boolean — user-toggled state takes priority over programmatic setSwitch()
},
```
**User interaction tracking pattern:**
- **Button**: `pointerdown` event → `widget._pressed = true`. `getButton()` reads and resets (latch).
- **Slider**: `_userHasInteracted` flag persists after drag release. `setSlider()` skips `_userValue` overwrite once user has interacted.
- **Switch**: `_userState` stores user toggle separately. `setSwitch()` skips re-render once `_userState` is set.
- **Design-time**: `hmi-preview.js` auto-increments widget names when duplicate blocks are placed (`LED1` → `LED2`).
---

View File

@ -0,0 +1,190 @@
# Real Block Examples
Annotated source code of blocks already built in this project, and a master table of all current block categories.
---
## `digitalOut` — Statement block with FieldNumber + ValueInput
**JS** ([blocks/digitalOut.js](../blockly_app/ui/blockly/blocks/digitalOut.js)):
```js
BlockRegistry.register({
name: 'digitalOut',
category: 'Robot',
categoryColor: '#5b80a5',
color: '#4CAF50',
tooltip: 'set a digital output pin to HIGH (turn on LED)',
definition: {
init: function () {
this.appendValueInput('digitalOut')
.appendField(' gpio:')
.appendField(new Blockly.FieldNumber(1, 0, 27, 1), 'GPIO')
// ↑ ↑ ↑ ↑
// default min max step
.setCheck('Boolean') // accepts Boolean output blocks
.appendField(' state:');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#4CAF50');
this.setTooltip('set a digital output pin to HIGH (turn on LED)');
}
},
generator: function (block) {
const GPIO = block.getFieldValue('GPIO');
const STATE = Blockly.JavaScript.valueToCode(
block, 'digitalOut', Blockly.JavaScript.ORDER_ATOMIC
) || 'false';
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('digital_out', { gpio: '" + GPIO + "', state: '" + STATE + "' });\n"
// ↑ command name (must match @handler)
// ↑ param keys ↑ param values (always string)
);
}
});
```
**Python** ([handlers/gpio.py](../../blockly_executor/blockly_executor/handlers/gpio.py)):
```python
from . import handler
from .hardware import Hardware
@handler("digital_out") # ← must match JS name
def handle_digital_out(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
gpio = int(params["gpio"]) # params values are always strings — cast
state_raw = str(params["state"]).lower()
state = state_raw in ("true", "1", "high") # explicit string-to-bool parsing
state_str = "HIGH" if state else "LOW"
hardware.log(f"set_digital_out(gpio={gpio}, state={state})")
if hardware.is_real():
from blockly_interfaces.msg import GpioWrite
pub = _get_gpio_write_publisher(hardware) # lazy publisher creation
msg = GpioWrite()
msg.pin = gpio
msg.state = state
pub.publish(msg) # publish to /gpio/write → gpio_node
return (True, f"GPIO pin {gpio} set to {state_str}")
```
---
## `digitalIn` — Output block (returns a value)
**JS** ([blocks/digitalIn.js](../blockly_app/ui/blockly/blocks/digitalIn.js)):
```js
BlockRegistry.register({
name: 'digitalIn',
category: 'Robot',
categoryColor: '#5b80a5',
color: '#E91E63',
tooltip: 'Read digital input pin state (returns 0 or 1)',
definition: {
init: function () {
this.appendDummyInput()
.appendField('Digital Read gpio:')
.appendField(new Blockly.FieldNumber(5, 0, 27, 1), 'GPIO');
this.setOutput(true, 'Number'); // ← output block, returns Number
this.setColour('#E91E63');
this.setTooltip('Read digital input pin state (returns 0 or 1)');
}
},
generator: function (block) {
const gpio = block.getFieldValue('GPIO');
const code =
"parseFloat((await executeAction('digital_in', { gpio: '" + gpio + "' })).message)";
return [code, Blockly.JavaScript.ORDER_AWAIT];
// ↑ returns [code, precedence] for output blocks
}
});
```
**Python** ([handlers/gpio.py](../../blockly_executor/blockly_executor/handlers/gpio.py)):
```python
@handler("digital_in")
def handle_digital_in(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
gpio = int(params["gpio"])
hardware.log(f"read_digital_in(gpio={gpio})")
if hardware.is_real():
cache = _get_gpio_state_subscriber(hardware) # lazy subscriber + cache
with hardware.node._gpio_state_lock:
state = cache.get(gpio)
return (True, "1" if state else "0")
return (True, "0") # dummy mode — always LOW
```
> Multiple handlers can live in the same `.py` file — they are auto-discovered by `pkgutil.iter_modules`.
---
## `delay` — Statement block with multiple field decorators
**JS** ([blocks/delay.js](../blockly_app/ui/blockly/blocks/delay.js)):
```js
BlockRegistry.register({
name: 'delay',
category: 'Robot',
categoryColor: '#5b80a5',
color: '#2196F3',
tooltip: 'Wait for the specified duration in milliseconds',
definition: {
init: function () {
this.appendDummyInput()
.appendField('Delay')
.appendField(new Blockly.FieldNumber(500, 0, 60000, 100), 'DURATION_MS')
.appendField('ms'); // ← plain text label appended after the field
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour('#2196F3');
this.setTooltip('Wait for the specified duration in milliseconds');
},
},
generator: function (block) {
const ms = block.getFieldValue('DURATION_MS');
return (
"highlightBlock('" + block.id + "');\n" +
"await executeAction('delay', { duration_ms: '" + ms + "' });\n"
);
},
});
```
**Python** ([handlers/timing.py](../../blockly_executor/blockly_executor/handlers/timing.py)):
```python
import time
from . import handler
from .hardware import Hardware
@handler("delay")
def handle_delay(params: dict[str, str], hardware: Hardware) -> tuple[bool, str]:
duration_ms = int(params["duration_ms"])
time.sleep(duration_ms / 1000.0)
return (True, f"Delayed {duration_ms}ms")
```
---
## Block Type Overview — All Categories
| Category | Blocks | Execution Model |
|----------|--------|-----------------|
| **Program** | `main_program`, `main_hmi_program`, `print` | Entry points / client-side |
| **Robot** | `digitalOut`, `digitalIn`, `delay`, `pwmWrite`, `odometryRead`, `odometryGet` | ROS2 action (except `odometryGet`) |
| **HMI** | `hmiSetLed`, `hmiSetNumber`, `hmiSetText`, `hmiSetGauge`, `hmiSetButton`, `hmiGetButton`, `hmiSetSlider`, `hmiGetSlider`, `hmiSetSwitch`, `hmiGetSwitch` | Client-side (`HMI.set*()`/`HMI.get*()`) |
| **Logic** | `controls_if`, `logic_compare`, `logic_operation`, `logic_boolean` | Built-in Blockly |
| **Loops** | `controls_repeat_ext`, `controls_whileUntil` | Built-in Blockly |
| **Math** | `math_number`, `math_arithmetic` | Built-in Blockly |
| **Text** | `text`, `text_join`, `text_length` | Built-in Blockly |
| **Variables** | `variables_set`, `variables_get` | Built-in Blockly (with highlight override) |
| **Functions** | `procedures_defnoreturn`, `procedures_callnoreturn`, etc. | Built-in Blockly (with async override) |
---

View File

@ -0,0 +1,29 @@
# Creating Custom Blocks — Documentation Index
This directory documents how to create, configure, and extend Blockly blocks
for the AMR ROS2 K4 visual programming interface.
## Contents
| File | What it covers |
|------|----------------|
| [01-quickstart.md](01-quickstart.md) | End-to-end walkthrough: create JS block file, register Python handler, write integration test, verify with CLI. Includes the full data-flow diagram and a step-by-step checklist. |
| [02-block-api-ref.md](02-block-api-ref.md) | Complete API reference: `BlockRegistry.register()` fields, `definition.init` input rows, connection/shape methods, style methods, field type table, naming conventions, `executeAction()` / `highlightBlock()` runtime API. |
| [03-templates.md](03-templates.md) | Copy-paste starting points for all block shapes: Template A (statement/ROS2), B (output/sensor), C (value input sockets), D (container/loop), E (client-side statement), F (HMI widget). |
| [04-examples.md](04-examples.md) | Annotated real-project examples (`digitalOut`, `digitalIn`, `delay`) and a master table of all current block categories and their execution models. |
## Quick Decision Guide
- **Adding your first block?** → Start with [01-quickstart.md](01-quickstart.md)
- **Need a copy-paste template?** → Go to [03-templates.md](03-templates.md)
- **Looking up a specific API (field types, connection methods)?** → [02-block-api-ref.md](02-block-api-ref.md)
- **Want to see how an existing block is built?** → [04-examples.md](04-examples.md)
## Two Execution Models
All blocks fall into one of two categories:
| Model | Execution | Python handler needed? |
|-------|-----------|----------------------|
| **ROS2 action block** | `await executeAction()` → network round-trip to executor | Yes — `@handler("name")` in `handlers/<file>.py` |
| **Client-side block** | Direct JS call (`consoleLog()`, `HMI.set*()`) | No |

View File

@ -133,7 +133,10 @@ def main(args=None):
pass
finally:
node.destroy_node()
try:
rclpy.shutdown()
except Exception:
pass
if __name__ == "__main__":

View File

@ -79,4 +79,4 @@ from blockly_interfaces.msg import GpioWrite, GpioRead, PwmWrite, EncoderRead
## Usage
See [`src/blockly_executor/README.md`](../blockly_executor/README.md) for how the executor uses this interface, and [`src/blockly_app/BLOCKS.md`](../blockly_app/BLOCKS.md#79-data-flow-js-block--python-handler--hardware) for the full data flow from JS block to hardware.
See [`src/blockly_executor/README.md`](../blockly_executor/README.md) for how the executor uses this interface, and [`src/blockly_app/docs/01-quickstart.md`](../blockly_app/docs/01-quickstart.md#data-flow-js-block--python-handler--hardware) for the full data flow from JS block to hardware.

View File

@ -386,6 +386,15 @@
}
}
}
},
"next": {
"block": {
"type": "delay",
"id": "U,h/dhHRu214`~i0M6u$",
"fields": {
"DURATION_MS": 1000
}
}
}
}
}
@ -743,7 +752,7 @@
"name": "Switch1",
"type": "switch",
"x": 2,
"y": 6,
"y": 7,
"w": 2,
"h": 1,
"config": {}