Compare commits

...

3 Commits

Author SHA1 Message Date
a2nr 6b912191d6 clean up readme 2026-03-20 15:14:07 +07:00
a2nr cfc390e1d6 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
2026-03-20 15:13:07 +07:00
a2nr c19f6656b8 docs: update README.md with enhanced structure for potential enhancements and feasibility studies 2026-03-20 14:23:48 +07:00
20 changed files with 1463 additions and 1280 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]

107
readme.md
View File

@ -7,45 +7,69 @@
Dokumentasi lengkap dapat dilihat di [DOCUMENTATION.md](DOCUMENTATION.md).
# Aturan pengggunaan dokumen
**Aturan pengggunaan dokumen**
bab pada dokumen merepresentasikan alur rencana pengembangan.
## Potential Enhancements
bab ini digunakan untuk Feasibility Study
## Planned Feature
Backlog. Setelah kita pelajari untuk di kerjakan maka kita pindah ke backlog
## Feature Task
penjabaran Pekerjaan yang ready untuk dikerjakan. Task harus dijelaskan apa yang akan dikerjakan dan terdapat definition of done nya
Berikut ini adalah template untuk pembuatan task :
```
## <nomor task> <judul task> : <state: [ ] >
jelaskan permasalah di bab ini
### Bug 1 [ ] : Keterangan bug untuk rekap permasalahan
**Symtomp** : jelaskan masalahnya!
**Root Couse** : masalah ini dikarenakan apa?
**Fix** : bagaimana cara fix nya?
### Definition Of Done
[ ] DoD 1
[ ] DoD 2
[ ] Bug 1 ...
```
> **Potential Enhancements**
>
> bab ini digunakan untuk Feasibility Study
>
> **Planned Feature**
>
> Backlog. Setelah kita pelajari untuk di kerjakan maka kita pindah ke backlog
>
> **Feature Task**
>
> penjabaran Pekerjaan yang ready untuk dikerjakan. Task harus dijelaskan apa yang akan dikerjakan dan terdapat definition of done nya
> Berikut ini adalah template untuk pembuatan task :
>
> ```
> ## <nomor task> <judul task> : <state: [ ] >
> jelaskan permasalah di bab ini
> ### Bug 1 [ ] : Keterangan bug untuk rekap permasalahan
> **Symtomp** : jelaskan masalahnya!
> **Root Couse** : masalah ini dikarenakan apa?
> **Fix** : bagaimana cara fix nya?
> ### Definition Of Done
> [ ] DoD 1
> [ ] DoD 2
> [ ] Bug 1 ...
>
> ```
---
# Potential Enhancements
this list is short by priority
- **Feasibility Study to implement Controller**: mobile robot need controller to move flawlesly.
- **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.
- **Simulation**: Integrate with Gazebo/Isaac Sim for testing Kiwi Wheel kinematics before deploying to hardware
- **Block categories**: Future blocks grouped into Robot, Sensors, Navigation categories
this sub title list is short by priority
## **Feasibility Study to implement Vision Sensor**
coba kamu lakukan penelitian literatur untuk penerapan vision sensor dengan kemampuan berikut ini
1. Melakukan pengenalan warna dan obyek beserta prosedur untuk training warna
2. Melakukan penghitungan obyek yang tersusun secara urut dari kiri ke kanan.
berikan proposal mu untuk mencakup penerapannya juga di blockly app.
berikut ini adalah path yang aku sarankan untuk menampung hasil penelitianmu
```
src/amr_vision_node
├── docs/
│ └── feasibility.md
```
## **Feasibility Study to implement Controller**
do some research about to make my mobile robot move flawlesly. the scope is controller, kenematics, identification system. kamu bisa rekap hasil feasibility study mu di path berikut ini dan folder amt_controller_node sekaligus sebagai workspace node kusus controller robot tapi tetap rekap task di amr-ros-k4/readme.md
```
src/amr_controller_node
├── docs/
│ └── feasibility.md
```
## **Simulation**
Integrate with Gazebo/Isaac Sim for testing Kiwi Wheel kinematics before deploying to hardware
# Feature Task
@ -312,21 +336,4 @@ Fields: `X (cm)`, `Y (cm)`, `Heading (rad)`, `Vel X (cm/s)`, `Vel Y (cm/s)`, `An
- [x] `pixi run build-as5600` berhasil — as5600_node compile dengan nav_msgs dependency
- [x] Handler `odometry_read` berfungsi di dummy mode (test passes)
- [ ] 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).
### 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] Integration test `test_block_odometry.py` passes di dummy mode

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()
rclpy.shutdown()
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": {}