libVDA5050++ 어댑터 만들기: 핸들러·설정·order 처리 흐름과 실행 확인

이 글의 핵심

libVDA5050++는 VDA5050 2.0.0의 검증·순서 관리·state 조립·MQTT 송수신을 맡고, 차량을 실제로 움직이는 부분은 핸들러로 남겨 둔 라이브러리입니다. 이동·액션·쿼리 핸들러 세 개와 Config만으로 동작하는 어댑터를 만들고, order 한 건이 state에 어떻게 나타나는지 실행한 메시지로 따라갑니다.

VDA5050으로 차량 쪽 소프트웨어를 만들 때 가장 먼저 부딪히는 일은 메시지 규칙을 직접 구현하는 것입니다. order를 받으면 JSON 스키마를 검사하고, 이미 진행 중인 order와 이어 붙일 수 있는지 판단하고, 액션을 순서대로 실행하고, 그 결과를 state로 조립해 주기적으로 발행해야 합니다. libVDA5050++는 이 부분을 라이브러리 안에 담고, 차량이 실제로 어떻게 움직이는지만 어댑터가 채우게 만든 C++ 구현입니다. 이 글은 v2.11.3(VDA5050 2.0.0 구현)을 기준으로 합니다. 라이브러리 README에도 표준과 라이브러리 인터페이스가 아직 개발 중이라 이후 릴리스에서 인터페이스가 바뀔 수 있다고 적혀 있으므로, 다른 버전을 쓴다면 헤더와 docs/ 문서를 함께 확인해야 합니다.

VDA5050의 메시지 구조와 MQTT 토픽은 VDA5050으로 AGV·AMR 관제 통신하기에서, order의 node·edge 시퀀스 규칙은 order 수명주기에서 다룹니다. 여기서는 라이브러리를 어떻게 붙이는지에 집중합니다.

라이브러리가 하는 일과 어댑터가 하는 일

라이브러리 README가 꼽는 역할은 order 검증(예: 알 수 없는 액션이면 오류 state로 응답), state·visualization 주기 관리, 내비게이션 감독(목표, 일시정지, 재개, 정지), order 진행에 따라 AGV에 할 일 알리기, 상태 변화 통지, 현재 state 조회, AGV 위치 처리입니다. 반대로 라이브러리는 차량을 모릅니다. 어댑터가 정해야 하는 것은 아래입니다.

어댑터가 하는 일쓰는 인터페이스
지정한 노드로 실제로 이동하고, 도착하면 알린다BaseNavigationHandler를 상속해 등록
pick, drop, 충전 같은 액션을 실행하고 상태를 알린다BaseActionHandler(또는 SimpleMultiActionHandler)를 상속해 등록
지금 일시정지·재개가 가능한지, 이 zone set을 받을 수 있는지 답한다BaseQueryHandler를 상속하거나 Config 기본값 사용
위치, 배터리, 적재물, 오류를 state에 넣는다StatusSink, NavigationSink, ControlSink
제조사·시리얼·MQTT 접속 정보와 factsheet 내용을 정한다vda5050pp::Config

README의 설명대로 라이브러리는 대체로 수동적인 프레임워크입니다. 메시지가 오면 무엇을 해야 하는지 판단해 핸들러를 부르고, 어댑터가 넣어 주는 데이터를 모아 MC에 보고합니다.

준비: 빌드와 설치

docs/install.md와 docs/dependencies.md 기준으로 요구 사항은 다음과 같습니다.

  • C++17을 지원하는 컴파일러(gcc 또는 clang).
  • CMake 3.25 이상. 문서에 Ubuntu 22.04의 기본 패키지는 3.22라서 Kitware APT 저장소가 필요하다고 적혀 있습니다.
  • OpenSSL(libssl-dev). Paho MQTT가 요구합니다. 나머지 의존성(Paho MQTT C++ 1.5.0, eventpp, spdlog, tomlplusplus, nlohmann json 등)은 CPM이 자동으로 내려받습니다. 시스템에 이미 있는 것을 쓰려면 LIBVDA5050PP_<dep>_VERSION CMake 변수로 찾을 버전을 지정합니다.

