diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c76f58c..ccabc84 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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 ``` diff --git a/pixi.toml b/pixi.toml index 7a88bb7..1a6223d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -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] diff --git a/readme.md b/readme.md index 4c09ea3..a08fb5e 100644 --- a/readme.md +++ b/readme.md @@ -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 -│ └── .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 (A–F) +- [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 diff --git a/src/amr_bringup/amr_bringup/__init__.py b/src/amr_bringup/amr_bringup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/amr_bringup/launch/blockly.launch.py b/src/amr_bringup/launch/blockly.launch.py new file mode 100644 index 0000000..a6ab02f --- /dev/null +++ b/src/amr_bringup/launch/blockly.launch.py @@ -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')], + )), + ]) diff --git a/src/amr_bringup/launch/blockly_hw.launch.py b/src/amr_bringup/launch/blockly_hw.launch.py new file mode 100644 index 0000000..ceaa790 --- /dev/null +++ b/src/amr_bringup/launch/blockly_hw.launch.py @@ -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')], + )), + ]) diff --git a/src/amr_bringup/package.xml b/src/amr_bringup/package.xml new file mode 100644 index 0000000..5f9162c --- /dev/null +++ b/src/amr_bringup/package.xml @@ -0,0 +1,16 @@ + + + amr_bringup + 0.1.0 + Launch files for AMR ROS2 Kiwi Wheel system bringup + developer + MIT + + blockly_executor + blockly_app + ros2launch + + + ament_python + + diff --git a/src/amr_bringup/resource/amr_bringup b/src/amr_bringup/resource/amr_bringup new file mode 100644 index 0000000..e69de29 diff --git a/src/amr_bringup/setup.cfg b/src/amr_bringup/setup.cfg new file mode 100644 index 0000000..d0bccfe --- /dev/null +++ b/src/amr_bringup/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/amr_bringup +[install] +install_scripts=$base/lib/amr_bringup diff --git a/src/amr_bringup/setup.py b/src/amr_bringup/setup.py new file mode 100644 index 0000000..3076e34 --- /dev/null +++ b/src/amr_bringup/setup.py @@ -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={}, +) diff --git a/src/blockly_app/BLOCKS.md b/src/blockly_app/BLOCKS.md deleted file mode 100644 index 7a47923..0000000 --- a/src/blockly_app/BLOCKS.md +++ /dev/null @@ -1,1225 +0,0 @@ -# Creating Custom Blocks in Blockly - -## 7. Creating Custom Blocks in Blockly - -### 7.1 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/.js Create — BlockRegistry.register({...}) - src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js Edit — add filename to BLOCK_FILES array -2. Py src/blockly_executor/blockly_executor/handlers/.py 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. - -### 7.2 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. - -### 7.3 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]}"' -``` - -### 7.4 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() -``` - -### 7.5 Name Consistency Reference Table - -``` -handlers.py blocks/.js blocks/.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') -``` - - -### 7.6 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=...)`) - ---- - -### 7.7 `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. - ---- - -### 7.8 `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 (0–360) -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) │ │ -│ └────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────┘ -``` - ---- - -### 7.9 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 eval()s 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 -``` - ---- - -### 7.10 Real Block Examples from This Project - -#### `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") -``` - ---- - -### 7.11 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 -]; -``` - ---- - -### 7.12 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)`. - ---- - -### 7.13 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})") -``` - ---- - -### 7.14 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. - ---- - -### 7.15 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. - ---- - -### 7.16 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`). - ---- - -### 7.17 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) | - ---- - -### 7.18 `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). - ---- - -### 7.19 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. - ---- - -### 7.20 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'`) | - ---- - -### 7.21 Naming Conventions - -| Item | Convention | Example | -|------|-----------|---------| -| Block `name` / `@handler` string | `snake_case` | `digital_out`, `read_distance`, `move_to` | -| JS file | `.js` or `camelCase.js` | `digitalOut.js`, `digitalIn.js` | -| Python handler file | `.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"]` | - ---- - -### 7.22 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/.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 '.js' to the BLOCK_FILES array - -3. Create src/blockly_executor/…/handlers/.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_.py -v -``` - -**Path B — Client-side Block** (print, HMI, pure JS): - -``` -1. Create src/blockly_app/…/ui/blockly/blocks/.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 '.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`. - - ---- diff --git a/src/blockly_app/README.md b/src/blockly_app/README.md index 5e24a5a..bcd58c5 100644 --- a/src/blockly_app/README.md +++ b/src/blockly_app/README.md @@ -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. diff --git a/src/blockly_app/docs/01-quickstart.md b/src/blockly_app/docs/01-quickstart.md new file mode 100644 index 0000000..b85b14f --- /dev/null +++ b/src/blockly_app/docs/01-quickstart.md @@ -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/.js Create — BlockRegistry.register({...}) + src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js Edit — add filename to BLOCK_FILES array +2. Py src/blockly_executor/blockly_executor/handlers/.py 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/.js blocks/.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/.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 '.js' to the BLOCK_FILES array + +3. Create src/blockly_executor/…/handlers/.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_.py -v +``` + +**Path B — Client-side Block** (print, HMI, pure JS): + +``` +1. Create src/blockly_app/…/ui/blockly/blocks/.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 '.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`. + +--- diff --git a/src/blockly_app/docs/02-block-api-ref.md b/src/blockly_app/docs/02-block-api-ref.md new file mode 100644 index 0000000..535bd82 --- /dev/null +++ b/src/blockly_app/docs/02-block-api-ref.md @@ -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 (0–360) +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 | `.js` or `camelCase.js` | `digitalOut.js`, `digitalIn.js` | +| Python handler file | `.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"]` | + +--- diff --git a/src/blockly_app/docs/03-templates.md b/src/blockly_app/docs/03-templates.md new file mode 100644 index 0000000..f8d16bc --- /dev/null +++ b/src/blockly_app/docs/03-templates.md @@ -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`). + +--- diff --git a/src/blockly_app/docs/04-examples.md b/src/blockly_app/docs/04-examples.md new file mode 100644 index 0000000..cee7985 --- /dev/null +++ b/src/blockly_app/docs/04-examples.md @@ -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) | + +--- diff --git a/src/blockly_app/docs/BLOCKS.md b/src/blockly_app/docs/BLOCKS.md new file mode 100644 index 0000000..9c3211e --- /dev/null +++ b/src/blockly_app/docs/BLOCKS.md @@ -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/.py` | +| **Client-side block** | Direct JS call (`consoleLog()`, `HMI.set*()`) | No | diff --git a/src/blockly_executor/blockly_executor/executor_node.py b/src/blockly_executor/blockly_executor/executor_node.py index a12766d..ea7bc6a 100644 --- a/src/blockly_executor/blockly_executor/executor_node.py +++ b/src/blockly_executor/blockly_executor/executor_node.py @@ -133,7 +133,10 @@ def main(args=None): pass finally: node.destroy_node() - rclpy.shutdown() + try: + rclpy.shutdown() + except Exception: + pass if __name__ == "__main__": diff --git a/src/blockly_interfaces/README.md b/src/blockly_interfaces/README.md index 5d0b622..2062006 100644 --- a/src/blockly_interfaces/README.md +++ b/src/blockly_interfaces/README.md @@ -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. diff --git a/workspace.json b/workspace.json index 67ca356..2c586ed 100644 --- a/workspace.json +++ b/workspace.json @@ -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": {}