diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8287684..aabe8f4 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -21,17 +21,31 @@ Comprehensive technical documentation for the Blockly ROS2 Robot Controller proj - [5.2 Running the Executor Node Standalone](#52-running-the-executor-node-standalone) - [5.3 Running the Test Suite](#53-running-the-test-suite) - [6. Detailed File Reference](#6-detailed-file-reference) - - [6.1 Application Layer — blockly_app](#61-application-layer--blockly_app) - - [6.2 Executor Layer — blockly_executor](#62-executor-layer--blockly_executor) - - [6.3 ROS2 Interfaces — blockly_interfaces](#63-ros2-interfaces--blockly_interfaces) + - [6.1 Application Layer — `blockly_app`](#61-application-layer-blockly_app) + - [6.2 Executor Layer — `blockly_executor`](#62-executor-layer-blockly_executor) + - [6.3 ROS2 Interfaces — `blockly_interfaces`](#63-ros2-interfaces-blockly_interfaces) - [6.4 Test Suite](#64-test-suite) - [6.5 Configuration Files](#65-configuration-files) - [7. Creating Custom Blocks in Blockly](#7-creating-custom-blocks-in-blockly) - - [7.1 Overview: 2 Steps with Auto-Discovery](#71-overview-2-steps-with-auto-discovery) - - [7.2 Step 1 — Create Block File (JS)](#72-step-1--create-block-file-js) - - [7.3 Step 2 — Register Handler in Python](#73-step-2--register-handler-in-python) - - [7.4 Step 3 — Write Integration Test (Optional)](#74-step-3--write-integration-test-optional) + - [7.1 Overview: Auto-Discovery on Both Sides](#71-overview-auto-discovery-on-both-sides) + - [7.2 Step 1 — Create Block File (JS)](#72-step-1-create-block-file-js) + - [7.3 Step 2 — Register Handler in Python](#73-step-2-register-handler-in-python) + - [7.4 Step 3 — Write Integration Test (Optional)](#74-step-3-write-integration-test-optional) - [7.5 Name Consistency Reference Table](#75-name-consistency-reference-table) + - [7.6 Block Type Overview](#76-block-type-overview) + - [7.7 `BlockRegistry.register()` — All Fields](#77-blockregistryregister-all-fields) + - [7.8 `definition.init` — Complete Reference](#78-definitioninit-complete-reference) + - [7.9 Data Flow: JS Block → Python Handler → Hardware](#79-data-flow-js-block-python-handler-hardware) + - [7.10 Real Block Examples from This Project](#710-real-block-examples-from-this-project) + - [7.11 Template A — Statement Block (action command)](#711-template-a-statement-block-action-command) + - [7.12 Template B — Output Block (sensor / computed value)](#712-template-b-output-block-sensor-computed-value) + - [7.13 Template C — Block with Value Input Sockets (accepts other blocks)](#713-template-c-block-with-value-input-sockets-accepts-other-blocks) + - [7.14 Template D — Container Block (statement input socket)](#714-template-d-container-block-statement-input-socket) + - [7.15 `executeAction()` and `highlightBlock()` Reference](#715-executeaction-and-highlightblock-reference) + - [7.16 Quick Reference: Blockly Field Types](#716-quick-reference-blockly-field-types) + - [7.17 Quick Reference: Input and Connection Types](#717-quick-reference-input-and-connection-types) + - [7.18 Naming Conventions](#718-naming-conventions) + - [7.19 Step-by-Step Checklist — Adding a New Block](#719-step-by-step-checklist-adding-a-new-block) - [8. Blockly–ROS2 Integration Flow](#8-blocklyros2-integration-flow) - [8.1 End-to-End Execution Flow](#81-end-to-end-execution-flow) - [8.2 Code Generation Pipeline](#82-code-generation-pipeline) @@ -40,11 +54,12 @@ Comprehensive technical documentation for the Blockly ROS2 Robot Controller proj - [8.5 Debug Mode Flow](#85-debug-mode-flow) - [9. Testing](#9-testing) - [9.1 Testing Philosophy](#91-testing-philosophy) - - [9.2 conftest.py — Shared Fixtures](#92-conftestpy--shared-fixtures) + - [9.2 `conftest.py` — Shared Fixtures](#92-conftestpy-shared-fixtures) - [9.3 Test File Structure](#93-test-file-structure) - [9.4 Adding a New Test File](#94-adding-a-new-test-file) - [9.5 Running Tests](#95-running-tests) -- [10. Troubleshooting & Known Issues](#10-troubleshooting--known-issues) +- [10. Guide: Adding a New ROS2 Package](#10-guide-adding-a-new-ros2-package) +- [11. Troubleshooting & Known Issues](#11-troubleshooting-known-issues) --- @@ -720,6 +735,819 @@ 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 | `led_on`, `led_off`, `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.set_led(pin, True) ← DummyHardware or RealHardware + │ + ▼ +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 + +#### `led_on` — Statement block with FieldNumber + +**JS** ([blocks/led_on.js](src/blockly_app/blockly_app/ui/blockly/blocks/led_on.js)): +```js +BlockRegistry.register({ + name: 'led_on', + category: 'Robot', + categoryColor: '#5b80a5', + color: '#4CAF50', + tooltip: 'Turn on LED at the specified GPIO pin', + + definition: { + init: function () { + this.appendDummyInput() + .appendField('LED ON pin') + .appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN'); + // ↑ ↑ ↑ ↑ + // default min max step + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour('#4CAF50'); + this.setTooltip('Turn on LED at the specified GPIO pin'); + }, + }, + + generator: function (block) { + const pin = block.getFieldValue('PIN'); // reads the PIN field value + return ( + "highlightBlock('" + block.id + "');\n" + // visual debugger highlight + "await executeAction('led_on', { pin: '" + pin + "' });\n" + // ↑ command name (must match @handler) + // ↑ param key ↑ param value (always string) + ); + }, +}); +``` + +**Python** ([handlers/gpio.py](src/blockly_executor/blockly_executor/handlers/gpio.py)): +```python +from . import handler + +@handler("led_on") # ← must match JS name +def handle_led_on(params: dict[str, str], hardware) -> tuple[bool, str]: + pin = int(params["pin"]) # params values are always strings — cast as needed + hardware.set_led(pin, True) + return (True, f"LED on pin {pin} turned ON") # (success: bool, message: str) +``` + +--- + +#### `led_off` — Statement block (same pattern, different color) + +**JS** ([blocks/led_off.js](src/blockly_app/blockly_app/ui/blockly/blocks/led_off.js)): +```js +BlockRegistry.register({ + name: 'led_off', + category: 'Robot', + categoryColor: '#5b80a5', + color: '#FF9800', // orange — visually distinct from led_on + tooltip: 'Turn off LED at the specified GPIO pin', + + definition: { + init: function () { + this.appendDummyInput() + .appendField('LED OFF pin') + .appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN'); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setColour('#FF9800'); + this.setTooltip('Turn off LED at the specified GPIO pin'); + }, + }, + + generator: function (block) { + const pin = block.getFieldValue('PIN'); + return ( + "highlightBlock('" + block.id + "');\n" + + "await executeAction('led_off', { pin: '" + pin + "' });\n" + ); + }, +}); +``` + +**Python** ([handlers/gpio.py](src/blockly_executor/blockly_executor/handlers/gpio.py)): +```python +@handler("led_off") +def handle_led_off(params: dict[str, str], hardware) -> tuple[bool, str]: + pin = int(params["pin"]) + hardware.set_led(pin, False) + return (True, f"LED on pin {pin} turned OFF") +``` + +> 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](src/blockly_app/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](src/blockly_executor/blockly_executor/handlers/timing.py)): +```python +import time +from . import handler + +@handler("delay") +def handle_delay(params: dict[str, str], hardware) -> tuple[bool, str]: + duration_ms = int(params["duration_ms"]) + time.sleep(duration_ms / 1000.0) + return (True, f"Delayed {duration_ms}ms") +``` + +--- + +### 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 + +@handler("MY_COMMAND") +def handle_my_command(params: dict[str, str], hardware) -> tuple[bool, str]: + param1 = params["param1"] # always str — cast as needed (int, float, etc.) + # ... do something with hardware ... + return (True, f"Done: {param1}") +``` + +After creating both files, add the JS filename to [manifest.js](src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js): +```js +const BLOCK_FILES = [ + 'led_on.js', + 'led_off.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) -> tuple[bool, str]: + sensor_id = params["sensor_id"] + distance = hardware.read_distance(sensor_id) # returns float in cm + 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) -> tuple[bool, str]: + x = float(params["x"]) + y = float(params["y"]) + hardware.move_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 `executeAction()` and `highlightBlock()` Reference + +Both functions are provided by [bridge.js](src/blockly_app/blockly_app/ui/blockly/core/bridge.js) and injected into the eval context by [debug-engine.js](src/blockly_app/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.16 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.17 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.18 Naming Conventions + +| Item | Convention | Example | +|------|-----------|---------| +| Block `name` / `@handler` string | `snake_case` | `led_on`, `read_distance`, `move_to` | +| JS file | `.js` or `camelCase.js` | `led_on.js`, `digitalOut.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.19 Step-by-Step Checklist — Adding a New Block + +``` +1. Create src/blockly_app/…/ui/blockly/blocks/.js + └─ Call BlockRegistry.register({ name, category, color, definition, generator }) + └─ 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 + └─ @handler("name") + └─ def handle_name(params, hardware): ... → return (bool, str) + +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 +``` + +No changes needed to `index.html`, `executor_node.py`, `handlers/__init__.py`, or `BlocklyAction.action`. + + --- ## 8. Blockly–ROS2 Integration Flow @@ -1002,145 +1830,7 @@ setup( --- -## 11. Blockly Block Types — Templates & Quick Reference - -### Block Type Overview - -| Pattern | Shape | Use Case | Example | -|---------|-------|----------|---------| -| **Statement block** | Top/bottom connectors | Perform an action | `led_on`, `delay`, `motor_stop` | -| **Output block** | Left plug only | Return a sensor value | `read_distance`, `read_temperature` | -| **Input block** | Input sockets | Accept values from other blocks | `move_to(x, y)` | - -### Template A — Statement Block (action command) - -```js -BlockRegistry.register({ - name: 'led_on', // Must match Python handler name - category: 'Robot', - categoryColor: '#5b80a5', - color: '#4CAF50', - tooltip: 'Turn on LED at the specified GPIO pin', - - definition: { - init: function () { - this.appendDummyInput() - .appendField('LED ON pin') - // FieldNumber(default, min, max, step) - .appendField(new Blockly.FieldNumber(1, 0, 40, 1), 'PIN'); - - this.setPreviousStatement(true, null); // connect above - this.setNextStatement(true, null); // connect below - this.setColour('#4CAF50'); - this.setTooltip('Turn on LED at the specified GPIO pin'); - } - }, - - generator: function (block) { - const pin = block.getFieldValue('PIN'); - return ( - 'highlightBlock(\'' + block.id + '\');\n' + - 'await executeAction(\'led_on\', { pin: \'' + pin + '\' });\n' - ); - } -}); -``` - -### Template B — Output Block (sensor value) - -```js -BlockRegistry.register({ - name: 'read_distance', - category: 'Sensors', - categoryColor: '#a5745b', - color: '#E91E63', - tooltip: 'Read distance from ultrasonic sensor in cm', - outputType: 'Number', - - definition: { - init: function () { - this.appendDummyInput() - .appendField('Distance sensor') - .appendField(new Blockly.FieldDropdown([ - ['Front', 'front'], ['Left', 'left'], ['Right', 'right'], - ]), 'SENSOR_ID'); - - this.setOutput(true, 'Number'); // left plug, no top/bottom connectors - this.setColour('#E91E63'); - this.setTooltip('Read distance from ultrasonic sensor in cm'); - } - }, - - // Output blocks return [code, order] ARRAY, not a string - generator: function (block) { - const sensorId = block.getFieldValue('SENSOR_ID'); - const code = - '(await executeAction(\'read_distance\', { sensor_id: \'' + sensorId + '\' })).message'; - return [code, javascript.Order.AWAIT]; - } -}); -``` - -### Template C — Block with Value Inputs (accepts other blocks) - -```js -BlockRegistry.register({ - name: 'move_to', - category: 'Navigation', - categoryColor: '#5ba55b', - color: '#00BCD4', - tooltip: 'Move robot to target X and Y coordinates', - - definition: { - init: function () { - // appendValueInput creates a socket where output blocks plug in - this.appendValueInput('X').setCheck('Number').appendField('Move to X'); - this.appendValueInput('Y').setCheck('Number').appendField('Y'); - this.setInputsInline(true); - this.setPreviousStatement(true, null); - this.setNextStatement(true, null); - this.setColour('#00BCD4'); - this.setTooltip('Move robot to target X and Y coordinates'); - } - }, - - generator: function (block) { - // valueToCode reads the plugged-in block, returns '0' if empty - const x = javascript.javascriptGenerator.valueToCode(block, 'X', javascript.Order.ATOMIC) || '0'; - const y = javascript.javascriptGenerator.valueToCode(block, 'Y', javascript.Order.ATOMIC) || '0'; - return ( - 'highlightBlock(\'' + block.id + '\');\n' + - 'await executeAction(\'move_to\', { x: \'' + x + '\', y: \'' + y + '\' });\n' - ); - } -}); -``` - -### Quick Reference: Blockly Field Types - -| Field Type | Code | Use Case | -|------------|------|----------| -| Number | `new Blockly.FieldNumber(default, min, max, step)` | Pin number, duration, speed | -| Text | `new Blockly.FieldTextInput('default')` | Custom labels, names | -| Dropdown | `new Blockly.FieldDropdown([['Label','value'], ...])` | Sensor selection, direction | -| Checkbox | `new Blockly.FieldCheckbox('TRUE')` | On/off toggle | -| Color | `new Blockly.FieldColour('#ff0000')` | LED color picker | -| Angle | `new Blockly.FieldAngle(90)` | Rotation angle with dial | -| Image | `new Blockly.FieldImage('url', width, height)` | Icon on block | - -### Quick Reference: Block Connection Types - -| Method | Effect | When to use | -|--------|--------|-------------| -| `setPreviousStatement(true)` + `setNextStatement(true)` | Stackable block | Action/command blocks | -| `setOutput(true, type)` | Output plug — returns a value | Sensor/calculation blocks | -| `appendDummyInput()` | Row with only inline fields | Simple blocks with fixed fields | -| `appendValueInput('NAME')` | Input socket — accepts output blocks | Blocks that take dynamic values | -| `appendStatementInput('DO')` | Statement socket — accepts block stack | Container blocks like loops, if-then | - ---- - -## 12. Troubleshooting & Known Issues +## 11. Troubleshooting & Known Issues ### "Executor is already spinning" in `app.py` diff --git a/readme.md b/readme.md index 693ea43..8263045 100644 --- a/readme.md +++ b/readme.md @@ -18,6 +18,8 @@ Feature Task : Penjabaran Pekerjaan yang ready untuk dikerjakan. Ta # Potential Enhancements this list is short by priority +- **Potensial inefective development**: in handlers/hardware use interface.py to all hardware (dummy, ros2, and hardware) class that posibly haavily change. +- ** UI bug **: stop button not actualy stop execution. tried with long delay with loop and press stop button, program still continue - **Launch files**: `blockly_bringup` package with ROS2 launch files to start all nodes with one command - **Sensor integration**: Subscriber nodes for sensor data feeding back into Blockly visual feedback - **RealHardware implementation**: Fill in ROS2 publishers/service clients for actual Pi hardware nodes (topics TBD) diff --git a/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js b/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js new file mode 100644 index 0000000..ae1ce86 --- /dev/null +++ b/src/blockly_app/blockly_app/ui/blockly/blocks/digitalOut.js @@ -0,0 +1,33 @@ + +BlockRegistry.register({ + name: 'digitalOut', // Must match Python handler name + 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:') + // FieldNumber(default, min, max, step) + .appendField(new Blockly.FieldNumbint(er(1, 0, 27, 1), 'GPIO') + .setCheck('Boolean') + .appendField(' state:'); + + this.setPreviousStatement(true, null); // connect above + this.setNextStatement(true, null); // connect below + 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' + ); + } +}); \ No newline at end of file diff --git a/src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js b/src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js index 2435904..ea1a620 100644 --- a/src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js +++ b/src/blockly_app/blockly_app/ui/blockly/blocks/manifest.js @@ -9,7 +9,6 @@ // eslint-disable-next-line no-unused-vars const BLOCK_FILES = [ - 'led_on.js', - 'led_off.js', + 'digitalOut.js', 'delay.js', ]; diff --git a/src/blockly_executor/blockly_executor/handlers/gpio.py b/src/blockly_executor/blockly_executor/handlers/gpio.py index e513007..6c0426e 100644 --- a/src/blockly_executor/blockly_executor/handlers/gpio.py +++ b/src/blockly_executor/blockly_executor/handlers/gpio.py @@ -15,3 +15,11 @@ def handle_led_off(params: dict[str, str], hardware) -> tuple[bool, str]: pin = int(params["pin"]) hardware.set_led(pin, False) return (True, f"LED on pin {pin} turned OFF") + +@handler("digital_out") +def handle_digital_out(params: dict[str, str], hardware) -> tuple[bool, str]: + gpio = int(params["gpio"]) + state = bool(params["state"]) + hardware.set_digital_out(gpio, state) + state_str = "HIGH" if state else "LOW" + return (True, f"GPIO pin {gpio} set to {state_str}") \ No newline at end of file diff --git a/src/blockly_executor/blockly_executor/hardware/dummy_hardware.py b/src/blockly_executor/blockly_executor/hardware/dummy_hardware.py index 026316d..3d69289 100644 --- a/src/blockly_executor/blockly_executor/hardware/dummy_hardware.py +++ b/src/blockly_executor/blockly_executor/hardware/dummy_hardware.py @@ -22,3 +22,6 @@ class DummyHardware(HardwareInterface): def is_ready(self) -> bool: self.call_log.append("is_ready()") return True + + def set_digital_out(self, gpio: int, state: bool) -> None: + self.call_log.append(f"set_digital_out(gpio={gpio}, state={state})") diff --git a/src/blockly_executor/blockly_executor/hardware/gpio_hardware.py b/src/blockly_executor/blockly_executor/hardware/gpio_hardware.py index b2f7521..2772447 100644 --- a/src/blockly_executor/blockly_executor/hardware/gpio_hardware.py +++ b/src/blockly_executor/blockly_executor/hardware/gpio_hardware.py @@ -30,3 +30,9 @@ class GpioHardware(HardwareInterface): def is_ready(self) -> bool: return self._initialized + + def set_digital_out(self, gpio: int, state: bool) -> None: + if not self._initialized: + raise RuntimeError("GPIO not available on this platform") + self._gpio.setup(gpio, self._gpio.OUT) + self._gpio.output(gpio, self._gpio.HIGH if state else self._gpio.LOW) diff --git a/src/blockly_executor/blockly_executor/hardware/interface.py b/src/blockly_executor/blockly_executor/hardware/interface.py index ac5d334..d4c524c 100644 --- a/src/blockly_executor/blockly_executor/hardware/interface.py +++ b/src/blockly_executor/blockly_executor/hardware/interface.py @@ -32,3 +32,14 @@ class HardwareInterface(ABC): True if hardware is initialized and ready. """ ... + + @abstractmethod + def set_digital_out(self, gpio: int, state: bool) -> None: + """ + Set a GPIO pin to HIGH or LOW. + + Args: + gpio: GPIO pin number. + state: True for HIGH, False for LOW. + """ + ... diff --git a/src/blockly_executor/blockly_executor/hardware/real_hardware.py b/src/blockly_executor/blockly_executor/hardware/real_hardware.py index 0034749..d45a172 100644 --- a/src/blockly_executor/blockly_executor/hardware/real_hardware.py +++ b/src/blockly_executor/blockly_executor/hardware/real_hardware.py @@ -29,3 +29,7 @@ class RealHardware(HardwareInterface): def is_ready(self) -> bool: # TODO: check if Pi hardware nodes are reachable return True + + def set_digital_out(self, gpio: int, state: bool) -> None: + # TODO: publish to Pi hardware node + self._logger.info(f"RealHardware.set_digital_out(gpio={gpio}, state={state}) — stub") diff --git a/src/blockly_executor/test/test_block_gpio.py b/src/blockly_executor/test/test_block_gpio.py new file mode 100644 index 0000000..e69de29 diff --git a/workspace.json b/workspace.json index 505100e..5aee7bc 100644 --- a/workspace.json +++ b/workspace.json @@ -3,17 +3,75 @@ "languageVersion": 0, "blocks": [ { - "type": "controls_if", - "id": "WLmpkq!^,rcoKGH+bQgN", - "x": 210, - "y": 70, + "type": "controls_repeat_ext", + "id": "j+usYB.X4%o``Le;Q,LA", + "x": 190, + "y": 150, "inputs": { - "DO0": { - "block": { - "type": "led_on", - "id": "._%j-2DZ*BZJlyW:1#`Q", + "TIMES": { + "shadow": { + "type": "math_number", + "id": "cI`]|Us0+)Ol~XY0L`wE", "fields": { - "PIN": 1 + "NUM": 3 + } + } + }, + "DO": { + "block": { + "type": "digitalOut", + "id": "3}G7M%~([nNy9YHlug!|", + "fields": { + "GPIO": 17 + }, + "inputs": { + "digitalOut": { + "block": { + "type": "logic_boolean", + "id": "Sw__`~LvxpKyo}./q]1/", + "fields": { + "BOOL": "TRUE" + } + } + } + }, + "next": { + "block": { + "type": "delay", + "id": "?o-l1IBd(^YR,u[;k$-|", + "fields": { + "DURATION_MS": 500 + }, + "next": { + "block": { + "type": "digitalOut", + "id": "t}@.X|Ac7F?J;C4v`5ic", + "fields": { + "GPIO": 17 + }, + "inputs": { + "digitalOut": { + "block": { + "type": "logic_boolean", + "id": "]I]_@v6=ErAYdiolo|+n", + "fields": { + "BOOL": "FALSE" + } + } + } + }, + "next": { + "block": { + "type": "delay", + "id": "45BA([9g7/_tItSnUwB-", + "fields": { + "DURATION_MS": 500 + } + } + } + } + } + } } } }