가져다 쓰는 방법은 세 가지입니다.

  1. 설치: cmake -Bbuild -DLIBVDA5050PP_INSTALL=ON -DCMAKE_INSTALL_PREFIX=<prefix> 다음 cmake --build build --target install.
  2. .deb 패키지: 문서는 Ubuntu 22.04용으로 docker/run.sh . default-deb로 패키지를 만드는 방법을 안내합니다.
  3. 서브모듈: 어댑터 저장소의 third_party/ 아래에 두고 add_subdirectory로 포함합니다.

어느 쪽이든 어댑터의 CMake에서는 아래 두 줄로 연결합니다.

find_package(libvda5050++ REQUIRED)
target_link_libraries(my_app PUBLIC libvda5050++::vda5050++)

서브모듈로 포함하면 첫 빌드가 오래 걸립니다. 이 글의 실행 환경에서는 라이브러리와 의존성을 한 번 빌드해 둔 트리를 재사용했고, 예제 어댑터는 그 빌드 산출물에 링크해 컴파일했습니다.

최소 어댑터 만들기

실행 환경부터 적습니다. Ubuntu 24.04.5(aarch64) 컨테이너, g++ 13.3.0, CMake 3.28.3, libVDA5050++ v2.11.3, 브로커는 eclipse-mosquitto:2이고 익명 접속을 허용했습니다. 아래 예제는 모두 이 환경에서 컴파일하고 실행한 것입니다. 1m/s로 직선 이동하는 가짜 차량이라서 실제 장비의 지연·오차는 반영하지 않습니다.

어댑터의 뼈대는 Config를 채우고, Handle::initialize를 부르고, 핸들러를 등록하는 순서입니다.

int main() {
  vda5050pp::Config cfg;
  auto &agv = cfg.refAgvDescription();
  agv.agv_id = "toy001";
  agv.manufacturer = "sim";
  agv.serial_number = "toy001";
  agv.description = "blog example";
  auto &mqtt = cfg.refMqttSubConfig().refOptions();
  mqtt.server = "tcp://pkg-vda-mq:1883";
  mqtt.interface = "uagv";
  ...
  vda5050pp::Handle handle;
  handle.initialize(cfg);          // 여기서부터 MQTT에 접속해 connection·state를 발행한다
  handle.registerNavigationHandler(nav);
  handle.registerActionHandler(...);
  ...
  handle.shutdown();               // OFFLINE 메시지를 보내고 정리
}

토픽 이름은 <interface>/<버전>/<manufacturer>/<serial_number>/...로 만들어집니다. 앞 두 값이 uagv/v2이면 이 차량의 토픽은 uagv/v2/sim/toy001/order, .../state 같은 모양입니다. manufacturer와 serial_number는 MQTT 토픽 선택에 필수이고, agv_id는 MQTT 클라이언트 ID에 쓰입니다. 같은 브로커에 차량 여러 대를 붙일 때 agv_id가 겹치면 서로 연결을 빼앗으므로 차량마다 달라야 합니다.

첫 번째 함정: interface 기본값

docs/configuration.md의 MQTT 표는 interface의 기본값을 uagv로 적고 있습니다. 그런데 interface를 설정하지 않고 코드로 만든 vda5050pp::Config를 그대로 넘기자, 실제로 발행된 토픽은 uagv/v2/...가 아니라 /v2/sim/toy001/connection이었습니다. 소스(mqtt_subconfig.cpp)를 보면 "uagv" 기본값은 TOML에서 설정을 읽을 때(value_or("uagv")) 적용되고, 코드로 만든 기본 Config의 옵션은 빈 문자열로 시작합니다. 그래서 위 예제처럼 mqtt.interface = "uagv";를 직접 넣었고, 그 뒤로 토픽이 uagv/v2/sim/toy001/...로 나왔습니다. 관제 시스템과 연결했는데 아무 메시지도 안 보일 때 가장 먼저 확인할 지점입니다.

내비게이션 핸들러

