Blocklyによる動作シーケンスで実機ロボットを制御する統合検証(ROS2 + rosbridge)

統合実証

概要(前提)

前回までの検証において、

  • ROS2の/cmd_velによる実機ロボット制御(統合①)
  • rosbridgeおよびroslibjsを用いたブラウザからの操作(統合②)

を確認しました。以下です。

ROS2(Jazzy)からcmd_velで実機ロボットを制御する統合検証(Arduino UNO R4 WiFi + タミヤ車体)
ROS2(Jazzy)からcmd_velで実機ロボットを制御する統合検証(Arduino UNO R4 WiFi +ブラウザから

本検証ではその続きとして、Blocklyを用いたブロックベースの操作により、動作シーケンスを作成し、実機ロボットを制御できるかを確認しました。

Blocklyは、ブロックを組み合わせることでプログラムを視覚的に構築できるツールであり、非エンジニアや初学者でもロボット制御の流れを理解しやすい点が特徴です。

このようなロボットを

以下のようなブロックを組み合わせて動かします。


構成

本検証の構成は以下の通りです。

ブラウザ(Blockly + roslibjs)

rosbridge_server

ROS2(cmd_vel)

ROS2ノード(cmd_vel → コマンド変換)

Arduino UNO R4 WiFi

タミヤ車体

実施内容

前提環境

本検証では、以下を事前に起動しておきます。

  • ロボット駆動用のROS2ノード(cmd_vel → 実機制御)
  • rosbridge_server(WebSocket通信)

Blocklyページの用意

ブラウザ上でBlocklyを利用した操作ページを用意しました。
このページでは、ブロックを組み合わせて動作シーケンスを構築し、/cmd_velへpublishする仕組みとしています。

今回使用したHTMLページでは、以下のようなブロックを定義しています。

  • 前進(時間指定)
  • 後退(時間指定)
  • 方向転換(左右)

ブロックを縦に連結することで、動作の順序を構築できます。
初期状態で以下のように表示されます。


動作シーケンスの作成

Blockly上で以下のようなシーケンスを複数パターン作成し、動作確認を行いました。

  • 前進 → 停止
  • 前進 → 方向転換 → 前進
  • 後退 → 方向転換

以下のように動作方法から動作を選択することが出来るので動作を組み合わせます。


各ブロックは内部的に/cmd_velをpublishし、一定時間後に停止する処理として実装されています。
参考までに今回のhtmlページは以下のようなページです。本当に参考程度に見てください。

JavaScriptとCSSも一緒に書いています。AI(Gemini)とやり取りしながら生成したページです。実際にはBlocklyは本当にブロックとしてのGUIとして利用していて、実際のロボットの動作のJavaScriptコードを実行する流れです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>簡単ブロックロボット操作</title>
    <script src="https://unpkg.com/blockly/blockly.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/roslibjs/1.1.0/roslib.min.js"></script>
    <style>
        body { font-family: 'Segoe UI', sans-serif; background-color: #f8f9fa; margin: 0; display: flex; flex-direction: column; height: 100vh; }
        .header { background: #2c3e50; color: white; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
        .main-container { display: flex; flex: 1; overflow: hidden; }
        #blocklyDiv { flex: 1; border-right: 1px solid #dee2e6; }
        .side-panel { width: 350px; display: flex; flex-direction: column; background: white; padding: 15px; box-sizing: border-box; }
        #logArea { flex: 1; margin-top: 15px; padding: 12px; background: #1e1e1e; color: #61dafb; font-family: 'Consolas', monospace; overflow-y: auto; font-size: 13px; border-radius: 8px; line-height: 1.5; }
        .controls { display: flex; gap: 10px; }
        button { padding: 10px 20px; font-size: 14px; font-weight: bold; cursor: pointer; border: none; border-radius: 5px; transition: 0.2s; }
        .btn-run { background: #28a745; color: white; }
        .btn-run:hover { background: #218838; }
        .btn-stop { background: #e74c3c; color: white; }
        .btn-stop:hover { background: #c0392b; }
        #rosStatus { font-weight: bold; margin-left: 5px; }
    </style>
</head>
<body>

<div class="header">
    <div><strong>🤖 ロボット操作</strong></div>
    <div class="controls">
        <button class="btn-run" onclick="runCode()">▶ 動作開始</button>
        <button class="btn-stop" onclick="emergencyStop()">🛑 停止</button>
    </div>
</div>

<div class="main-container">
    <div id="blocklyDiv"></div>
    <div class="side-panel">
        <div id="status">
            <span>ROS:</span>
            <span id="rosStatus" style="color: gray;">未接続</span>
        </div>
        <div id="logArea">--- 動作状況 ---<br></div>
    </div>
</div>

<xml id="toolbox" style="display: none">
    <category name="動作方法" colour="210">
        <block type="move_forward_time"></block>
        <block type="move_backward_time"></block>
        <block type="turn_direction"></block>
    </category>
</xml>

<script>
  let baseSpeed = 0.2; // 標準の速さ
  let ros = null;
  window.cmdVelPub = null;
  let workspace = null;
  let isEmergencyStop = false;
  const logArea = document.getElementById('logArea');

  // --- ブロック定義(「動かしかた」のみ) ---

  Blockly.Blocks['move_forward_time'] = {
    init: function() {
      this.appendDummyInput().appendField("🚀 前進する (").appendField(new Blockly.FieldDropdown([["3秒","3"], ["5秒","5"], ["7秒","7"]]), "TIME").appendField(")");
      this.setPreviousStatement(true);
      this.setNextStatement(true);
      this.setColour(210);
    }
  };

  Blockly.Blocks['move_backward_time'] = {
    init: function() {
      this.appendDummyInput().appendField("🔙 後退する (").appendField(new Blockly.FieldDropdown([["3秒","3"], ["5秒","5"], ["7秒","7"]]), "TIME").appendField(")");
      this.setPreviousStatement(true);
      this.setNextStatement(true);
      this.setColour(210);
    }
  };

  Blockly.Blocks['turn_direction'] = {
    init: function() {
      this.appendDummyInput().appendField("🔄 向きをかえる (").appendField(new Blockly.FieldDropdown([["右まわり","RIGHT"], ["左まわり","LEFT"]]), "DIR").appendField(")");
      this.setPreviousStatement(true);
      this.setNextStatement(true);
      this.setColour(290);
    }
  };

  // --- 制御ロジック ---

  function initRosConnection() {
    const statusSpan = document.getElementById('rosStatus');
    ros = new ROSLIB.Ros({ url: 'ws://localhost:9090'});
    ros.on('connection', () => {
      statusSpan.textContent = '接続完了';
      statusSpan.style.color = 'green';
      window.cmdVelPub = new ROSLIB.Topic({ ros: ros, name: '/cmd_vel', messageType: 'geometry_msgs/Twist' });
    });
    ros.on('error', () => { statusSpan.textContent = 'エラー'; statusSpan.style.color = 'red'; });
    ros.on('close', () => { statusSpan.textContent = '切断'; statusSpan.style.color = 'red'; });
  }

  function emergencyStop() {
    isEmergencyStop = true;
    if (window.cmdVelPub) {
      window.cmdVelPub.publish(new ROSLIB.Message({ linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }));
    }
    logToArea("<span style='color:red; font-weight:bold;'>🛑 緊急停止しました</span>");
  }

  async function sendRobotCommand(linearX, angularZ, durationMs, label) {
    return new Promise((resolve, reject) => {
      if (isEmergencyStop) { reject(new Error("Stopped")); return; }
      if (!window.cmdVelPub) { resolve(); return; }

      window.cmdVelPub.publish(new ROSLIB.Message({ linear: { x: linearX, y: 0, z: 0 }, angular: { x: 0, y: 0, z: angularZ } }));
      logToArea(`▶ ${label}...`);

      const startTime = Date.now();
      const timer = setInterval(() => {
        if (isEmergencyStop) {
          clearInterval(timer);
          window.cmdVelPub.publish(new ROSLIB.Message({ linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }));
          reject(new Error("Interrupted"));
        } else if (Date.now() - startTime >= durationMs) {
          clearInterval(timer);
          window.cmdVelPub.publish(new ROSLIB.Message({ linear: { x: 0, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } }));
          logToArea(`■ 完了`);
          resolve();
        }
      }, 50);
    });
  }

  // --- 実行処理 ---

  function generateCustomCode(block) {
    if (!block) return '';
    let code = '';
    switch(block.type) {
      case 'move_forward_time':
        const fTime = parseInt(block.getFieldValue('TIME'));
        code = `await sendRobotCommand(${baseSpeed}, 0, ${fTime * 1000}, "前進する");\n`;
        break;
      case 'move_backward_time':
        const bTime = parseInt(block.getFieldValue('TIME'));
        code = `await sendRobotCommand(${-baseSpeed}, 0, ${bTime * 1000}, "後退する");\n`;
        break;
      case 'turn_direction':
        const dir = block.getFieldValue('DIR');
        code = `await sendRobotCommand(0, ${dir === 'RIGHT' ? -0.8 : 0.8}, 1500, "向きをかえる");\n`;
        break;
    }
    return code + generateCustomCode(block.getNextBlock());
  }

  function logToArea(msg) {
    const time = new Date().toLocaleTimeString();
    logArea.innerHTML += `[${time}] ${msg}<br>`;
    logArea.scrollTop = logArea.scrollHeight;
  }

  async function runCode() {
    isEmergencyStop = false;
    const topBlocks = workspace.getTopBlocks(true);
    if (topBlocks.length === 0) {
        logToArea("⚠️ ブロックを配置してください");
        return;
    }
    
    // 全てのトップブロックからコードを生成して結合
    let fullCode = "";
    topBlocks.forEach(block => {
        fullCode += generateCustomCode(block);
    });

    try { 
      logToArea("🚀 スタート!");
      await eval(`(async () => { ${fullCode} })()`); 
      if(!isEmergencyStop) logToArea("✅ おわり!"); 
    } catch (e) { 
      if (e.message !== "Interrupted" && e.message !== "Stopped") logToArea("❌ エラー: " + e.message); 
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    initRosConnection();
    workspace = Blockly.inject('blocklyDiv', { toolbox: document.getElementById('toolbox'), trashcan: true });
  });
</script>
</body>
</html>

実行方法

Blockly上で作成したブロック構成に対して「動作開始」ボタンを押すことで、シーケンスが順次実行されます。また、緊急停止ボタンを用意し、動作途中でも停止できるようにしています。


検証結果

Blocklyで作成した動作シーケンスに従い、

  • 複数動作の連続実行
  • 指定時間での停止
  • 方向転換を含む動作

が実機ロボットで正しく再現されることを確認しました。

また、前回までと同様に、超音波センサーによる距離検知も有効であり、一定距離での停止動作が優先されることを確認しました。


技術的ポイント

  • Blocklyにより視覚的に動作フローを構築可能
  • roslibjsを通じてJavaScriptから/cmd_velを直接操作
  • 非同期処理(await)により動作の順次実行を実現
  • 緊急停止フラグにより動作中断が可能
  • ロボット側のセンサー制御と干渉しない設計

位置づけ(ロボット実機の統合検証③)

本検証は以下を統合した段階として位置付けます。

  • ROS2によるロボット制御
  • Webインターフェース(rosbridge)
  • ブロックベースのプログラミング(Blockly)
  • 実機ロボットの駆動

これにより、単純な操作から一歩進み、動作の組み合わせによる制御が可能であることを確認しました。


今後の展開

本構成を基に、以下の展開を検討しています。

  • Blocklyブロックの拡張(条件分岐、ループ等)
  • センサー情報のフィードバック表示
  • より複雑な動作シナリオの構築
  • 教育用途やデモ用途への展開

まとめ

本検証により、Blocklyを用いて動作シーケンスを構築し、ブラウザ経由でROS2の/cmd_velをpublishすることで、実機ロボットを制御できることを確認しました。