BaseNavigationHandler에서 구현해야 하는 순수 가상 함수는 horizonUpdated, baseIncreased, navigateToNextNode, upcomingSegment, cancel, pause, resume, reset입니다. 이 중 navigateToNextNode와 upcomingSegment는 둘 중 하나를 골라 쓰는 대안입니다. navigateToNextNode는 다음 노드로 한 번에 한 구간씩 가라는 호출이고, upcomingSegment는 블로킹 액션(pick 같은 것)으로 서지 않고 이어서 달릴 수 있는 시퀀스 범위를 한꺼번에 알려 줍니다. 노드마다 서는 것이 불필요한 차량은 후자를 쓰면 됩니다. 이 글의 예제는 전자를 씁니다.

핸들러가 라이브러리에 알리는 방법은 베이스 클래스가 제공하는 setPosition, setNodeReached, setPaused, setResumed, setFailed입니다. 위치를 보고만 하는 setPosition과 달리 evalPosition은 받은 위치로 노드 도착 여부를 라이브러리가 판정하게 합니다(노드의 허용 오차를 사용).

#include <atomic>
#include <chrono>
#include <cmath>
#include <csignal>
#include <iostream>
#include <mutex>
#include <thread>

#include <vda5050++/config.h>
#include <vda5050++/handle.h>
#include <vda5050++/handler/base_navigation_handler.h>
#include <vda5050++/handler/simple_multi_action_handler.h>
#include <vda5050++/misc/action_declarations.h>
#include <vda5050++/sinks/navigation_sink.h>

using namespace std::chrono_literals;

// 1 m/s로 직선 이동하는 가짜 차량. 라이브러리는 핸들러를 전용 스레드 하나에서 부르므로
// 이동 자체는 별도 스레드에서 돌리고 핸들러 함수는 바로 돌아온다.
class ToyNavigation : public vda5050pp::handler::BaseNavigationHandler {
public:
  ~ToyNavigation() override { stopWorker(); }

  void horizonUpdated(const std::list<std::shared_ptr<const vda5050::Node>> &,
                      const std::list<std::shared_ptr<const vda5050::Edge>> &) override {}
  void baseIncreased(const std::list<std::shared_ptr<const vda5050::Node>> &,
                     const std::list<std::shared_ptr<const vda5050::Edge>> &) override {}

  void navigateToNextNode(std::shared_ptr<const vda5050::Node> next_node,
                          std::shared_ptr<const vda5050::Edge>) override {
    stopWorker();
    const auto pos = next_node->nodePosition.value();  // 이 예제의 order는 항상 좌표를 보낸다
    const uint32_t seq = next_node->sequenceId;
    cancel_ = false;
    paused_ = false;
    worker_ = std::thread([this, pos, seq] {
      vda5050pp::sinks::NavigationSink().setDriving(true);  // driving은 라이브러리가 알 수 없어 직접 알려야 한다
      while (!cancel_) {
        std::this_thread::sleep_for(100ms);
        if (paused_) continue;
        double dx = pos.x - x_, dy = pos.y - y_, d = std::hypot(dx, dy);
        double step = 0.1;  // 100ms * 1m/s
        if (d <= step) {
          x_ = pos.x; y_ = pos.y;
          setPosition(x_, y_, 0.0, "map");
          vda5050pp::sinks::NavigationSink().setDriving(false);
          setNodeReached(seq);  // "이 노드에 도착했다"를 알려야 라이브러리가 다음 단계로 넘어간다
          return;
        }
        x_ += dx / d * step; y_ += dy / d * step;
        setPosition(x_, y_, 0.0, "map");
      }
    });
  }

  void upcomingSegment(decltype(vda5050::Node::sequenceId), decltype(vda5050::Node::sequenceId)) override {}

  void cancel() override { stopWorker(); setFailed(); }
  void pause() override { paused_ = true; setPaused(); }
  void resume() override { paused_ = false; setResumed(); }
  void reset() override { stopWorker(); }

  void startAt(double x, double y) { x_ = x; y_ = y; setPosition(x_, y_, 0.0, "map"); }

private:
  void stopWorker() { cancel_ = true; if (worker_.joinable()) worker_.join(); }
  std::thread worker_;
  std::atomic<bool> cancel_{false}, paused_{false};
  double x_ = 0, y_ = 0;
};

몇 가지를 짚습니다.

  • setNodeReached(seq)를 부르지 않으면 order는 거기서 멈춥니다. 이 호출이 곧 다음 구간으로 넘어가라는 신호입니다.
  • 헤더 주석에 따르면 라이브러리는 이 핸들러의 함수를 전용 스레드 하나에서 호출합니다. 그래서 예제는 이동을 별도 스레드에서 돌리고, 새 이동을 시작할 때와 cancel/reset에서 이전 스레드를 멈춥니다.
  • driving은 라이브러리가 알 수 없습니다. 처음에는 NavigationSink().setDriving(...)을 넣지 않은 채로 3m를 이동시켜 봤는데, 이동 중에도 state의 driving은 계속 false였습니다. 위 예제처럼 이동을 시작할 때 setDriving(true), 도착할 때 setDriving(false)를 부르면 order 수락 시점의 state에서 driving: true가 나왔습니다.
  • cancel()에서 setFailed()를 부르는 것은 퀵스타트 문서의 예제를 따른 것입니다. 뒤의 pause·cancel 실험은 이 예제가 아니라 더 큰 어댑터로 한 것입니다.

액션 핸들러

pick, drop, 충전 같은 일반 액션은 라이브러리가 모르기 때문에 어댑터가 이름과 파라미터를 정의합니다. 가장 쉬운 방법은 SimpleMultiActionHandler에 ActionDeclaration 목록을 넘기는 것입니다. 라이브러리는 vda5050pp::misc::action_declarations에 k_pick, k_drop, k_start_charging, k_stop_charging, k_detect_object, k_fine_positioning, k_wait_for_trigger 같은 표준 액션 선언을 미리 제공합니다. 선언에는 파라미터 이름·타입·필수 여부가 들어 있어서 다음 두 가지가 자동으로 됩니다.

  • order 검증 단계에서 필수 파라미터 누락을 거른다.
  • factsheet의 agvActions에 그 액션이 파라미터 설명과 함께 올라간다.
// pick / drop을 한 핸들러로. 파라미터 검증은 ActionDeclaration이 맡는다.
class ToyPickDrop : public vda5050pp::handler::SimpleMultiActionHandler {
public:
  ToyPickDrop()
      : SimpleMultiActionHandler({vda5050pp::misc::action_declarations::k_pick,
                                  vda5050pp::misc::action_declarations::k_drop}) {}

  vda5050pp::handler::ActionCallbacks prepare(
      std::shared_ptr<vda5050pp::handler::ActionState> action_state,
      std::shared_ptr<vda5050pp::handler::ParametersMap>) noexcept(false) override {
    vda5050pp::handler::ActionCallbacks cbs;
    cbs.on_start = [](vda5050pp::handler::ActionState &st) {
      st.setRunning();
      // 실제 장비라면 여기서 명령을 내리고 완료 신호를 기다린다. 이 예제는 스레드에서 2초 뒤 완료.
      std::thread([&st] {
        std::this_thread::sleep_for(2s);
        st.setFinished();
      }).detach();
    };
    cbs.on_pause = [](vda5050pp::handler::ActionState &st) { st.setPaused(); };
    cbs.on_resume = [](vda5050pp::handler::ActionState &st) { st.setRunning(); };
    cbs.on_cancel = [](vda5050pp::handler::ActionState &st) { st.setFailed(); };
    return cbs;
  }
  void reset() noexcept(false) override {}
};

main은 아래처럼 구성합니다. 핸들러 등록은 initialize 뒤에만 가능합니다.

static std::atomic<bool> g_stop{false};

int main() {
  vda5050pp::Config cfg;
  auto &agv = cfg.refAgvDescription();
  agv.agv_id = "toy001";
  agv.manufacturer = "sim";
  agv.serial_number = "toy001";
  agv.description = "blog example";
  auto &mqtt = cfg.refMqttSubConfig().refOptions();
  mqtt.server = "tcp://pkg-vda-mq:1883";
  mqtt.interface = "uagv";  // 코드로 만든 Config의 interface 기본값은 빈 문자열이다 (TOML로 읽을 때만 "uagv")
  // 방금 받은 위치가 order 노드 좌표와 맞는지 보는 허용 오차 기본값 (order에 값이 없을 때)
  cfg.refNodeReachedSubConfig().setDefaultNodeDeviationXY(0.5);
  cfg.refQueryEventHandlerSubConfig().setDefaultPauseableSuccess(true);
  cfg.refQueryEventHandlerSubConfig().setDefaultResumableSuccess(true);

  vda5050pp::Handle handle;
  handle.initialize(cfg);

  auto nav = std::make_shared<ToyNavigation>();
  handle.registerNavigationHandler(nav);
  handle.registerActionHandler(std::make_shared<ToyPickDrop>());
  nav->startAt(0.0, 0.0);

  std::signal(SIGTERM, [](int) { g_stop = true; });
  std::signal(SIGINT, [](int) { g_stop = true; });
  while (!g_stop) std::this_thread::sleep_for(200ms);
  handle.shutdown();
}

prepare는 액션이 검증을 통과해 앞으로 실행될 것이 정해졌을 때 불립니다. 여기서 start, pause, resume, cancel 콜백 네 개를 돌려주면, 이후 라이브러리가 필요한 시점에 그 콜백을 부릅니다. ActionState는 액션 하나에 붙은 상태 기계라서, 콜백 안에서 setRunning(), setFinished(), setFailed()로 진행을 알립니다. 파라미터는 std::map<std::string, std::variant<std::string, int64_t, double, bool, AnyPtr>> 모양으로 prepare에 함께 전달되고, SimpleMultiActionHandler는 선언에 맞춰 이 값을 자동으로 파싱합니다.

위 예제에서 on_start가 ActionState의 참조를 별도 스레드에 넘기는 부분은 예제를 짧게 하려고 단순화한 것입니다. 액션이 취소되거나 order가 리셋되어 객체가 사라진 뒤에 그 스레드가 참조를 쓰면 위험하므로, 실제 어댑터에서는 prepare가 받은 shared_ptr<ActionState>를 복사해 쥐고 있는 쪽이 안전합니다.

쿼리 핸들러와 Config 기본값

일시정지·재개가 가능한지, 어떤 zone set을 받을 수 있는지는 라이브러리가 차량에 물어봐야 알 수 있습니다. 이를 BaseQueryHandler로 구현해도 되고, 단순하게는 Config 기본값으로 대신할 수 있습니다. 예제의 main이 쓴 방식입니다.

cfg.refQueryEventHandlerSubConfig().setDefaultPauseableSuccess(true);
cfg.refQueryEventHandlerSubConfig().setDefaultResumableSuccess(true);

startPause/stopPause instant action을 받으면 라이브러리가 먼저 이 질의로 가능 여부를 확인하고, 통과해야 핸들러에 알립니다. 승강 중처럼 지금은 멈출 수 없는 상황이 있는 장비라면 queryPauseable()에서 오류를 돌려주도록 직접 구현합니다.

실행해서 확인하기

예제를 컴파일해 컨테이너에서 실행하고, mosquitto_pub으로 order를 보내면서 state 토픽을 구독했습니다. n0(0,0)에서 n1(3,0)으로 가서 pick, n2(3,2)로 가서 drop하는 order입니다. 아래는 state가 바뀔 때마다 필요한 필드만 뽑은 결과입니다.

경과lastNodeIdnodeStatesactionStates
order 수락 직후n0 (seq 0)n1, n2 모두 releaseddrop WAITING, pick WAITING
약 3.5초, n1 도착n1 (seq 2)n2pick RUNNING
약 5.5초n1n2pick FINISHED (예제의 2초 대기 후)
약 7.6초, n2 도착n2 (seq 4)비어 있음drop RUNNING
약 9.6초n2비어 있음drop FINISHED

눈에 띄는 점이 몇 가지 있습니다. 도착한 노드와 지나간 엣지는 nodeStates/edgeStates에서 빠지고 lastNodeId로 넘어갑니다. actionStates는 실행 순서가 아니라 actionId 순으로 나열됩니다(위 결과에서 drop이 pick보다 앞에 나왔습니다). 그리고 3m를 이동하는 5초 동안 state 메시지는 order 수락과 노드 도착 두 번만 나왔습니다. 이동 중의 위치는 state가 아니라 visualization 토픽에 주기적으로 실려 옵니다.

state의 발행 시점은 이벤트 기반입니다. order를 받았을 때, 노드에 도착했을 때, 액션 상태가 바뀌었을 때 보내고, 이벤트가 없어도 StateUpdateTimer의 최대 주기마다 한 번 보냅니다. 문서의 기본 최대 주기는 30000ms이고, UI가 배터리나 위치를 더 자주 보여 줘야 한다면 cfg.refStateUpdateTimerSubConfig().setMaxUpdatePeriod(std::chrono::milliseconds(1000))처럼 줄입니다.

필수 파라미터를 빼고 보내면 order가 통째로 버려집니다. pick에서 loadType을 뺀 order를 보내자 차량은 움직이지 않았고, state의 errors에 orderError, 설명 Missing action parameters: ["loadType"]이 들어왔습니다(같은 오류가 두 번 나열됐습니다).

설정 파일로 빼기

코드에 값을 박지 않고 TOML로 읽고 저장할 수도 있습니다. vda5050pp::Config::loadFrom(std::filesystem::path) 또는 문자열로 읽고, save로 씁니다. 표는 [global], [agv_description], [module.Mqtt] 같은 모듈별 테이블로 나뉩니다. factsheet의 typeSpecification·physicalParameters·agvGeometry·loadSpecification은 JSON 문자열(json) 또는 파일 경로(json_file)로 넣을 수 있습니다. 앞의 interface 함정은 이 경로로 설정하면 기본값 uagv가 적용되어 나타나지 않습니다. 모듈마다 로그 수준을 [module.<이름>] 테이블에서 따로 조절할 수 있고, 로그 줄 앞의 [mqtt], [instance] 같은 이름이 그 모듈 이름입니다.

MQTT 쪽에서 운영에 자주 만지는 키는 server(tcp/ws/ssl/wss://호스트:포트), username/password, use_ssl, enable_cert_check, 재접속 간격(min_retry_interval_ms 기본 2000, max_retry_interval_ms 기본 16000)입니다. 노드 도착 판정은 [module.NodeReachedHandler]의 default_node_deviation_xy 같은 값으로 order에 허용 오차가 없을 때의 기본을 정합니다.

order 한 건이 처리되는 과정

라이브러리 소스의 모듈 구성(messages, validation, interpreter, order, agv_handler, state)을 따라 정리하면 order 하나는 아래 순서로 처리됩니다. 각 단계를 모두 직접 시험한 것은 아니고, 직접 오류를 내 본 것은 뒤의 실행 결과 표에 있습니다. 어느 단계에서 막히는지 알면 state의 errors를 읽기가 쉬워집니다.

  1. MQTT 구독: <interface>/<버전>/<manufacturer>/<serial>/order와 instantActions를 구독해 JSON을 객체로 바꿉니다.
  2. 중복·정렬: 직전에 처리한 것과 같은 orderId와 orderUpdateId이면 버립니다. node와 edge는 sequenceId 순으로 정렬합니다.
  3. 검증: 헤더(제조사·시리얼·버전), 그래프 일관성, orderId와 orderUpdateId, 진행 중인 order와 이어 붙일 수 있는지, 액션 id 중복, 첫 노드에 갈 수 있는지, 액션 검증(타입과 필수 파라미터)을 차례로 검사합니다. 하나라도 걸리면 order 전체를 버리고 사유를 errors에 넣습니다.
  4. 해석: node와 edge를 훑어 “다음 노드로 가라”, “여기서 이 액션들을 실행하라” 같은 작은 이벤트로 쪼갭니다. 이때 released가 true인 구간(base)만 실제로 이동하고, false인 구간(horizon)은 예고일 뿐 가지 않습니다. 액션의 blockingType은 NONE이면 달리면서 가능, SOFT와 HARD이면 노드에서 서서 실행한다는 뜻으로 해석됩니다.
  5. 스케줄러: 상태 기계로 “이 노드의 액션이 모두 끝나야 다음 구간으로 간다” 같은 순서를 지키고, 등록된 핸들러를 부릅니다.
  6. state 조립과 발행: 위치, 주행 여부, 마지막 노드, 액션 상태, 짐, 배터리, 에러를 모아 이벤트가 있을 때와 주기마다 발행합니다.

검증은 부분 수락 없이 전부 통과하지 못하면 전부 버립니다. 노드 하나의 오류로 order 전체가 무시되므로, 차량이 반응이 없을 때는 항상 state의 errors부터 봅니다.

경계 상황을 실행해 본 결과

아래는 같은 라이브러리(v2.11.3) 위에 만든 더 큰 학습용 시뮬레이터에서 실행한 것입니다. 이동·pick·drop·충전·일시정지를 구현한 어댑터이고, 라이브러리가 처리하는 부분을 관찰하려고 썼습니다. 토픽은 같은 규칙이고 노드는 n0(0,0), n1(3,0), n2(3,2), n3(0,2) 사각형입니다.

상황보낸 메시지관찰된 동작
horizon은 가지 않는다n2(released) → n3(released) → n0(released=false)n3까지 가서 멈춤. nodeStates에 n0가 released=false로 남음
update로 horizon을 푼다같은 orderId, orderUpdateId 1, 마지막 확정 노드 n3(seq 2)부터 다시이어서 n0까지 이동, state의 orderUpdateId가 1이 됨
orderUpdateId가 줄어듦orderUpdateId 0orderUpdateError: “6.6.4.3 - Cannot accept decreasing orderUpdateId”, 나머지 state는 그대로
sequenceId가 건너뜀새 orderId, node seq 0 다음 node seq 4orderError: “The order skips sequence ids”. 앞의 오류와 함께 errors에 누적됨
같은 orderId를 다시 전송이미 끝난 orderId·orderUpdateId 0을 내용만 바꿔 재전송오류도 이동도 없음. 조용히 무시됨
새 order새 orderId로 마지막 노드에서 시작정상 수락, lastNodeSequenceId는 0부터 다시 시작

같은 orderId가 조용히 무시되는 동작은 재시도 로직에서 특히 조심해야 합니다. 실패한 order를 취소하고 곧바로 같은 방식으로 orderId를 만들어 다시 보내면(예: 초 단위 시각으로 ID를 만들면 1초 안에 같은 ID가 나옵니다) 응답이 없는 것처럼 보입니다. orderId는 단조 증가 카운터나 UUID처럼 매번 달라지게 만듭니다.

instant action도 비슷합니다. 이동 중에 startPause를 보내면 state에 paused: true, driving: false가 되고 그 자리에서 멈췄으며, 해당 액션은 FINISHED로 표시됐습니다. stopPause를 보내자 다시 달렸습니다. cancelOrder를 보내면 nodeStates와 edgeStates가 비고 lastNodeSequenceId가 0으로 돌아갔지만, 위치는 엣지 중간(3.0, 1.02)에 그대로 남았습니다. 취소 뒤에 보내는 새 order의 시작 노드가 차량 위치에서 허용 오차보다 멀면 거부될 수 있으므로, 엣지 중간에서 멈춘 차량에는 가까운 노드로 보내는 복구 이동이 따로 필요할 수 있습니다.

stateRequest를 blockingType: HARD로 보내자 액션은 FAILED가 되고 instantActionError, 설명 BlockingType "HARD" is not allowed, expected one of {NONE}가 state에 들어왔습니다. cancelOrder 같은 다른 instant action은 HARD를 쓰지만 stateRequest와 factsheetRequest는 NONE만 허용합니다. 모르는 액션 타입(doBackflip)은 액션이 FAILED가 되고 unknownActionType 오류가 나왔습니다.

운영할 때 확인할 것

  • 연결 상태: 컨테이너를 강제로 끄자 브로커가 connection 토픽에 CONNECTIONBROKEN을 발행했습니다(MQTT 유언 메시지). 정상 종료인 handle.shutdown()은 문서에 offline 메시지를 보낸다고 적혀 있고, 이번에는 그 경로를 확인하지 않았습니다. 관제는 connection 토픽의 값으로 차량 생존 여부를 판단할 수 있습니다.
  • factsheet: factsheetRequest instant action을 보내면 factsheet 토픽에 발행됩니다. 라이브러리가 기본으로 처리하는 instant action(cancelOrder, factsheetRequest, startPause, stopPause, stateRequest)과 등록한 액션의 선언(파라미터 이름, 타입, 필수 여부)이 agvActions에 올라왔습니다. 관제가 어떤 액션을 쓸 수 있는지 알려면 이 목록을 보면 됩니다.
  • 오래 남는 오류: state의 errors는 다음 order가 수락될 때까지 남습니다. 화면에 지난 오류가 보여도 라이브러리가 새 그래프로 교체하기 전이라서 정상입니다.
  • 액션 실패: 액션이 FAILED가 되어도 order는 진행 중으로 남습니다. 다음 order를 보내려면 먼저 cancelOrder가 끝나야 합니다. 그렇지 않으면 orderUpdateError, “Order ID does not match the current (unfinished) one”이 나옵니다.
  • 핸들러 스레드: 핸들러 함수를 오래 막지 않습니다. 하드웨어 대기는 별도 스레드나 콜백으로 처리하고 완료 시점에 setNodeReached·state.setFinished를 부릅니다.
  • 이벤트 직접 구독: 퀵스타트의 “Raw Events”처럼 vda5050pp::events::EventHandle로 라이브러리 내부 이벤트(예: NavigationNextNode)를 직접 구독하고 NavigationStatusNodeReached를 디스패치할 수도 있습니다. 핸들러 클래스가 맞지 않는 구조(예: ROS 2 액션과 직접 엮는 경우)에서 쓰는 우회로입니다. 이번 실행에서는 이 경로를 시험하지 않았습니다.

막힐 때 점검 순서

  1. MQTT에서 connection 토픽에 ONLINE이 보이는가. 안 보이면 interface, 서버 주소, manufacturer/serial_number를 확인합니다. 코드로 만든 Config에서는 interface가 비어 있을 수 있습니다.
  2. order를 보냈을 때 state의 errors가 늘었는가. 늘었다면 그 설명이 검증 단계 어디인지 알려 줍니다.
  3. errors가 그대로인데 움직이지 않는가. 같은 orderId·orderUpdateId 재전송이거나, 핸들러가 setNodeReached를 부르지 않은 경우입니다. 이동 핸들러가 호출됐는지 로그([info] 줄)를 봅니다.
  4. 도착했는데 다음 구간으로 안 가는가. 블로킹 액션이 FAILED로 끝났는지 actionStates를 확인합니다.
  5. state가 안 갱신되는 것처럼 보이는가. 이벤트 기반이라 정상일 수 있습니다. 위치는 visualization 토픽에서 보고, 주기를 줄여야 하면 StateUpdateTimer 설정을 바꿉니다.

한계

이 글의 실행 결과는 로컬 컨테이너에서 가짜 차량으로 얻은 것입니다. 실제 하드웨어의 위치 잡음, 통신 지연, 노드 허용 오차 설정은 다루지 않았고, VDA5050 3.0과 이 라이브러리의 관계도 확인하지 않았습니다. upcomingSegment 방식과 BaseQueryHandler를 직접 구현하는 경우, 존(zone set) 처리, TLS 접속은 소스와 문서를 읽기만 했고 실행해 보지 않았습니다. 라이브러리 공식 문서와 내용이 다르면 공식 문서와 헤더가 우선입니다.

order와 액션 모델을 더 깊이 보려면 pick 액션 상태 머신과 커스텀 데이터 전송이 이어지고, 이기종 로봇과 관제 사이의 게이트웨이 구조는 플릿 게이트웨이 개발 가이드에서 다룹니다. MQTT 자체의 QoS·retained·LWT는 MQTT 발행/구독 가이드에 있습니다.