본문 바로가기
Unreal Engine/언리얼-ROS-Physical 통합 프로젝트

[UnrealRobotics: SO-101] (16) WebSocket부터 USB까지 4단계 장애 진단과 자동 복구 구현

by 테크앤아트 2026. 5. 27.
728x90
반응형

 

 

 


 

 

1단계: 오류/안전 처리

  • 연결 끊김 시 Unreal UI 경고 (RosBridgeSubsystem에 OnDisconnected 델리게이트가 있지만 RobotVisualizer가 바인딩하지 않음. OnRosBridgeDisconnected() 콜백을 추가하고 뷰포트에 빨간 경고 표시 + 재연결 시 복구 메시지)
    예를 들어 rosbridge가 죽었는데 worker가 replay를 계속 돌리고 있으면 Unreal에서 멈출 방법이 없으니까, worker 쪽에서 "bridge node로부터 일정 시간 heartbeat가 안 오면 자동으로 동작을 멈추는" 안전장치를 넣는 것.
  • 플래닝 실패 시 사용자 피드백 (moveit_goal_node.py가 MoveIt 에러를 로그에만 출력하고 /robot_status로 전파하지 않음. Python 노드를 수정해서 에러를 /robot_status에 publish → Unreal의 기존 OnRobotStatus() 핸들러가 자동으로 빨간 에러 메시지 표시)
  • E-stop: 연결 끊김 상태에서 E-stop 버튼이 눌리면 "Not connected" 경고만 뜸 (현재 PublishRobotCommand에서 처리). 추가로 연결이 끊기면 worker에서 진행 중인 동작이 그대로 계속될 수 있는 문제 → worker 측에서 ZMQ heartbeat timeout으로 자동 정지하는 안전장치 검토 필요.

 

 

RobotVisualizer.h 수정

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "RobotVisualizer.generated.h"

class UStaticMesh;
class UStaticMeshComponent;
class USceneComponent;
class URosBridgeSubsystem;

/**
 * Visualizes the SO-ARM-101 follower arm in Unreal Engine and provides
 * MoveIt command interface via rosbridge.
 *
 * The component hierarchy mirrors the URDF link/joint structure:
 *   BaseLink -> ShoulderPanJoint -> ShoulderLink -> ShoulderLiftJoint -> ...
 *
 * Each "Joint" SceneComponent is where the ROS joint angle gets applied
 * as a local Z-axis rotation. All child links/meshes rotate with it.
 *
 * Phase 8 additions:
 *   - SendNamedTarget(): publish to /moveit_goal_named (std_msgs/String)
 *   - SendJointGoal(): publish to /moveit_goal_joints (sensor_msgs/JointState)
 *   - SendPoseGoal(): publish to /moveit_goal_pose (geometry_msgs/PoseStamped)
 *   - Blueprint-callable + editor-testable via UPROPERTY buttons
 *
 * Phase 9 additions (Record/Replay/E-Stop):
 *   - StartRecord(): begin teleop recording on worker
 *   - StopRecord(): stop recording, save trajectory
 *   - StartReplay(): replay most recent (or named) recording
 *   - StopReplay(): stop replay
 *   - EStop(): emergency stop all motion
 *   - All commands publish JSON to /robot_command topic
 *   - Worker state feedback via /robot_status subscription
 */
UCLASS()
class SO101_TWIN_API ARobotVisualizer : public AActor
{
	GENERATED_BODY()

public:
	ARobotVisualizer();

protected:
	virtual void BeginPlay() override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	// =================================================================
	// Configuration
	// =================================================================

	UPROPERTY(EditAnywhere, Category = "ROS|Bridge")
	FString RosBridgeUrl = TEXT("ws://127.0.0.1:9090/?x=1");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateTopic = TEXT("/joint_states");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateType = TEXT("sensor_msgs/JointState");

	// =================================================================
	// MoveIt Command Interface (Phase 8)
	// =================================================================

	// --- Named target ---

	/** Named target to send (e.g. "home", "ready"). Set in Details panel, then call SendNamedTarget(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt")
	FString MoveItNamedTarget = TEXT("home");

	/** Send the named target to MoveIt via /moveit_goal_named topic. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt")
	void SendNamedTarget();

	// --- Joint goal ---

	/** Joint goal values in radians. Set in Details panel, then call SendJointGoal(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderPan = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderLift = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalElbowFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristRoll = 0.0f;

	/** Send joint goal to MoveIt via /moveit_goal_joints topic. Values are in radians. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Joints")
	void SendJointGoal();

	// --- Pose goal (Cartesian, position-only for 5DOF) ---

	/** Target position in UE coordinates (cm). Converted to ROS (meters) on send. */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Pose")
	FVector GoalPositionUE = FVector(10.0f, 0.0f, 15.0f);

	/** Send position-only goal to MoveIt via /moveit_goal_pose topic.
	 *  GoalPositionUE is in Unreal cm, auto-converted to ROS meters with Y flip. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Pose")
	void SendPoseGoal();

	// =================================================================
	// Teleop Sync (Phase 9)
	// =================================================================

	/** Activate leader→follower sync (teleop). Must be ON before recording. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOn();

	/** Deactivate leader→follower sync. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOff();

	/** Whether teleop (sync) is currently active. Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	bool bSyncActive = false;

	// =================================================================
	// Record / Replay / E-Stop (Phase 9)
	// =================================================================

	/** Start recording: activates teleop on worker, buffers joint trajectory. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StartRecord();

	/** Stop recording: saves trajectory to file on worker. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StopRecord();

	/** Replay filename (empty = most recent recording). */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	FString ReplayFilename;

	/** Whether to loop the replay continuously. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	bool bReplayLoop = false;

	/** Approach speed in degrees/sec. Controls how fast the robot moves
	 *  to the start position before replay begins. Lower = smoother. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay", meta = (ClampMin = "5.0", ClampMax = "300.0"))
	float ApproachSpeed = 45.0f;

	/** Start replaying a recorded trajectory on the follower arm. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StartReplay();

	/** Stop replay immediately. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StopReplay();

	/** Emergency stop: abort ALL motion immediately (recording, replay, teleop). */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Safety")
	void EStop();

	/** Current worker state (idle/recording/replaying). Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	FString WorkerState = TEXT("unknown");

private:
	// =================================================================
	// Component hierarchy
	// =================================================================

	UPROPERTY(VisibleAnywhere, Category = "Robot")
	TObjectPtr<USceneComponent> RobotRoot;

	// Link SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> BaseLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> ShoulderLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> UpperArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> LowerArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> WristLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> GripperLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> MovingJawLink;

	// Joint SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderPanJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderLiftJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ElbowFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristRollJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> GripperJoint;

	// Joint name -> component mapping
	UPROPERTY()
	TMap<FName, TObjectPtr<USceneComponent>> JointComponentMap;

	// Mesh components
	UPROPERTY()
	TArray<TObjectPtr<UStaticMeshComponent>> AllMeshComponents;

	// =================================================================
	// ROS connection
	// =================================================================

	UFUNCTION()
	void OnRosBridgeConnected();

	UFUNCTION()
	void OnRosBridgeDisconnected();

	UFUNCTION()
	void OnRosMessage(const FString& Topic, const FString& MessageJson);

	/** Tracks rosbridge connection state for viewport warnings. */
	bool bRosBridgeConnected = false;

	void ParseAndApplyJointStates(const FString& MessageJson);

	// =================================================================
	// MoveIt publish helpers
	// =================================================================

	/** Advertise MoveIt command topics. Called once on connect. */
	void AdvertiseMoveItTopics();

	/** Whether MoveIt topics have been advertised in this connection session. */
	bool bMoveItTopicsAdvertised = false;

	// =================================================================
	// Record / Replay / E-Stop helpers
	// =================================================================

	/** Advertise /robot_command and subscribe /robot_status. Called once on connect. */
	void SetupRecordReplayTopics();

	/** Whether record/replay topics have been set up. */
	bool bRecordReplayTopicsSetup = false;

	/** Send a JSON command to /robot_command topic. */
	void PublishRobotCommand(const FString& JsonCmd);

	/** Handle /robot_status messages from the bridge node. */
	UFUNCTION()
	void OnRobotStatus(const FString& Topic, const FString& MessageJson);

	// =================================================================
	// Helpers (declared in original header, kept for compatibility)
	// =================================================================

	USceneComponent* CreateJointComponent(const FName& Name, USceneComponent* Parent,
		const FVector& Location, const FRotator& Rotation);

	USceneComponent* CreateLinkComponent(const FName& Name, USceneComponent* Parent);

	UStaticMeshComponent* AttachMesh(USceneComponent* Parent, UStaticMesh* Mesh,
		const FName& Name, const FVector& Location, const FRotator& Rotation,
		bool bIsMotor);
};

수정 사항

 

  • OnRosBridgeDisconnected() UFUNCTION 선언 추가
  • bRosBridgeConnected 상태 변수 추가

 

 

 

RobotVisualizer.cpp 수정

더보기
#include "RobotVisualizer.h"
#include "RosCoordConv.h"
#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "Components/SceneComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
#include "UObject/ConstructorHelpers.h"

// =============================================================================
// Mesh asset path helper
// =============================================================================

static UStaticMesh* LoadMeshAsset(const TCHAR* AssetName)
{
	// All meshes live under /Game/Robot/Meshes/
	FString Path = FString::Printf(TEXT("/Game/Robot/Meshes/%s.%s"), AssetName, AssetName);
	UStaticMesh* Mesh = Cast<UStaticMesh>(StaticLoadObject(UStaticMesh::StaticClass(), nullptr, *Path));
	if (!Mesh)
	{
		UE_LOG(LogRosBridge, Warning, TEXT("Failed to load mesh: %s"), *Path);
	}
	return Mesh;
}

// =============================================================================
// Constructor — build the entire component hierarchy
// =============================================================================

ARobotVisualizer::ARobotVisualizer()
{
	PrimaryActorTick.bCanEverTick = false;

	// --- Root ---
	RobotRoot = CreateDefaultSubobject<USceneComponent>(TEXT("RobotRoot"));
	RootComponent = RobotRoot;

	// =========================================================================
	// URDF data converted to UE coordinates:
	//   Position: meters * 100 = cm, Y flipped
	//   Rotation: RPY radians -> FRotator degrees, pitch & yaw negated
	//
	// All values below are pre-computed from so101_follower.urdf.
	// =========================================================================

	// --- base_link (attached directly to root, no joint) ---
	BaseLink = CreateDefaultSubobject<USceneComponent>(TEXT("BaseLink"));
	BaseLink->SetupAttachment(RobotRoot);

	// --- shoulder_pan joint ---
	// URDF origin: xyz(0.0388353, 0, 0.0624) rpy(3.14159, 0, -3.14159)
	ShoulderPanJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderPanJoint"));
	ShoulderPanJoint->SetupAttachment(BaseLink);
	ShoulderPanJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0388353, 0.0, 0.0624));
	ShoulderPanJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159));

	ShoulderLink = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLink"));
	ShoulderLink->SetupAttachment(ShoulderPanJoint);

	// --- shoulder_lift joint ---
	// URDF origin: xyz(-0.0303992, -0.0182778, -0.0542) rpy(-1.5708, -1.5708, 0)
	ShoulderLiftJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLiftJoint"));
	ShoulderLiftJoint->SetupAttachment(ShoulderLink);
	ShoulderLiftJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.0303992, -0.0182778, -0.0542));
	ShoulderLiftJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0));

	UpperArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("UpperArmLink"));
	UpperArmLink->SetupAttachment(ShoulderLiftJoint);

	// --- elbow_flex joint ---
	// URDF origin: xyz(-0.11257, -0.028, 0) rpy(0, 0, 1.5708)
	ElbowFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ElbowFlexJoint"));
	ElbowFlexJoint->SetupAttachment(UpperArmLink);
	ElbowFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.11257, -0.028, 0.0));
	ElbowFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708));

	LowerArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("LowerArmLink"));
	LowerArmLink->SetupAttachment(ElbowFlexJoint);

	// --- wrist_flex joint ---
	// URDF origin: xyz(-0.1349, 0.0052, 0) rpy(0, 0, -1.5708)
	WristFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristFlexJoint"));
	WristFlexJoint->SetupAttachment(LowerArmLink);
	WristFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.1349, 0.0052, 0.0));
	WristFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708));

	WristLink = CreateDefaultSubobject<USceneComponent>(TEXT("WristLink"));
	WristLink->SetupAttachment(WristFlexJoint);

	// --- wrist_roll joint ---
	// URDF origin: xyz(0, -0.0611, 0.0181) rpy(1.5708, 0.0486795, 3.14159)
	WristRollJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristRollJoint"));
	WristRollJoint->SetupAttachment(WristLink);
	WristRollJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0, -0.0611, 0.0181));
	WristRollJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159));

	GripperLink = CreateDefaultSubobject<USceneComponent>(TEXT("GripperLink"));
	GripperLink->SetupAttachment(WristRollJoint);

	// --- gripper joint ---
	// URDF origin: xyz(0.0202, 0.0188, -0.0234) rpy(1.5708, 0, 0)
	GripperJoint = CreateDefaultSubobject<USceneComponent>(TEXT("GripperJoint"));
	GripperJoint->SetupAttachment(GripperLink);
	GripperJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0202, 0.0188, -0.0234));
	GripperJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0));

	MovingJawLink = CreateDefaultSubobject<USceneComponent>(TEXT("MovingJawLink"));
	MovingJawLink->SetupAttachment(GripperJoint);

	// --- Joint name mapping (matches ROS /joint_states names) ---
	JointComponentMap.Add(FName("shoulder_pan"),  ShoulderPanJoint);
	JointComponentMap.Add(FName("shoulder_lift"), ShoulderLiftJoint);
	JointComponentMap.Add(FName("elbow_flex"),    ElbowFlexJoint);
	JointComponentMap.Add(FName("wrist_flex"),    WristFlexJoint);
	JointComponentMap.Add(FName("wrist_roll"),    WristRollJoint);
	JointComponentMap.Add(FName("gripper"),       GripperJoint);
}

// =============================================================================
// BeginPlay — load meshes and attach, connect to ROS
// =============================================================================

void ARobotVisualizer::BeginPlay()
{
	Super::BeginPlay();

	// --- Load meshes and attach to links ---
	// Meshes are loaded at runtime (not in constructor) because
	// StaticLoadObject is safer to call here and allows hot-reload.

	// Helper lambda to reduce repetition
	auto Attach = [this](USceneComponent* Parent, const TCHAR* MeshName,
		double RosX, double RosY, double RosZ,
		double RosRoll, double RosPitch, double RosYaw,
		bool bIsMotor = false)
	{
		UStaticMesh* Mesh = LoadMeshAsset(MeshName);
		if (!Mesh) return;

		UStaticMeshComponent* SMC = NewObject<UStaticMeshComponent>(this);
		SMC->SetStaticMesh(Mesh);
		SMC->SetRelativeLocation(RosCoordConv::RosToUePosition(RosX, RosY, RosZ));
		SMC->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(RosRoll, RosPitch, RosYaw));
		SMC->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		SMC->AttachToComponent(Parent, FAttachmentTransformRules::KeepRelativeTransform);
		SMC->RegisterComponent();
		AllMeshComponents.Add(SMC);
	};

	// === base_link meshes ===
	Attach(BaseLink, TEXT("base_motor_holder_so101_v1"),
		-0.00636471, -0.0000994414, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("base_so101_v2"),
		-0.00636471, 0.0, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("sts3215_03a_v1"),
		0.0263353, 0.0, 0.0437,
		0.0, 0.0, 0.0, true);
	Attach(BaseLink, TEXT("waveshare_mounting_plate_so101_v2"),
		-0.0309827, -0.000199441, 0.0474,
		1.5708, 0.0, 1.5708);

	// === shoulder_link meshes ===
	Attach(ShoulderLink, TEXT("sts3215_03a_v1"),
		-0.0303992, 0.000422241, -0.0417,
		1.5708, 1.5708, 0.0, true);
	Attach(ShoulderLink, TEXT("motor_holder_so101_base_v1"),
		-0.0675992, -0.000177759, 0.0158499,
		1.5708, -1.5708, 0.0);
	Attach(ShoulderLink, TEXT("rotation_pitch_so101_v1"),
		0.0122008, 0.0000222413, 0.0464,
		-1.5708, 0.0, 0.0);

	// === upper_arm_link meshes ===
	Attach(UpperArmLink, TEXT("sts3215_03a_v1"),
		-0.11257, -0.0155, 0.0187,
		-3.14159, 0.0, -1.5708, true);
	Attach(UpperArmLink, TEXT("upper_arm_so101_v1"),
		-0.065085, 0.012, 0.0182,
		3.14159, 0.0, 0.0);

	// === lower_arm_link meshes ===
	Attach(LowerArmLink, TEXT("under_arm_so101_v1"),
		-0.0648499, -0.032, 0.0182,
		3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("motor_holder_so101_wrist_v1"),
		-0.0648499, -0.032, 0.018,
		-3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("sts3215_03a_v1"),
		-0.1224, 0.0052, 0.0187,
		-3.14159, 0.0, -3.14159, true);

	// === wrist_link meshes ===
	Attach(WristLink, TEXT("sts3215_03a_no_horn_v1"),
		0.0, -0.0424, 0.0306,
		1.5708, 1.5708, 0.0, true);
	Attach(WristLink, TEXT("wrist_roll_pitch_so101_v2"),
		0.0, -0.028, 0.0181,
		-1.5708, -1.5708, 0.0);

	// === gripper_link meshes ===
	Attach(GripperLink, TEXT("sts3215_03a_v1"),
		0.0077, 0.0001, -0.0234,
		-1.5708, 0.0, 0.0, true);
	Attach(GripperLink, TEXT("wrist_roll_follower_so101_v1"),
		0.0, -0.000218214, 0.000949706,
		-3.14159, 0.0, 0.0);

	// === moving_jaw_link meshes ===
	Attach(MovingJawLink, TEXT("moving_jaw_so101_v1"),
		0.0, 0.0, 0.0189,
		0.0, 0.0, 0.0);

	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: %d mesh components created"), AllMeshComponents.Num());

	// --- Connect to ROS via Subsystem ---
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Bind delegates.
	Ros->OnTopicMessage.AddDynamic(this, &ARobotVisualizer::OnRosMessage);
	Ros->OnConnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
	Ros->OnDisconnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);

	// Subscribe is now queued even before connection — the subsystem will
	// send it automatically when connected (including on reconnect).
	Ros->Subscribe(JointStateTopic, JointStateType);

	// Queue MoveIt topic advertisements (sent on connect).
	AdvertiseMoveItTopics();

	// Queue record/replay/estop topics.
	SetupRecordReplayTopics();

	// Initiate connection if not already connected.
	if (!Ros->IsConnected())
	{
		Ros->Connect(RosBridgeUrl);
	}
	else
	{
		// Already connected (e.g. another actor already called Connect).
		UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: already connected, subscription sent."));
	}
}

// =============================================================================
// EndPlay
// =============================================================================

void ARobotVisualizer::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (UGameInstance* GI = UGameplayStatics::GetGameInstance(this))
	{
		if (URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>())
		{
			Ros->OnTopicMessage.RemoveDynamic(this, &ARobotVisualizer::OnRosMessage);
			Ros->OnConnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
			Ros->OnDisconnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);
		}
	}

	bMoveItTopicsAdvertised = false;
	bRecordReplayTopicsSetup = false;

	Super::EndPlay(EndPlayReason);
}

// =============================================================================
// ROS connection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeConnected()
{
	bRosBridgeConnected = true;
	UE_LOG(LogRosBridge, Log,
		TEXT("RobotVisualizer: rosbridge connected — subscriptions restored by subsystem."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
			TEXT("ROS Bridge: Connected"));
	}
}

// =============================================================================
// ROS disconnection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeDisconnected()
{
	bRosBridgeConnected = false;
	WorkerState = TEXT("disconnected");

	UE_LOG(LogRosBridge, Warning,
		TEXT("RobotVisualizer: rosbridge DISCONNECTED — cannot send commands. Auto-reconnect active."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
			TEXT("*** ROS Bridge DISCONNECTED *** Cannot send commands. Reconnecting..."));
	}
}

// =============================================================================
// MoveIt topic advertisements
// =============================================================================

void ARobotVisualizer::AdvertiseMoveItTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// These are queued and sent automatically when connected.
	Ros->Advertise(TEXT("/moveit_goal_named"), TEXT("std_msgs/String"));
	Ros->Advertise(TEXT("/moveit_goal_joints"), TEXT("sensor_msgs/JointState"));
	Ros->Advertise(TEXT("/moveit_goal_pose"), TEXT("geometry_msgs/PoseStamped"));

	bMoveItTopicsAdvertised = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: MoveIt command topics advertised."));
}

// =============================================================================
// MoveIt commands — SendNamedTarget
// =============================================================================

void ARobotVisualizer::SendNamedTarget()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendNamedTarget: not connected to rosbridge."));
		return;
	}

	// std_msgs/String: {"data": "home"}
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *MoveItNamedTarget);
	Ros->Publish(TEXT("/moveit_goal_named"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("SendNamedTarget: published '%s' to /moveit_goal_named"), *MoveItNamedTarget);
}

// =============================================================================
// MoveIt commands — SendJointGoal
// =============================================================================

void ARobotVisualizer::SendJointGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendJointGoal: not connected to rosbridge."));
		return;
	}

	// sensor_msgs/JointState:
	// {
	//   "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": ""},
	//   "name": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"],
	//   "position": [0.0, -0.5, 0.5, 0.0, 0.0],
	//   "velocity": [],
	//   "effort": []
	// }

	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"\"},")
		TEXT("\"name\":[\"shoulder_pan\",\"shoulder_lift\",\"elbow_flex\",\"wrist_flex\",\"wrist_roll\"],")
		TEXT("\"position\":[%f,%f,%f,%f,%f],")
		TEXT("\"velocity\":[],\"effort\":[]}"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll
	);

	Ros->Publish(TEXT("/moveit_goal_joints"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendJointGoal: [%.3f, %.3f, %.3f, %.3f, %.3f] to /moveit_goal_joints"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll);
}

// =============================================================================
// MoveIt commands — SendPoseGoal
// =============================================================================

void ARobotVisualizer::SendPoseGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendPoseGoal: not connected to rosbridge."));
		return;
	}

	// Convert UE position (cm, left-handed) to ROS (meters, right-handed)
	double RosX, RosY, RosZ;
	RosCoordConv::UeToRosPosition(GoalPositionUE, RosX, RosY, RosZ);

	// geometry_msgs/PoseStamped (orientation defaults to identity — position-only goal)
	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"base_link\"},")
		TEXT("\"pose\":{\"position\":{\"x\":%f,\"y\":%f,\"z\":%f},")
		TEXT("\"orientation\":{\"x\":0.0,\"y\":0.0,\"z\":0.0,\"w\":1.0}}}"),
		RosX, RosY, RosZ
	);

	Ros->Publish(TEXT("/moveit_goal_pose"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendPoseGoal: UE(%.1f, %.1f, %.1f)cm -> ROS(%.4f, %.4f, %.4f)m to /moveit_goal_pose"),
		GoalPositionUE.X, GoalPositionUE.Y, GoalPositionUE.Z,
		RosX, RosY, RosZ);
}

// =============================================================================
// Message handling
// =============================================================================

void ARobotVisualizer::OnRosMessage(const FString& Topic, const FString& MessageJson)
{
	if (Topic == JointStateTopic)
	{
		ParseAndApplyJointStates(MessageJson);
	}
	else if (Topic == TEXT("/robot_status"))
	{
		OnRobotStatus(Topic, MessageJson);
	}
}

void ARobotVisualizer::ParseAndApplyJointStates(const FString& MessageJson)
{
	// Parse sensor_msgs/JointState JSON:
	// { "name": ["shoulder_pan", ...], "position": [0.1, ...], ... }

	TSharedPtr<FJsonObject> Json;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(Reader, Json) || !Json.IsValid())
	{
		return;
	}

	const TArray<TSharedPtr<FJsonValue>>* NameArray = nullptr;
	const TArray<TSharedPtr<FJsonValue>>* PosArray = nullptr;

	if (!Json->TryGetArrayField(TEXT("name"), NameArray) ||
		!Json->TryGetArrayField(TEXT("position"), PosArray))
	{
		return;
	}

	const int32 Count = FMath::Min(NameArray->Num(), PosArray->Num());
	for (int32 i = 0; i < Count; ++i)
	{
		const FName JointName(*(*NameArray)[i]->AsString());
		const double AngleRad = (*PosArray)[i]->AsNumber();

		TObjectPtr<USceneComponent>* JointComp = JointComponentMap.Find(JointName);
		if (JointComp && *JointComp)
		{
			const float AngleDeg = RosCoordConv::RosJointAngleToUeDegrees(AngleRad);

			FRotator BaseRot;
			if (JointName == FName("shoulder_pan"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159);
			else if (JointName == FName("shoulder_lift"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0);
			else if (JointName == FName("elbow_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708);
			else if (JointName == FName("wrist_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708);
			else if (JointName == FName("wrist_roll"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159);
			else if (JointName == FName("gripper"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0);

			const FQuat BaseQuat = BaseRot.Quaternion();
			const FQuat JointQuat = FQuat(FVector::UpVector, FMath::DegreesToRadians(AngleDeg));
			const FQuat FinalQuat = BaseQuat * JointQuat;

			(*JointComp)->SetRelativeRotation(FinalQuat.Rotator());
		}
	}
}

// =============================================================================
// Record / Replay / E-Stop — Topic setup
// =============================================================================

void ARobotVisualizer::SetupRecordReplayTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Advertise the command topic
	Ros->Advertise(TEXT("/robot_command"), TEXT("std_msgs/String"));

	// Subscribe to status feedback
	Ros->Subscribe(TEXT("/robot_status"), TEXT("std_msgs/String"));

	bRecordReplayTopicsSetup = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: Record/Replay topics set up."));
}

// =============================================================================
// Record / Replay / E-Stop — Command publisher helper
// =============================================================================

void ARobotVisualizer::PublishRobotCommand(const FString& JsonCmd)
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("PublishRobotCommand: not connected to rosbridge."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Red,
				TEXT("Robot: Not connected to rosbridge!"));
		}
		return;
	}

	// std_msgs/String: {"data": "<json_cmd>"}
	// The JSON command is nested inside the "data" field, with quotes escaped.
	FString EscapedCmd = JsonCmd.Replace(TEXT("\""), TEXT("\\\""));
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *EscapedCmd);
	Ros->Publish(TEXT("/robot_command"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("PublishRobotCommand: %s"), *JsonCmd);
}

// =============================================================================
// Record / Replay / E-Stop — Button handlers
// =============================================================================

void ARobotVisualizer::StartRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Robot: Recording started (teleop active)"));
	}
}

void ARobotVisualizer::StopRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Recording stopped, saving..."));
	}
}

void ARobotVisualizer::StartReplay()
{
	FString ArgsJson;
	if (ReplayFilename.IsEmpty())
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"loop\":%s,\"approach_speed\":%f}}"),
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	else
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"filename\":\"%s\",\"loop\":%s,\"approach_speed\":%f}}"),
			*ReplayFilename,
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	PublishRobotCommand(ArgsJson);

	if (GEngine)
	{
		FString DisplayName = ReplayFilename.IsEmpty() ? TEXT("(most recent)") : ReplayFilename;
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Cyan,
			FString::Printf(TEXT("Robot: Replaying %s (loop=%s, speed=%.0f°/s)"),
				*DisplayName, bReplayLoop ? TEXT("yes") : TEXT("no"), ApproachSpeed));
	}
}

void ARobotVisualizer::StopReplay()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_replay\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Replay stopped"));
	}
}

void ARobotVisualizer::EStop()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"estop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
			TEXT("*** E-STOP *** All motion halted"));
	}
}

// =============================================================================
// Teleop Sync — SyncOn / SyncOff
// =============================================================================

void ARobotVisualizer::SyncOn()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Sync ON: leader -> follower active"));
	}
}

void ARobotVisualizer::SyncOff()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Sync OFF: leader -> follower deactivated"));
	}
}

// =============================================================================
// Record / Replay — Status feedback handler
// =============================================================================

void ARobotVisualizer::OnRobotStatus(const FString& Topic, const FString& MessageJson)
{
	// rosbridge wraps std_msgs/String as: {"data": "..."}
	TSharedPtr<FJsonObject> OuterJson;
	TSharedRef<TJsonReader<>> OuterReader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(OuterReader, OuterJson) || !OuterJson.IsValid())
	{
		return;
	}

	FString DataStr;
	if (!OuterJson->TryGetStringField(TEXT("data"), DataStr))
	{
		return;
	}

	// Parse the inner JSON status
	TSharedPtr<FJsonObject> StatusJson;
	TSharedRef<TJsonReader<>> StatusReader = TJsonReaderFactory<>::Create(DataStr);
	if (!FJsonSerializer::Deserialize(StatusReader, StatusJson) || !StatusJson.IsValid())
	{
		return;
	}

	FString State;
	if (StatusJson->TryGetStringField(TEXT("state"), State))
	{
		if (State != WorkerState)
		{
			WorkerState = State;
			UE_LOG(LogRosBridge, Log, TEXT("Worker state: %s"), *WorkerState);

			if (GEngine)
			{
				FColor Color = FColor::White;
				if (State == TEXT("recording")) Color = FColor::Green;
				else if (State == TEXT("replaying")) Color = FColor::Cyan;
				else if (State == TEXT("idle")) Color = FColor::Silver;

				GEngine->AddOnScreenDebugMessage(-1, 3.0f, Color,
					FString::Printf(TEXT("Robot state: %s"), *WorkerState));
			}
		}
	}

	// Update sync (teleop) status
	bool bTeleop = false;
	if (StatusJson->TryGetBoolField(TEXT("teleop"), bTeleop))
	{
		if (bTeleop != bSyncActive)
		{
			bSyncActive = bTeleop;
			UE_LOG(LogRosBridge, Log, TEXT("Sync (teleop): %s"),
				bSyncActive ? TEXT("ON") : TEXT("OFF"));
		}
	}

	// Log errors from commands
	FString Status;
	if (StatusJson->TryGetStringField(TEXT("status"), Status) && Status == TEXT("error"))
	{
		FString Reason;
		StatusJson->TryGetStringField(TEXT("reason"), Reason);
		UE_LOG(LogRosBridge, Warning, TEXT("Robot command error: %s"), *Reason);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
				FString::Printf(TEXT("Robot error: %s"), *Reason));
		}
	}

	// Log recording saved info
	FString Filename;
	if (StatusJson->TryGetStringField(TEXT("filename"), Filename) && !Filename.IsEmpty())
	{
		int32 Frames = 0;
		StatusJson->TryGetNumberField(TEXT("frames"), Frames);
		double Duration = 0.0;
		StatusJson->TryGetNumberField(TEXT("duration_sec"), Duration);

		UE_LOG(LogRosBridge, Log, TEXT("Recording saved: %s (%d frames, %.1fs)"),
			*Filename, Frames, Duration);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				FString::Printf(TEXT("Recording saved: %s (%d frames, %.1fs)"),
					*Filename, Frames, Duration));
		}
	}
}

수정 사항

 

  • BeginPlay: Ros->OnDisconnected.AddDynamic 바인딩 추가
  • EndPlay: Ros->OnDisconnected.RemoveDynamic 해제 추가
  • OnRosBridgeConnected: bRosBridgeConnected = true + 뷰포트에 초록색 "Connected" 메시지
  • OnRosBridgeDisconnected (신규): bRosBridgeConnected = false, WorkerState = "disconnected", 뷰포트에 빨간색 경고 10초간 표시

 

 

 

moveit_goal_node.py 수정

더보기
#!/usr/bin/env python3
"""
moveit_goal_node.py — 중간 ROS2 노드

SO-ARM-101은 5DOF arm이므로 임의의 6DOF Cartesian pose를 만족시킬 수 없음.
따라서 Joint space goal을 기본으로 사용하고, Cartesian goal은 position-only 모드로 지원.

=== 토픽 인터페이스 ===

1) /moveit_goal_joints (sensor_msgs/JointState) — Joint space goal (추천)
   - name: 목표로 설정할 관절 이름 배열
   - position: 각 관절의 목표 각도 (radians)

2) /moveit_goal_pose (geometry_msgs/PoseStamped) — Cartesian position-only goal
   - position만 사용, orientation은 무시 (5DOF 제약)

3) /moveit_goal_named (std_msgs/String) — Named target (SRDF에 정의된 포즈)
   - "home", "ready" 등

=== 테스트 명령 (ROS2 터미널) ===

# Named target (가장 간단):
ros2 topic pub --once /moveit_goal_named std_msgs/msg/String '{data: "home"}'
ros2 topic pub --once /moveit_goal_named std_msgs/msg/String '{data: "ready"}'

# Joint space goal (radians):
ros2 topic pub --once /moveit_goal_joints sensor_msgs/msg/JointState \
  '{name: ["shoulder_pan","shoulder_lift","elbow_flex","wrist_flex","wrist_roll"], \
    position: [0.0, -0.5, 0.5, 0.0, 0.0]}'

# Cartesian position-only goal (meters, base_link 기준):
ros2 topic pub --once /moveit_goal_pose geometry_msgs/msg/PoseStamped \
  '{header: {frame_id: "base_link"}, pose: {position: {x: 0.1, y: 0.0, z: 0.15}}}'
"""

import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from rclpy.callback_groups import ReentrantCallbackGroup

from geometry_msgs.msg import PoseStamped, Pose
from sensor_msgs.msg import JointState
from std_msgs.msg import String
from moveit_msgs.action import MoveGroup
from moveit_msgs.msg import (
    Constraints,
    JointConstraint,
    PositionConstraint,
    BoundingVolume,
    MoveItErrorCodes,
)
from shape_msgs.msg import SolidPrimitive


# SO-ARM-101 arm group joints (from SRDF)
ARM_JOINTS = [
    'shoulder_pan',
    'shoulder_lift',
    'elbow_flex',
    'wrist_flex',
    'wrist_roll',
]

# Named targets (from SRDF — Phase 7.2)
NAMED_TARGETS = {
    'home': {
        'shoulder_pan': 0.0,
        'shoulder_lift': 0.0,
        'elbow_flex': 0.0,
        'wrist_flex': 0.0,
        'wrist_roll': 0.0,
    },
    'ready': {
        'shoulder_pan': 0.0,
        'shoulder_lift': -0.5,
        'elbow_flex': 0.5,
        'wrist_flex': 0.0,
        'wrist_roll': 0.0,
    },
}


class MoveItGoalNode(Node):
    """
    3가지 토픽을 구독하여 MoveGroup action으로 전달하는 중간 노드.
    - /moveit_goal_joints  (sensor_msgs/JointState)   → joint space goal
    - /moveit_goal_pose    (geometry_msgs/PoseStamped) → Cartesian position-only goal
    - /moveit_goal_named   (std_msgs/String)           → named target
    """

    def __init__(self):
        super().__init__('moveit_goal_node')

        # --- Parameters ---
        self.declare_parameter('group_name', 'arm')
        self.declare_parameter('end_effector_link', 'gripper_frame_link')
        self.declare_parameter('planning_frame', 'base_link')
        self.declare_parameter('num_planning_attempts', 5)
        self.declare_parameter('allowed_planning_time', 5.0)
        self.declare_parameter('max_velocity_scaling', 0.3)
        self.declare_parameter('max_acceleration_scaling', 0.3)
        self.declare_parameter('position_tolerance', 0.01)
        self.declare_parameter('joint_tolerance', 0.01)

        self.group_name = self.get_parameter('group_name').value
        self.ee_link = self.get_parameter('end_effector_link').value
        self.planning_frame = self.get_parameter('planning_frame').value
        self.num_attempts = self.get_parameter('num_planning_attempts').value
        self.planning_time = self.get_parameter('allowed_planning_time').value
        self.vel_scaling = self.get_parameter('max_velocity_scaling').value
        self.acc_scaling = self.get_parameter('max_acceleration_scaling').value
        self.pos_tol = self.get_parameter('position_tolerance').value
        self.joint_tol = self.get_parameter('joint_tolerance').value

        # --- Callback group ---
        self._cb_group = ReentrantCallbackGroup()

        # --- Action client ---
        self._action_client = ActionClient(
            self, MoveGroup, 'move_action',
            callback_group=self._cb_group,
        )

        # --- Subscribers ---
        self.create_subscription(
            JointState, '/moveit_goal_joints', self._on_goal_joints, 10,
            callback_group=self._cb_group,
        )
        self.create_subscription(
            PoseStamped, '/moveit_goal_pose', self._on_goal_pose, 10,
            callback_group=self._cb_group,
        )
        self.create_subscription(
            String, '/moveit_goal_named', self._on_goal_named, 10,
            callback_group=self._cb_group,
        )

        # --- Status publisher (→ Unreal via bridge) ---
        self._status_pub = self.create_publisher(String, '/robot_status', 10)

        # --- State ---
        self._executing = False

        self.get_logger().info(
            f'MoveIt Goal Node ready. group={self.group_name}, ee={self.ee_link}'
        )
        self.get_logger().info('Listening on:')
        self.get_logger().info('  /moveit_goal_joints  (sensor_msgs/JointState)')
        self.get_logger().info('  /moveit_goal_pose    (geometry_msgs/PoseStamped) — position only')
        self.get_logger().info('  /moveit_goal_named   (std_msgs/String) — "home", "ready"')

    # ─────────────────────────────────────────────
    # Topic callbacks
    # ─────────────────────────────────────────────

    def _on_goal_named(self, msg: String):
        """Named target → joint space goal로 변환."""
        name = msg.data.strip().lower()

        if name not in NAMED_TARGETS:
            self.get_logger().error(
                f'Unknown named target "{name}". Available: {list(NAMED_TARGETS.keys())}'
            )
            return

        self.get_logger().info(f'Named target: "{name}"')
        joint_values = NAMED_TARGETS[name]
        self._send_joint_goal(joint_values)

    def _on_goal_joints(self, msg: JointState):
        """Joint space goal — name/position 쌍으로 목표 설정."""
        if len(msg.name) == 0 or len(msg.name) != len(msg.position):
            self.get_logger().error('Invalid JointState: name/position length mismatch or empty')
            return

        joint_values = {}
        for name, pos in zip(msg.name, msg.position):
            if name not in ARM_JOINTS:
                self.get_logger().warn(f'Ignoring unknown joint: {name}')
                continue
            joint_values[name] = pos

        if not joint_values:
            self.get_logger().error('No valid arm joints in message')
            return

        names_str = ', '.join(f'{k}={v:.3f}' for k, v in joint_values.items())
        self.get_logger().info(f'Joint goal: {names_str}')
        self._send_joint_goal(joint_values)

    def _on_goal_pose(self, msg: PoseStamped):
        """Cartesian position-only goal (orientation 무시 — 5DOF 제약)."""
        self.get_logger().info(
            f'Cartesian goal (position only): '
            f'({msg.pose.position.x:.3f}, {msg.pose.position.y:.3f}, {msg.pose.position.z:.3f})'
        )
        self._send_pose_goal(msg)

    # ─────────────────────────────────────────────
    # Goal builders
    # ─────────────────────────────────────────────

    def _send_joint_goal(self, joint_values: dict):
        """Joint space goal을 MoveGroup action으로 전송."""
        if not self._pre_send_check():
            return

        goal = self._make_base_goal()
        req = goal.request

        constraints = Constraints()
        for joint_name, position in joint_values.items():
            jc = JointConstraint()
            jc.joint_name = joint_name
            jc.position = position
            jc.tolerance_above = self.joint_tol
            jc.tolerance_below = self.joint_tol
            jc.weight = 1.0
            constraints.joint_constraints.append(jc)

        req.goal_constraints.append(constraints)
        self._send_goal(goal)

    def _send_pose_goal(self, msg: PoseStamped):
        """Cartesian position-only goal — orientation constraint 없음 (5DOF)."""
        if not self._pre_send_check():
            return

        goal = self._make_base_goal()
        req = goal.request

        frame_id = msg.header.frame_id if msg.header.frame_id else self.planning_frame

        # Position constraint only (no orientation — 5DOF arm)
        pos_constraint = PositionConstraint()
        pos_constraint.header.frame_id = frame_id
        pos_constraint.link_name = self.ee_link
        pos_constraint.target_point_offset.x = 0.0
        pos_constraint.target_point_offset.y = 0.0
        pos_constraint.target_point_offset.z = 0.0

        bounding = BoundingVolume()
        primitive = SolidPrimitive()
        primitive.type = SolidPrimitive.SPHERE
        primitive.dimensions = [self.pos_tol]
        bounding.primitives.append(primitive)

        target_pose = Pose()
        target_pose.position = msg.pose.position
        target_pose.orientation.w = 1.0
        bounding.primitive_poses.append(target_pose)

        pos_constraint.constraint_region = bounding
        pos_constraint.weight = 1.0

        constraints = Constraints()
        constraints.position_constraints.append(pos_constraint)
        req.goal_constraints.append(constraints)

        self._send_goal(goal)

    # ─────────────────────────────────────────────
    # Helpers
    # ─────────────────────────────────────────────

    def _pre_send_check(self) -> bool:
        """공통 사전 검사."""
        if self._executing:
            self.get_logger().warn('Previous motion still executing, ignoring new goal')
            return False
        if not self._action_client.wait_for_server(timeout_sec=3.0):
            self.get_logger().error('/move_action server not available!')
            self._publish_status({
                'status': 'error',
                'source': 'moveit',
                'reason': 'MoveIt action server not available (is move_group running?)',
            })
            return False
        return True

    def _make_base_goal(self) -> MoveGroup.Goal:
        """공통 MoveGroup goal 템플릿."""
        goal = MoveGroup.Goal()
        req = goal.request
        req.group_name = self.group_name
        req.num_planning_attempts = self.num_attempts
        req.allowed_planning_time = self.planning_time
        req.max_velocity_scaling_factor = self.vel_scaling
        req.max_acceleration_scaling_factor = self.acc_scaling

        goal.planning_options.plan_only = False
        goal.planning_options.planning_scene_diff.is_diff = True
        goal.planning_options.planning_scene_diff.robot_state.is_diff = True
        goal.planning_options.replan = True
        goal.planning_options.replan_attempts = 3

        return goal

    def _send_goal(self, goal: MoveGroup.Goal):
        """Action goal 전송."""
        self._executing = True
        self.get_logger().info('Sending MoveGroup goal...')
        send_future = self._action_client.send_goal_async(
            goal, feedback_callback=self._on_feedback,
        )
        send_future.add_done_callback(self._on_goal_response)

    def _on_feedback(self, feedback_msg):
        state = feedback_msg.feedback.state
        if state:
            self.get_logger().info(f'MoveGroup feedback: {state}')

    def _on_result(self, future):
        result = future.result().result
        error_code = result.error_code.val

        if error_code == MoveItErrorCodes.SUCCESS:
            self.get_logger().info(
                f'Motion SUCCEEDED! Planning time: {result.planning_time:.2f}s'
            )
            self._publish_status({
                'status': 'ok',
                'source': 'moveit',
                'message': f'Motion succeeded (planning: {result.planning_time:.2f}s)',
            })
        else:
            error_name = self._error_code_to_string(error_code)
            self.get_logger().error(
                f'Motion FAILED: {error_name} (code={error_code})'
            )
            self._publish_status({
                'status': 'error',
                'source': 'moveit',
                'reason': f'Planning failed: {error_name} (code={error_code})',
            })

        self._executing = False

    def _on_goal_response(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().error('MoveGroup goal REJECTED')
            self._publish_status({
                'status': 'error',
                'source': 'moveit',
                'reason': 'MoveGroup goal rejected by server',
            })
            self._executing = False
            return
        self.get_logger().info('MoveGroup goal accepted, waiting for result...')
        result_future = goal_handle.get_result_async()
        result_future.add_done_callback(self._on_result)

    def _publish_status(self, data: dict):
        """Publish a status dict as JSON string to /robot_status."""
        import json
        msg = String()
        msg.data = json.dumps(data)
        self._status_pub.publish(msg)

    @staticmethod
    def _error_code_to_string(code: int) -> str:
        code_map = {
            1: 'SUCCESS',
            -1: 'PLANNING_FAILED',
            -2: 'INVALID_MOTION_PLAN',
            -3: 'MOTION_PLAN_INVALIDATED_BY_ENVIRONMENT_CHANGE',
            -4: 'CONTROL_FAILED',
            -5: 'UNABLE_TO_ACQUIRE_SENSOR_DATA',
            -6: 'TIMED_OUT',
            -7: 'PREEMPTED',
            -10: 'START_STATE_IN_COLLISION',
            -11: 'START_STATE_VIOLATES_PATH_CONSTRAINTS',
            -12: 'GOAL_IN_COLLISION',
            -13: 'GOAL_VIOLATES_PATH_CONSTRAINTS',
            -14: 'GOAL_CONSTRAINTS_VIOLATED',
            -15: 'INVALID_GROUP_NAME',
            -16: 'INVALID_GOAL_CONSTRAINTS',
            -17: 'INVALID_ROBOT_STATE',
            -18: 'INVALID_LINK_NAME',
            -19: 'INVALID_OBJECT_NAME',
            -21: 'FRAME_TRANSFORM_FAILURE',
            -22: 'COLLISION_CHECKING_UNAVAILABLE',
            -23: 'ROBOT_STATE_STALE',
            -24: 'SENSOR_INFO_STALE',
            -25: 'COMMUNICATION_FAILURE',
            -26: 'START_STATE_INVALID',
            -27: 'GOAL_STATE_INVALID',
            -28: 'UNRECOGNIZED_GOAL_TYPE',
            -29: 'CRASH',
            -30: 'ABORT',
            -31: 'NO_IK_SOLUTION',
            99999: 'FAILURE',
        }
        return code_map.get(code, f'UNKNOWN({code})')


def main():
    rclpy.init()
    node = MoveItGoalNode()

    from rclpy.executors import MultiThreadedExecutor
    executor = MultiThreadedExecutor()
    executor.add_node(node)

    try:
        executor.spin()
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

수정 사항

 

  • __init__에 /robot_status publisher 추가
  • _on_result: 성공/실패를 JSON으로 /robot_status에 publish → Unreal의 기존 OnRobotStatus() 핸들러가 자동으로 뷰포트에 표시
  • _on_goal_response: goal 거부 시에도 /robot_status에 에러 publish
  • _pre_send_check: action server 불가 시 /robot_status에 에러 publish
  • _publish_status() 헬퍼 메서드 추가

 

 

 

모두 수정 했으면 언리얼 빌드 확인

 

 


 

 

1-1단계: heartbeat timeout 구현

heartbeat timeout을 구현한다. bridge_node에서 주기적으로 ping 명령을 보내고, worker가 마지막 ping 시각을 기록하여 timeout을 감지하는 방식

 

  • ros_bridge_node.py - 주기적 ping을 worker에 전송 (2초마다)
  • lerobot_worker.py - 마지막 명령 수신 시각 기록 + timeout(5초) 시 자동 E-stop

 

 

ros_bridge_node.py 수정

더보기
#!/usr/bin/env python3
"""
ros_bridge_node.py — ZMQ ↔ ROS2 bridge node

Runs in .venv-ros-bridge (Python 3.10) with rclpy + pyzmq.
Subscribes to joint states from lerobot_worker via ZMQ,
converts degrees→radians, and publishes sensor_msgs/JointState.
Also subscribes to /follower_joint_commands and forwards to worker via ZMQ REQ.

Relays record/replay/estop commands:
  - /robot_command (std_msgs/String) → ZMQ REQ → worker
  - Worker state published on /robot_status (std_msgs/String) every tick

Usage:
    export PATH=$(echo $PATH | tr ':' '\\n' | grep -v miniforge | tr '\\n' ':' | sed 's/:$//')
    cd ~/UnrealRobotics
    source .venv-ros-bridge/bin/activate
    source /opt/ros/humble/setup.bash
    python src/lerobot_ros2_bridge/lerobot_ros2_bridge/ros_bridge_node.py \\
        [--sub-addr tcp://127.0.0.1:5555] \\
        [--req-addr tcp://127.0.0.1:5556]
"""

import argparse
import json
import math
import sys
import traceback

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import String
import zmq


# ---------------------------------------------------------------------------
# Joint names — must match URDF, LeRobot calibration, and worker output
# (Phase 3.2 verified: all 6 names identical across all three sources)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]


def deg2rad(deg: float) -> float:
    return deg * math.pi / 180.0


def rad2deg(rad: float) -> float:
    return rad * 180.0 / math.pi


class RosBridgeNode(Node):
    def __init__(self, args):
        super().__init__("lerobot_ros2_bridge")
        self.get_logger().info("Initializing lerobot_ros2_bridge node")

        # --- ZMQ setup ---
        self.zmq_ctx = zmq.Context()

        # SUB: receive joint states from worker
        self.zmq_sub = self.zmq_ctx.socket(zmq.SUB)
        self.zmq_sub.connect(args.sub_addr)
        self.zmq_sub.setsockopt_string(zmq.SUBSCRIBE, "")
        # Don't block waiting for messages — use RCVTIMEO
        self.zmq_sub.setsockopt(zmq.RCVTIMEO, 0)
        self.get_logger().info(f"ZMQ SUB connected to {args.sub_addr}")

        # REQ: send commands to worker
        self.zmq_req = self.zmq_ctx.socket(zmq.REQ)
        self.zmq_req.connect(args.req_addr)
        self.zmq_req.setsockopt(zmq.RCVTIMEO, 1000)  # 1s timeout for replies
        self.get_logger().info(f"ZMQ REQ connected to {args.req_addr}")

        # --- ROS2 publishers ---
        # Follower joint states (for robot_state_publisher + rosbridge → Unreal)
        self.follower_pub = self.create_publisher(JointState, "/joint_states", 10)
        # Leader joint states (separate topic)
        self.leader_pub = self.create_publisher(JointState, "/leader_joint_states", 10)

        # --- ROS2 subscriber for commands from Unreal (via rosbridge) ---
        self.cmd_sub = self.create_subscription(
            JointState,
            "/follower_joint_commands",
            self.on_joint_command,
            10,
        )

        # --- Record/Replay/E-Stop command relay ---
        # Unreal publishes JSON command strings to /robot_command,
        # bridge relays them to worker via ZMQ REQ.
        self.robot_cmd_sub = self.create_subscription(
            String,
            "/robot_command",
            self.on_robot_command,
            10,
        )
        # Worker state feedback → Unreal
        self.robot_status_pub = self.create_publisher(String, "/robot_status", 10)
        self.last_worker_state = ""
        self.last_worker_teleop = None

        # --- Timer: poll ZMQ at ~100Hz (faster than worker's 30Hz to avoid buffering) ---
        self.poll_timer = self.create_timer(0.01, self.poll_zmq)

        # --- Timer: heartbeat ping to worker every 2s ---
        self.heartbeat_timer = self.create_timer(2.0, self.send_heartbeat)

        # Stats
        self.recv_count = 0
        self.last_log_time = self.get_clock().now()

    def poll_zmq(self):
        """Non-blocking poll of ZMQ SUB for worker messages."""
        # Drain all available messages (in case we're slightly behind)
        messages_this_tick = 0
        while messages_this_tick < 5:  # cap per tick to avoid starving ROS callbacks
            try:
                raw = self.zmq_sub.recv_string(zmq.NOBLOCK)
            except zmq.Again:
                break

            messages_this_tick += 1
            self.recv_count += 1

            try:
                msg = json.loads(raw)
            except json.JSONDecodeError as e:
                self.get_logger().warn(f"JSON parse error: {e}")
                continue

            now = self.get_clock().now().to_msg()

            # Publish follower joint states
            if "follower" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["follower"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                # velocity and effort left empty (not available from LeRobot API)
                self.follower_pub.publish(js)

            # Publish leader joint states
            if "leader" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["leader"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                self.leader_pub.publish(js)

            # Publish worker state to /robot_status (only on change)
            worker_state = msg.get("state", "idle")
            worker_teleop = msg.get("teleop", False)
            state_changed = (worker_state != self.last_worker_state or
                             worker_teleop != self.last_worker_teleop)
            if state_changed:
                status_msg = String()
                status_data = {"state": worker_state, "teleop": worker_teleop}
                # Include recording/replay metadata if present
                if "recording_frames" in msg:
                    status_data["recording_frames"] = msg["recording_frames"]
                if "replay_progress" in msg:
                    status_data["replay_progress"] = msg["replay_progress"]
                status_msg.data = json.dumps(status_data)
                self.robot_status_pub.publish(status_msg)
                self.last_worker_state = worker_state
                self.last_worker_teleop = worker_teleop
                self.get_logger().info(
                    f"Worker state: {worker_state}, teleop: {worker_teleop}")

        # Periodic logging (every 10 seconds)
        now_time = self.get_clock().now()
        if (now_time - self.last_log_time).nanoseconds > 10_000_000_000:
            self.get_logger().info(
                f"ZMQ recv total: {self.recv_count} messages"
            )
            self.last_log_time = now_time

    def on_joint_command(self, msg: JointState):
        """
        Receive joint commands from ROS2 (e.g. from Unreal via rosbridge),
        convert radians→degrees, and forward to worker via ZMQ REQ.
        """
        if len(msg.name) == 0 or len(msg.position) == 0:
            self.get_logger().warn("Empty joint command received, ignoring")
            return

        # Build command dict (degrees)
        joint_args = {}
        for name, pos_rad in zip(msg.name, msg.position):
            if name in JOINT_NAMES:
                joint_args[name] = rad2deg(pos_rad)

        if not joint_args:
            self.get_logger().warn("No recognized joint names in command")
            return

        cmd = {
            "cmd": "send_follower_action",
            "args": joint_args,
        }

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)
            if reply.get("status") != "ok":
                self.get_logger().warn(
                    f"Worker command error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error("Worker REQ timeout — is lerobot_worker running?")
        except Exception as e:
            self.get_logger().error(f"Worker command exception: {e}")

    def on_robot_command(self, msg: String):
        """
        Receive record/replay/estop commands from Unreal (via rosbridge),
        relay to worker via ZMQ REQ, and publish response on /robot_status.

        Expected msg.data formats:
            '{"cmd": "start_record"}'
            '{"cmd": "stop_record"}'
            '{"cmd": "start_replay", "args": {"filename": "...", "loop": false}}'
            '{"cmd": "start_replay"}'                    (plays most recent)
            '{"cmd": "stop_replay"}'
            '{"cmd": "estop"}'
            '{"cmd": "list_recordings"}'
        """
        try:
            cmd = json.loads(msg.data)
        except json.JSONDecodeError:
            # Support simple string commands: "estop", "start_record", etc.
            cmd = {"cmd": msg.data.strip()}

        cmd_name = cmd.get("cmd", "")
        self.get_logger().info(f"Robot command received: {cmd_name}")

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)

            # Publish response on /robot_status
            status_msg = String()
            status_msg.data = reply_raw
            self.robot_status_pub.publish(status_msg)

            status = reply.get("status", "unknown")
            if status == "ok":
                new_state = reply.get("state", "")
                if new_state:
                    self.last_worker_state = new_state
                self.get_logger().info(f"Command '{cmd_name}' OK: {reply}")
            else:
                self.get_logger().warn(
                    f"Command '{cmd_name}' error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error(
                f"Worker REQ timeout for '{cmd_name}' — is lerobot_worker running?"
            )
            # Publish timeout error on /robot_status
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": "worker timeout", "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)
        except Exception as e:
            self.get_logger().error(f"Robot command exception: {e}")
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": str(e), "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)

    def send_heartbeat(self):
        """Send periodic ping to worker so it knows the bridge is alive."""
        try:
            self.zmq_req.send_string('{"cmd":"ping"}')
            self.zmq_req.recv_string()  # discard reply, just keeping the link alive
        except zmq.Again:
            self.get_logger().warn("Heartbeat ping timeout — worker may be unresponsive")
        except Exception as e:
            self.get_logger().warn(f"Heartbeat ping error: {e}")

    def destroy_node(self):
        """Clean shutdown of ZMQ resources."""
        self.get_logger().info("Shutting down ZMQ...")
        self.zmq_sub.close()
        self.zmq_req.close()
        self.zmq_ctx.term()
        super().destroy_node()


def main():
    parser = argparse.ArgumentParser(description="ROS2 ↔ ZMQ bridge node")
    parser.add_argument("--sub-addr", default="tcp://127.0.0.1:5555",
                        help="ZMQ SUB address (worker PUB)")
    parser.add_argument("--req-addr", default="tcp://127.0.0.1:5556",
                        help="ZMQ REQ address (worker REP)")
    # ROS2 may pass extra args — use parse_known_args
    args, unknown = parser.parse_known_args()

    rclpy.init(args=unknown if unknown else None)
    node = RosBridgeNode(args)

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

 

수정 내역

 

  • __init__에 self.heartbeat_timer = self.create_timer(2.0, self.send_heartbeat) 추가
  • send_heartbeat() 메서드 추가
    2초마다 {"cmd":"ping"}을 worker REP에 전송. 타임아웃이나 에러 발생 시 경고 로그만 출력 (bridge_node 자체는 중단하지 않음)

 

 

 

lerobot_worker.py 수정

더보기
#!/usr/bin/env python3
"""
lerobot_worker.py — LeRobot ↔ ZMQ bridge worker

Runs in conda env 'lerobot' (Python 3.12).
Reads joint states from SO-ARM-101 via LeRobot API at ~30Hz,
publishes them over ZMQ PUB, and accepts commands via ZMQ REP.

Supports record/replay of joint trajectories via ZMQ commands:
  - start_teleop: activate leader→follower sync (teleop)
  - stop_teleop:  deactivate teleop sync
  - start_record: begin teleop + record joint frames
  - stop_record:  stop recording, save to ~/recordings/ (teleop stays on)
  - start_replay: replay a saved recording on the follower arm (teleop off)
  - stop_replay:  stop replay immediately
  - estop:        emergency stop — abort all motion immediately
  - list_recordings: list saved recording files

Usage:
    conda activate lerobot
    sudo chmod 666 /dev/ttyACM*
    python scripts/lerobot_worker.py [--follower-port /dev/ttyACM0] [--leader-port /dev/ttyACM1]
                                      [--pub-addr tcp://127.0.0.1:5555]
                                      [--rep-addr tcp://127.0.0.1:5556]
                                      [--rate 30]
                                      [--follower-only | --leader-only]
                                      [--teleop]
                                      [--recordings-dir ~/recordings]
"""

import argparse
import json
import math
import os
import pathlib
import signal
import sys
import time
import traceback
from datetime import datetime

import zmq


# ---------------------------------------------------------------------------
# Joint name ordering (matches URDF & LeRobot calibration — Phase 3.2 verified)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]

# ---------------------------------------------------------------------------
# Joint limits in LeRobot DEGREES (empirically measured via calibration).
# These are absolute physical limits — commands are never allowed beyond these.
# Measured by reading get_observation() degrees at calibration range_min/max
# raw encoder positions, using two-point linear interpolation.
# NOTE: These are in LeRobot's coordinate frame (0° = calibration midpoint),
#       NOT URDF coordinates (which use a different zero point).
# ---------------------------------------------------------------------------
JOINT_LIMITS_DEG = {
    "shoulder_pan":  (-119.91, +119.91),
    "shoulder_lift": (-104.62, +104.62),
    "elbow_flex":    ( -97.01,  +97.01),
    "wrist_flex":    (-102.68, +102.68),
    "wrist_roll":    (-180.00, +180.00),
    "gripper":       (  +0.14,  +99.45),
}


def build_args():
    p = argparse.ArgumentParser(description="LeRobot ZMQ worker for SO-ARM-101")
    p.add_argument("--follower-port", default="/dev/ttyACM0",
                    help="Serial port for follower arm")
    p.add_argument("--leader-port", default="/dev/ttyACM1",
                    help="Serial port for leader arm")
    p.add_argument("--follower-id", default="so101_twin_follower",
                    help="Calibration ID for follower")
    p.add_argument("--leader-id", default="so101_twin_leader",
                    help="Calibration ID for leader")
    p.add_argument("--pub-addr", default="tcp://127.0.0.1:5555",
                    help="ZMQ PUB bind address for joint states")
    p.add_argument("--rep-addr", default="tcp://127.0.0.1:5556",
                    help="ZMQ REP bind address for commands")
    p.add_argument("--rate", type=float, default=30.0,
                    help="Read loop frequency in Hz")
    p.add_argument("--follower-only", action="store_true",
                    help="Only connect follower (no leader)")
    p.add_argument("--leader-only", action="store_true",
                    help="Only connect leader (no follower)")
    p.add_argument("--teleop", action="store_true",
                    help="Teleoperation mode: leader arm controls follower arm")
    p.add_argument("--cmd-limit", type=float, default=5.0,
                    help="Safety clamp: max degrees from initial position per joint (default 5.0)")
    p.add_argument("--cmd-joints", default="shoulder_pan",
                    help="Comma-separated joint names that accept commands "
                         "(default: shoulder_pan). Use 'all' for all joints.")
    p.add_argument("--recordings-dir", default="~/recordings",
                    help="Directory to save/load recorded trajectories")
    p.add_argument("--approach-speed", type=float, default=45.0,
                    help="Max degrees/sec for approach moves before replay "
                         "and loop transitions (default: 45.0)")
    return p.parse_args()


class LeRobotWorker:
    def __init__(self, args):
        self.args = args
        self.running = True
        self.follower = None
        self.leader = None
        self.teleop = args.teleop

        # Safety clamp: initial follower position recorded at startup,
        # commands are clamped to ±cmd_limit degrees from this baseline.
        # Only joints listed in allowed_cmd_joints accept commands;
        # others are silently ignored (held at current position).
        self.follower_initial_pos = None       # dict: {joint_name: degrees}
        self.cmd_limit_deg = args.cmd_limit    # default 5.0°
        self.allowed_cmd_joints = (
            set(JOINT_NAMES) if args.cmd_joints.strip().lower() == "all"
            else set(j.strip() for j in args.cmd_joints.split(",") if j.strip())
        )

        # ---------------------------------------------------------------------------
        # Record / Replay state
        # ---------------------------------------------------------------------------
        # state: "idle", "recording", "replaying"
        self.state = "idle"

        # Recording buffer: list of {"ts": float, "joints": {name: deg, ...}}
        self.recording_buffer = []
        self.recording_start_time = None

        # Replay state
        self.replay_frames = []        # loaded frames from file
        self.replay_index = 0          # current frame index
        self.replay_start_time = None  # monotonic time when replay started
        self.replay_loop = False       # whether to loop replay
        self.replay_filename = ""      # currently playing file

        # Approach (smooth transition) state
        # "approaching" phase runs before replay and between loop iterations
        self.approaching = False         # whether we're in approach phase
        self.approach_start_pos = {}     # joint positions at start of approach
        self.approach_target_pos = {}    # target joint positions (first frame)
        self.approach_start_time = None  # monotonic time when approach started
        self.approach_duration = 0.0     # calculated duration in seconds
        self.approach_speed = args.approach_speed  # degrees per second

        # Recordings directory
        self.recordings_dir = pathlib.Path(os.path.expanduser(args.recordings_dir))
        self.recordings_dir.mkdir(parents=True, exist_ok=True)
        print(f"[worker] Recordings dir: {self.recordings_dir}")

        # ZMQ setup
        self.ctx = zmq.Context()

        self.pub = self.ctx.socket(zmq.PUB)
        self.pub.bind(args.pub_addr)
        print(f"[worker] PUB bound on {args.pub_addr}")

        self.rep = self.ctx.socket(zmq.REP)
        self.rep.bind(args.rep_addr)
        print(f"[worker] REP bound on {args.rep_addr}")

        # Poller for non-blocking REP check inside the read loop
        self.poller = zmq.Poller()
        self.poller.register(self.rep, zmq.POLLIN)

        # Heartbeat: track last command received from bridge node.
        # If no command arrives for heartbeat_timeout_sec, auto E-stop.
        # Initialized to current time so we don't trigger immediately at startup.
        self.last_cmd_time = time.monotonic()
        self.heartbeat_timeout_sec = 5.0
        self.heartbeat_triggered = False  # avoid spamming E-stop logs

    def connect_robots(self):
        """Connect to follower and/or leader via LeRobot API."""
        if not self.args.leader_only:
            print(f"[worker] Connecting follower on {self.args.follower_port} "
                  f"(id={self.args.follower_id}) ...")
            from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
            cfg = SO101FollowerConfig(
                port=self.args.follower_port,
                id=self.args.follower_id,
            )
            self.follower = SO101Follower(cfg)
            self.follower.connect()
            print("[worker] Follower connected.")

        if not self.args.follower_only:
            print(f"[worker] Connecting leader on {self.args.leader_port} "
                  f"(id={self.args.leader_id}) ...")
            from lerobot.teleoperators.so_leader import SOLeader, SO101LeaderConfig
            cfg = SO101LeaderConfig(
                port=self.args.leader_port,
                id=self.args.leader_id,
            )
            self.leader = SOLeader(cfg)
            self.leader.connect()
            print("[worker] Leader connected.")

        # Validate teleop requirements
        if self.teleop:
            if self.follower is None or self.leader is None:
                print("[worker] ERROR: --teleop requires both follower and leader connected.")
                print("[worker] Cannot use --teleop with --follower-only or --leader-only.")
                sys.exit(1)
            print("[worker] *** TELEOP MODE ENABLED ***")
            print("[worker] Leader arm will control follower arm in real-time.")
            print("[worker] Move leader arm to starting position, then press Enter to begin...")
            input()
            print("[worker] Teleop active!")

        # Record initial follower position for safety clamping
        if self.follower is not None:
            init_pos = self.read_follower()

            # Load calibration JSON to display physical range info
            calib_range = self._load_calibration_range("follower")

            if init_pos is not None:
                self.follower_initial_pos = dict(init_pos)

                # Print calibration range info
                if calib_range:
                    print("[worker] === CALIBRATION RANGE (raw encoder values) ===")
                    for name in JOINT_NAMES:
                        if name in calib_range:
                            rmin, rmax, cur_raw = calib_range[name]
                            total = rmax - rmin
                            if cur_raw is not None and total > 0:
                                pct = (cur_raw - rmin) / total * 100.0
                                print(f"  {name:20s}: raw {cur_raw:4d}  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]  "
                                      f"({pct:5.1f}% of range)")
                            else:
                                print(f"  {name:20s}: raw  ???  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]")

                print("[worker] === SAFETY CLAMP BASELINE (follower initial position) ===")
                for name in JOINT_NAMES:
                    v = init_pos.get(name, 0.0)
                    soft_lo = v - self.cmd_limit_deg
                    soft_hi = v + self.cmd_limit_deg
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)
                    marker = " <-- CMD OK" if name in self.allowed_cmd_joints else ""
                    print(f"  {name:20s}: {v:+8.2f}°  "
                          f"effective: [{eff_lo:+8.2f}° ~ {eff_hi:+8.2f}°]  "
                          f"(physical: [{hard_lo:+8.2f}°~{hard_hi:+8.2f}°]){marker}")
                print(f"[worker] Allowed command joints: {sorted(self.allowed_cmd_joints)}")
                print(f"[worker] Clamp limit: ±{self.cmd_limit_deg}° from baseline")
            else:
                print("[worker] WARNING: Could not read initial follower position. "
                      "Commands will be REJECTED until baseline is set.")

    def _load_calibration_range(self, arm_type="follower"):
        """
        Load calibration JSON and read current raw positions.
        Returns dict: {joint_name: (range_min, range_max, current_raw)} or None.
        """
        if arm_type == "follower":
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/robots/so_follower"
                / f"{self.args.follower_id}.json"
            )
            robot = self.follower
        else:
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/teleoperators/so_leader"
                / f"{self.args.leader_id}.json"
            )
            robot = self.leader

        try:
            with open(calib_path) as f:
                calib = json.load(f)
            print(f"[worker] Loaded calibration from {calib_path}")

            # Read current raw positions (without normalization)
            current_raw = {}
            if robot is not None:
                try:
                    raw = robot.bus.sync_read("Present_Position", normalize=False)
                    current_raw = dict(raw)
                except Exception as e:
                    print(f"[worker] Could not read raw positions: {e}")

            result = {}
            for name in JOINT_NAMES:
                if name in calib:
                    rmin = calib[name].get("range_min", 0)
                    rmax = calib[name].get("range_max", 4095)
                    cur = current_raw.get(name, None)
                    result[name] = (rmin, rmax, cur)

            return result

        except FileNotFoundError:
            print(f"[worker] Calibration file not found: {calib_path}")
            return None
        except Exception as e:
            print(f"[worker] Error loading calibration: {e}")
            return None

    def read_follower(self):
        """Read follower joint positions in degrees. Returns dict or None."""
        if self.follower is None:
            return None
        try:
            obs = self.follower.get_observation()
            return {name: float(obs[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            print(f"[worker] Follower read error: {e}")
            return None

    def read_leader(self):
        """Read leader joint positions in degrees. Returns dict or None."""
        if self.leader is None:
            return None
        try:
            action = self.leader.get_action()
            return {name: float(action[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            print(f"[worker] Leader read error: {e}")
            return None

    def send_follower_action(self, joint_positions_deg):
        """
        Send joint positions (degrees) to follower via LeRobot send_action().
        joint_positions_deg: dict like {"shoulder_pan": 10.0, ...}
        """
        if self.follower is None:
            return
        try:
            action_dict = {f"{name}.pos": float(joint_positions_deg[name])
                           for name in JOINT_NAMES if name in joint_positions_deg}
            self.follower.send_action(action_dict)
        except Exception as e:
            print(f"[worker] Follower send_action error: {e}")

    def handle_command(self, msg):
        """
        Process a command from the ROS bridge node.
        Expected format:
            {"cmd": "send_follower_action", "args": {"shoulder_pan": 0.0, ...}}
            {"cmd": "ping"}
            {"cmd": "start_teleop"}
            {"cmd": "stop_teleop"}
            {"cmd": "start_record"}
            {"cmd": "stop_record"}
            {"cmd": "start_replay", "args": {"filename": "...", "loop": false}}
            {"cmd": "stop_replay"}
            {"cmd": "estop"}
            {"cmd": "list_recordings"}
        Returns a response dict.
        """
        cmd = msg.get("cmd", "")

        if cmd == "ping":
            return {"status": "ok", "state": self.state,
                    "teleop": self.teleop, "ts": time.time()}

        elif cmd == "estop":
            return self._handle_estop()

        elif cmd == "start_teleop":
            return self._handle_start_teleop()

        elif cmd == "stop_teleop":
            return self._handle_stop_teleop()

        elif cmd == "start_record":
            return self._handle_start_record()

        elif cmd == "stop_record":
            return self._handle_stop_record()

        elif cmd == "start_replay":
            args = msg.get("args", {})
            return self._handle_start_replay(args)

        elif cmd == "stop_replay":
            return self._handle_stop_replay()

        elif cmd == "list_recordings":
            return self._handle_list_recordings()

        elif cmd == "send_follower_action":
            if self.follower is None:
                return {"status": "error", "reason": "follower not connected"}
            if self.follower_initial_pos is None:
                return {"status": "error", "reason": "safety baseline not set (no initial position)"}
            joint_args = msg.get("args", {})
            if not joint_args:
                return {"status": "error", "reason": "empty args"}
            try:
                # Two-layer safety clamp:
                #   Layer 1: baseline ± cmd_limit (session safety)
                #   Layer 2: JOINT_LIMITS_DEG (absolute physical limits, measured empirically)
                clamped = {}
                rejected = []
                for name, target_deg in joint_args.items():
                    if name not in JOINT_NAMES:
                        continue
                    if name not in self.allowed_cmd_joints:
                        rejected.append(name)
                        continue

                    target = float(target_deg)

                    # Layer 1: baseline ± cmd_limit
                    baseline = self.follower_initial_pos.get(name, 0.0)
                    soft_lo = baseline - self.cmd_limit_deg
                    soft_hi = baseline + self.cmd_limit_deg

                    # Layer 2: absolute physical limits
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))

                    # Effective range = intersection of both
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)

                    clamped_val = max(eff_lo, min(eff_hi, target))
                    clamped[name] = clamped_val

                    if abs(clamped_val - target) > 0.01:
                        reason = ""
                        if target < hard_lo or target > hard_hi:
                            reason = " [PHYSICAL LIMIT]"
                        else:
                            reason = " [BASELINE LIMIT]"
                        print(f"[worker] CLAMP {name}: "
                              f"requested {target:.2f}° → clamped to {clamped_val:.2f}° "
                              f"(effective: {eff_lo:.2f}° ~ {eff_hi:.2f}°){reason}")

                if not clamped:
                    return {"status": "error",
                            "reason": f"no allowed joints in command "
                                      f"(rejected: {rejected}, allowed: {sorted(self.allowed_cmd_joints)})"}

                action_dict = {f"{name}.pos": val for name, val in clamped.items()}
                self.follower.send_action(action_dict)
                resp = {"status": "ok", "clamped": clamped}
                if rejected:
                    resp["rejected_joints"] = rejected
                return resp
            except Exception as e:
                return {"status": "error", "reason": str(e)}

        else:
            return {"status": "error", "reason": f"unknown cmd: {cmd}"}

    # -----------------------------------------------------------------------
    # Teleop (Sync) handlers
    # -----------------------------------------------------------------------

    def _handle_start_teleop(self):
        """Activate leader→follower sync (teleop) with smooth approach."""
        if self.state == "replaying":
            return {"status": "error",
                    "reason": "cannot start teleop during replay — stop replay first"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "teleop requires both follower and leader connected"}
        if self.teleop:
            return {"status": "ok", "state": self.state, "teleop": True,
                    "msg": "teleop already active"}
        if self.state == "syncing":
            return {"status": "ok", "state": self.state, "teleop": False,
                    "msg": "already approaching leader position"}

        # Read leader's current position as approach target
        leader_pos = self.read_leader()
        if leader_pos is None:
            # Can't read leader — just turn on teleop directly
            self.teleop = True
            print("[worker] Teleop (sync) ON — leader → follower (no approach)")
            return {"status": "ok", "state": self.state, "teleop": True}

        # Start approach to leader position, then activate teleop
        self.state = "syncing"
        self._start_approach(leader_pos)
        if not self.approaching:
            # Already close enough — activate teleop immediately
            self.state = "idle"
            self.teleop = True
            print("[worker] Teleop (sync) ON — already aligned")
            return {"status": "ok", "state": self.state, "teleop": True}

        print("[worker] Syncing: approaching leader position before teleop ON")
        return {"status": "ok", "state": "syncing", "teleop": False,
                "approach_sec": round(self.approach_duration, 2)}

    def _handle_stop_teleop(self):
        """Deactivate leader→follower sync (teleop)."""
        if self.state == "recording":
            return {"status": "error",
                    "reason": "cannot stop teleop during recording — stop recording first"}
        # Cancel syncing approach if in progress
        if self.state == "syncing":
            self.approaching = False
            self.state = "idle"
            print("[worker] Syncing cancelled")
        self.teleop = False
        print("[worker] Teleop (sync) OFF")
        return {"status": "ok", "state": self.state, "teleop": False}

    # -----------------------------------------------------------------------
    # Record / Replay / E-Stop handlers
    # -----------------------------------------------------------------------

    def _handle_estop(self):
        """Emergency stop: abort any ongoing motion, return to idle."""
        prev_state = self.state
        prev_teleop = self.teleop
        self.state = "idle"
        self.teleop = False
        self.recording_buffer = []
        self.replay_frames = []
        self.replay_index = 0
        self.approaching = False
        print(f"[worker] *** E-STOP *** (was: state={prev_state}, teleop={prev_teleop})")
        return {"status": "ok", "state": "idle", "teleop": False,
                "previous_state": prev_state}

    def _handle_start_record(self):
        """Start recording: teleop must already be active (Sync On first)."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start recording in state '{self.state}'"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "recording requires both follower and leader connected"}
        if not self.teleop:
            return {"status": "error",
                    "reason": "teleop (sync) must be active before recording — "
                              "press Sync On first"}

        self.recording_buffer = []
        self.recording_start_time = time.monotonic()
        self.state = "recording"
        print("[worker] *** RECORDING STARTED *** (teleop already active)")
        return {"status": "ok", "state": "recording", "teleop": True}

    def _handle_stop_record(self):
        """Stop recording: save buffer to file. Teleop stays ON."""
        if self.state != "recording":
            return {"status": "error",
                    "reason": f"not recording (state: '{self.state}')"}

        num_frames = len(self.recording_buffer)
        if num_frames == 0:
            self.state = "idle"
            # Teleop stays ON — user controls it via Sync button
            print("[worker] Recording stopped — 0 frames, nothing saved.")
            return {"status": "ok", "state": "idle", "teleop": self.teleop,
                    "frames": 0, "filename": None}

        # Calculate duration from frame timestamps
        duration = self.recording_buffer[-1]["ts"] - self.recording_buffer[0]["ts"]

        # Build recording file
        timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"recording_{timestamp_str}.json"
        filepath = self.recordings_dir / filename

        recording_data = {
            "version": 1,
            "recorded_at": datetime.now().isoformat(),
            "rate_hz": self.args.rate,
            "num_frames": num_frames,
            "duration_sec": round(duration, 3),
            "joint_names": JOINT_NAMES,
            "frames": self.recording_buffer,
        }

        with open(filepath, "w") as f:
            json.dump(recording_data, f, indent=2)

        self.state = "idle"
        # Teleop stays ON — user controls it via Sync button
        self.recording_buffer = []
        print(f"[worker] Recording saved: {filepath}")
        print(f"[worker]   {num_frames} frames, {duration:.1f}s")
        return {
            "status": "ok",
            "state": "idle",
            "teleop": self.teleop,
            "filename": filename,
            "frames": num_frames,
            "duration_sec": round(duration, 3),
        }

    def _handle_start_replay(self, args):
        """Load a recording file and start replaying on the follower."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start replay in state '{self.state}'"}
        if self.follower is None:
            return {"status": "error",
                    "reason": "replay requires follower connected"}

        filename = args.get("filename", "")
        self.replay_loop = args.get("loop", False)

        # Allow overriding approach speed per command
        speed = args.get("approach_speed", self.approach_speed)

        # If no filename, use the most recent recording
        if not filename:
            recordings = self._get_recording_files()
            if not recordings:
                return {"status": "error", "reason": "no recordings found"}
            filename = recordings[-1]  # most recent (sorted by name = sorted by time)

        filepath = self.recordings_dir / filename
        if not filepath.exists():
            return {"status": "error",
                    "reason": f"file not found: {filename}"}

        try:
            with open(filepath) as f:
                data = json.load(f)
        except Exception as e:
            return {"status": "error", "reason": f"failed to load: {e}"}

        frames = data.get("frames", [])
        if len(frames) < 2:
            return {"status": "error",
                    "reason": f"recording too short ({len(frames)} frames)"}

        self.replay_frames = frames
        self.replay_index = 0
        self.replay_filename = filename
        self.teleop = False  # disable teleop during replay
        self.state = "replaying"

        # Start approach phase: smoothly move from current position to first frame
        first_frame_joints = frames[0].get("joints", {})
        self._start_approach(first_frame_joints, speed)

        duration = data.get("duration_sec", 0)
        print(f"[worker] *** REPLAY STARTED *** {filename}")
        print(f"[worker]   {len(frames)} frames, {duration}s, "
              f"loop={'yes' if self.replay_loop else 'no'}, "
              f"approach={self.approach_duration:.1f}s")
        return {
            "status": "ok",
            "state": "replaying",
            "filename": filename,
            "frames": len(frames),
            "duration_sec": duration,
            "loop": self.replay_loop,
            "approach_sec": round(self.approach_duration, 2),
        }

    def _handle_stop_replay(self):
        """Stop replay immediately."""
        if self.state != "replaying":
            return {"status": "error",
                    "reason": f"not replaying (state: '{self.state}')"}

        self.state = "idle"
        self.replay_frames = []
        self.replay_index = 0
        print(f"[worker] Replay stopped ({self.replay_filename})")
        return {"status": "ok", "state": "idle"}

    def _handle_list_recordings(self):
        """List all saved recording files."""
        files = self._get_recording_files()
        # Add metadata summary for each file
        summaries = []
        for fname in files:
            try:
                with open(self.recordings_dir / fname) as f:
                    data = json.load(f)
                summaries.append({
                    "filename": fname,
                    "frames": data.get("num_frames", 0),
                    "duration_sec": data.get("duration_sec", 0),
                    "recorded_at": data.get("recorded_at", ""),
                })
            except Exception:
                summaries.append({"filename": fname})
        return {"status": "ok", "recordings": summaries}

    def _get_recording_files(self):
        """Get sorted list of recording JSON files."""
        files = sorted(
            f.name for f in self.recordings_dir.glob("recording_*.json")
            if f.is_file()
        )
        return files

    # -----------------------------------------------------------------------
    # Approach (smooth transition) logic
    # -----------------------------------------------------------------------

    def _start_approach(self, target_joints, speed=None):
        """
        Begin a smooth approach from current follower position to target_joints.
        Duration is calculated from the largest joint angle difference
        divided by speed (degrees/sec).
        """
        if speed is None:
            speed = self.approach_speed

        current = self.read_follower()
        if current is None:
            # Can't read current position — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_start_pos = dict(current)
        self.approach_target_pos = dict(target_joints)

        # Calculate duration based on max joint difference
        max_diff = 0.0
        for name in JOINT_NAMES:
            if name in current and name in target_joints:
                diff = abs(target_joints[name] - current[name])
                max_diff = max(max_diff, diff)

        if max_diff < 1.0:
            # Already close enough — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_duration = max_diff / speed
        # Minimum 0.3s, even for small moves
        self.approach_duration = max(self.approach_duration, 0.3)
        self.approach_start_time = time.monotonic()
        self.approaching = True
        print(f"[worker] Approach: {max_diff:.1f}° max diff, "
              f"{self.approach_duration:.1f}s at {speed}°/s")

    def _tick_approach(self):
        """
        Advance the approach interpolation by one tick.
        Returns interpolated joint positions, or None if approach is finished.
        """
        if not self.approaching:
            return None

        elapsed = time.monotonic() - self.approach_start_time
        t = elapsed / self.approach_duration  # 0.0 → 1.0

        if t >= 1.0:
            # Approach finished — send final target
            self.send_follower_action(self.approach_target_pos)
            self.approaching = False
            self.replay_start_time = time.monotonic()
            print("[worker] Approach complete")
            return self.read_follower()

        # Smooth interpolation using ease-in-out (cosine)
        # t=0 → factor=0, t=1 → factor=1, smooth acceleration/deceleration
        factor = (1.0 - math.cos(t * math.pi)) / 2.0

        interpolated = {}
        for name in JOINT_NAMES:
            start_val = self.approach_start_pos.get(name, 0.0)
            end_val = self.approach_target_pos.get(name, 0.0)
            interpolated[name] = start_val + (end_val - start_val) * factor

        self.send_follower_action(interpolated)
        return self.read_follower()

    # -----------------------------------------------------------------------
    # Replay tick — called each iteration of the main loop
    # -----------------------------------------------------------------------

    def _tick_replay(self):
        """
        Advance replay by one tick. Handles approach phase, then frame playback.
        Returns the follower position after sending (for PUB).
        """
        # --- Approach phase: smooth transition to start position ---
        if self.approaching:
            return self._tick_approach()

        # --- Normal replay ---
        if not self.replay_frames or self.replay_index >= len(self.replay_frames):
            # Replay finished
            if self.replay_loop and self.replay_frames:
                # Loop: approach back to first frame before restarting
                first_frame_joints = self.replay_frames[0].get("joints", {})
                self.replay_index = 0
                self._start_approach(first_frame_joints)
                if self.approaching:
                    print(f"[worker] Replay loop — approaching start position")
                    return self.read_follower()
                else:
                    # Already close enough, restart immediately
                    print(f"[worker] Replay looping ({self.replay_filename})")
            else:
                self.state = "idle"
                print(f"[worker] Replay finished ({self.replay_filename})")
                return self.read_follower()

        elapsed = time.monotonic() - self.replay_start_time
        first_ts = self.replay_frames[0]["ts"]

        # Advance to the frame that matches current elapsed time
        while self.replay_index < len(self.replay_frames):
            frame = self.replay_frames[self.replay_index]
            frame_time = frame["ts"] - first_ts
            if frame_time <= elapsed:
                # Send this frame to follower
                joints = frame.get("joints", {})
                if joints:
                    self.send_follower_action(joints)
                self.replay_index += 1
            else:
                break  # wait for next tick

        # Re-read follower actual position
        return self.read_follower()

    def check_commands(self):
        """Non-blocking check for incoming REP commands."""
        events = dict(self.poller.poll(timeout=0))  # 0 = non-blocking
        if self.rep in events:
            try:
                raw = self.rep.recv_string(zmq.NOBLOCK)
                msg = json.loads(raw)
                self.last_cmd_time = time.monotonic()
                self.heartbeat_triggered = False
                response = self.handle_command(msg)
                self.rep.send_string(json.dumps(response))
            except zmq.Again:
                pass
            except Exception as e:
                # Must always send a reply on REP socket
                self.rep.send_string(json.dumps({
                    "status": "error", "reason": str(e)
                }))

    def run(self):
        """Main read loop at configured rate."""
        period = 1.0 / self.args.rate
        seq = 0
        teleop_count = 0
        print(f"[worker] Starting read loop at {self.args.rate} Hz "
              f"(period={period*1000:.1f}ms)")

        while self.running:
            t_start = time.monotonic()

            # --- Replaying mode: advance replay timeline ---
            if self.state == "replaying":
                follower_pos = self._tick_replay()
                leader_pos = self.read_leader()

            # --- Syncing mode: approach leader position, then activate teleop ---
            elif self.state == "syncing":
                if self.approaching:
                    follower_pos = self._tick_approach()
                else:
                    # Approach finished — activate teleop
                    self.state = "idle"
                    self.teleop = True
                    print("[worker] Teleop (sync) ON — approach complete")
                    follower_pos = self.read_follower()
                leader_pos = self.read_leader()

            else:
                # Read joint states
                follower_pos = self.read_follower()
                leader_pos = self.read_leader()

                # --- Teleop: send leader position to follower ---
                if self.teleop and leader_pos is not None:
                    self.send_follower_action(leader_pos)
                    teleop_count += 1
                    # Re-read follower after sending action to get updated position
                    follower_pos = self.read_follower()

                # --- Recording mode: buffer the current frame ---
                if self.state == "recording" and follower_pos is not None:
                    frame = {
                        "ts": time.monotonic() - self.recording_start_time,
                        "joints": dict(follower_pos),
                    }
                    self.recording_buffer.append(frame)

            # Build and publish message (degrees — bridge node converts to radians)
            msg = {
                "seq": seq,
                "ts": time.time(),
                "state": self.state,
                "teleop": self.teleop,
            }
            if follower_pos is not None:
                msg["follower"] = follower_pos
            if leader_pos is not None:
                msg["leader"] = leader_pos
            # Add recording/replay metadata
            if self.state == "recording":
                msg["recording_frames"] = len(self.recording_buffer)
            elif self.state == "replaying":
                msg["replay_progress"] = {
                    "index": self.replay_index,
                    "total": len(self.replay_frames),
                    "filename": self.replay_filename,
                    "loop": self.replay_loop,
                    "approaching": self.approaching,
                }

            self.pub.send_string(json.dumps(msg))
            seq += 1

            # Check for commands (non-blocking)
            self.check_commands()

            # Heartbeat timeout: if no command from bridge for too long,
            # auto E-stop to prevent uncontrolled motion when the
            # Unreal→rosbridge→bridge path is broken.
            # Only triggers when actively doing something (not idle).
            if self.state != "idle" or self.teleop:
                since_last = time.monotonic() - self.last_cmd_time
                if since_last > self.heartbeat_timeout_sec and not self.heartbeat_triggered:
                    self.heartbeat_triggered = True
                    print(f"[worker] *** HEARTBEAT TIMEOUT ({since_last:.1f}s) "
                          f"— auto E-STOP ***")
                    self._handle_estop()

            # Rate limiting
            elapsed = time.monotonic() - t_start
            sleep_time = period - elapsed
            if sleep_time > 0:
                time.sleep(sleep_time)

    def shutdown(self):
        """Clean shutdown."""
        print("\n[worker] Shutting down...")
        self.running = False
        self.state = "idle"

        # Save any in-progress recording
        if self.recording_buffer:
            try:
                self._handle_stop_record()
                print("[worker] Saved in-progress recording before shutdown.")
            except Exception:
                pass

        if self.follower is not None:
            try:
                self.follower.disconnect()
                print("[worker] Follower disconnected.")
            except Exception:
                pass

        if self.leader is not None:
            try:
                self.leader.disconnect()
                print("[worker] Leader disconnected.")
            except Exception:
                pass

        self.pub.close()
        self.rep.close()
        self.ctx.term()
        print("[worker] ZMQ cleaned up. Bye.")


def main():
    args = build_args()
    worker = LeRobotWorker(args)

    # Graceful shutdown on Ctrl+C
    def sig_handler(sig, frame):
        worker.shutdown()
        sys.exit(0)

    signal.signal(signal.SIGINT, sig_handler)
    signal.signal(signal.SIGTERM, sig_handler)

    try:
        worker.connect_robots()
        worker.run()
    except Exception:
        traceback.print_exc()
        worker.shutdown()
        sys.exit(1)


if __name__ == "__main__":
    main()

수정 사항

 

  • __init__에 3개 변수 추가: last_cmd_time, heartbeat_timeout_sec (5초), heartbeat_triggered
  • check_commands()에서 명령 수신 시 last_cmd_time 갱신 + heartbeat_triggered 리셋
  • run() 메인 루프에서 매 tick마다 체크: idle 상태가 아니거나 teleop이 켜져 있을 때 마지막 명령으로부터 5초 이상 경과하면 자동 E-stop 발동

 

 

동작 흐름

 

bridge_node (2초마다)
       → ping → worker REP
worker:
       ← ping 수신 → last_cmd_time 갱신
       ...
bridge_node 죽음 (rosbridge crash 등)
       → ping 안 옴
worker (5초 후):
       → "HEARTBEAT TIMEOUT — auto E-STOP"
       → state=idle, teleop=false, 모든 동작 중단

 

 


 

 

2단계: 테스트

로봇 연결 후 PowerShell에서

Attach-Both

 

 

터미널 1 (conda - worker)

source ~/miniforge3/etc/profile.d/conda.sh
conda activate lerobot
sudo chmod 666 /dev/ttyACM*
cd ~/UnrealRobotics/src/lerobot_ros2_bridge
python scripts/lerobot_worker.py --cmd-joints all --cmd-limit 120

 

 

터미널 2 (ROS2 - bridge node)

export PATH=$(echo $PATH | tr ':' '\n' | grep -v miniforge | tr '\n' ':' | sed 's/:$//')
source /opt/ros/humble/setup.bash
source ~/UnrealRobotics/install/setup.bash
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export ROS_LOCALHOST_ONLY=1
ros2 run lerobot_ros2_bridge bridge_node

 

 

터미널 3 (ROS2 - rosbridge)

export PATH=$(echo $PATH | tr ':' '\n' | grep -v miniforge | tr '\n' ':' | sed 's/:$//')
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export ROS_LOCALHOST_ONLY=1
ros2 launch rosbridge_server rosbridge_websocket_launch.xml

 

 

 

테스트 1: 연결 끊김 경고

확인 방법: Unreal 뷰포트 좌상단 on-screen 메시지가 뜨는지 확인

  1. PIE 시작 후 뷰포트에 초록색 "ROS Bridge: Connected" 메시지가 나오는지 확인
  2. 터미널 3 (rosbridge)을 Ctrl+C로 죽이기
  3. 뷰포트에 빨간색 "* ROS Bridge DISCONNECTED *" 경고가 나오는지 확인
  4. 터미널 3에서 rosbridge를 다시 시작
  5. 몇 초 후 뷰포트에 다시 **초록색 "Connected"**가 나오는지 확인

확인 완료.

 

 

테스트 2: Heartbeat timeout 자동 E-stop

  1. Details 패널에서 Sync On 버튼 클릭 → follower가 leader 위치로 approach
  2. 뷰포트에 초록색 "Sync ON" 확인
  3. 터미널 2 (bridge node)를 Ctrl+C로 죽이기
  4. 터미널 1 (worker) 콘솔을 확인 — 약 5초 후 *** HEARTBEAT TIMEOUT (5.x초) — auto E-STOP *** 출력이 나오는지 확인
  5. Worker가 자동으로 idle 상태 + teleop off가 되었는지 확인

테스트 후 터미널 2 재시작.

터미널1에서 HEARTBEAT TIMEOUT 확인.

 

 

테스트 3: 일반 동작 확인 (기존 기능 깨지지 않았는지)

터미널 2, 3 모두 정상 가동 상태에서:

  1. Sync On → follower가 leader를 따라가는지
  2. Start RecordStop Record → 파일 저장 메시지
  3. Start Replay → 재생 동작
  4. EStop → 즉시 정지

뷰포트에 각 상태에 맞는 색상 메시지가 뜨는지도 같이 확인합니다 (초록=Sync ON/저장완료, 시안=replaying, 노랑=중단, 빨강=E-stop).

모든 모드 확인 완료.

 

 


 

 

2-1단계: 테스트 후 보완점 - 추후 오류 UI를 위한 구간 별 수신 체크

위 단계 중 테스트2에서, bridge node 터미널을 죽여서 E-Stop 되는 상황에 언리얼 뷰포트에는 E-Stop 표시가 되지 않는 것을 확인. 그 이유는 상태 변화를 Unreal에 전달할 경로가 없기 때문.

worker (auto E-stop 발동) → ZMQ PUB → bridge_node (죽음) X→ /robot_status X→ Unreal

 

 

추후 로봇 상태 체크 UI 중 연결 확인 및 오류 확인에서 어떤 단계에서 문제가 있는지를 보기 위해서는 모든 단계를 감시해야 한다.

Unreal ←WebSocket→ rosbridge ←DDS→ bridge_node ←ZMQ→ worker ←USB→ 로봇
    [1]                                    [2]                               [3]                            [4]

[1] WebSocket 연결 부는 이미 OnDisconnected 델리게이트로 구현되어 있다. (rosbridge가 죽으면 감지)

[2~3]: Unreal에서 /robot_status 메시지가 일정 시간 안 오면 "Worker/Bridge 연결 끊김" 경고를 표시

 

 

[2~3]에 해당하는 부분을 추가한다. [2]와 [3] 각각에서 끊어짐 이슈가 있을 때를 가정해본다.

bridge_node → /bridge_heartbeat (1초마다 발행)
worker → /joint_states (30Hz, bridge_node 경유)

위의 두 부분이 수신되는 과정에 따라서 어떤 부분이 끊어졌는지를 유추할 수 있다.

/bridge_heartbeat /joint_states 진단
✅ 수신됨 ✅ 수신됨 전부 정상
✅ 수신됨 ❌ 안 옴 bridge는 살아있고 worker가 죽음 [3]
❌ 안 옴 ❌ 안 옴 bridge가 죽음 [2] (worker는 알 수 없음)

 

 

RobotVisualizer.h 수정

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "RobotVisualizer.generated.h"

class UStaticMesh;
class UStaticMeshComponent;
class USceneComponent;
class URosBridgeSubsystem;

/**
 * Visualizes the SO-ARM-101 follower arm in Unreal Engine and provides
 * MoveIt command interface via rosbridge.
 *
 * The component hierarchy mirrors the URDF link/joint structure:
 *   BaseLink -> ShoulderPanJoint -> ShoulderLink -> ShoulderLiftJoint -> ...
 *
 * Each "Joint" SceneComponent is where the ROS joint angle gets applied
 * as a local Z-axis rotation. All child links/meshes rotate with it.
 *
 * Phase 8 additions:
 *   - SendNamedTarget(): publish to /moveit_goal_named (std_msgs/String)
 *   - SendJointGoal(): publish to /moveit_goal_joints (sensor_msgs/JointState)
 *   - SendPoseGoal(): publish to /moveit_goal_pose (geometry_msgs/PoseStamped)
 *   - Blueprint-callable + editor-testable via UPROPERTY buttons
 *
 * Phase 9 additions (Record/Replay/E-Stop):
 *   - StartRecord(): begin teleop recording on worker
 *   - StopRecord(): stop recording, save trajectory
 *   - StartReplay(): replay most recent (or named) recording
 *   - StopReplay(): stop replay
 *   - EStop(): emergency stop all motion
 *   - All commands publish JSON to /robot_command topic
 *   - Worker state feedback via /robot_status subscription
 */
UCLASS()
class SO101_TWIN_API ARobotVisualizer : public AActor
{
	GENERATED_BODY()

public:
	ARobotVisualizer();

protected:
	virtual void BeginPlay() override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	// =================================================================
	// Configuration
	// =================================================================

	UPROPERTY(EditAnywhere, Category = "ROS|Bridge")
	FString RosBridgeUrl = TEXT("ws://127.0.0.1:9090/?x=1");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateTopic = TEXT("/joint_states");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateType = TEXT("sensor_msgs/JointState");

	// =================================================================
	// MoveIt Command Interface (Phase 8)
	// =================================================================

	// --- Named target ---

	/** Named target to send (e.g. "home", "ready"). Set in Details panel, then call SendNamedTarget(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt")
	FString MoveItNamedTarget = TEXT("home");

	/** Send the named target to MoveIt via /moveit_goal_named topic. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt")
	void SendNamedTarget();

	// --- Joint goal ---

	/** Joint goal values in radians. Set in Details panel, then call SendJointGoal(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderPan = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderLift = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalElbowFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristRoll = 0.0f;

	/** Send joint goal to MoveIt via /moveit_goal_joints topic. Values are in radians. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Joints")
	void SendJointGoal();

	// --- Pose goal (Cartesian, position-only for 5DOF) ---

	/** Target position in UE coordinates (cm). Converted to ROS (meters) on send. */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Pose")
	FVector GoalPositionUE = FVector(10.0f, 0.0f, 15.0f);

	/** Send position-only goal to MoveIt via /moveit_goal_pose topic.
	 *  GoalPositionUE is in Unreal cm, auto-converted to ROS meters with Y flip. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Pose")
	void SendPoseGoal();

	// =================================================================
	// Teleop Sync (Phase 9)
	// =================================================================

	/** Activate leader→follower sync (teleop). Must be ON before recording. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOn();

	/** Deactivate leader→follower sync. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOff();

	/** Whether teleop (sync) is currently active. Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	bool bSyncActive = false;

	// =================================================================
	// Record / Replay / E-Stop (Phase 9)
	// =================================================================

	/** Start recording: activates teleop on worker, buffers joint trajectory. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StartRecord();

	/** Stop recording: saves trajectory to file on worker. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StopRecord();

	/** Replay filename (empty = most recent recording). */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	FString ReplayFilename;

	/** Whether to loop the replay continuously. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	bool bReplayLoop = false;

	/** Approach speed in degrees/sec. Controls how fast the robot moves
	 *  to the start position before replay begins. Lower = smoother. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay", meta = (ClampMin = "5.0", ClampMax = "300.0"))
	float ApproachSpeed = 45.0f;

	/** Start replaying a recorded trajectory on the follower arm. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StartReplay();

	/** Stop replay immediately. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StopReplay();

	/** Emergency stop: abort ALL motion immediately (recording, replay, teleop). */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Safety")
	void EStop();

	/** Current worker state (idle/recording/replaying). Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	FString WorkerState = TEXT("unknown");

private:
	// =================================================================
	// Component hierarchy
	// =================================================================

	UPROPERTY(VisibleAnywhere, Category = "Robot")
	TObjectPtr<USceneComponent> RobotRoot;

	// Link SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> BaseLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> ShoulderLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> UpperArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> LowerArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> WristLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> GripperLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> MovingJawLink;

	// Joint SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderPanJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderLiftJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ElbowFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristRollJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> GripperJoint;

	// Joint name -> component mapping
	UPROPERTY()
	TMap<FName, TObjectPtr<USceneComponent>> JointComponentMap;

	// Mesh components
	UPROPERTY()
	TArray<TObjectPtr<UStaticMeshComponent>> AllMeshComponents;

	// =================================================================
	// ROS connection
	// =================================================================

	UFUNCTION()
	void OnRosBridgeConnected();

	UFUNCTION()
	void OnRosBridgeDisconnected();

	UFUNCTION()
	void OnRosMessage(const FString& Topic, const FString& MessageJson);

	/** Tracks rosbridge connection state for viewport warnings. */
	bool bRosBridgeConnected = false;

	void ParseAndApplyJointStates(const FString& MessageJson);

	// =================================================================
	// MoveIt publish helpers
	// =================================================================

	/** Advertise MoveIt command topics. Called once on connect. */
	void AdvertiseMoveItTopics();

	/** Whether MoveIt topics have been advertised in this connection session. */
	bool bMoveItTopicsAdvertised = false;

	// =================================================================
	// Record / Replay / E-Stop helpers
	// =================================================================

	/** Advertise /robot_command and subscribe /robot_status. Called once on connect. */
	void SetupRecordReplayTopics();

	/** Whether record/replay topics have been set up. */
	bool bRecordReplayTopicsSetup = false;

	/** Send a JSON command to /robot_command topic. */
	void PublishRobotCommand(const FString& JsonCmd);

	/** Handle /robot_status messages from the bridge node. */
	UFUNCTION()
	void OnRobotStatus(const FString& Topic, const FString& MessageJson);

	// =================================================================
	// Connection health monitoring (Unreal-side)
	// =================================================================

	/** Called periodically to check bridge and worker heartbeats. */
	void CheckConnectionHealth();

	/** Timer handle for health check. */
	FTimerHandle ConnectionHealthTimerHandle;

	/** Last time we received /bridge_heartbeat. */
	double LastBridgeHeartbeatTime = 0.0;

	/** Last time we received /joint_states (worker data via bridge). */
	double LastJointStatesTime = 0.0;

	/** Timeout in seconds for bridge heartbeat (bridge publishes every 1s). */
	float BridgeHeartbeatTimeoutSec = 4.0f;

	/** Timeout in seconds for worker data (/joint_states at 30Hz). */
	float WorkerDataTimeoutSec = 3.0f;

	/** Whether bridge heartbeat has been lost. */
	bool bBridgeHeartbeatLost = false;

	/** Whether worker data has been lost. */
	bool bWorkerDataLost = false;

	// =================================================================
	// Helpers (declared in original header, kept for compatibility)
	// =================================================================

	USceneComponent* CreateJointComponent(const FName& Name, USceneComponent* Parent,
		const FVector& Location, const FRotator& Rotation);

	USceneComponent* CreateLinkComponent(const FName& Name, USceneComponent* Parent);

	UStaticMeshComponent* AttachMesh(USceneComponent* Parent, UStaticMesh* Mesh,
		const FName& Name, const FVector& Location, const FRotator& Rotation,
		bool bIsMotor);
};

수정 내역

 

  • bRosBridgeConnected - WebSocket 연결 상태 추적 변수
  • CheckConnectionHealth() - 2초 주기로 bridge/worker 상태 체크하는 메서드
  • ConnectionHealthTimerHandle - 타이머 핸들
  • LastBridgeHeartbeatTime - 마지막 /bridge_heartbeat 수신 시각
  • LastJointStatesTime - 마지막 /joint_states 수신 시각
  • BridgeHeartbeatTimeoutSec - bridge 타임아웃 4초
  • WorkerDataTimeoutSec - worker 데이터 타임아웃 3초
  • bBridgeHeartbeatLost - bridge 끊김 경고 중복 방지 플래그
  • bWorkerDataLost - worker 끊김 경고 중복 방지 플래그

 

 

 

 

 

RobotVisualizer.cpp 수정

더보기
#include "RobotVisualizer.h"
#include "RosCoordConv.h"
#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "Components/SceneComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
#include "UObject/ConstructorHelpers.h"

// =============================================================================
// Mesh asset path helper
// =============================================================================

static UStaticMesh* LoadMeshAsset(const TCHAR* AssetName)
{
	// All meshes live under /Game/Robot/Meshes/
	FString Path = FString::Printf(TEXT("/Game/Robot/Meshes/%s.%s"), AssetName, AssetName);
	UStaticMesh* Mesh = Cast<UStaticMesh>(StaticLoadObject(UStaticMesh::StaticClass(), nullptr, *Path));
	if (!Mesh)
	{
		UE_LOG(LogRosBridge, Warning, TEXT("Failed to load mesh: %s"), *Path);
	}
	return Mesh;
}

// =============================================================================
// Constructor — build the entire component hierarchy
// =============================================================================

ARobotVisualizer::ARobotVisualizer()
{
	PrimaryActorTick.bCanEverTick = false;

	// --- Root ---
	RobotRoot = CreateDefaultSubobject<USceneComponent>(TEXT("RobotRoot"));
	RootComponent = RobotRoot;

	// =========================================================================
	// URDF data converted to UE coordinates:
	//   Position: meters * 100 = cm, Y flipped
	//   Rotation: RPY radians -> FRotator degrees, pitch & yaw negated
	//
	// All values below are pre-computed from so101_follower.urdf.
	// =========================================================================

	// --- base_link (attached directly to root, no joint) ---
	BaseLink = CreateDefaultSubobject<USceneComponent>(TEXT("BaseLink"));
	BaseLink->SetupAttachment(RobotRoot);

	// --- shoulder_pan joint ---
	// URDF origin: xyz(0.0388353, 0, 0.0624) rpy(3.14159, 0, -3.14159)
	ShoulderPanJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderPanJoint"));
	ShoulderPanJoint->SetupAttachment(BaseLink);
	ShoulderPanJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0388353, 0.0, 0.0624));
	ShoulderPanJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159));

	ShoulderLink = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLink"));
	ShoulderLink->SetupAttachment(ShoulderPanJoint);

	// --- shoulder_lift joint ---
	// URDF origin: xyz(-0.0303992, -0.0182778, -0.0542) rpy(-1.5708, -1.5708, 0)
	ShoulderLiftJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLiftJoint"));
	ShoulderLiftJoint->SetupAttachment(ShoulderLink);
	ShoulderLiftJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.0303992, -0.0182778, -0.0542));
	ShoulderLiftJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0));

	UpperArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("UpperArmLink"));
	UpperArmLink->SetupAttachment(ShoulderLiftJoint);

	// --- elbow_flex joint ---
	// URDF origin: xyz(-0.11257, -0.028, 0) rpy(0, 0, 1.5708)
	ElbowFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ElbowFlexJoint"));
	ElbowFlexJoint->SetupAttachment(UpperArmLink);
	ElbowFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.11257, -0.028, 0.0));
	ElbowFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708));

	LowerArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("LowerArmLink"));
	LowerArmLink->SetupAttachment(ElbowFlexJoint);

	// --- wrist_flex joint ---
	// URDF origin: xyz(-0.1349, 0.0052, 0) rpy(0, 0, -1.5708)
	WristFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristFlexJoint"));
	WristFlexJoint->SetupAttachment(LowerArmLink);
	WristFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.1349, 0.0052, 0.0));
	WristFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708));

	WristLink = CreateDefaultSubobject<USceneComponent>(TEXT("WristLink"));
	WristLink->SetupAttachment(WristFlexJoint);

	// --- wrist_roll joint ---
	// URDF origin: xyz(0, -0.0611, 0.0181) rpy(1.5708, 0.0486795, 3.14159)
	WristRollJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristRollJoint"));
	WristRollJoint->SetupAttachment(WristLink);
	WristRollJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0, -0.0611, 0.0181));
	WristRollJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159));

	GripperLink = CreateDefaultSubobject<USceneComponent>(TEXT("GripperLink"));
	GripperLink->SetupAttachment(WristRollJoint);

	// --- gripper joint ---
	// URDF origin: xyz(0.0202, 0.0188, -0.0234) rpy(1.5708, 0, 0)
	GripperJoint = CreateDefaultSubobject<USceneComponent>(TEXT("GripperJoint"));
	GripperJoint->SetupAttachment(GripperLink);
	GripperJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0202, 0.0188, -0.0234));
	GripperJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0));

	MovingJawLink = CreateDefaultSubobject<USceneComponent>(TEXT("MovingJawLink"));
	MovingJawLink->SetupAttachment(GripperJoint);

	// --- Joint name mapping (matches ROS /joint_states names) ---
	JointComponentMap.Add(FName("shoulder_pan"),  ShoulderPanJoint);
	JointComponentMap.Add(FName("shoulder_lift"), ShoulderLiftJoint);
	JointComponentMap.Add(FName("elbow_flex"),    ElbowFlexJoint);
	JointComponentMap.Add(FName("wrist_flex"),    WristFlexJoint);
	JointComponentMap.Add(FName("wrist_roll"),    WristRollJoint);
	JointComponentMap.Add(FName("gripper"),       GripperJoint);
}

// =============================================================================
// BeginPlay — load meshes and attach, connect to ROS
// =============================================================================

void ARobotVisualizer::BeginPlay()
{
	Super::BeginPlay();

	// --- Load meshes and attach to links ---
	// Meshes are loaded at runtime (not in constructor) because
	// StaticLoadObject is safer to call here and allows hot-reload.

	// Helper lambda to reduce repetition
	auto Attach = [this](USceneComponent* Parent, const TCHAR* MeshName,
		double RosX, double RosY, double RosZ,
		double RosRoll, double RosPitch, double RosYaw,
		bool bIsMotor = false)
	{
		UStaticMesh* Mesh = LoadMeshAsset(MeshName);
		if (!Mesh) return;

		UStaticMeshComponent* SMC = NewObject<UStaticMeshComponent>(this);
		SMC->SetStaticMesh(Mesh);
		SMC->SetRelativeLocation(RosCoordConv::RosToUePosition(RosX, RosY, RosZ));
		SMC->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(RosRoll, RosPitch, RosYaw));
		SMC->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		SMC->AttachToComponent(Parent, FAttachmentTransformRules::KeepRelativeTransform);
		SMC->RegisterComponent();
		AllMeshComponents.Add(SMC);
	};

	// === base_link meshes ===
	Attach(BaseLink, TEXT("base_motor_holder_so101_v1"),
		-0.00636471, -0.0000994414, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("base_so101_v2"),
		-0.00636471, 0.0, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("sts3215_03a_v1"),
		0.0263353, 0.0, 0.0437,
		0.0, 0.0, 0.0, true);
	Attach(BaseLink, TEXT("waveshare_mounting_plate_so101_v2"),
		-0.0309827, -0.000199441, 0.0474,
		1.5708, 0.0, 1.5708);

	// === shoulder_link meshes ===
	Attach(ShoulderLink, TEXT("sts3215_03a_v1"),
		-0.0303992, 0.000422241, -0.0417,
		1.5708, 1.5708, 0.0, true);
	Attach(ShoulderLink, TEXT("motor_holder_so101_base_v1"),
		-0.0675992, -0.000177759, 0.0158499,
		1.5708, -1.5708, 0.0);
	Attach(ShoulderLink, TEXT("rotation_pitch_so101_v1"),
		0.0122008, 0.0000222413, 0.0464,
		-1.5708, 0.0, 0.0);

	// === upper_arm_link meshes ===
	Attach(UpperArmLink, TEXT("sts3215_03a_v1"),
		-0.11257, -0.0155, 0.0187,
		-3.14159, 0.0, -1.5708, true);
	Attach(UpperArmLink, TEXT("upper_arm_so101_v1"),
		-0.065085, 0.012, 0.0182,
		3.14159, 0.0, 0.0);

	// === lower_arm_link meshes ===
	Attach(LowerArmLink, TEXT("under_arm_so101_v1"),
		-0.0648499, -0.032, 0.0182,
		3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("motor_holder_so101_wrist_v1"),
		-0.0648499, -0.032, 0.018,
		-3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("sts3215_03a_v1"),
		-0.1224, 0.0052, 0.0187,
		-3.14159, 0.0, -3.14159, true);

	// === wrist_link meshes ===
	Attach(WristLink, TEXT("sts3215_03a_no_horn_v1"),
		0.0, -0.0424, 0.0306,
		1.5708, 1.5708, 0.0, true);
	Attach(WristLink, TEXT("wrist_roll_pitch_so101_v2"),
		0.0, -0.028, 0.0181,
		-1.5708, -1.5708, 0.0);

	// === gripper_link meshes ===
	Attach(GripperLink, TEXT("sts3215_03a_v1"),
		0.0077, 0.0001, -0.0234,
		-1.5708, 0.0, 0.0, true);
	Attach(GripperLink, TEXT("wrist_roll_follower_so101_v1"),
		0.0, -0.000218214, 0.000949706,
		-3.14159, 0.0, 0.0);

	// === moving_jaw_link meshes ===
	Attach(MovingJawLink, TEXT("moving_jaw_so101_v1"),
		0.0, 0.0, 0.0189,
		0.0, 0.0, 0.0);

	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: %d mesh components created"), AllMeshComponents.Num());

	// --- Connect to ROS via Subsystem ---
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Bind delegates.
	Ros->OnTopicMessage.AddDynamic(this, &ARobotVisualizer::OnRosMessage);
	Ros->OnConnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
	Ros->OnDisconnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);

	// Subscribe is now queued even before connection — the subsystem will
	// send it automatically when connected (including on reconnect).
	Ros->Subscribe(JointStateTopic, JointStateType);

	// Queue MoveIt topic advertisements (sent on connect).
	AdvertiseMoveItTopics();

	// Queue record/replay/estop topics.
	SetupRecordReplayTopics();

	// Subscribe to bridge heartbeat for connection health monitoring.
	Ros->Subscribe(TEXT("/bridge_heartbeat"), TEXT("std_msgs/String"));

	// Start connection health monitor (checks every 2 seconds).
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;
	GetWorldTimerManager().SetTimer(
		ConnectionHealthTimerHandle, this,
		&ARobotVisualizer::CheckConnectionHealth, 2.0f, true);

	// Initiate connection if not already connected.
	if (!Ros->IsConnected())
	{
		Ros->Connect(RosBridgeUrl);
	}
	else
	{
		// Already connected (e.g. another actor already called Connect).
		UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: already connected, subscription sent."));
	}
}

// =============================================================================
// EndPlay
// =============================================================================

void ARobotVisualizer::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (UGameInstance* GI = UGameplayStatics::GetGameInstance(this))
	{
		if (URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>())
		{
			Ros->OnTopicMessage.RemoveDynamic(this, &ARobotVisualizer::OnRosMessage);
			Ros->OnConnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
			Ros->OnDisconnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);
		}
	}

	bMoveItTopicsAdvertised = false;
	bRecordReplayTopicsSetup = false;

	GetWorldTimerManager().ClearTimer(ConnectionHealthTimerHandle);

	Super::EndPlay(EndPlayReason);
}

// =============================================================================
// ROS connection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeConnected()
{
	bRosBridgeConnected = true;
	UE_LOG(LogRosBridge, Log,
		TEXT("RobotVisualizer: rosbridge connected — subscriptions restored by subsystem."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
			TEXT("ROS Bridge: Connected"));
	}
}

// =============================================================================
// ROS disconnection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeDisconnected()
{
	bRosBridgeConnected = false;
	WorkerState = TEXT("disconnected");

	UE_LOG(LogRosBridge, Warning,
		TEXT("RobotVisualizer: rosbridge DISCONNECTED — cannot send commands. Auto-reconnect active."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
			TEXT("*** ROS Bridge DISCONNECTED *** Cannot send commands. Reconnecting..."));
	}
}

// =============================================================================
// Connection health monitoring
// =============================================================================

void ARobotVisualizer::CheckConnectionHealth()
{
	// Only check when WebSocket is connected — if WebSocket is down,
	// OnRosBridgeDisconnected already shows a warning for that.
	if (!bRosBridgeConnected)
	{
		return;
	}

	const double Now = FPlatformTime::Seconds();

	// Check bridge heartbeat (published every 1s by bridge_node)
	const double BridgeElapsed = Now - LastBridgeHeartbeatTime;
	if (BridgeElapsed > BridgeHeartbeatTimeoutSec && !bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = true;
		bWorkerDataLost = true;  // if bridge is down, worker data can't reach us either
		WorkerState = TEXT("bridge lost");

		UE_LOG(LogRosBridge, Warning,
			TEXT("Bridge heartbeat lost (%.1fs). bridge_node may be down."), BridgeElapsed);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
				TEXT("*** Bridge Node: DOWN *** Check bridge_node terminal (terminal 2)."));
		}
		return;  // no need to check worker separately
	}

	// Check worker data (/joint_states at ~30Hz, flows through bridge)
	// Only meaningful if bridge is alive.
	if (!bBridgeHeartbeatLost)
	{
		const double WorkerElapsed = Now - LastJointStatesTime;
		if (WorkerElapsed > WorkerDataTimeoutSec && !bWorkerDataLost)
		{
			bWorkerDataLost = true;
			WorkerState = TEXT("worker lost");

			UE_LOG(LogRosBridge, Warning,
				TEXT("Worker data lost (%.1fs). lerobot_worker may be down."), WorkerElapsed);

			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
					TEXT("*** Worker: DOWN *** Check lerobot_worker terminal (terminal 1)."));
			}
		}
	}
}

// =============================================================================
// MoveIt topic advertisements
// =============================================================================

void ARobotVisualizer::AdvertiseMoveItTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// These are queued and sent automatically when connected.
	Ros->Advertise(TEXT("/moveit_goal_named"), TEXT("std_msgs/String"));
	Ros->Advertise(TEXT("/moveit_goal_joints"), TEXT("sensor_msgs/JointState"));
	Ros->Advertise(TEXT("/moveit_goal_pose"), TEXT("geometry_msgs/PoseStamped"));

	bMoveItTopicsAdvertised = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: MoveIt command topics advertised."));
}

// =============================================================================
// MoveIt commands — SendNamedTarget
// =============================================================================

void ARobotVisualizer::SendNamedTarget()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendNamedTarget: not connected to rosbridge."));
		return;
	}

	// std_msgs/String: {"data": "home"}
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *MoveItNamedTarget);
	Ros->Publish(TEXT("/moveit_goal_named"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("SendNamedTarget: published '%s' to /moveit_goal_named"), *MoveItNamedTarget);
}

// =============================================================================
// MoveIt commands — SendJointGoal
// =============================================================================

void ARobotVisualizer::SendJointGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendJointGoal: not connected to rosbridge."));
		return;
	}

	// sensor_msgs/JointState:
	// {
	//   "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": ""},
	//   "name": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"],
	//   "position": [0.0, -0.5, 0.5, 0.0, 0.0],
	//   "velocity": [],
	//   "effort": []
	// }

	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"\"},")
		TEXT("\"name\":[\"shoulder_pan\",\"shoulder_lift\",\"elbow_flex\",\"wrist_flex\",\"wrist_roll\"],")
		TEXT("\"position\":[%f,%f,%f,%f,%f],")
		TEXT("\"velocity\":[],\"effort\":[]}"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll
	);

	Ros->Publish(TEXT("/moveit_goal_joints"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendJointGoal: [%.3f, %.3f, %.3f, %.3f, %.3f] to /moveit_goal_joints"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll);
}

// =============================================================================
// MoveIt commands — SendPoseGoal
// =============================================================================

void ARobotVisualizer::SendPoseGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendPoseGoal: not connected to rosbridge."));
		return;
	}

	// Convert UE position (cm, left-handed) to ROS (meters, right-handed)
	double RosX, RosY, RosZ;
	RosCoordConv::UeToRosPosition(GoalPositionUE, RosX, RosY, RosZ);

	// geometry_msgs/PoseStamped (orientation defaults to identity — position-only goal)
	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"base_link\"},")
		TEXT("\"pose\":{\"position\":{\"x\":%f,\"y\":%f,\"z\":%f},")
		TEXT("\"orientation\":{\"x\":0.0,\"y\":0.0,\"z\":0.0,\"w\":1.0}}}"),
		RosX, RosY, RosZ
	);

	Ros->Publish(TEXT("/moveit_goal_pose"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendPoseGoal: UE(%.1f, %.1f, %.1f)cm -> ROS(%.4f, %.4f, %.4f)m to /moveit_goal_pose"),
		GoalPositionUE.X, GoalPositionUE.Y, GoalPositionUE.Z,
		RosX, RosY, RosZ);
}

// =============================================================================
// Message handling
// =============================================================================

void ARobotVisualizer::OnRosMessage(const FString& Topic, const FString& MessageJson)
{
	if (Topic == JointStateTopic)
	{
		// /joint_states arrives at ~30Hz — proves both bridge AND worker are alive.
		LastJointStatesTime = FPlatformTime::Seconds();
		LastBridgeHeartbeatTime = LastJointStatesTime; // joint_states flows through bridge
		if (bWorkerDataLost)
		{
			bWorkerDataLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Worker data restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Worker: Connection restored"));
			}
		}
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored (via joint_states)."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}

		ParseAndApplyJointStates(MessageJson);
	}
	else if (Topic == TEXT("/bridge_heartbeat"))
	{
		// Bridge heartbeat arrives every 1s — proves bridge is alive
		// (but worker may still be dead if /joint_states is not arriving).
		LastBridgeHeartbeatTime = FPlatformTime::Seconds();
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}
	}
	else if (Topic == TEXT("/robot_status"))
	{
		OnRobotStatus(Topic, MessageJson);
	}
}

void ARobotVisualizer::ParseAndApplyJointStates(const FString& MessageJson)
{
	// Parse sensor_msgs/JointState JSON:
	// { "name": ["shoulder_pan", ...], "position": [0.1, ...], ... }

	TSharedPtr<FJsonObject> Json;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(Reader, Json) || !Json.IsValid())
	{
		return;
	}

	const TArray<TSharedPtr<FJsonValue>>* NameArray = nullptr;
	const TArray<TSharedPtr<FJsonValue>>* PosArray = nullptr;

	if (!Json->TryGetArrayField(TEXT("name"), NameArray) ||
		!Json->TryGetArrayField(TEXT("position"), PosArray))
	{
		return;
	}

	const int32 Count = FMath::Min(NameArray->Num(), PosArray->Num());
	for (int32 i = 0; i < Count; ++i)
	{
		const FName JointName(*(*NameArray)[i]->AsString());
		const double AngleRad = (*PosArray)[i]->AsNumber();

		TObjectPtr<USceneComponent>* JointComp = JointComponentMap.Find(JointName);
		if (JointComp && *JointComp)
		{
			const float AngleDeg = RosCoordConv::RosJointAngleToUeDegrees(AngleRad);

			FRotator BaseRot;
			if (JointName == FName("shoulder_pan"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159);
			else if (JointName == FName("shoulder_lift"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0);
			else if (JointName == FName("elbow_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708);
			else if (JointName == FName("wrist_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708);
			else if (JointName == FName("wrist_roll"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159);
			else if (JointName == FName("gripper"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0);

			const FQuat BaseQuat = BaseRot.Quaternion();
			const FQuat JointQuat = FQuat(FVector::UpVector, FMath::DegreesToRadians(AngleDeg));
			const FQuat FinalQuat = BaseQuat * JointQuat;

			(*JointComp)->SetRelativeRotation(FinalQuat.Rotator());
		}
	}
}

// =============================================================================
// Record / Replay / E-Stop — Topic setup
// =============================================================================

void ARobotVisualizer::SetupRecordReplayTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Advertise the command topic
	Ros->Advertise(TEXT("/robot_command"), TEXT("std_msgs/String"));

	// Subscribe to status feedback
	Ros->Subscribe(TEXT("/robot_status"), TEXT("std_msgs/String"));

	bRecordReplayTopicsSetup = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: Record/Replay topics set up."));
}

// =============================================================================
// Record / Replay / E-Stop — Command publisher helper
// =============================================================================

void ARobotVisualizer::PublishRobotCommand(const FString& JsonCmd)
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("PublishRobotCommand: not connected to rosbridge."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Red,
				TEXT("Robot: Not connected to rosbridge!"));
		}
		return;
	}

	// std_msgs/String: {"data": "<json_cmd>"}
	// The JSON command is nested inside the "data" field, with quotes escaped.
	FString EscapedCmd = JsonCmd.Replace(TEXT("\""), TEXT("\\\""));
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *EscapedCmd);
	Ros->Publish(TEXT("/robot_command"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("PublishRobotCommand: %s"), *JsonCmd);
}

// =============================================================================
// Record / Replay / E-Stop — Button handlers
// =============================================================================

void ARobotVisualizer::StartRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Robot: Recording started (teleop active)"));
	}
}

void ARobotVisualizer::StopRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Recording stopped, saving..."));
	}
}

void ARobotVisualizer::StartReplay()
{
	FString ArgsJson;
	if (ReplayFilename.IsEmpty())
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"loop\":%s,\"approach_speed\":%f}}"),
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	else
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"filename\":\"%s\",\"loop\":%s,\"approach_speed\":%f}}"),
			*ReplayFilename,
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	PublishRobotCommand(ArgsJson);

	if (GEngine)
	{
		FString DisplayName = ReplayFilename.IsEmpty() ? TEXT("(most recent)") : ReplayFilename;
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Cyan,
			FString::Printf(TEXT("Robot: Replaying %s (loop=%s, speed=%.0f°/s)"),
				*DisplayName, bReplayLoop ? TEXT("yes") : TEXT("no"), ApproachSpeed));
	}
}

void ARobotVisualizer::StopReplay()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_replay\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Replay stopped"));
	}
}

void ARobotVisualizer::EStop()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"estop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
			TEXT("*** E-STOP *** All motion halted"));
	}
}

// =============================================================================
// Teleop Sync — SyncOn / SyncOff
// =============================================================================

void ARobotVisualizer::SyncOn()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Sync ON: leader -> follower active"));
	}
}

void ARobotVisualizer::SyncOff()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Sync OFF: leader -> follower deactivated"));
	}
}

// =============================================================================
// Record / Replay — Status feedback handler
// =============================================================================

void ARobotVisualizer::OnRobotStatus(const FString& Topic, const FString& MessageJson)
{
	// /robot_status flows through bridge from worker — both are alive.
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;

	if (bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Bridge: Connection restored"));
		}
	}
	if (bWorkerDataLost)
	{
		bWorkerDataLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Worker: Connection restored"));
		}
	}

	// rosbridge wraps std_msgs/String as: {"data": "..."}
	TSharedPtr<FJsonObject> OuterJson;
	TSharedRef<TJsonReader<>> OuterReader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(OuterReader, OuterJson) || !OuterJson.IsValid())
	{
		return;
	}

	FString DataStr;
	if (!OuterJson->TryGetStringField(TEXT("data"), DataStr))
	{
		return;
	}

	// Parse the inner JSON status
	TSharedPtr<FJsonObject> StatusJson;
	TSharedRef<TJsonReader<>> StatusReader = TJsonReaderFactory<>::Create(DataStr);
	if (!FJsonSerializer::Deserialize(StatusReader, StatusJson) || !StatusJson.IsValid())
	{
		return;
	}

	FString State;
	if (StatusJson->TryGetStringField(TEXT("state"), State))
	{
		if (State != WorkerState)
		{
			WorkerState = State;
			UE_LOG(LogRosBridge, Log, TEXT("Worker state: %s"), *WorkerState);

			if (GEngine)
			{
				FColor Color = FColor::White;
				if (State == TEXT("recording")) Color = FColor::Green;
				else if (State == TEXT("replaying")) Color = FColor::Cyan;
				else if (State == TEXT("idle")) Color = FColor::Silver;

				GEngine->AddOnScreenDebugMessage(-1, 3.0f, Color,
					FString::Printf(TEXT("Robot state: %s"), *WorkerState));
			}
		}
	}

	// Update sync (teleop) status
	bool bTeleop = false;
	if (StatusJson->TryGetBoolField(TEXT("teleop"), bTeleop))
	{
		if (bTeleop != bSyncActive)
		{
			bSyncActive = bTeleop;
			UE_LOG(LogRosBridge, Log, TEXT("Sync (teleop): %s"),
				bSyncActive ? TEXT("ON") : TEXT("OFF"));
		}
	}

	// Log errors from commands
	FString Status;
	if (StatusJson->TryGetStringField(TEXT("status"), Status) && Status == TEXT("error"))
	{
		FString Reason;
		StatusJson->TryGetStringField(TEXT("reason"), Reason);
		UE_LOG(LogRosBridge, Warning, TEXT("Robot command error: %s"), *Reason);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
				FString::Printf(TEXT("Robot error: %s"), *Reason));
		}
	}

	// Log recording saved info
	FString Filename;
	if (StatusJson->TryGetStringField(TEXT("filename"), Filename) && !Filename.IsEmpty())
	{
		int32 Frames = 0;
		StatusJson->TryGetNumberField(TEXT("frames"), Frames);
		double Duration = 0.0;
		StatusJson->TryGetNumberField(TEXT("duration_sec"), Duration);

		UE_LOG(LogRosBridge, Log, TEXT("Recording saved: %s (%d frames, %.1fs)"),
			*Filename, Frames, Duration);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				FString::Printf(TEXT("Recording saved: %s (%d frames, %.1fs)"),
					*Filename, Frames, Duration));
		}
	}
}

수정 내역

 

  • BeginPlay: OnDisconnected 델리게이트 바인딩, /bridge_heartbeat 구독, 2초 주기 CheckConnectionHealth 타이머 시작
  • EndPlay: OnDisconnected 해제, ConnectionHealthTimerHandle 타이머 해제
  • OnRosBridgeConnected (수정): bRosBridgeConnected = true + 뷰포트에 초록색 "Connected" 메시지
  • OnRosBridgeDisconnected (신규): bRosBridgeConnected = false, 뷰포트에 빨간색 "DISCONNECTED" 경고 10초간 표시
  • CheckConnectionHealth (신규): bridge heartbeat 타임아웃 → 빨간색 "Bridge Node: DOWN", worker 데이터 타임아웃 → 주황색 "Worker: DOWN"
  • OnRosMessage (수정): /joint_states 수신 시 LastJointStatesTime + LastBridgeHeartbeatTime 갱신, /bridge_heartbeat 수신 시 LastBridgeHeartbeatTime 갱신, 복구 시 초록색 메시지
  • OnRobotStatus (수정): 수신 시 LastBridgeHeartbeatTime + LastJointStatesTime 갱신 + 복구 메시지

 

 

 

ros_bridge_node.py 수정

더보기
#!/usr/bin/env python3
"""
ros_bridge_node.py — ZMQ ↔ ROS2 bridge node

Runs in .venv-ros-bridge (Python 3.10) with rclpy + pyzmq.
Subscribes to joint states from lerobot_worker via ZMQ,
converts degrees→radians, and publishes sensor_msgs/JointState.
Also subscribes to /follower_joint_commands and forwards to worker via ZMQ REQ.

Relays record/replay/estop commands:
  - /robot_command (std_msgs/String) → ZMQ REQ → worker
  - Worker state published on /robot_status (std_msgs/String) every tick

Usage:
    export PATH=$(echo $PATH | tr ':' '\\n' | grep -v miniforge | tr '\\n' ':' | sed 's/:$//')
    cd ~/UnrealRobotics
    source .venv-ros-bridge/bin/activate
    source /opt/ros/humble/setup.bash
    python src/lerobot_ros2_bridge/lerobot_ros2_bridge/ros_bridge_node.py \\
        [--sub-addr tcp://127.0.0.1:5555] \\
        [--req-addr tcp://127.0.0.1:5556]
"""

import argparse
import json
import math
import sys
import traceback

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import String
import zmq


# ---------------------------------------------------------------------------
# Joint names — must match URDF, LeRobot calibration, and worker output
# (Phase 3.2 verified: all 6 names identical across all three sources)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]


def deg2rad(deg: float) -> float:
    return deg * math.pi / 180.0


def rad2deg(rad: float) -> float:
    return rad * 180.0 / math.pi


class RosBridgeNode(Node):
    def __init__(self, args):
        super().__init__("lerobot_ros2_bridge")
        self.get_logger().info("Initializing lerobot_ros2_bridge node")

        # --- ZMQ setup ---
        self.zmq_ctx = zmq.Context()

        # SUB: receive joint states from worker
        self.zmq_sub = self.zmq_ctx.socket(zmq.SUB)
        self.zmq_sub.connect(args.sub_addr)
        self.zmq_sub.setsockopt_string(zmq.SUBSCRIBE, "")
        # Don't block waiting for messages — use RCVTIMEO
        self.zmq_sub.setsockopt(zmq.RCVTIMEO, 0)
        self.get_logger().info(f"ZMQ SUB connected to {args.sub_addr}")

        # REQ: send commands to worker
        self.zmq_req = self.zmq_ctx.socket(zmq.REQ)
        self.zmq_req.connect(args.req_addr)
        self.zmq_req.setsockopt(zmq.RCVTIMEO, 1000)  # 1s timeout for replies
        self.get_logger().info(f"ZMQ REQ connected to {args.req_addr}")

        # --- ROS2 publishers ---
        # Follower joint states (for robot_state_publisher + rosbridge → Unreal)
        self.follower_pub = self.create_publisher(JointState, "/joint_states", 10)
        # Leader joint states (separate topic)
        self.leader_pub = self.create_publisher(JointState, "/leader_joint_states", 10)

        # --- ROS2 subscriber for commands from Unreal (via rosbridge) ---
        self.cmd_sub = self.create_subscription(
            JointState,
            "/follower_joint_commands",
            self.on_joint_command,
            10,
        )

        # --- Record/Replay/E-Stop command relay ---
        # Unreal publishes JSON command strings to /robot_command,
        # bridge relays them to worker via ZMQ REQ.
        self.robot_cmd_sub = self.create_subscription(
            String,
            "/robot_command",
            self.on_robot_command,
            10,
        )
        # Worker state feedback → Unreal
        self.robot_status_pub = self.create_publisher(String, "/robot_status", 10)
        self.last_worker_state = ""
        self.last_worker_teleop = None

        # Bridge heartbeat → Unreal (so Unreal can distinguish bridge vs worker death)
        self.bridge_heartbeat_pub = self.create_publisher(String, "/bridge_heartbeat", 10)

        # --- Timer: poll ZMQ at ~100Hz (faster than worker's 30Hz to avoid buffering) ---
        self.poll_timer = self.create_timer(0.01, self.poll_zmq)

        # --- Timer: heartbeat ping to worker every 2s ---
        self.heartbeat_timer = self.create_timer(2.0, self.send_heartbeat)

        # --- Timer: bridge heartbeat to Unreal every 1s ---
        self.bridge_heartbeat_timer = self.create_timer(1.0, self.publish_bridge_heartbeat)

        # Stats
        self.recv_count = 0
        self.last_log_time = self.get_clock().now()

    def poll_zmq(self):
        """Non-blocking poll of ZMQ SUB for worker messages."""
        # Drain all available messages (in case we're slightly behind)
        messages_this_tick = 0
        while messages_this_tick < 5:  # cap per tick to avoid starving ROS callbacks
            try:
                raw = self.zmq_sub.recv_string(zmq.NOBLOCK)
            except zmq.Again:
                break

            messages_this_tick += 1
            self.recv_count += 1

            try:
                msg = json.loads(raw)
            except json.JSONDecodeError as e:
                self.get_logger().warn(f"JSON parse error: {e}")
                continue

            now = self.get_clock().now().to_msg()

            # Publish follower joint states
            if "follower" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["follower"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                # velocity and effort left empty (not available from LeRobot API)
                self.follower_pub.publish(js)

            # Publish leader joint states
            if "leader" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["leader"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                self.leader_pub.publish(js)

            # Publish worker state to /robot_status (only on change)
            worker_state = msg.get("state", "idle")
            worker_teleop = msg.get("teleop", False)
            state_changed = (worker_state != self.last_worker_state or
                             worker_teleop != self.last_worker_teleop)
            if state_changed:
                status_msg = String()
                status_data = {"state": worker_state, "teleop": worker_teleop}
                # Include recording/replay metadata if present
                if "recording_frames" in msg:
                    status_data["recording_frames"] = msg["recording_frames"]
                if "replay_progress" in msg:
                    status_data["replay_progress"] = msg["replay_progress"]
                status_msg.data = json.dumps(status_data)
                self.robot_status_pub.publish(status_msg)
                self.last_worker_state = worker_state
                self.last_worker_teleop = worker_teleop
                self.get_logger().info(
                    f"Worker state: {worker_state}, teleop: {worker_teleop}")

        # Periodic logging (every 10 seconds)
        now_time = self.get_clock().now()
        if (now_time - self.last_log_time).nanoseconds > 10_000_000_000:
            self.get_logger().info(
                f"ZMQ recv total: {self.recv_count} messages"
            )
            self.last_log_time = now_time

    def on_joint_command(self, msg: JointState):
        """
        Receive joint commands from ROS2 (e.g. from Unreal via rosbridge),
        convert radians→degrees, and forward to worker via ZMQ REQ.
        """
        if len(msg.name) == 0 or len(msg.position) == 0:
            self.get_logger().warn("Empty joint command received, ignoring")
            return

        # Build command dict (degrees)
        joint_args = {}
        for name, pos_rad in zip(msg.name, msg.position):
            if name in JOINT_NAMES:
                joint_args[name] = rad2deg(pos_rad)

        if not joint_args:
            self.get_logger().warn("No recognized joint names in command")
            return

        cmd = {
            "cmd": "send_follower_action",
            "args": joint_args,
        }

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)
            if reply.get("status") != "ok":
                self.get_logger().warn(
                    f"Worker command error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error("Worker REQ timeout — is lerobot_worker running?")
        except Exception as e:
            self.get_logger().error(f"Worker command exception: {e}")

    def on_robot_command(self, msg: String):
        """
        Receive record/replay/estop commands from Unreal (via rosbridge),
        relay to worker via ZMQ REQ, and publish response on /robot_status.

        Expected msg.data formats:
            '{"cmd": "start_record"}'
            '{"cmd": "stop_record"}'
            '{"cmd": "start_replay", "args": {"filename": "...", "loop": false}}'
            '{"cmd": "start_replay"}'                    (plays most recent)
            '{"cmd": "stop_replay"}'
            '{"cmd": "estop"}'
            '{"cmd": "list_recordings"}'
        """
        try:
            cmd = json.loads(msg.data)
        except json.JSONDecodeError:
            # Support simple string commands: "estop", "start_record", etc.
            cmd = {"cmd": msg.data.strip()}

        cmd_name = cmd.get("cmd", "")
        self.get_logger().info(f"Robot command received: {cmd_name}")

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)

            # Publish response on /robot_status
            status_msg = String()
            status_msg.data = reply_raw
            self.robot_status_pub.publish(status_msg)

            status = reply.get("status", "unknown")
            if status == "ok":
                new_state = reply.get("state", "")
                if new_state:
                    self.last_worker_state = new_state
                self.get_logger().info(f"Command '{cmd_name}' OK: {reply}")
            else:
                self.get_logger().warn(
                    f"Command '{cmd_name}' error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error(
                f"Worker REQ timeout for '{cmd_name}' — is lerobot_worker running?"
            )
            # Publish timeout error on /robot_status
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": "worker timeout", "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)
        except Exception as e:
            self.get_logger().error(f"Robot command exception: {e}")
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": str(e), "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)

    def publish_bridge_heartbeat(self):
        """Publish a lightweight heartbeat so Unreal knows the bridge is alive."""
        msg = String()
        msg.data = '{"bridge":"alive"}'
        self.bridge_heartbeat_pub.publish(msg)

    def send_heartbeat(self):
        """Send periodic ping to worker so it knows the bridge is alive."""
        try:
            self.zmq_req.send_string('{"cmd":"ping"}')
            self.zmq_req.recv_string()  # discard reply, just keeping the link alive
        except zmq.Again:
            self.get_logger().warn("Heartbeat ping timeout — worker may be unresponsive")
        except Exception as e:
            self.get_logger().warn(f"Heartbeat ping error: {e}")

    def destroy_node(self):
        """Clean shutdown of ZMQ resources."""
        self.get_logger().info("Shutting down ZMQ...")
        self.zmq_sub.close()
        self.zmq_req.close()
        self.zmq_ctx.term()
        super().destroy_node()


def main():
    parser = argparse.ArgumentParser(description="ROS2 ↔ ZMQ bridge node")
    parser.add_argument("--sub-addr", default="tcp://127.0.0.1:5555",
                        help="ZMQ SUB address (worker PUB)")
    parser.add_argument("--req-addr", default="tcp://127.0.0.1:5556",
                        help="ZMQ REQ address (worker REP)")
    # ROS2 may pass extra args — use parse_known_args
    args, unknown = parser.parse_known_args()

    rclpy.init(args=unknown if unknown else None)
    node = RosBridgeNode(args)

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

수정 내역

 

  • bridge_heartbeat_pub - /bridge_heartbeat 토픽 publisher 추가
  • bridge_heartbeat_timer - 1초 주기 타이머 추가
  • publish_bridge_heartbeat() (신규) - {"bridge":"alive"} 메시지 매초 발행
  • send_heartbeat() (신규) - 2초마다 worker에 {"cmd":"ping"} 전송 (worker heartbeat timeout용)

 

 

 

수정했으면 언리얼 재빌드

 

 

판단 로직

  • /bridge_heartbeat가 안 오면 → bridge가 죽음 → "Bridge Node: DOWN"
  • /bridge_heartbeat는 오는데 /joint_states가 안 오면 → bridge는 살아있고 worker가 죽음 → "Worker: DOWN"
  • WebSocket 자체가 끊기면 → rosbridge가 죽음 → "ROS Bridge DISCONNECTED"

터미널3 WebSocket 끊겼을 때

 

터미널2 Bridge Node 끊겼을 때

 

터미널1 Worker 끊겼을 때

 

 


 

 

2-2단계: 테스트 후 보완점2 - worker 재시작시 bridge_node 소켓 재생성

 

 

여기서 또 문제가 되는 부분은 터미널 1이 끊긴 후 다시 연결하면 터미널 2도 끊고 다시 연결해야 한다는 점.

이건 ZMQ의 REQ/REP 패턴 때문이다.

bridge_node의 REQ 소켓이 worker의 REP 소켓에 연결되어 있는데, worker가 죽으면 TCP 연결이 끊어진다. Worker가 다시 시작되면 새로운 REP 소켓이 새 포트에서 bind하지만, bridge_node의 REQ 소켓은 이전 연결이 "보내고 응답 대기 중" 상태로 stuck되고 있는 상황. REQ/REP는 strict한 send-recv-send-recv 순서를 강제하기 때문에, 한 번 꼬이면 복구가 되지 않는다...

 

 

bridge_node가 worker 타임아웃을 감지하면 REQ 소켓을 닫고 새로 만드는 로직으로 수정해본다.

 

 

ros_bridge_node.py 수정

더보기
#!/usr/bin/env python3
"""
ros_bridge_node.py — ZMQ ↔ ROS2 bridge node

Runs in .venv-ros-bridge (Python 3.10) with rclpy + pyzmq.
Subscribes to joint states from lerobot_worker via ZMQ,
converts degrees→radians, and publishes sensor_msgs/JointState.
Also subscribes to /follower_joint_commands and forwards to worker via ZMQ REQ.

Relays record/replay/estop commands:
  - /robot_command (std_msgs/String) → ZMQ REQ → worker
  - Worker state published on /robot_status (std_msgs/String) every tick

Usage:
    export PATH=$(echo $PATH | tr ':' '\\n' | grep -v miniforge | tr '\\n' ':' | sed 's/:$//')
    cd ~/UnrealRobotics
    source .venv-ros-bridge/bin/activate
    source /opt/ros/humble/setup.bash
    python src/lerobot_ros2_bridge/lerobot_ros2_bridge/ros_bridge_node.py \\
        [--sub-addr tcp://127.0.0.1:5555] \\
        [--req-addr tcp://127.0.0.1:5556]
"""

import argparse
import json
import math
import sys
import traceback

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import String
import zmq


# ---------------------------------------------------------------------------
# Joint names — must match URDF, LeRobot calibration, and worker output
# (Phase 3.2 verified: all 6 names identical across all three sources)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]


def deg2rad(deg: float) -> float:
    return deg * math.pi / 180.0


def rad2deg(rad: float) -> float:
    return rad * 180.0 / math.pi


class RosBridgeNode(Node):
    def __init__(self, args):
        super().__init__("lerobot_ros2_bridge")
        self.get_logger().info("Initializing lerobot_ros2_bridge node")

        # --- ZMQ setup ---
        self.zmq_ctx = zmq.Context()

        # SUB: receive joint states from worker
        self.zmq_sub = self.zmq_ctx.socket(zmq.SUB)
        self.zmq_sub.connect(args.sub_addr)
        self.zmq_sub.setsockopt_string(zmq.SUBSCRIBE, "")
        # Don't block waiting for messages — use RCVTIMEO
        self.zmq_sub.setsockopt(zmq.RCVTIMEO, 0)
        self.get_logger().info(f"ZMQ SUB connected to {args.sub_addr}")

        # REQ: send commands to worker
        self.req_addr = args.req_addr
        self.zmq_req = self._create_req_socket()
        self.req_consecutive_timeouts = 0
        self.req_max_timeouts = 3  # recreate socket after this many consecutive timeouts

        # --- ROS2 publishers ---
        # Follower joint states (for robot_state_publisher + rosbridge → Unreal)
        self.follower_pub = self.create_publisher(JointState, "/joint_states", 10)
        # Leader joint states (separate topic)
        self.leader_pub = self.create_publisher(JointState, "/leader_joint_states", 10)

        # --- ROS2 subscriber for commands from Unreal (via rosbridge) ---
        self.cmd_sub = self.create_subscription(
            JointState,
            "/follower_joint_commands",
            self.on_joint_command,
            10,
        )

        # --- Record/Replay/E-Stop command relay ---
        # Unreal publishes JSON command strings to /robot_command,
        # bridge relays them to worker via ZMQ REQ.
        self.robot_cmd_sub = self.create_subscription(
            String,
            "/robot_command",
            self.on_robot_command,
            10,
        )
        # Worker state feedback → Unreal
        self.robot_status_pub = self.create_publisher(String, "/robot_status", 10)
        self.last_worker_state = ""
        self.last_worker_teleop = None

        # Bridge heartbeat → Unreal (so Unreal can distinguish bridge vs worker death)
        self.bridge_heartbeat_pub = self.create_publisher(String, "/bridge_heartbeat", 10)

        # --- Timer: poll ZMQ at ~100Hz (faster than worker's 30Hz to avoid buffering) ---
        self.poll_timer = self.create_timer(0.01, self.poll_zmq)

        # --- Timer: heartbeat ping to worker every 2s ---
        self.heartbeat_timer = self.create_timer(2.0, self.send_heartbeat)

        # --- Timer: bridge heartbeat to Unreal every 1s ---
        self.bridge_heartbeat_timer = self.create_timer(1.0, self.publish_bridge_heartbeat)

        # Stats
        self.recv_count = 0
        self.last_log_time = self.get_clock().now()

    def poll_zmq(self):
        """Non-blocking poll of ZMQ SUB for worker messages."""
        # Drain all available messages (in case we're slightly behind)
        messages_this_tick = 0
        while messages_this_tick < 5:  # cap per tick to avoid starving ROS callbacks
            try:
                raw = self.zmq_sub.recv_string(zmq.NOBLOCK)
            except zmq.Again:
                break

            messages_this_tick += 1
            self.recv_count += 1

            try:
                msg = json.loads(raw)
            except json.JSONDecodeError as e:
                self.get_logger().warn(f"JSON parse error: {e}")
                continue

            now = self.get_clock().now().to_msg()

            # Publish follower joint states
            if "follower" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["follower"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                # velocity and effort left empty (not available from LeRobot API)
                self.follower_pub.publish(js)

            # Publish leader joint states
            if "leader" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["leader"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                self.leader_pub.publish(js)

            # Publish worker state to /robot_status (only on change)
            worker_state = msg.get("state", "idle")
            worker_teleop = msg.get("teleop", False)
            state_changed = (worker_state != self.last_worker_state or
                             worker_teleop != self.last_worker_teleop)
            if state_changed:
                status_msg = String()
                status_data = {"state": worker_state, "teleop": worker_teleop}
                # Include recording/replay metadata if present
                if "recording_frames" in msg:
                    status_data["recording_frames"] = msg["recording_frames"]
                if "replay_progress" in msg:
                    status_data["replay_progress"] = msg["replay_progress"]
                status_msg.data = json.dumps(status_data)
                self.robot_status_pub.publish(status_msg)
                self.last_worker_state = worker_state
                self.last_worker_teleop = worker_teleop
                self.get_logger().info(
                    f"Worker state: {worker_state}, teleop: {worker_teleop}")

        # Periodic logging (every 10 seconds)
        now_time = self.get_clock().now()
        if (now_time - self.last_log_time).nanoseconds > 10_000_000_000:
            self.get_logger().info(
                f"ZMQ recv total: {self.recv_count} messages"
            )
            self.last_log_time = now_time

    def on_joint_command(self, msg: JointState):
        """
        Receive joint commands from ROS2 (e.g. from Unreal via rosbridge),
        convert radians→degrees, and forward to worker via ZMQ REQ.
        """
        if len(msg.name) == 0 or len(msg.position) == 0:
            self.get_logger().warn("Empty joint command received, ignoring")
            return

        # Build command dict (degrees)
        joint_args = {}
        for name, pos_rad in zip(msg.name, msg.position):
            if name in JOINT_NAMES:
                joint_args[name] = rad2deg(pos_rad)

        if not joint_args:
            self.get_logger().warn("No recognized joint names in command")
            return

        cmd = {
            "cmd": "send_follower_action",
            "args": joint_args,
        }

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)
            if reply.get("status") != "ok":
                self.get_logger().warn(
                    f"Worker command error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error("Worker REQ timeout — is lerobot_worker running?")
        except Exception as e:
            self.get_logger().error(f"Worker command exception: {e}")

    def on_robot_command(self, msg: String):
        """
        Receive record/replay/estop commands from Unreal (via rosbridge),
        relay to worker via ZMQ REQ, and publish response on /robot_status.

        Expected msg.data formats:
            '{"cmd": "start_record"}'
            '{"cmd": "stop_record"}'
            '{"cmd": "start_replay", "args": {"filename": "...", "loop": false}}'
            '{"cmd": "start_replay"}'                    (plays most recent)
            '{"cmd": "stop_replay"}'
            '{"cmd": "estop"}'
            '{"cmd": "list_recordings"}'
        """
        try:
            cmd = json.loads(msg.data)
        except json.JSONDecodeError:
            # Support simple string commands: "estop", "start_record", etc.
            cmd = {"cmd": msg.data.strip()}

        cmd_name = cmd.get("cmd", "")
        self.get_logger().info(f"Robot command received: {cmd_name}")

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)

            # Publish response on /robot_status
            status_msg = String()
            status_msg.data = reply_raw
            self.robot_status_pub.publish(status_msg)

            status = reply.get("status", "unknown")
            if status == "ok":
                new_state = reply.get("state", "")
                if new_state:
                    self.last_worker_state = new_state
                self.get_logger().info(f"Command '{cmd_name}' OK: {reply}")
            else:
                self.get_logger().warn(
                    f"Command '{cmd_name}' error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error(
                f"Worker REQ timeout for '{cmd_name}' — is lerobot_worker running?"
            )
            # Publish timeout error on /robot_status
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": "worker timeout", "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)
        except Exception as e:
            self.get_logger().error(f"Robot command exception: {e}")
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": str(e), "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)

    def _create_req_socket(self):
        """Create a fresh ZMQ REQ socket connected to the worker."""
        sock = self.zmq_ctx.socket(zmq.REQ)
        sock.setsockopt(zmq.RCVTIMEO, 1000)  # 1s timeout for replies
        sock.setsockopt(zmq.LINGER, 0)        # don't block on close
        sock.connect(self.req_addr)
        self.get_logger().info(f"ZMQ REQ connected to {self.req_addr}")
        return sock

    def _reset_req_socket(self):
        """Close and recreate the REQ socket to recover from stuck state."""
        self.get_logger().warn("Resetting ZMQ REQ socket (worker may have restarted)")
        try:
            self.zmq_req.close()
        except Exception:
            pass
        self.zmq_req = self._create_req_socket()
        self.req_consecutive_timeouts = 0

    def publish_bridge_heartbeat(self):
        """Publish a lightweight heartbeat so Unreal knows the bridge is alive."""
        msg = String()
        msg.data = '{"bridge":"alive"}'
        self.bridge_heartbeat_pub.publish(msg)

    def send_heartbeat(self):
        """Send periodic ping to worker so it knows the bridge is alive."""
        try:
            self.zmq_req.send_string('{"cmd":"ping"}')
            self.zmq_req.recv_string()  # discard reply, just keeping the link alive
            self.req_consecutive_timeouts = 0  # reset on success
        except zmq.Again:
            self.req_consecutive_timeouts += 1
            self.get_logger().warn(
                f"Heartbeat ping timeout ({self.req_consecutive_timeouts}/"
                f"{self.req_max_timeouts})")
            if self.req_consecutive_timeouts >= self.req_max_timeouts:
                self._reset_req_socket()
        except Exception as e:
            self.get_logger().warn(f"Heartbeat ping error: {e}")
            self.req_consecutive_timeouts += 1
            if self.req_consecutive_timeouts >= self.req_max_timeouts:
                self._reset_req_socket()

    def destroy_node(self):
        """Clean shutdown of ZMQ resources."""
        self.get_logger().info("Shutting down ZMQ...")
        self.zmq_sub.close()
        self.zmq_req.close()
        self.zmq_ctx.term()
        super().destroy_node()


def main():
    parser = argparse.ArgumentParser(description="ROS2 ↔ ZMQ bridge node")
    parser.add_argument("--sub-addr", default="tcp://127.0.0.1:5555",
                        help="ZMQ SUB address (worker PUB)")
    parser.add_argument("--req-addr", default="tcp://127.0.0.1:5556",
                        help="ZMQ REQ address (worker REP)")
    # ROS2 may pass extra args — use parse_known_args
    args, unknown = parser.parse_known_args()

    rclpy.init(args=unknown if unknown else None)
    node = RosBridgeNode(args)

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

수정 내역

 

  • req_addr - REQ 주소 저장 (소켓 재생성 시 필요)
  • req_consecutive_timeouts - 연속 타임아웃 카운터
  • req_max_timeouts = 3 - 3회 연속 타임아웃 시 소켓 리셋
  • _create_req_socket() (신규) - REQ 소켓 생성 헬퍼. LINGER=0 설정으로 close 시 블로킹 방지
  • _reset_req_socket() (신규) - 기존 소켓 닫고 새로 생성. worker 재시작 후 stuck 상태 복구
  • send_heartbeat (수정) - 성공 시 카운터 리셋, 타임아웃 시 카운터 증가 → 3회 초과하면 소켓 리셋

 

 

 

worker 재시작 시 동작 흐름

Worker 죽음
    → bridge heartbeat ping 타임아웃 (1/3, 2/3, 3/3)
    → 3회 연속 타임아웃
    → REQ 소켓 리셋
Worker 다시 시작
    → 새 REQ 소켓이 새 worker에 연결
    → ping 성공
    → bridge_node 재시작 불필요

 

잘 동작하는 것을 확인했다.

 

 


 

 

2-3단계: 테스트 후 보완점3 - 리더암 USB 강제 제거시 에러 뷰포트 전파

lerobot_worker.py 수정

더보기
#!/usr/bin/env python3
"""
lerobot_worker.py — LeRobot ↔ ZMQ bridge worker

Runs in conda env 'lerobot' (Python 3.12).
Reads joint states from SO-ARM-101 via LeRobot API at ~30Hz,
publishes them over ZMQ PUB, and accepts commands via ZMQ REP.

Supports record/replay of joint trajectories via ZMQ commands:
  - start_teleop: activate leader→follower sync (teleop)
  - stop_teleop:  deactivate teleop sync
  - start_record: begin teleop + record joint frames
  - stop_record:  stop recording, save to ~/recordings/ (teleop stays on)
  - start_replay: replay a saved recording on the follower arm (teleop off)
  - stop_replay:  stop replay immediately
  - estop:        emergency stop — abort all motion immediately
  - list_recordings: list saved recording files

Usage:
    conda activate lerobot
    sudo chmod 666 /dev/ttyACM*
    python scripts/lerobot_worker.py [--follower-port /dev/ttyACM0] [--leader-port /dev/ttyACM1]
                                      [--pub-addr tcp://127.0.0.1:5555]
                                      [--rep-addr tcp://127.0.0.1:5556]
                                      [--rate 30]
                                      [--follower-only | --leader-only]
                                      [--teleop]
                                      [--recordings-dir ~/recordings]
"""

import argparse
import json
import math
import os
import pathlib
import signal
import sys
import time
import traceback
from datetime import datetime

import zmq


# ---------------------------------------------------------------------------
# Joint name ordering (matches URDF & LeRobot calibration — Phase 3.2 verified)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]

# ---------------------------------------------------------------------------
# Joint limits in LeRobot DEGREES (empirically measured via calibration).
# These are absolute physical limits — commands are never allowed beyond these.
# Measured by reading get_observation() degrees at calibration range_min/max
# raw encoder positions, using two-point linear interpolation.
# NOTE: These are in LeRobot's coordinate frame (0° = calibration midpoint),
#       NOT URDF coordinates (which use a different zero point).
# ---------------------------------------------------------------------------
JOINT_LIMITS_DEG = {
    "shoulder_pan":  (-119.91, +119.91),
    "shoulder_lift": (-104.62, +104.62),
    "elbow_flex":    ( -97.01,  +97.01),
    "wrist_flex":    (-102.68, +102.68),
    "wrist_roll":    (-180.00, +180.00),
    "gripper":       (  +0.14,  +99.45),
}


def build_args():
    p = argparse.ArgumentParser(description="LeRobot ZMQ worker for SO-ARM-101")
    p.add_argument("--follower-port", default="/dev/ttyACM0",
                    help="Serial port for follower arm")
    p.add_argument("--leader-port", default="/dev/ttyACM1",
                    help="Serial port for leader arm")
    p.add_argument("--follower-id", default="so101_twin_follower",
                    help="Calibration ID for follower")
    p.add_argument("--leader-id", default="so101_twin_leader",
                    help="Calibration ID for leader")
    p.add_argument("--pub-addr", default="tcp://127.0.0.1:5555",
                    help="ZMQ PUB bind address for joint states")
    p.add_argument("--rep-addr", default="tcp://127.0.0.1:5556",
                    help="ZMQ REP bind address for commands")
    p.add_argument("--rate", type=float, default=30.0,
                    help="Read loop frequency in Hz")
    p.add_argument("--follower-only", action="store_true",
                    help="Only connect follower (no leader)")
    p.add_argument("--leader-only", action="store_true",
                    help="Only connect leader (no follower)")
    p.add_argument("--teleop", action="store_true",
                    help="Teleoperation mode: leader arm controls follower arm")
    p.add_argument("--cmd-limit", type=float, default=5.0,
                    help="Safety clamp: max degrees from initial position per joint (default 5.0)")
    p.add_argument("--cmd-joints", default="shoulder_pan",
                    help="Comma-separated joint names that accept commands "
                         "(default: shoulder_pan). Use 'all' for all joints.")
    p.add_argument("--recordings-dir", default="~/recordings",
                    help="Directory to save/load recorded trajectories")
    p.add_argument("--approach-speed", type=float, default=45.0,
                    help="Max degrees/sec for approach moves before replay "
                         "and loop transitions (default: 45.0)")
    return p.parse_args()


class LeRobotWorker:
    def __init__(self, args):
        self.args = args
        self.running = True
        self.follower = None
        self.leader = None
        self.teleop = args.teleop

        # Safety clamp: initial follower position recorded at startup,
        # commands are clamped to ±cmd_limit degrees from this baseline.
        # Only joints listed in allowed_cmd_joints accept commands;
        # others are silently ignored (held at current position).
        self.follower_initial_pos = None       # dict: {joint_name: degrees}
        self.cmd_limit_deg = args.cmd_limit    # default 5.0°
        self.allowed_cmd_joints = (
            set(JOINT_NAMES) if args.cmd_joints.strip().lower() == "all"
            else set(j.strip() for j in args.cmd_joints.split(",") if j.strip())
        )

        # ---------------------------------------------------------------------------
        # Record / Replay state
        # ---------------------------------------------------------------------------
        # state: "idle", "recording", "replaying"
        self.state = "idle"

        # Recording buffer: list of {"ts": float, "joints": {name: deg, ...}}
        self.recording_buffer = []
        self.recording_start_time = None

        # Replay state
        self.replay_frames = []        # loaded frames from file
        self.replay_index = 0          # current frame index
        self.replay_start_time = None  # monotonic time when replay started
        self.replay_loop = False       # whether to loop replay
        self.replay_filename = ""      # currently playing file

        # Approach (smooth transition) state
        # "approaching" phase runs before replay and between loop iterations
        self.approaching = False         # whether we're in approach phase
        self.approach_start_pos = {}     # joint positions at start of approach
        self.approach_target_pos = {}    # target joint positions (first frame)
        self.approach_start_time = None  # monotonic time when approach started
        self.approach_duration = 0.0     # calculated duration in seconds
        self.approach_speed = args.approach_speed  # degrees per second

        # Recordings directory
        self.recordings_dir = pathlib.Path(os.path.expanduser(args.recordings_dir))
        self.recordings_dir.mkdir(parents=True, exist_ok=True)
        print(f"[worker] Recordings dir: {self.recordings_dir}")

        # ZMQ setup
        self.ctx = zmq.Context()

        self.pub = self.ctx.socket(zmq.PUB)
        self.pub.bind(args.pub_addr)
        print(f"[worker] PUB bound on {args.pub_addr}")

        self.rep = self.ctx.socket(zmq.REP)
        self.rep.bind(args.rep_addr)
        print(f"[worker] REP bound on {args.rep_addr}")

        # Poller for non-blocking REP check inside the read loop
        self.poller = zmq.Poller()
        self.poller.register(self.rep, zmq.POLLIN)

        # Heartbeat: track last command received from bridge node.
        # If no command arrives for heartbeat_timeout_sec, auto E-stop.
        # Initialized to current time so we don't trigger immediately at startup.
        self.last_cmd_time = time.monotonic()
        self.heartbeat_timeout_sec = 5.0
        self.heartbeat_triggered = False  # avoid spamming E-stop logs

        # Serial error tracking: consecutive read/write failures per device.
        # After error_threshold consecutive failures, report in PUB message.
        self.follower_error_count = 0
        self.leader_error_count = 0
        self.follower_error_msg = ""
        self.leader_error_msg = ""
        self.error_threshold = 5  # consecutive failures before reporting

    def connect_robots(self):
        """Connect to follower and/or leader via LeRobot API."""
        if not self.args.leader_only:
            print(f"[worker] Connecting follower on {self.args.follower_port} "
                  f"(id={self.args.follower_id}) ...")
            from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
            cfg = SO101FollowerConfig(
                port=self.args.follower_port,
                id=self.args.follower_id,
            )
            self.follower = SO101Follower(cfg)
            self.follower.connect()
            print("[worker] Follower connected.")

        if not self.args.follower_only:
            print(f"[worker] Connecting leader on {self.args.leader_port} "
                  f"(id={self.args.leader_id}) ...")
            from lerobot.teleoperators.so_leader import SOLeader, SO101LeaderConfig
            cfg = SO101LeaderConfig(
                port=self.args.leader_port,
                id=self.args.leader_id,
            )
            self.leader = SOLeader(cfg)
            self.leader.connect()
            print("[worker] Leader connected.")

        # Validate teleop requirements
        if self.teleop:
            if self.follower is None or self.leader is None:
                print("[worker] ERROR: --teleop requires both follower and leader connected.")
                print("[worker] Cannot use --teleop with --follower-only or --leader-only.")
                sys.exit(1)
            print("[worker] *** TELEOP MODE ENABLED ***")
            print("[worker] Leader arm will control follower arm in real-time.")
            print("[worker] Move leader arm to starting position, then press Enter to begin...")
            input()
            print("[worker] Teleop active!")

        # Record initial follower position for safety clamping
        if self.follower is not None:
            init_pos = self.read_follower()

            # Load calibration JSON to display physical range info
            calib_range = self._load_calibration_range("follower")

            if init_pos is not None:
                self.follower_initial_pos = dict(init_pos)

                # Print calibration range info
                if calib_range:
                    print("[worker] === CALIBRATION RANGE (raw encoder values) ===")
                    for name in JOINT_NAMES:
                        if name in calib_range:
                            rmin, rmax, cur_raw = calib_range[name]
                            total = rmax - rmin
                            if cur_raw is not None and total > 0:
                                pct = (cur_raw - rmin) / total * 100.0
                                print(f"  {name:20s}: raw {cur_raw:4d}  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]  "
                                      f"({pct:5.1f}% of range)")
                            else:
                                print(f"  {name:20s}: raw  ???  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]")

                print("[worker] === SAFETY CLAMP BASELINE (follower initial position) ===")
                for name in JOINT_NAMES:
                    v = init_pos.get(name, 0.0)
                    soft_lo = v - self.cmd_limit_deg
                    soft_hi = v + self.cmd_limit_deg
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)
                    marker = " <-- CMD OK" if name in self.allowed_cmd_joints else ""
                    print(f"  {name:20s}: {v:+8.2f}°  "
                          f"effective: [{eff_lo:+8.2f}° ~ {eff_hi:+8.2f}°]  "
                          f"(physical: [{hard_lo:+8.2f}°~{hard_hi:+8.2f}°]){marker}")
                print(f"[worker] Allowed command joints: {sorted(self.allowed_cmd_joints)}")
                print(f"[worker] Clamp limit: ±{self.cmd_limit_deg}° from baseline")
            else:
                print("[worker] WARNING: Could not read initial follower position. "
                      "Commands will be REJECTED until baseline is set.")

    def _load_calibration_range(self, arm_type="follower"):
        """
        Load calibration JSON and read current raw positions.
        Returns dict: {joint_name: (range_min, range_max, current_raw)} or None.
        """
        if arm_type == "follower":
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/robots/so_follower"
                / f"{self.args.follower_id}.json"
            )
            robot = self.follower
        else:
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/teleoperators/so_leader"
                / f"{self.args.leader_id}.json"
            )
            robot = self.leader

        try:
            with open(calib_path) as f:
                calib = json.load(f)
            print(f"[worker] Loaded calibration from {calib_path}")

            # Read current raw positions (without normalization)
            current_raw = {}
            if robot is not None:
                try:
                    raw = robot.bus.sync_read("Present_Position", normalize=False)
                    current_raw = dict(raw)
                except Exception as e:
                    print(f"[worker] Could not read raw positions: {e}")

            result = {}
            for name in JOINT_NAMES:
                if name in calib:
                    rmin = calib[name].get("range_min", 0)
                    rmax = calib[name].get("range_max", 4095)
                    cur = current_raw.get(name, None)
                    result[name] = (rmin, rmax, cur)

            return result

        except FileNotFoundError:
            print(f"[worker] Calibration file not found: {calib_path}")
            return None
        except Exception as e:
            print(f"[worker] Error loading calibration: {e}")
            return None

    def read_follower(self):
        """Read follower joint positions in degrees. Returns dict or None."""
        if self.follower is None:
            return None
        try:
            obs = self.follower.get_observation()
            self.follower_error_count = 0
            self.follower_error_msg = ""
            return {name: float(obs[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            self.follower_error_count += 1
            self.follower_error_msg = str(e)
            if self.follower_error_count <= self.error_threshold:
                print(f"[worker] Follower read error: {e}")
            elif self.follower_error_count == self.error_threshold + 1:
                print(f"[worker] Follower read errors continuing, suppressing logs...")
            return None

    def read_leader(self):
        """Read leader joint positions in degrees. Returns dict or None."""
        if self.leader is None:
            return None
        try:
            action = self.leader.get_action()
            self.leader_error_count = 0
            self.leader_error_msg = ""
            return {name: float(action[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            self.leader_error_count += 1
            self.leader_error_msg = str(e)
            if self.leader_error_count <= self.error_threshold:
                print(f"[worker] Leader read error: {e}")
            elif self.leader_error_count == self.error_threshold + 1:
                print(f"[worker] Leader read errors continuing, suppressing logs...")
            return None

    def send_follower_action(self, joint_positions_deg):
        """
        Send joint positions (degrees) to follower via LeRobot send_action().
        joint_positions_deg: dict like {"shoulder_pan": 10.0, ...}
        """
        if self.follower is None:
            return
        try:
            action_dict = {f"{name}.pos": float(joint_positions_deg[name])
                           for name in JOINT_NAMES if name in joint_positions_deg}
            self.follower.send_action(action_dict)
        except Exception as e:
            self.follower_error_count += 1
            self.follower_error_msg = str(e)
            if self.follower_error_count <= self.error_threshold:
                print(f"[worker] Follower send_action error: {e}")
            elif self.follower_error_count == self.error_threshold + 1:
                print(f"[worker] Follower send errors continuing, suppressing logs...")

    def handle_command(self, msg):
        """
        Process a command from the ROS bridge node.
        Expected format:
            {"cmd": "send_follower_action", "args": {"shoulder_pan": 0.0, ...}}
            {"cmd": "ping"}
            {"cmd": "start_teleop"}
            {"cmd": "stop_teleop"}
            {"cmd": "start_record"}
            {"cmd": "stop_record"}
            {"cmd": "start_replay", "args": {"filename": "...", "loop": false}}
            {"cmd": "stop_replay"}
            {"cmd": "estop"}
            {"cmd": "list_recordings"}
        Returns a response dict.
        """
        cmd = msg.get("cmd", "")

        if cmd == "ping":
            return {"status": "ok", "state": self.state,
                    "teleop": self.teleop, "ts": time.time()}

        elif cmd == "estop":
            return self._handle_estop()

        elif cmd == "start_teleop":
            return self._handle_start_teleop()

        elif cmd == "stop_teleop":
            return self._handle_stop_teleop()

        elif cmd == "start_record":
            return self._handle_start_record()

        elif cmd == "stop_record":
            return self._handle_stop_record()

        elif cmd == "start_replay":
            args = msg.get("args", {})
            return self._handle_start_replay(args)

        elif cmd == "stop_replay":
            return self._handle_stop_replay()

        elif cmd == "list_recordings":
            return self._handle_list_recordings()

        elif cmd == "send_follower_action":
            if self.follower is None:
                return {"status": "error", "reason": "follower not connected"}
            if self.follower_initial_pos is None:
                return {"status": "error", "reason": "safety baseline not set (no initial position)"}
            joint_args = msg.get("args", {})
            if not joint_args:
                return {"status": "error", "reason": "empty args"}
            try:
                # Two-layer safety clamp:
                #   Layer 1: baseline ± cmd_limit (session safety)
                #   Layer 2: JOINT_LIMITS_DEG (absolute physical limits, measured empirically)
                clamped = {}
                rejected = []
                for name, target_deg in joint_args.items():
                    if name not in JOINT_NAMES:
                        continue
                    if name not in self.allowed_cmd_joints:
                        rejected.append(name)
                        continue

                    target = float(target_deg)

                    # Layer 1: baseline ± cmd_limit
                    baseline = self.follower_initial_pos.get(name, 0.0)
                    soft_lo = baseline - self.cmd_limit_deg
                    soft_hi = baseline + self.cmd_limit_deg

                    # Layer 2: absolute physical limits
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))

                    # Effective range = intersection of both
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)

                    clamped_val = max(eff_lo, min(eff_hi, target))
                    clamped[name] = clamped_val

                    if abs(clamped_val - target) > 0.01:
                        reason = ""
                        if target < hard_lo or target > hard_hi:
                            reason = " [PHYSICAL LIMIT]"
                        else:
                            reason = " [BASELINE LIMIT]"
                        print(f"[worker] CLAMP {name}: "
                              f"requested {target:.2f}° → clamped to {clamped_val:.2f}° "
                              f"(effective: {eff_lo:.2f}° ~ {eff_hi:.2f}°){reason}")

                if not clamped:
                    return {"status": "error",
                            "reason": f"no allowed joints in command "
                                      f"(rejected: {rejected}, allowed: {sorted(self.allowed_cmd_joints)})"}

                action_dict = {f"{name}.pos": val for name, val in clamped.items()}
                self.follower.send_action(action_dict)
                resp = {"status": "ok", "clamped": clamped}
                if rejected:
                    resp["rejected_joints"] = rejected
                return resp
            except Exception as e:
                return {"status": "error", "reason": str(e)}

        else:
            return {"status": "error", "reason": f"unknown cmd: {cmd}"}

    # -----------------------------------------------------------------------
    # Teleop (Sync) handlers
    # -----------------------------------------------------------------------

    def _handle_start_teleop(self):
        """Activate leader→follower sync (teleop) with smooth approach."""
        if self.state == "replaying":
            return {"status": "error",
                    "reason": "cannot start teleop during replay — stop replay first"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "teleop requires both follower and leader connected"}
        if self.teleop:
            return {"status": "ok", "state": self.state, "teleop": True,
                    "msg": "teleop already active"}
        if self.state == "syncing":
            return {"status": "ok", "state": self.state, "teleop": False,
                    "msg": "already approaching leader position"}

        # Read leader's current position as approach target
        leader_pos = self.read_leader()
        if leader_pos is None:
            # Can't read leader — just turn on teleop directly
            self.teleop = True
            print("[worker] Teleop (sync) ON — leader → follower (no approach)")
            return {"status": "ok", "state": self.state, "teleop": True}

        # Start approach to leader position, then activate teleop
        self.state = "syncing"
        self._start_approach(leader_pos)
        if not self.approaching:
            # Already close enough — activate teleop immediately
            self.state = "idle"
            self.teleop = True
            print("[worker] Teleop (sync) ON — already aligned")
            return {"status": "ok", "state": self.state, "teleop": True}

        print("[worker] Syncing: approaching leader position before teleop ON")
        return {"status": "ok", "state": "syncing", "teleop": False,
                "approach_sec": round(self.approach_duration, 2)}

    def _handle_stop_teleop(self):
        """Deactivate leader→follower sync (teleop)."""
        if self.state == "recording":
            return {"status": "error",
                    "reason": "cannot stop teleop during recording — stop recording first"}
        # Cancel syncing approach if in progress
        if self.state == "syncing":
            self.approaching = False
            self.state = "idle"
            print("[worker] Syncing cancelled")
        self.teleop = False
        print("[worker] Teleop (sync) OFF")
        return {"status": "ok", "state": self.state, "teleop": False}

    # -----------------------------------------------------------------------
    # Record / Replay / E-Stop handlers
    # -----------------------------------------------------------------------

    def _handle_estop(self):
        """Emergency stop: abort any ongoing motion, return to idle."""
        prev_state = self.state
        prev_teleop = self.teleop
        self.state = "idle"
        self.teleop = False
        self.recording_buffer = []
        self.replay_frames = []
        self.replay_index = 0
        self.approaching = False
        print(f"[worker] *** E-STOP *** (was: state={prev_state}, teleop={prev_teleop})")
        return {"status": "ok", "state": "idle", "teleop": False,
                "previous_state": prev_state}

    def _handle_start_record(self):
        """Start recording: teleop must already be active (Sync On first)."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start recording in state '{self.state}'"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "recording requires both follower and leader connected"}
        if not self.teleop:
            return {"status": "error",
                    "reason": "teleop (sync) must be active before recording — "
                              "press Sync On first"}

        self.recording_buffer = []
        self.recording_start_time = time.monotonic()
        self.state = "recording"
        print("[worker] *** RECORDING STARTED *** (teleop already active)")
        return {"status": "ok", "state": "recording", "teleop": True}

    def _handle_stop_record(self):
        """Stop recording: save buffer to file. Teleop stays ON."""
        if self.state != "recording":
            return {"status": "error",
                    "reason": f"not recording (state: '{self.state}')"}

        num_frames = len(self.recording_buffer)
        if num_frames == 0:
            self.state = "idle"
            # Teleop stays ON — user controls it via Sync button
            print("[worker] Recording stopped — 0 frames, nothing saved.")
            return {"status": "ok", "state": "idle", "teleop": self.teleop,
                    "frames": 0, "filename": None}

        # Calculate duration from frame timestamps
        duration = self.recording_buffer[-1]["ts"] - self.recording_buffer[0]["ts"]

        # Build recording file
        timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"recording_{timestamp_str}.json"
        filepath = self.recordings_dir / filename

        recording_data = {
            "version": 1,
            "recorded_at": datetime.now().isoformat(),
            "rate_hz": self.args.rate,
            "num_frames": num_frames,
            "duration_sec": round(duration, 3),
            "joint_names": JOINT_NAMES,
            "frames": self.recording_buffer,
        }

        with open(filepath, "w") as f:
            json.dump(recording_data, f, indent=2)

        self.state = "idle"
        # Teleop stays ON — user controls it via Sync button
        self.recording_buffer = []
        print(f"[worker] Recording saved: {filepath}")
        print(f"[worker]   {num_frames} frames, {duration:.1f}s")
        return {
            "status": "ok",
            "state": "idle",
            "teleop": self.teleop,
            "filename": filename,
            "frames": num_frames,
            "duration_sec": round(duration, 3),
        }

    def _handle_start_replay(self, args):
        """Load a recording file and start replaying on the follower."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start replay in state '{self.state}'"}
        if self.follower is None:
            return {"status": "error",
                    "reason": "replay requires follower connected"}

        filename = args.get("filename", "")
        self.replay_loop = args.get("loop", False)

        # Allow overriding approach speed per command
        speed = args.get("approach_speed", self.approach_speed)

        # If no filename, use the most recent recording
        if not filename:
            recordings = self._get_recording_files()
            if not recordings:
                return {"status": "error", "reason": "no recordings found"}
            filename = recordings[-1]  # most recent (sorted by name = sorted by time)

        filepath = self.recordings_dir / filename
        if not filepath.exists():
            return {"status": "error",
                    "reason": f"file not found: {filename}"}

        try:
            with open(filepath) as f:
                data = json.load(f)
        except Exception as e:
            return {"status": "error", "reason": f"failed to load: {e}"}

        frames = data.get("frames", [])
        if len(frames) < 2:
            return {"status": "error",
                    "reason": f"recording too short ({len(frames)} frames)"}

        self.replay_frames = frames
        self.replay_index = 0
        self.replay_filename = filename
        self.teleop = False  # disable teleop during replay
        self.state = "replaying"

        # Start approach phase: smoothly move from current position to first frame
        first_frame_joints = frames[0].get("joints", {})
        self._start_approach(first_frame_joints, speed)

        duration = data.get("duration_sec", 0)
        print(f"[worker] *** REPLAY STARTED *** {filename}")
        print(f"[worker]   {len(frames)} frames, {duration}s, "
              f"loop={'yes' if self.replay_loop else 'no'}, "
              f"approach={self.approach_duration:.1f}s")
        return {
            "status": "ok",
            "state": "replaying",
            "filename": filename,
            "frames": len(frames),
            "duration_sec": duration,
            "loop": self.replay_loop,
            "approach_sec": round(self.approach_duration, 2),
        }

    def _handle_stop_replay(self):
        """Stop replay immediately."""
        if self.state != "replaying":
            return {"status": "error",
                    "reason": f"not replaying (state: '{self.state}')"}

        self.state = "idle"
        self.replay_frames = []
        self.replay_index = 0
        print(f"[worker] Replay stopped ({self.replay_filename})")
        return {"status": "ok", "state": "idle"}

    def _handle_list_recordings(self):
        """List all saved recording files."""
        files = self._get_recording_files()
        # Add metadata summary for each file
        summaries = []
        for fname in files:
            try:
                with open(self.recordings_dir / fname) as f:
                    data = json.load(f)
                summaries.append({
                    "filename": fname,
                    "frames": data.get("num_frames", 0),
                    "duration_sec": data.get("duration_sec", 0),
                    "recorded_at": data.get("recorded_at", ""),
                })
            except Exception:
                summaries.append({"filename": fname})
        return {"status": "ok", "recordings": summaries}

    def _get_recording_files(self):
        """Get sorted list of recording JSON files."""
        files = sorted(
            f.name for f in self.recordings_dir.glob("recording_*.json")
            if f.is_file()
        )
        return files

    # -----------------------------------------------------------------------
    # Approach (smooth transition) logic
    # -----------------------------------------------------------------------

    def _start_approach(self, target_joints, speed=None):
        """
        Begin a smooth approach from current follower position to target_joints.
        Duration is calculated from the largest joint angle difference
        divided by speed (degrees/sec).
        """
        if speed is None:
            speed = self.approach_speed

        current = self.read_follower()
        if current is None:
            # Can't read current position — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_start_pos = dict(current)
        self.approach_target_pos = dict(target_joints)

        # Calculate duration based on max joint difference
        max_diff = 0.0
        for name in JOINT_NAMES:
            if name in current and name in target_joints:
                diff = abs(target_joints[name] - current[name])
                max_diff = max(max_diff, diff)

        if max_diff < 1.0:
            # Already close enough — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_duration = max_diff / speed
        # Minimum 0.3s, even for small moves
        self.approach_duration = max(self.approach_duration, 0.3)
        self.approach_start_time = time.monotonic()
        self.approaching = True
        print(f"[worker] Approach: {max_diff:.1f}° max diff, "
              f"{self.approach_duration:.1f}s at {speed}°/s")

    def _tick_approach(self):
        """
        Advance the approach interpolation by one tick.
        Returns interpolated joint positions, or None if approach is finished.
        """
        if not self.approaching:
            return None

        elapsed = time.monotonic() - self.approach_start_time
        t = elapsed / self.approach_duration  # 0.0 → 1.0

        if t >= 1.0:
            # Approach finished — send final target
            self.send_follower_action(self.approach_target_pos)
            self.approaching = False
            self.replay_start_time = time.monotonic()
            print("[worker] Approach complete")
            return self.read_follower()

        # Smooth interpolation using ease-in-out (cosine)
        # t=0 → factor=0, t=1 → factor=1, smooth acceleration/deceleration
        factor = (1.0 - math.cos(t * math.pi)) / 2.0

        interpolated = {}
        for name in JOINT_NAMES:
            start_val = self.approach_start_pos.get(name, 0.0)
            end_val = self.approach_target_pos.get(name, 0.0)
            interpolated[name] = start_val + (end_val - start_val) * factor

        self.send_follower_action(interpolated)
        return self.read_follower()

    # -----------------------------------------------------------------------
    # Replay tick — called each iteration of the main loop
    # -----------------------------------------------------------------------

    def _tick_replay(self):
        """
        Advance replay by one tick. Handles approach phase, then frame playback.
        Returns the follower position after sending (for PUB).
        """
        # --- Approach phase: smooth transition to start position ---
        if self.approaching:
            return self._tick_approach()

        # --- Normal replay ---
        if not self.replay_frames or self.replay_index >= len(self.replay_frames):
            # Replay finished
            if self.replay_loop and self.replay_frames:
                # Loop: approach back to first frame before restarting
                first_frame_joints = self.replay_frames[0].get("joints", {})
                self.replay_index = 0
                self._start_approach(first_frame_joints)
                if self.approaching:
                    print(f"[worker] Replay loop — approaching start position")
                    return self.read_follower()
                else:
                    # Already close enough, restart immediately
                    print(f"[worker] Replay looping ({self.replay_filename})")
            else:
                self.state = "idle"
                print(f"[worker] Replay finished ({self.replay_filename})")
                return self.read_follower()

        elapsed = time.monotonic() - self.replay_start_time
        first_ts = self.replay_frames[0]["ts"]

        # Advance to the frame that matches current elapsed time
        while self.replay_index < len(self.replay_frames):
            frame = self.replay_frames[self.replay_index]
            frame_time = frame["ts"] - first_ts
            if frame_time <= elapsed:
                # Send this frame to follower
                joints = frame.get("joints", {})
                if joints:
                    self.send_follower_action(joints)
                self.replay_index += 1
            else:
                break  # wait for next tick

        # Re-read follower actual position
        return self.read_follower()

    def check_commands(self):
        """Non-blocking check for incoming REP commands."""
        events = dict(self.poller.poll(timeout=0))  # 0 = non-blocking
        if self.rep in events:
            try:
                raw = self.rep.recv_string(zmq.NOBLOCK)
                msg = json.loads(raw)
                self.last_cmd_time = time.monotonic()
                self.heartbeat_triggered = False
                response = self.handle_command(msg)
                self.rep.send_string(json.dumps(response))
            except zmq.Again:
                pass
            except Exception as e:
                # Must always send a reply on REP socket
                self.rep.send_string(json.dumps({
                    "status": "error", "reason": str(e)
                }))

    def run(self):
        """Main read loop at configured rate."""
        period = 1.0 / self.args.rate
        seq = 0
        teleop_count = 0
        print(f"[worker] Starting read loop at {self.args.rate} Hz "
              f"(period={period*1000:.1f}ms)")

        while self.running:
            t_start = time.monotonic()

            # --- Replaying mode: advance replay timeline ---
            if self.state == "replaying":
                follower_pos = self._tick_replay()
                leader_pos = self.read_leader()

            # --- Syncing mode: approach leader position, then activate teleop ---
            elif self.state == "syncing":
                if self.approaching:
                    follower_pos = self._tick_approach()
                else:
                    # Approach finished — activate teleop
                    self.state = "idle"
                    self.teleop = True
                    print("[worker] Teleop (sync) ON — approach complete")
                    follower_pos = self.read_follower()
                leader_pos = self.read_leader()

            else:
                # Read joint states
                follower_pos = self.read_follower()
                leader_pos = self.read_leader()

                # --- Teleop: send leader position to follower ---
                if self.teleop and leader_pos is not None:
                    self.send_follower_action(leader_pos)
                    teleop_count += 1
                    # Re-read follower after sending action to get updated position
                    follower_pos = self.read_follower()

                # --- Recording mode: buffer the current frame ---
                if self.state == "recording" and follower_pos is not None:
                    frame = {
                        "ts": time.monotonic() - self.recording_start_time,
                        "joints": dict(follower_pos),
                    }
                    self.recording_buffer.append(frame)

            # Build and publish message (degrees — bridge node converts to radians)
            msg = {
                "seq": seq,
                "ts": time.time(),
                "state": self.state,
                "teleop": self.teleop,
            }
            if follower_pos is not None:
                msg["follower"] = follower_pos
            if leader_pos is not None:
                msg["leader"] = leader_pos

            # Report device errors if threshold exceeded
            device_errors = {}
            if self.follower_error_count >= self.error_threshold:
                device_errors["follower"] = self.follower_error_msg
            if self.leader_error_count >= self.error_threshold:
                device_errors["leader"] = self.leader_error_msg
            if device_errors:
                msg["device_errors"] = device_errors

            # Add recording/replay metadata
            if self.state == "recording":
                msg["recording_frames"] = len(self.recording_buffer)
            elif self.state == "replaying":
                msg["replay_progress"] = {
                    "index": self.replay_index,
                    "total": len(self.replay_frames),
                    "filename": self.replay_filename,
                    "loop": self.replay_loop,
                    "approaching": self.approaching,
                }

            self.pub.send_string(json.dumps(msg))
            seq += 1

            # Check for commands (non-blocking)
            self.check_commands()

            # Heartbeat timeout: if no command from bridge for too long,
            # auto E-stop to prevent uncontrolled motion when the
            # Unreal→rosbridge→bridge path is broken.
            # Only triggers when actively doing something (not idle).
            if self.state != "idle" or self.teleop:
                since_last = time.monotonic() - self.last_cmd_time
                if since_last > self.heartbeat_timeout_sec and not self.heartbeat_triggered:
                    self.heartbeat_triggered = True
                    print(f"[worker] *** HEARTBEAT TIMEOUT ({since_last:.1f}s) "
                          f"— auto E-STOP ***")
                    self._handle_estop()

            # Rate limiting
            elapsed = time.monotonic() - t_start
            sleep_time = period - elapsed
            if sleep_time > 0:
                time.sleep(sleep_time)

    def shutdown(self):
        """Clean shutdown."""
        print("\n[worker] Shutting down...")
        self.running = False
        self.state = "idle"

        # Save any in-progress recording
        if self.recording_buffer:
            try:
                self._handle_stop_record()
                print("[worker] Saved in-progress recording before shutdown.")
            except Exception:
                pass

        if self.follower is not None:
            try:
                self.follower.disconnect()
                print("[worker] Follower disconnected.")
            except Exception:
                pass

        if self.leader is not None:
            try:
                self.leader.disconnect()
                print("[worker] Leader disconnected.")
            except Exception:
                pass

        self.pub.close()
        self.rep.close()
        self.ctx.term()
        print("[worker] ZMQ cleaned up. Bye.")


def main():
    args = build_args()
    worker = LeRobotWorker(args)

    # Graceful shutdown on Ctrl+C
    def sig_handler(sig, frame):
        worker.shutdown()
        sys.exit(0)

    signal.signal(signal.SIGINT, sig_handler)
    signal.signal(signal.SIGTERM, sig_handler)

    try:
        worker.connect_robots()
        worker.run()
    except Exception:
        traceback.print_exc()
        worker.shutdown()
        sys.exit(1)


if __name__ == "__main__":
    main()

변경 사항

 

  • follower_error_count, leader_error_count: 연속 에러 카운터
  • follower_error_msg, leader_error_msg: 최근 에러 메시지 저장
  • error_threshold = 5: 5회 연속 실패 후 PUB에 에러 보고
  • read_follower (수정): 성공 시 카운터 리셋, 실패 시 카운터 증가 + threshold 초과 후 로그 억제
  • read_leader (수정): 동일
  • send_follower_action (수정): 동일
  • PUB 메시지에 device_errors 필드 추가 (예: {"device_errors": {"leader": "Port is in use!"}})

 

 

 

ros_bridge_node.py 수정

더보기
#!/usr/bin/env python3
"""
ros_bridge_node.py — ZMQ ↔ ROS2 bridge node

Runs in .venv-ros-bridge (Python 3.10) with rclpy + pyzmq.
Subscribes to joint states from lerobot_worker via ZMQ,
converts degrees→radians, and publishes sensor_msgs/JointState.
Also subscribes to /follower_joint_commands and forwards to worker via ZMQ REQ.

Relays record/replay/estop commands:
  - /robot_command (std_msgs/String) → ZMQ REQ → worker
  - Worker state published on /robot_status (std_msgs/String) every tick

Usage:
    export PATH=$(echo $PATH | tr ':' '\\n' | grep -v miniforge | tr '\\n' ':' | sed 's/:$//')
    cd ~/UnrealRobotics
    source .venv-ros-bridge/bin/activate
    source /opt/ros/humble/setup.bash
    python src/lerobot_ros2_bridge/lerobot_ros2_bridge/ros_bridge_node.py \\
        [--sub-addr tcp://127.0.0.1:5555] \\
        [--req-addr tcp://127.0.0.1:5556]
"""

import argparse
import json
import math
import sys
import traceback

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import String
import zmq


# ---------------------------------------------------------------------------
# Joint names — must match URDF, LeRobot calibration, and worker output
# (Phase 3.2 verified: all 6 names identical across all three sources)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]


def deg2rad(deg: float) -> float:
    return deg * math.pi / 180.0


def rad2deg(rad: float) -> float:
    return rad * 180.0 / math.pi


class RosBridgeNode(Node):
    def __init__(self, args):
        super().__init__("lerobot_ros2_bridge")
        self.get_logger().info("Initializing lerobot_ros2_bridge node")

        # --- ZMQ setup ---
        self.zmq_ctx = zmq.Context()

        # SUB: receive joint states from worker
        self.zmq_sub = self.zmq_ctx.socket(zmq.SUB)
        self.zmq_sub.connect(args.sub_addr)
        self.zmq_sub.setsockopt_string(zmq.SUBSCRIBE, "")
        # Don't block waiting for messages — use RCVTIMEO
        self.zmq_sub.setsockopt(zmq.RCVTIMEO, 0)
        self.get_logger().info(f"ZMQ SUB connected to {args.sub_addr}")

        # REQ: send commands to worker
        self.req_addr = args.req_addr
        self.zmq_req = self._create_req_socket()
        self.req_consecutive_timeouts = 0
        self.req_max_timeouts = 3  # recreate socket after this many consecutive timeouts

        # --- ROS2 publishers ---
        # Follower joint states (for robot_state_publisher + rosbridge → Unreal)
        self.follower_pub = self.create_publisher(JointState, "/joint_states", 10)
        # Leader joint states (separate topic)
        self.leader_pub = self.create_publisher(JointState, "/leader_joint_states", 10)

        # --- ROS2 subscriber for commands from Unreal (via rosbridge) ---
        self.cmd_sub = self.create_subscription(
            JointState,
            "/follower_joint_commands",
            self.on_joint_command,
            10,
        )

        # --- Record/Replay/E-Stop command relay ---
        # Unreal publishes JSON command strings to /robot_command,
        # bridge relays them to worker via ZMQ REQ.
        self.robot_cmd_sub = self.create_subscription(
            String,
            "/robot_command",
            self.on_robot_command,
            10,
        )
        # Worker state feedback → Unreal
        self.robot_status_pub = self.create_publisher(String, "/robot_status", 10)
        self.last_worker_state = ""
        self.last_worker_teleop = None
        self.last_device_errors = {}

        # Bridge heartbeat → Unreal (so Unreal can distinguish bridge vs worker death)
        self.bridge_heartbeat_pub = self.create_publisher(String, "/bridge_heartbeat", 10)

        # --- Timer: poll ZMQ at ~100Hz (faster than worker's 30Hz to avoid buffering) ---
        self.poll_timer = self.create_timer(0.01, self.poll_zmq)

        # --- Timer: heartbeat ping to worker every 2s ---
        self.heartbeat_timer = self.create_timer(2.0, self.send_heartbeat)

        # --- Timer: bridge heartbeat to Unreal every 1s ---
        self.bridge_heartbeat_timer = self.create_timer(1.0, self.publish_bridge_heartbeat)

        # Stats
        self.recv_count = 0
        self.last_log_time = self.get_clock().now()

    def poll_zmq(self):
        """Non-blocking poll of ZMQ SUB for worker messages."""
        # Drain all available messages (in case we're slightly behind)
        messages_this_tick = 0
        while messages_this_tick < 5:  # cap per tick to avoid starving ROS callbacks
            try:
                raw = self.zmq_sub.recv_string(zmq.NOBLOCK)
            except zmq.Again:
                break

            messages_this_tick += 1
            self.recv_count += 1

            try:
                msg = json.loads(raw)
            except json.JSONDecodeError as e:
                self.get_logger().warn(f"JSON parse error: {e}")
                continue

            now = self.get_clock().now().to_msg()

            # Publish follower joint states
            if "follower" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["follower"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                # velocity and effort left empty (not available from LeRobot API)
                self.follower_pub.publish(js)

            # Publish leader joint states
            if "leader" in msg:
                js = JointState()
                js.header.stamp = now
                js.header.frame_id = ""
                js.name = list(JOINT_NAMES)
                js.position = [
                    deg2rad(msg["leader"].get(name, 0.0))
                    for name in JOINT_NAMES
                ]
                self.leader_pub.publish(js)

            # Publish worker state to /robot_status (only on change)
            worker_state = msg.get("state", "idle")
            worker_teleop = msg.get("teleop", False)
            device_errors = msg.get("device_errors", {})
            state_changed = (worker_state != self.last_worker_state or
                             worker_teleop != self.last_worker_teleop)

            # Also publish on device error changes
            if device_errors != self.last_device_errors:
                state_changed = True
                self.last_device_errors = dict(device_errors)
                if device_errors:
                    self.get_logger().warn(f"Device errors: {device_errors}")

            if state_changed:
                status_msg = String()
                status_data = {"state": worker_state, "teleop": worker_teleop}
                # Include recording/replay metadata if present
                if "recording_frames" in msg:
                    status_data["recording_frames"] = msg["recording_frames"]
                if "replay_progress" in msg:
                    status_data["replay_progress"] = msg["replay_progress"]
                if device_errors:
                    status_data["device_errors"] = device_errors
                status_msg.data = json.dumps(status_data)
                self.robot_status_pub.publish(status_msg)
                self.last_worker_state = worker_state
                self.last_worker_teleop = worker_teleop
                self.get_logger().info(
                    f"Worker state: {worker_state}, teleop: {worker_teleop}")

        # Periodic logging (every 10 seconds)
        now_time = self.get_clock().now()
        if (now_time - self.last_log_time).nanoseconds > 10_000_000_000:
            self.get_logger().info(
                f"ZMQ recv total: {self.recv_count} messages"
            )
            self.last_log_time = now_time

    def on_joint_command(self, msg: JointState):
        """
        Receive joint commands from ROS2 (e.g. from Unreal via rosbridge),
        convert radians→degrees, and forward to worker via ZMQ REQ.
        """
        if len(msg.name) == 0 or len(msg.position) == 0:
            self.get_logger().warn("Empty joint command received, ignoring")
            return

        # Build command dict (degrees)
        joint_args = {}
        for name, pos_rad in zip(msg.name, msg.position):
            if name in JOINT_NAMES:
                joint_args[name] = rad2deg(pos_rad)

        if not joint_args:
            self.get_logger().warn("No recognized joint names in command")
            return

        cmd = {
            "cmd": "send_follower_action",
            "args": joint_args,
        }

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)
            if reply.get("status") != "ok":
                self.get_logger().warn(
                    f"Worker command error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error("Worker REQ timeout — is lerobot_worker running?")
        except Exception as e:
            self.get_logger().error(f"Worker command exception: {e}")

    def on_robot_command(self, msg: String):
        """
        Receive record/replay/estop commands from Unreal (via rosbridge),
        relay to worker via ZMQ REQ, and publish response on /robot_status.

        Expected msg.data formats:
            '{"cmd": "start_record"}'
            '{"cmd": "stop_record"}'
            '{"cmd": "start_replay", "args": {"filename": "...", "loop": false}}'
            '{"cmd": "start_replay"}'                    (plays most recent)
            '{"cmd": "stop_replay"}'
            '{"cmd": "estop"}'
            '{"cmd": "list_recordings"}'
        """
        try:
            cmd = json.loads(msg.data)
        except json.JSONDecodeError:
            # Support simple string commands: "estop", "start_record", etc.
            cmd = {"cmd": msg.data.strip()}

        cmd_name = cmd.get("cmd", "")
        self.get_logger().info(f"Robot command received: {cmd_name}")

        try:
            self.zmq_req.send_string(json.dumps(cmd))
            reply_raw = self.zmq_req.recv_string()
            reply = json.loads(reply_raw)

            # Publish response on /robot_status
            status_msg = String()
            status_msg.data = reply_raw
            self.robot_status_pub.publish(status_msg)

            status = reply.get("status", "unknown")
            if status == "ok":
                new_state = reply.get("state", "")
                if new_state:
                    self.last_worker_state = new_state
                self.get_logger().info(f"Command '{cmd_name}' OK: {reply}")
            else:
                self.get_logger().warn(
                    f"Command '{cmd_name}' error: {reply.get('reason', 'unknown')}"
                )
        except zmq.Again:
            self.get_logger().error(
                f"Worker REQ timeout for '{cmd_name}' — is lerobot_worker running?"
            )
            # Publish timeout error on /robot_status
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": "worker timeout", "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)
        except Exception as e:
            self.get_logger().error(f"Robot command exception: {e}")
            err_msg = String()
            err_msg.data = json.dumps({
                "status": "error", "reason": str(e), "cmd": cmd_name
            })
            self.robot_status_pub.publish(err_msg)

    def _create_req_socket(self):
        """Create a fresh ZMQ REQ socket connected to the worker."""
        sock = self.zmq_ctx.socket(zmq.REQ)
        sock.setsockopt(zmq.RCVTIMEO, 1000)  # 1s timeout for replies
        sock.setsockopt(zmq.LINGER, 0)        # don't block on close
        sock.connect(self.req_addr)
        self.get_logger().info(f"ZMQ REQ connected to {self.req_addr}")
        return sock

    def _reset_req_socket(self):
        """Close and recreate the REQ socket to recover from stuck state."""
        self.get_logger().warn("Resetting ZMQ REQ socket (worker may have restarted)")
        try:
            self.zmq_req.close()
        except Exception:
            pass
        self.zmq_req = self._create_req_socket()
        self.req_consecutive_timeouts = 0

    def publish_bridge_heartbeat(self):
        """Publish a lightweight heartbeat so Unreal knows the bridge is alive."""
        msg = String()
        msg.data = '{"bridge":"alive"}'
        self.bridge_heartbeat_pub.publish(msg)

    def send_heartbeat(self):
        """Send periodic ping to worker so it knows the bridge is alive."""
        try:
            self.zmq_req.send_string('{"cmd":"ping"}')
            self.zmq_req.recv_string()  # discard reply, just keeping the link alive
            self.req_consecutive_timeouts = 0  # reset on success
        except zmq.Again:
            self.req_consecutive_timeouts += 1
            self.get_logger().warn(
                f"Heartbeat ping timeout ({self.req_consecutive_timeouts}/"
                f"{self.req_max_timeouts})")
            if self.req_consecutive_timeouts >= self.req_max_timeouts:
                self._reset_req_socket()
        except Exception as e:
            self.get_logger().warn(f"Heartbeat ping error: {e}")
            self.req_consecutive_timeouts += 1
            if self.req_consecutive_timeouts >= self.req_max_timeouts:
                self._reset_req_socket()

    def destroy_node(self):
        """Clean shutdown of ZMQ resources."""
        self.get_logger().info("Shutting down ZMQ...")
        self.zmq_sub.close()
        self.zmq_req.close()
        self.zmq_ctx.term()
        super().destroy_node()


def main():
    parser = argparse.ArgumentParser(description="ROS2 ↔ ZMQ bridge node")
    parser.add_argument("--sub-addr", default="tcp://127.0.0.1:5555",
                        help="ZMQ SUB address (worker PUB)")
    parser.add_argument("--req-addr", default="tcp://127.0.0.1:5556",
                        help="ZMQ REQ address (worker REP)")
    # ROS2 may pass extra args — use parse_known_args
    args, unknown = parser.parse_known_args()

    rclpy.init(args=unknown if unknown else None)
    node = RosBridgeNode(args)

    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

변경 사항

 

  • last_device_errors: 이전 에러 상태 추적
  • poll_zmq (수정): device_errors 변화 감지 시 /robot_status에 포함하여 publish

 

 

 

RobotVisualizer.cpp 수정

더보기
#include "RobotVisualizer.h"
#include "RosCoordConv.h"
#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "Components/SceneComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
#include "UObject/ConstructorHelpers.h"

// =============================================================================
// Mesh asset path helper
// =============================================================================

static UStaticMesh* LoadMeshAsset(const TCHAR* AssetName)
{
	// All meshes live under /Game/Robot/Meshes/
	FString Path = FString::Printf(TEXT("/Game/Robot/Meshes/%s.%s"), AssetName, AssetName);
	UStaticMesh* Mesh = Cast<UStaticMesh>(StaticLoadObject(UStaticMesh::StaticClass(), nullptr, *Path));
	if (!Mesh)
	{
		UE_LOG(LogRosBridge, Warning, TEXT("Failed to load mesh: %s"), *Path);
	}
	return Mesh;
}

// =============================================================================
// Constructor — build the entire component hierarchy
// =============================================================================

ARobotVisualizer::ARobotVisualizer()
{
	PrimaryActorTick.bCanEverTick = false;

	// --- Root ---
	RobotRoot = CreateDefaultSubobject<USceneComponent>(TEXT("RobotRoot"));
	RootComponent = RobotRoot;

	// =========================================================================
	// URDF data converted to UE coordinates:
	//   Position: meters * 100 = cm, Y flipped
	//   Rotation: RPY radians -> FRotator degrees, pitch & yaw negated
	//
	// All values below are pre-computed from so101_follower.urdf.
	// =========================================================================

	// --- base_link (attached directly to root, no joint) ---
	BaseLink = CreateDefaultSubobject<USceneComponent>(TEXT("BaseLink"));
	BaseLink->SetupAttachment(RobotRoot);

	// --- shoulder_pan joint ---
	// URDF origin: xyz(0.0388353, 0, 0.0624) rpy(3.14159, 0, -3.14159)
	ShoulderPanJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderPanJoint"));
	ShoulderPanJoint->SetupAttachment(BaseLink);
	ShoulderPanJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0388353, 0.0, 0.0624));
	ShoulderPanJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159));

	ShoulderLink = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLink"));
	ShoulderLink->SetupAttachment(ShoulderPanJoint);

	// --- shoulder_lift joint ---
	// URDF origin: xyz(-0.0303992, -0.0182778, -0.0542) rpy(-1.5708, -1.5708, 0)
	ShoulderLiftJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLiftJoint"));
	ShoulderLiftJoint->SetupAttachment(ShoulderLink);
	ShoulderLiftJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.0303992, -0.0182778, -0.0542));
	ShoulderLiftJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0));

	UpperArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("UpperArmLink"));
	UpperArmLink->SetupAttachment(ShoulderLiftJoint);

	// --- elbow_flex joint ---
	// URDF origin: xyz(-0.11257, -0.028, 0) rpy(0, 0, 1.5708)
	ElbowFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ElbowFlexJoint"));
	ElbowFlexJoint->SetupAttachment(UpperArmLink);
	ElbowFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.11257, -0.028, 0.0));
	ElbowFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708));

	LowerArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("LowerArmLink"));
	LowerArmLink->SetupAttachment(ElbowFlexJoint);

	// --- wrist_flex joint ---
	// URDF origin: xyz(-0.1349, 0.0052, 0) rpy(0, 0, -1.5708)
	WristFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristFlexJoint"));
	WristFlexJoint->SetupAttachment(LowerArmLink);
	WristFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.1349, 0.0052, 0.0));
	WristFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708));

	WristLink = CreateDefaultSubobject<USceneComponent>(TEXT("WristLink"));
	WristLink->SetupAttachment(WristFlexJoint);

	// --- wrist_roll joint ---
	// URDF origin: xyz(0, -0.0611, 0.0181) rpy(1.5708, 0.0486795, 3.14159)
	WristRollJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristRollJoint"));
	WristRollJoint->SetupAttachment(WristLink);
	WristRollJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0, -0.0611, 0.0181));
	WristRollJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159));

	GripperLink = CreateDefaultSubobject<USceneComponent>(TEXT("GripperLink"));
	GripperLink->SetupAttachment(WristRollJoint);

	// --- gripper joint ---
	// URDF origin: xyz(0.0202, 0.0188, -0.0234) rpy(1.5708, 0, 0)
	GripperJoint = CreateDefaultSubobject<USceneComponent>(TEXT("GripperJoint"));
	GripperJoint->SetupAttachment(GripperLink);
	GripperJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0202, 0.0188, -0.0234));
	GripperJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0));

	MovingJawLink = CreateDefaultSubobject<USceneComponent>(TEXT("MovingJawLink"));
	MovingJawLink->SetupAttachment(GripperJoint);

	// --- Joint name mapping (matches ROS /joint_states names) ---
	JointComponentMap.Add(FName("shoulder_pan"),  ShoulderPanJoint);
	JointComponentMap.Add(FName("shoulder_lift"), ShoulderLiftJoint);
	JointComponentMap.Add(FName("elbow_flex"),    ElbowFlexJoint);
	JointComponentMap.Add(FName("wrist_flex"),    WristFlexJoint);
	JointComponentMap.Add(FName("wrist_roll"),    WristRollJoint);
	JointComponentMap.Add(FName("gripper"),       GripperJoint);
}

// =============================================================================
// BeginPlay — load meshes and attach, connect to ROS
// =============================================================================

void ARobotVisualizer::BeginPlay()
{
	Super::BeginPlay();

	// --- Load meshes and attach to links ---
	// Meshes are loaded at runtime (not in constructor) because
	// StaticLoadObject is safer to call here and allows hot-reload.

	// Helper lambda to reduce repetition
	auto Attach = [this](USceneComponent* Parent, const TCHAR* MeshName,
		double RosX, double RosY, double RosZ,
		double RosRoll, double RosPitch, double RosYaw,
		bool bIsMotor = false)
	{
		UStaticMesh* Mesh = LoadMeshAsset(MeshName);
		if (!Mesh) return;

		UStaticMeshComponent* SMC = NewObject<UStaticMeshComponent>(this);
		SMC->SetStaticMesh(Mesh);
		SMC->SetRelativeLocation(RosCoordConv::RosToUePosition(RosX, RosY, RosZ));
		SMC->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(RosRoll, RosPitch, RosYaw));
		SMC->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		SMC->AttachToComponent(Parent, FAttachmentTransformRules::KeepRelativeTransform);
		SMC->RegisterComponent();
		AllMeshComponents.Add(SMC);
	};

	// === base_link meshes ===
	Attach(BaseLink, TEXT("base_motor_holder_so101_v1"),
		-0.00636471, -0.0000994414, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("base_so101_v2"),
		-0.00636471, 0.0, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("sts3215_03a_v1"),
		0.0263353, 0.0, 0.0437,
		0.0, 0.0, 0.0, true);
	Attach(BaseLink, TEXT("waveshare_mounting_plate_so101_v2"),
		-0.0309827, -0.000199441, 0.0474,
		1.5708, 0.0, 1.5708);

	// === shoulder_link meshes ===
	Attach(ShoulderLink, TEXT("sts3215_03a_v1"),
		-0.0303992, 0.000422241, -0.0417,
		1.5708, 1.5708, 0.0, true);
	Attach(ShoulderLink, TEXT("motor_holder_so101_base_v1"),
		-0.0675992, -0.000177759, 0.0158499,
		1.5708, -1.5708, 0.0);
	Attach(ShoulderLink, TEXT("rotation_pitch_so101_v1"),
		0.0122008, 0.0000222413, 0.0464,
		-1.5708, 0.0, 0.0);

	// === upper_arm_link meshes ===
	Attach(UpperArmLink, TEXT("sts3215_03a_v1"),
		-0.11257, -0.0155, 0.0187,
		-3.14159, 0.0, -1.5708, true);
	Attach(UpperArmLink, TEXT("upper_arm_so101_v1"),
		-0.065085, 0.012, 0.0182,
		3.14159, 0.0, 0.0);

	// === lower_arm_link meshes ===
	Attach(LowerArmLink, TEXT("under_arm_so101_v1"),
		-0.0648499, -0.032, 0.0182,
		3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("motor_holder_so101_wrist_v1"),
		-0.0648499, -0.032, 0.018,
		-3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("sts3215_03a_v1"),
		-0.1224, 0.0052, 0.0187,
		-3.14159, 0.0, -3.14159, true);

	// === wrist_link meshes ===
	Attach(WristLink, TEXT("sts3215_03a_no_horn_v1"),
		0.0, -0.0424, 0.0306,
		1.5708, 1.5708, 0.0, true);
	Attach(WristLink, TEXT("wrist_roll_pitch_so101_v2"),
		0.0, -0.028, 0.0181,
		-1.5708, -1.5708, 0.0);

	// === gripper_link meshes ===
	Attach(GripperLink, TEXT("sts3215_03a_v1"),
		0.0077, 0.0001, -0.0234,
		-1.5708, 0.0, 0.0, true);
	Attach(GripperLink, TEXT("wrist_roll_follower_so101_v1"),
		0.0, -0.000218214, 0.000949706,
		-3.14159, 0.0, 0.0);

	// === moving_jaw_link meshes ===
	Attach(MovingJawLink, TEXT("moving_jaw_so101_v1"),
		0.0, 0.0, 0.0189,
		0.0, 0.0, 0.0);

	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: %d mesh components created"), AllMeshComponents.Num());

	// --- Connect to ROS via Subsystem ---
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Bind delegates.
	Ros->OnTopicMessage.AddDynamic(this, &ARobotVisualizer::OnRosMessage);
	Ros->OnConnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
	Ros->OnDisconnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);

	// Subscribe is now queued even before connection — the subsystem will
	// send it automatically when connected (including on reconnect).
	Ros->Subscribe(JointStateTopic, JointStateType);

	// Queue MoveIt topic advertisements (sent on connect).
	AdvertiseMoveItTopics();

	// Queue record/replay/estop topics.
	SetupRecordReplayTopics();

	// Subscribe to bridge heartbeat for connection health monitoring.
	Ros->Subscribe(TEXT("/bridge_heartbeat"), TEXT("std_msgs/String"));

	// Start connection health monitor (checks every 2 seconds).
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;
	GetWorldTimerManager().SetTimer(
		ConnectionHealthTimerHandle, this,
		&ARobotVisualizer::CheckConnectionHealth, 2.0f, true);

	// Initiate connection if not already connected.
	if (!Ros->IsConnected())
	{
		Ros->Connect(RosBridgeUrl);
	}
	else
	{
		// Already connected (e.g. another actor already called Connect).
		UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: already connected, subscription sent."));
	}
}

// =============================================================================
// EndPlay
// =============================================================================

void ARobotVisualizer::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (UGameInstance* GI = UGameplayStatics::GetGameInstance(this))
	{
		if (URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>())
		{
			Ros->OnTopicMessage.RemoveDynamic(this, &ARobotVisualizer::OnRosMessage);
			Ros->OnConnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
			Ros->OnDisconnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);
		}
	}

	bMoveItTopicsAdvertised = false;
	bRecordReplayTopicsSetup = false;

	GetWorldTimerManager().ClearTimer(ConnectionHealthTimerHandle);

	Super::EndPlay(EndPlayReason);
}

// =============================================================================
// ROS connection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeConnected()
{
	bRosBridgeConnected = true;
	UE_LOG(LogRosBridge, Log,
		TEXT("RobotVisualizer: rosbridge connected — subscriptions restored by subsystem."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
			TEXT("ROS Bridge: Connected"));
	}
}

// =============================================================================
// ROS disconnection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeDisconnected()
{
	bRosBridgeConnected = false;
	WorkerState = TEXT("disconnected");

	UE_LOG(LogRosBridge, Warning,
		TEXT("RobotVisualizer: rosbridge DISCONNECTED — cannot send commands. Auto-reconnect active."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
			TEXT("*** ROS Bridge DISCONNECTED *** Cannot send commands. Reconnecting..."));
	}
}

// =============================================================================
// Connection health monitoring
// =============================================================================

void ARobotVisualizer::CheckConnectionHealth()
{
	// Only check when WebSocket is connected — if WebSocket is down,
	// OnRosBridgeDisconnected already shows a warning for that.
	if (!bRosBridgeConnected)
	{
		return;
	}

	const double Now = FPlatformTime::Seconds();

	// Check bridge heartbeat (published every 1s by bridge_node)
	const double BridgeElapsed = Now - LastBridgeHeartbeatTime;
	if (BridgeElapsed > BridgeHeartbeatTimeoutSec && !bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = true;
		bWorkerDataLost = true;  // if bridge is down, worker data can't reach us either
		WorkerState = TEXT("bridge lost");

		UE_LOG(LogRosBridge, Warning,
			TEXT("Bridge heartbeat lost (%.1fs). bridge_node may be down."), BridgeElapsed);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
				TEXT("*** Bridge Node: DOWN *** Check bridge_node terminal (terminal 2)."));
		}
		return;  // no need to check worker separately
	}

	// Check worker data (/joint_states at ~30Hz, flows through bridge)
	// Only meaningful if bridge is alive.
	if (!bBridgeHeartbeatLost)
	{
		const double WorkerElapsed = Now - LastJointStatesTime;
		if (WorkerElapsed > WorkerDataTimeoutSec && !bWorkerDataLost)
		{
			bWorkerDataLost = true;
			WorkerState = TEXT("worker lost");

			UE_LOG(LogRosBridge, Warning,
				TEXT("Worker data lost (%.1fs). lerobot_worker may be down."), WorkerElapsed);

			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
					TEXT("*** Worker: DOWN *** Check lerobot_worker terminal (terminal 1)."));
			}
		}
	}
}

// =============================================================================
// MoveIt topic advertisements
// =============================================================================

void ARobotVisualizer::AdvertiseMoveItTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// These are queued and sent automatically when connected.
	Ros->Advertise(TEXT("/moveit_goal_named"), TEXT("std_msgs/String"));
	Ros->Advertise(TEXT("/moveit_goal_joints"), TEXT("sensor_msgs/JointState"));
	Ros->Advertise(TEXT("/moveit_goal_pose"), TEXT("geometry_msgs/PoseStamped"));

	bMoveItTopicsAdvertised = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: MoveIt command topics advertised."));
}

// =============================================================================
// MoveIt commands — SendNamedTarget
// =============================================================================

void ARobotVisualizer::SendNamedTarget()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendNamedTarget: not connected to rosbridge."));
		return;
	}

	// std_msgs/String: {"data": "home"}
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *MoveItNamedTarget);
	Ros->Publish(TEXT("/moveit_goal_named"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("SendNamedTarget: published '%s' to /moveit_goal_named"), *MoveItNamedTarget);
}

// =============================================================================
// MoveIt commands — SendJointGoal
// =============================================================================

void ARobotVisualizer::SendJointGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendJointGoal: not connected to rosbridge."));
		return;
	}

	// sensor_msgs/JointState:
	// {
	//   "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": ""},
	//   "name": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"],
	//   "position": [0.0, -0.5, 0.5, 0.0, 0.0],
	//   "velocity": [],
	//   "effort": []
	// }

	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"\"},")
		TEXT("\"name\":[\"shoulder_pan\",\"shoulder_lift\",\"elbow_flex\",\"wrist_flex\",\"wrist_roll\"],")
		TEXT("\"position\":[%f,%f,%f,%f,%f],")
		TEXT("\"velocity\":[],\"effort\":[]}"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll
	);

	Ros->Publish(TEXT("/moveit_goal_joints"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendJointGoal: [%.3f, %.3f, %.3f, %.3f, %.3f] to /moveit_goal_joints"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll);
}

// =============================================================================
// MoveIt commands — SendPoseGoal
// =============================================================================

void ARobotVisualizer::SendPoseGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendPoseGoal: not connected to rosbridge."));
		return;
	}

	// Convert UE position (cm, left-handed) to ROS (meters, right-handed)
	double RosX, RosY, RosZ;
	RosCoordConv::UeToRosPosition(GoalPositionUE, RosX, RosY, RosZ);

	// geometry_msgs/PoseStamped (orientation defaults to identity — position-only goal)
	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"base_link\"},")
		TEXT("\"pose\":{\"position\":{\"x\":%f,\"y\":%f,\"z\":%f},")
		TEXT("\"orientation\":{\"x\":0.0,\"y\":0.0,\"z\":0.0,\"w\":1.0}}}"),
		RosX, RosY, RosZ
	);

	Ros->Publish(TEXT("/moveit_goal_pose"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendPoseGoal: UE(%.1f, %.1f, %.1f)cm -> ROS(%.4f, %.4f, %.4f)m to /moveit_goal_pose"),
		GoalPositionUE.X, GoalPositionUE.Y, GoalPositionUE.Z,
		RosX, RosY, RosZ);
}

// =============================================================================
// Message handling
// =============================================================================

void ARobotVisualizer::OnRosMessage(const FString& Topic, const FString& MessageJson)
{
	if (Topic == JointStateTopic)
	{
		// /joint_states arrives at ~30Hz — proves both bridge AND worker are alive.
		LastJointStatesTime = FPlatformTime::Seconds();
		LastBridgeHeartbeatTime = LastJointStatesTime; // joint_states flows through bridge
		if (bWorkerDataLost)
		{
			bWorkerDataLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Worker data restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Worker: Connection restored"));
			}
		}
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored (via joint_states)."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}

		ParseAndApplyJointStates(MessageJson);
	}
	else if (Topic == TEXT("/bridge_heartbeat"))
	{
		// Bridge heartbeat arrives every 1s — proves bridge is alive
		// (but worker may still be dead if /joint_states is not arriving).
		LastBridgeHeartbeatTime = FPlatformTime::Seconds();
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}
	}
	else if (Topic == TEXT("/robot_status"))
	{
		OnRobotStatus(Topic, MessageJson);
	}
}

void ARobotVisualizer::ParseAndApplyJointStates(const FString& MessageJson)
{
	// Parse sensor_msgs/JointState JSON:
	// { "name": ["shoulder_pan", ...], "position": [0.1, ...], ... }

	TSharedPtr<FJsonObject> Json;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(Reader, Json) || !Json.IsValid())
	{
		return;
	}

	const TArray<TSharedPtr<FJsonValue>>* NameArray = nullptr;
	const TArray<TSharedPtr<FJsonValue>>* PosArray = nullptr;

	if (!Json->TryGetArrayField(TEXT("name"), NameArray) ||
		!Json->TryGetArrayField(TEXT("position"), PosArray))
	{
		return;
	}

	const int32 Count = FMath::Min(NameArray->Num(), PosArray->Num());
	for (int32 i = 0; i < Count; ++i)
	{
		const FName JointName(*(*NameArray)[i]->AsString());
		const double AngleRad = (*PosArray)[i]->AsNumber();

		TObjectPtr<USceneComponent>* JointComp = JointComponentMap.Find(JointName);
		if (JointComp && *JointComp)
		{
			const float AngleDeg = RosCoordConv::RosJointAngleToUeDegrees(AngleRad);

			FRotator BaseRot;
			if (JointName == FName("shoulder_pan"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159);
			else if (JointName == FName("shoulder_lift"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0);
			else if (JointName == FName("elbow_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708);
			else if (JointName == FName("wrist_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708);
			else if (JointName == FName("wrist_roll"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159);
			else if (JointName == FName("gripper"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0);

			const FQuat BaseQuat = BaseRot.Quaternion();
			const FQuat JointQuat = FQuat(FVector::UpVector, FMath::DegreesToRadians(AngleDeg));
			const FQuat FinalQuat = BaseQuat * JointQuat;

			(*JointComp)->SetRelativeRotation(FinalQuat.Rotator());
		}
	}
}

// =============================================================================
// Record / Replay / E-Stop — Topic setup
// =============================================================================

void ARobotVisualizer::SetupRecordReplayTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Advertise the command topic
	Ros->Advertise(TEXT("/robot_command"), TEXT("std_msgs/String"));

	// Subscribe to status feedback
	Ros->Subscribe(TEXT("/robot_status"), TEXT("std_msgs/String"));

	bRecordReplayTopicsSetup = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: Record/Replay topics set up."));
}

// =============================================================================
// Record / Replay / E-Stop — Command publisher helper
// =============================================================================

void ARobotVisualizer::PublishRobotCommand(const FString& JsonCmd)
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("PublishRobotCommand: not connected to rosbridge."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Red,
				TEXT("Robot: Not connected to rosbridge!"));
		}
		return;
	}

	// std_msgs/String: {"data": "<json_cmd>"}
	// The JSON command is nested inside the "data" field, with quotes escaped.
	FString EscapedCmd = JsonCmd.Replace(TEXT("\""), TEXT("\\\""));
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *EscapedCmd);
	Ros->Publish(TEXT("/robot_command"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("PublishRobotCommand: %s"), *JsonCmd);
}

// =============================================================================
// Record / Replay / E-Stop — Button handlers
// =============================================================================

void ARobotVisualizer::StartRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Robot: Recording started (teleop active)"));
	}
}

void ARobotVisualizer::StopRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Recording stopped, saving..."));
	}
}

void ARobotVisualizer::StartReplay()
{
	FString ArgsJson;
	if (ReplayFilename.IsEmpty())
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"loop\":%s,\"approach_speed\":%f}}"),
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	else
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"filename\":\"%s\",\"loop\":%s,\"approach_speed\":%f}}"),
			*ReplayFilename,
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	PublishRobotCommand(ArgsJson);

	if (GEngine)
	{
		FString DisplayName = ReplayFilename.IsEmpty() ? TEXT("(most recent)") : ReplayFilename;
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Cyan,
			FString::Printf(TEXT("Robot: Replaying %s (loop=%s, speed=%.0f°/s)"),
				*DisplayName, bReplayLoop ? TEXT("yes") : TEXT("no"), ApproachSpeed));
	}
}

void ARobotVisualizer::StopReplay()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_replay\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Replay stopped"));
	}
}

void ARobotVisualizer::EStop()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"estop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
			TEXT("*** E-STOP *** All motion halted"));
	}
}

// =============================================================================
// Teleop Sync — SyncOn / SyncOff
// =============================================================================

void ARobotVisualizer::SyncOn()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Sync ON: leader -> follower active"));
	}
}

void ARobotVisualizer::SyncOff()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Sync OFF: leader -> follower deactivated"));
	}
}

// =============================================================================
// Record / Replay — Status feedback handler
// =============================================================================

void ARobotVisualizer::OnRobotStatus(const FString& Topic, const FString& MessageJson)
{
	// /robot_status flows through bridge from worker — both are alive.
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;

	if (bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Bridge: Connection restored"));
		}
	}
	if (bWorkerDataLost)
	{
		bWorkerDataLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Worker: Connection restored"));
		}
	}

	// rosbridge wraps std_msgs/String as: {"data": "..."}
	TSharedPtr<FJsonObject> OuterJson;
	TSharedRef<TJsonReader<>> OuterReader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(OuterReader, OuterJson) || !OuterJson.IsValid())
	{
		return;
	}

	FString DataStr;
	if (!OuterJson->TryGetStringField(TEXT("data"), DataStr))
	{
		return;
	}

	// Parse the inner JSON status
	TSharedPtr<FJsonObject> StatusJson;
	TSharedRef<TJsonReader<>> StatusReader = TJsonReaderFactory<>::Create(DataStr);
	if (!FJsonSerializer::Deserialize(StatusReader, StatusJson) || !StatusJson.IsValid())
	{
		return;
	}

	FString State;
	if (StatusJson->TryGetStringField(TEXT("state"), State))
	{
		if (State != WorkerState)
		{
			WorkerState = State;
			UE_LOG(LogRosBridge, Log, TEXT("Worker state: %s"), *WorkerState);

			if (GEngine)
			{
				FColor Color = FColor::White;
				if (State == TEXT("recording")) Color = FColor::Green;
				else if (State == TEXT("replaying")) Color = FColor::Cyan;
				else if (State == TEXT("idle")) Color = FColor::Silver;

				GEngine->AddOnScreenDebugMessage(-1, 3.0f, Color,
					FString::Printf(TEXT("Robot state: %s"), *WorkerState));
			}
		}
	}

	// Update sync (teleop) status
	bool bTeleop = false;
	if (StatusJson->TryGetBoolField(TEXT("teleop"), bTeleop))
	{
		if (bTeleop != bSyncActive)
		{
			bSyncActive = bTeleop;
			UE_LOG(LogRosBridge, Log, TEXT("Sync (teleop): %s"),
				bSyncActive ? TEXT("ON") : TEXT("OFF"));
		}
	}

	// Log errors from commands
	FString Status;
	if (StatusJson->TryGetStringField(TEXT("status"), Status) && Status == TEXT("error"))
	{
		FString Reason;
		StatusJson->TryGetStringField(TEXT("reason"), Reason);
		UE_LOG(LogRosBridge, Warning, TEXT("Robot command error: %s"), *Reason);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
				FString::Printf(TEXT("Robot error: %s"), *Reason));
		}
	}

	// Log recording saved info
	FString Filename;
	if (StatusJson->TryGetStringField(TEXT("filename"), Filename) && !Filename.IsEmpty())
	{
		int32 Frames = 0;
		StatusJson->TryGetNumberField(TEXT("frames"), Frames);
		double Duration = 0.0;
		StatusJson->TryGetNumberField(TEXT("duration_sec"), Duration);

		UE_LOG(LogRosBridge, Log, TEXT("Recording saved: %s (%d frames, %.1fs)"),
			*Filename, Frames, Duration);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				FString::Printf(TEXT("Recording saved: %s (%d frames, %.1fs)"),
					*Filename, Frames, Duration));
		}
	}

	// Display device-level errors (USB disconnection, serial errors)
	const TSharedPtr<FJsonObject>* DeviceErrors = nullptr;
	if (StatusJson->TryGetObjectField(TEXT("device_errors"), DeviceErrors) && DeviceErrors)
	{
		FString FollowerErr, LeaderErr;
		(*DeviceErrors)->TryGetStringField(TEXT("follower"), FollowerErr);
		(*DeviceErrors)->TryGetStringField(TEXT("leader"), LeaderErr);

		if (!FollowerErr.IsEmpty())
		{
			UE_LOG(LogRosBridge, Error, TEXT("Follower USB error: %s"), *FollowerErr);
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
					TEXT("*** Follower: USB/Serial ERROR *** Check USB connection."));
			}
		}
		if (!LeaderErr.IsEmpty())
		{
			UE_LOG(LogRosBridge, Error, TEXT("Leader USB error: %s"), *LeaderErr);
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
					TEXT("*** Leader: USB/Serial ERROR *** Check USB connection."));
			}
		}
	}
}

변경 사항

  • OnRobotStatus (수정): device_errors JSON 객체를 파싱하여 follower/leader 각각 빨간색 경고 표시
    • "*** Follower: USB/Serial ERROR ***"
    • "*** Leader: USB/Serial ERROR ***"

 

 

cpp 파일이 업데이트 됐기 때문에 언리얼 빌드를 다시한다.

 

 

WSL2 터미널 3개를 열고 언리얼을 실행하여 테스트한다.

리더암 USB를 강제로 뽑았을 때 에러 메시지 출력 확인 완료. 팔로워암도 마찬가지로 확인 완료.

 

 


 

 

2-4단계: 테스트 후 보완점4 - USB 재연결 시 worker에서 인식 후 재연결

lerobot_worker.py 수정

더보기
#!/usr/bin/env python3
"""
lerobot_worker.py — LeRobot ↔ ZMQ bridge worker

Runs in conda env 'lerobot' (Python 3.12).
Reads joint states from SO-ARM-101 via LeRobot API at ~30Hz,
publishes them over ZMQ PUB, and accepts commands via ZMQ REP.

Supports record/replay of joint trajectories via ZMQ commands:
  - start_teleop: activate leader→follower sync (teleop)
  - stop_teleop:  deactivate teleop sync
  - start_record: begin teleop + record joint frames
  - stop_record:  stop recording, save to ~/recordings/ (teleop stays on)
  - start_replay: replay a saved recording on the follower arm (teleop off)
  - stop_replay:  stop replay immediately
  - estop:        emergency stop — abort all motion immediately
  - list_recordings: list saved recording files

Usage:
    conda activate lerobot
    sudo chmod 666 /dev/ttyACM*
    python scripts/lerobot_worker.py [--follower-port /dev/ttyACM0] [--leader-port /dev/ttyACM1]
                                      [--pub-addr tcp://127.0.0.1:5555]
                                      [--rep-addr tcp://127.0.0.1:5556]
                                      [--rate 30]
                                      [--follower-only | --leader-only]
                                      [--teleop]
                                      [--recordings-dir ~/recordings]
"""

import argparse
import json
import math
import os
import pathlib
import signal
import sys
import time
import traceback
from datetime import datetime

import zmq


# ---------------------------------------------------------------------------
# Joint name ordering (matches URDF & LeRobot calibration — Phase 3.2 verified)
# ---------------------------------------------------------------------------
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]

# ---------------------------------------------------------------------------
# Joint limits in LeRobot DEGREES (empirically measured via calibration).
# These are absolute physical limits — commands are never allowed beyond these.
# Measured by reading get_observation() degrees at calibration range_min/max
# raw encoder positions, using two-point linear interpolation.
# NOTE: These are in LeRobot's coordinate frame (0° = calibration midpoint),
#       NOT URDF coordinates (which use a different zero point).
# ---------------------------------------------------------------------------
JOINT_LIMITS_DEG = {
    "shoulder_pan":  (-119.91, +119.91),
    "shoulder_lift": (-104.62, +104.62),
    "elbow_flex":    ( -97.01,  +97.01),
    "wrist_flex":    (-102.68, +102.68),
    "wrist_roll":    (-180.00, +180.00),
    "gripper":       (  +0.14,  +99.45),
}


def build_args():
    p = argparse.ArgumentParser(description="LeRobot ZMQ worker for SO-ARM-101")
    p.add_argument("--follower-port", default="/dev/ttyACM0",
                    help="Serial port for follower arm")
    p.add_argument("--leader-port", default="/dev/ttyACM1",
                    help="Serial port for leader arm")
    p.add_argument("--follower-id", default="so101_twin_follower",
                    help="Calibration ID for follower")
    p.add_argument("--leader-id", default="so101_twin_leader",
                    help="Calibration ID for leader")
    p.add_argument("--pub-addr", default="tcp://127.0.0.1:5555",
                    help="ZMQ PUB bind address for joint states")
    p.add_argument("--rep-addr", default="tcp://127.0.0.1:5556",
                    help="ZMQ REP bind address for commands")
    p.add_argument("--rate", type=float, default=30.0,
                    help="Read loop frequency in Hz")
    p.add_argument("--follower-only", action="store_true",
                    help="Only connect follower (no leader)")
    p.add_argument("--leader-only", action="store_true",
                    help="Only connect leader (no follower)")
    p.add_argument("--teleop", action="store_true",
                    help="Teleoperation mode: leader arm controls follower arm")
    p.add_argument("--cmd-limit", type=float, default=5.0,
                    help="Safety clamp: max degrees from initial position per joint (default 5.0)")
    p.add_argument("--cmd-joints", default="shoulder_pan",
                    help="Comma-separated joint names that accept commands "
                         "(default: shoulder_pan). Use 'all' for all joints.")
    p.add_argument("--recordings-dir", default="~/recordings",
                    help="Directory to save/load recorded trajectories")
    p.add_argument("--approach-speed", type=float, default=45.0,
                    help="Max degrees/sec for approach moves before replay "
                         "and loop transitions (default: 45.0)")
    return p.parse_args()


class LeRobotWorker:
    def __init__(self, args):
        self.args = args
        self.running = True
        self.follower = None
        self.leader = None
        self.teleop = args.teleop

        # Safety clamp: initial follower position recorded at startup,
        # commands are clamped to ±cmd_limit degrees from this baseline.
        # Only joints listed in allowed_cmd_joints accept commands;
        # others are silently ignored (held at current position).
        self.follower_initial_pos = None       # dict: {joint_name: degrees}
        self.cmd_limit_deg = args.cmd_limit    # default 5.0°
        self.allowed_cmd_joints = (
            set(JOINT_NAMES) if args.cmd_joints.strip().lower() == "all"
            else set(j.strip() for j in args.cmd_joints.split(",") if j.strip())
        )

        # ---------------------------------------------------------------------------
        # Record / Replay state
        # ---------------------------------------------------------------------------
        # state: "idle", "recording", "replaying"
        self.state = "idle"

        # Recording buffer: list of {"ts": float, "joints": {name: deg, ...}}
        self.recording_buffer = []
        self.recording_start_time = None

        # Replay state
        self.replay_frames = []        # loaded frames from file
        self.replay_index = 0          # current frame index
        self.replay_start_time = None  # monotonic time when replay started
        self.replay_loop = False       # whether to loop replay
        self.replay_filename = ""      # currently playing file

        # Approach (smooth transition) state
        # "approaching" phase runs before replay and between loop iterations
        self.approaching = False         # whether we're in approach phase
        self.approach_start_pos = {}     # joint positions at start of approach
        self.approach_target_pos = {}    # target joint positions (first frame)
        self.approach_start_time = None  # monotonic time when approach started
        self.approach_duration = 0.0     # calculated duration in seconds
        self.approach_speed = args.approach_speed  # degrees per second

        # Recordings directory
        self.recordings_dir = pathlib.Path(os.path.expanduser(args.recordings_dir))
        self.recordings_dir.mkdir(parents=True, exist_ok=True)
        print(f"[worker] Recordings dir: {self.recordings_dir}")

        # ZMQ setup
        self.ctx = zmq.Context()

        self.pub = self.ctx.socket(zmq.PUB)
        self.pub.bind(args.pub_addr)
        print(f"[worker] PUB bound on {args.pub_addr}")

        self.rep = self.ctx.socket(zmq.REP)
        self.rep.bind(args.rep_addr)
        print(f"[worker] REP bound on {args.rep_addr}")

        # Poller for non-blocking REP check inside the read loop
        self.poller = zmq.Poller()
        self.poller.register(self.rep, zmq.POLLIN)

        # Heartbeat: track last command received from bridge node.
        # If no command arrives for heartbeat_timeout_sec, auto E-stop.
        # Initialized to current time so we don't trigger immediately at startup.
        self.last_cmd_time = time.monotonic()
        self.heartbeat_timeout_sec = 5.0
        self.heartbeat_triggered = False  # avoid spamming E-stop logs

        # Serial error tracking: consecutive read/write failures per device.
        # After error_threshold consecutive failures, report in PUB message.
        self.follower_error_count = 0
        self.leader_error_count = 0
        self.follower_error_msg = ""
        self.leader_error_msg = ""
        self.error_threshold = 5  # consecutive failures before reporting

        # Auto-reconnect: after reconnect_threshold consecutive errors,
        # attempt disconnect() → connect(). Retry every reconnect_interval_sec.
        self.reconnect_threshold = 30  # ~1 second at 30Hz
        self.reconnect_interval_sec = 5.0
        self.last_follower_reconnect_time = 0.0
        self.last_leader_reconnect_time = 0.0

    def connect_robots(self):
        """Connect to follower and/or leader via LeRobot API."""
        if not self.args.leader_only:
            print(f"[worker] Connecting follower on {self.args.follower_port} "
                  f"(id={self.args.follower_id}) ...")
            from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
            cfg = SO101FollowerConfig(
                port=self.args.follower_port,
                id=self.args.follower_id,
            )
            self.follower = SO101Follower(cfg)
            self.follower.connect()
            print("[worker] Follower connected.")

        if not self.args.follower_only:
            print(f"[worker] Connecting leader on {self.args.leader_port} "
                  f"(id={self.args.leader_id}) ...")
            from lerobot.teleoperators.so_leader import SOLeader, SO101LeaderConfig
            cfg = SO101LeaderConfig(
                port=self.args.leader_port,
                id=self.args.leader_id,
            )
            self.leader = SOLeader(cfg)
            self.leader.connect()
            print("[worker] Leader connected.")

        # Validate teleop requirements
        if self.teleop:
            if self.follower is None or self.leader is None:
                print("[worker] ERROR: --teleop requires both follower and leader connected.")
                print("[worker] Cannot use --teleop with --follower-only or --leader-only.")
                sys.exit(1)
            print("[worker] *** TELEOP MODE ENABLED ***")
            print("[worker] Leader arm will control follower arm in real-time.")
            print("[worker] Move leader arm to starting position, then press Enter to begin...")
            input()
            print("[worker] Teleop active!")

        # Record initial follower position for safety clamping
        if self.follower is not None:
            init_pos = self.read_follower()

            # Load calibration JSON to display physical range info
            calib_range = self._load_calibration_range("follower")

            if init_pos is not None:
                self.follower_initial_pos = dict(init_pos)

                # Print calibration range info
                if calib_range:
                    print("[worker] === CALIBRATION RANGE (raw encoder values) ===")
                    for name in JOINT_NAMES:
                        if name in calib_range:
                            rmin, rmax, cur_raw = calib_range[name]
                            total = rmax - rmin
                            if cur_raw is not None and total > 0:
                                pct = (cur_raw - rmin) / total * 100.0
                                print(f"  {name:20s}: raw {cur_raw:4d}  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]  "
                                      f"({pct:5.1f}% of range)")
                            else:
                                print(f"  {name:20s}: raw  ???  "
                                      f"range [{rmin:4d} ~ {rmax:4d}]")

                print("[worker] === SAFETY CLAMP BASELINE (follower initial position) ===")
                for name in JOINT_NAMES:
                    v = init_pos.get(name, 0.0)
                    soft_lo = v - self.cmd_limit_deg
                    soft_hi = v + self.cmd_limit_deg
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)
                    marker = " <-- CMD OK" if name in self.allowed_cmd_joints else ""
                    print(f"  {name:20s}: {v:+8.2f}°  "
                          f"effective: [{eff_lo:+8.2f}° ~ {eff_hi:+8.2f}°]  "
                          f"(physical: [{hard_lo:+8.2f}°~{hard_hi:+8.2f}°]){marker}")
                print(f"[worker] Allowed command joints: {sorted(self.allowed_cmd_joints)}")
                print(f"[worker] Clamp limit: ±{self.cmd_limit_deg}° from baseline")
            else:
                print("[worker] WARNING: Could not read initial follower position. "
                      "Commands will be REJECTED until baseline is set.")

    def _load_calibration_range(self, arm_type="follower"):
        """
        Load calibration JSON and read current raw positions.
        Returns dict: {joint_name: (range_min, range_max, current_raw)} or None.
        """
        if arm_type == "follower":
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/robots/so_follower"
                / f"{self.args.follower_id}.json"
            )
            robot = self.follower
        else:
            calib_path = (
                pathlib.Path.home()
                / ".cache/huggingface/lerobot/calibration/teleoperators/so_leader"
                / f"{self.args.leader_id}.json"
            )
            robot = self.leader

        try:
            with open(calib_path) as f:
                calib = json.load(f)
            print(f"[worker] Loaded calibration from {calib_path}")

            # Read current raw positions (without normalization)
            current_raw = {}
            if robot is not None:
                try:
                    raw = robot.bus.sync_read("Present_Position", normalize=False)
                    current_raw = dict(raw)
                except Exception as e:
                    print(f"[worker] Could not read raw positions: {e}")

            result = {}
            for name in JOINT_NAMES:
                if name in calib:
                    rmin = calib[name].get("range_min", 0)
                    rmax = calib[name].get("range_max", 4095)
                    cur = current_raw.get(name, None)
                    result[name] = (rmin, rmax, cur)

            return result

        except FileNotFoundError:
            print(f"[worker] Calibration file not found: {calib_path}")
            return None
        except Exception as e:
            print(f"[worker] Error loading calibration: {e}")
            return None

    def _try_reconnect_follower(self):
        """Attempt to reconnect follower after serial errors."""
        now = time.monotonic()
        if now - self.last_follower_reconnect_time < self.reconnect_interval_sec:
            return  # wait before retrying
        self.last_follower_reconnect_time = now
        print("[worker] Attempting follower reconnect...")
        try:
            self.follower.disconnect()
        except Exception:
            pass
        # Force port closed so is_connected returns False and connect() will proceed
        try:
            self.follower.bus.port_handler.closePort()
        except Exception:
            pass
        try:
            self.follower.connect()
            self.follower_error_count = 0
            self.follower_error_msg = ""
            print("[worker] Follower reconnected successfully!")
        except Exception as e:
            print(f"[worker] Follower reconnect failed: {e}")

    def _try_reconnect_leader(self):
        """Attempt to reconnect leader after serial errors."""
        now = time.monotonic()
        if now - self.last_leader_reconnect_time < self.reconnect_interval_sec:
            return  # wait before retrying
        self.last_leader_reconnect_time = now
        print("[worker] Attempting leader reconnect...")
        try:
            self.leader.disconnect()
        except Exception:
            pass
        # Force port closed so is_connected returns False and connect() will proceed
        try:
            self.leader.bus.port_handler.closePort()
        except Exception:
            pass
        try:
            self.leader.connect()
            self.leader_error_count = 0
            self.leader_error_msg = ""
            print("[worker] Leader reconnected successfully!")
        except Exception as e:
            print(f"[worker] Leader reconnect failed: {e}")

    def read_follower(self):
        """Read follower joint positions in degrees. Returns dict or None."""
        if self.follower is None:
            return None
        # Auto-reconnect if too many consecutive errors
        if self.follower_error_count >= self.reconnect_threshold:
            self._try_reconnect_follower()
        try:
            obs = self.follower.get_observation()
            self.follower_error_count = 0
            self.follower_error_msg = ""
            return {name: float(obs[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            self.follower_error_count += 1
            self.follower_error_msg = str(e)
            if self.follower_error_count <= self.error_threshold:
                print(f"[worker] Follower read error: {e}")
            elif self.follower_error_count == self.error_threshold + 1:
                print(f"[worker] Follower read errors continuing, suppressing logs...")
            return None

    def read_leader(self):
        """Read leader joint positions in degrees. Returns dict or None."""
        if self.leader is None:
            return None
        # Auto-reconnect if too many consecutive errors
        if self.leader_error_count >= self.reconnect_threshold:
            self._try_reconnect_leader()
        try:
            action = self.leader.get_action()
            self.leader_error_count = 0
            self.leader_error_msg = ""
            return {name: float(action[f"{name}.pos"]) for name in JOINT_NAMES}
        except Exception as e:
            self.leader_error_count += 1
            self.leader_error_msg = str(e)
            if self.leader_error_count <= self.error_threshold:
                print(f"[worker] Leader read error: {e}")
            elif self.leader_error_count == self.error_threshold + 1:
                print(f"[worker] Leader read errors continuing, suppressing logs...")
            return None

    def send_follower_action(self, joint_positions_deg):
        """
        Send joint positions (degrees) to follower via LeRobot send_action().
        joint_positions_deg: dict like {"shoulder_pan": 10.0, ...}
        """
        if self.follower is None:
            return
        try:
            action_dict = {f"{name}.pos": float(joint_positions_deg[name])
                           for name in JOINT_NAMES if name in joint_positions_deg}
            self.follower.send_action(action_dict)
        except Exception as e:
            self.follower_error_count += 1
            self.follower_error_msg = str(e)
            if self.follower_error_count <= self.error_threshold:
                print(f"[worker] Follower send_action error: {e}")
            elif self.follower_error_count == self.error_threshold + 1:
                print(f"[worker] Follower send errors continuing, suppressing logs...")

    def handle_command(self, msg):
        """
        Process a command from the ROS bridge node.
        Expected format:
            {"cmd": "send_follower_action", "args": {"shoulder_pan": 0.0, ...}}
            {"cmd": "ping"}
            {"cmd": "start_teleop"}
            {"cmd": "stop_teleop"}
            {"cmd": "start_record"}
            {"cmd": "stop_record"}
            {"cmd": "start_replay", "args": {"filename": "...", "loop": false}}
            {"cmd": "stop_replay"}
            {"cmd": "estop"}
            {"cmd": "list_recordings"}
        Returns a response dict.
        """
        cmd = msg.get("cmd", "")

        if cmd == "ping":
            return {"status": "ok", "state": self.state,
                    "teleop": self.teleop, "ts": time.time()}

        elif cmd == "estop":
            return self._handle_estop()

        elif cmd == "start_teleop":
            return self._handle_start_teleop()

        elif cmd == "stop_teleop":
            return self._handle_stop_teleop()

        elif cmd == "start_record":
            return self._handle_start_record()

        elif cmd == "stop_record":
            return self._handle_stop_record()

        elif cmd == "start_replay":
            args = msg.get("args", {})
            return self._handle_start_replay(args)

        elif cmd == "stop_replay":
            return self._handle_stop_replay()

        elif cmd == "list_recordings":
            return self._handle_list_recordings()

        elif cmd == "send_follower_action":
            if self.follower is None:
                return {"status": "error", "reason": "follower not connected"}
            if self.follower_initial_pos is None:
                return {"status": "error", "reason": "safety baseline not set (no initial position)"}
            joint_args = msg.get("args", {})
            if not joint_args:
                return {"status": "error", "reason": "empty args"}
            try:
                # Two-layer safety clamp:
                #   Layer 1: baseline ± cmd_limit (session safety)
                #   Layer 2: JOINT_LIMITS_DEG (absolute physical limits, measured empirically)
                clamped = {}
                rejected = []
                for name, target_deg in joint_args.items():
                    if name not in JOINT_NAMES:
                        continue
                    if name not in self.allowed_cmd_joints:
                        rejected.append(name)
                        continue

                    target = float(target_deg)

                    # Layer 1: baseline ± cmd_limit
                    baseline = self.follower_initial_pos.get(name, 0.0)
                    soft_lo = baseline - self.cmd_limit_deg
                    soft_hi = baseline + self.cmd_limit_deg

                    # Layer 2: absolute physical limits
                    hard_lo, hard_hi = JOINT_LIMITS_DEG.get(name, (-180.0, 180.0))

                    # Effective range = intersection of both
                    eff_lo = max(soft_lo, hard_lo)
                    eff_hi = min(soft_hi, hard_hi)

                    clamped_val = max(eff_lo, min(eff_hi, target))
                    clamped[name] = clamped_val

                    if abs(clamped_val - target) > 0.01:
                        reason = ""
                        if target < hard_lo or target > hard_hi:
                            reason = " [PHYSICAL LIMIT]"
                        else:
                            reason = " [BASELINE LIMIT]"
                        print(f"[worker] CLAMP {name}: "
                              f"requested {target:.2f}° → clamped to {clamped_val:.2f}° "
                              f"(effective: {eff_lo:.2f}° ~ {eff_hi:.2f}°){reason}")

                if not clamped:
                    return {"status": "error",
                            "reason": f"no allowed joints in command "
                                      f"(rejected: {rejected}, allowed: {sorted(self.allowed_cmd_joints)})"}

                action_dict = {f"{name}.pos": val for name, val in clamped.items()}
                self.follower.send_action(action_dict)
                resp = {"status": "ok", "clamped": clamped}
                if rejected:
                    resp["rejected_joints"] = rejected
                return resp
            except Exception as e:
                return {"status": "error", "reason": str(e)}

        else:
            return {"status": "error", "reason": f"unknown cmd: {cmd}"}

    # -----------------------------------------------------------------------
    # Teleop (Sync) handlers
    # -----------------------------------------------------------------------

    def _handle_start_teleop(self):
        """Activate leader→follower sync (teleop) with smooth approach."""
        if self.state == "replaying":
            return {"status": "error",
                    "reason": "cannot start teleop during replay — stop replay first"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "teleop requires both follower and leader connected"}
        if self.teleop:
            return {"status": "ok", "state": self.state, "teleop": True,
                    "msg": "teleop already active"}
        if self.state == "syncing":
            return {"status": "ok", "state": self.state, "teleop": False,
                    "msg": "already approaching leader position"}

        # Read leader's current position as approach target
        leader_pos = self.read_leader()
        if leader_pos is None:
            # Can't read leader — just turn on teleop directly
            self.teleop = True
            print("[worker] Teleop (sync) ON — leader → follower (no approach)")
            return {"status": "ok", "state": self.state, "teleop": True}

        # Start approach to leader position, then activate teleop
        self.state = "syncing"
        self._start_approach(leader_pos)
        if not self.approaching:
            # Already close enough — activate teleop immediately
            self.state = "idle"
            self.teleop = True
            print("[worker] Teleop (sync) ON — already aligned")
            return {"status": "ok", "state": self.state, "teleop": True}

        print("[worker] Syncing: approaching leader position before teleop ON")
        return {"status": "ok", "state": "syncing", "teleop": False,
                "approach_sec": round(self.approach_duration, 2)}

    def _handle_stop_teleop(self):
        """Deactivate leader→follower sync (teleop)."""
        if self.state == "recording":
            return {"status": "error",
                    "reason": "cannot stop teleop during recording — stop recording first"}
        # Cancel syncing approach if in progress
        if self.state == "syncing":
            self.approaching = False
            self.state = "idle"
            print("[worker] Syncing cancelled")
        self.teleop = False
        print("[worker] Teleop (sync) OFF")
        return {"status": "ok", "state": self.state, "teleop": False}

    # -----------------------------------------------------------------------
    # Record / Replay / E-Stop handlers
    # -----------------------------------------------------------------------

    def _handle_estop(self):
        """Emergency stop: abort any ongoing motion, return to idle."""
        prev_state = self.state
        prev_teleop = self.teleop
        self.state = "idle"
        self.teleop = False
        self.recording_buffer = []
        self.replay_frames = []
        self.replay_index = 0
        self.approaching = False
        print(f"[worker] *** E-STOP *** (was: state={prev_state}, teleop={prev_teleop})")
        return {"status": "ok", "state": "idle", "teleop": False,
                "previous_state": prev_state}

    def _handle_start_record(self):
        """Start recording: teleop must already be active (Sync On first)."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start recording in state '{self.state}'"}
        if self.follower is None or self.leader is None:
            return {"status": "error",
                    "reason": "recording requires both follower and leader connected"}
        if not self.teleop:
            return {"status": "error",
                    "reason": "teleop (sync) must be active before recording — "
                              "press Sync On first"}

        self.recording_buffer = []
        self.recording_start_time = time.monotonic()
        self.state = "recording"
        print("[worker] *** RECORDING STARTED *** (teleop already active)")
        return {"status": "ok", "state": "recording", "teleop": True}

    def _handle_stop_record(self):
        """Stop recording: save buffer to file. Teleop stays ON."""
        if self.state != "recording":
            return {"status": "error",
                    "reason": f"not recording (state: '{self.state}')"}

        num_frames = len(self.recording_buffer)
        if num_frames == 0:
            self.state = "idle"
            # Teleop stays ON — user controls it via Sync button
            print("[worker] Recording stopped — 0 frames, nothing saved.")
            return {"status": "ok", "state": "idle", "teleop": self.teleop,
                    "frames": 0, "filename": None}

        # Calculate duration from frame timestamps
        duration = self.recording_buffer[-1]["ts"] - self.recording_buffer[0]["ts"]

        # Build recording file
        timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"recording_{timestamp_str}.json"
        filepath = self.recordings_dir / filename

        recording_data = {
            "version": 1,
            "recorded_at": datetime.now().isoformat(),
            "rate_hz": self.args.rate,
            "num_frames": num_frames,
            "duration_sec": round(duration, 3),
            "joint_names": JOINT_NAMES,
            "frames": self.recording_buffer,
        }

        with open(filepath, "w") as f:
            json.dump(recording_data, f, indent=2)

        self.state = "idle"
        # Teleop stays ON — user controls it via Sync button
        self.recording_buffer = []
        print(f"[worker] Recording saved: {filepath}")
        print(f"[worker]   {num_frames} frames, {duration:.1f}s")
        return {
            "status": "ok",
            "state": "idle",
            "teleop": self.teleop,
            "filename": filename,
            "frames": num_frames,
            "duration_sec": round(duration, 3),
        }

    def _handle_start_replay(self, args):
        """Load a recording file and start replaying on the follower."""
        if self.state != "idle":
            return {"status": "error",
                    "reason": f"cannot start replay in state '{self.state}'"}
        if self.follower is None:
            return {"status": "error",
                    "reason": "replay requires follower connected"}

        filename = args.get("filename", "")
        self.replay_loop = args.get("loop", False)

        # Allow overriding approach speed per command
        speed = args.get("approach_speed", self.approach_speed)

        # If no filename, use the most recent recording
        if not filename:
            recordings = self._get_recording_files()
            if not recordings:
                return {"status": "error", "reason": "no recordings found"}
            filename = recordings[-1]  # most recent (sorted by name = sorted by time)

        filepath = self.recordings_dir / filename
        if not filepath.exists():
            return {"status": "error",
                    "reason": f"file not found: {filename}"}

        try:
            with open(filepath) as f:
                data = json.load(f)
        except Exception as e:
            return {"status": "error", "reason": f"failed to load: {e}"}

        frames = data.get("frames", [])
        if len(frames) < 2:
            return {"status": "error",
                    "reason": f"recording too short ({len(frames)} frames)"}

        self.replay_frames = frames
        self.replay_index = 0
        self.replay_filename = filename
        self.teleop = False  # disable teleop during replay
        self.state = "replaying"

        # Start approach phase: smoothly move from current position to first frame
        first_frame_joints = frames[0].get("joints", {})
        self._start_approach(first_frame_joints, speed)

        duration = data.get("duration_sec", 0)
        print(f"[worker] *** REPLAY STARTED *** {filename}")
        print(f"[worker]   {len(frames)} frames, {duration}s, "
              f"loop={'yes' if self.replay_loop else 'no'}, "
              f"approach={self.approach_duration:.1f}s")
        return {
            "status": "ok",
            "state": "replaying",
            "filename": filename,
            "frames": len(frames),
            "duration_sec": duration,
            "loop": self.replay_loop,
            "approach_sec": round(self.approach_duration, 2),
        }

    def _handle_stop_replay(self):
        """Stop replay immediately."""
        if self.state != "replaying":
            return {"status": "error",
                    "reason": f"not replaying (state: '{self.state}')"}

        self.state = "idle"
        self.replay_frames = []
        self.replay_index = 0
        print(f"[worker] Replay stopped ({self.replay_filename})")
        return {"status": "ok", "state": "idle"}

    def _handle_list_recordings(self):
        """List all saved recording files."""
        files = self._get_recording_files()
        # Add metadata summary for each file
        summaries = []
        for fname in files:
            try:
                with open(self.recordings_dir / fname) as f:
                    data = json.load(f)
                summaries.append({
                    "filename": fname,
                    "frames": data.get("num_frames", 0),
                    "duration_sec": data.get("duration_sec", 0),
                    "recorded_at": data.get("recorded_at", ""),
                })
            except Exception:
                summaries.append({"filename": fname})
        return {"status": "ok", "recordings": summaries}

    def _get_recording_files(self):
        """Get sorted list of recording JSON files."""
        files = sorted(
            f.name for f in self.recordings_dir.glob("recording_*.json")
            if f.is_file()
        )
        return files

    # -----------------------------------------------------------------------
    # Approach (smooth transition) logic
    # -----------------------------------------------------------------------

    def _start_approach(self, target_joints, speed=None):
        """
        Begin a smooth approach from current follower position to target_joints.
        Duration is calculated from the largest joint angle difference
        divided by speed (degrees/sec).
        """
        if speed is None:
            speed = self.approach_speed

        current = self.read_follower()
        if current is None:
            # Can't read current position — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_start_pos = dict(current)
        self.approach_target_pos = dict(target_joints)

        # Calculate duration based on max joint difference
        max_diff = 0.0
        for name in JOINT_NAMES:
            if name in current and name in target_joints:
                diff = abs(target_joints[name] - current[name])
                max_diff = max(max_diff, diff)

        if max_diff < 1.0:
            # Already close enough — skip approach
            self.approaching = False
            self.replay_start_time = time.monotonic()
            return

        self.approach_duration = max_diff / speed
        # Minimum 0.3s, even for small moves
        self.approach_duration = max(self.approach_duration, 0.3)
        self.approach_start_time = time.monotonic()
        self.approaching = True
        print(f"[worker] Approach: {max_diff:.1f}° max diff, "
              f"{self.approach_duration:.1f}s at {speed}°/s")

    def _tick_approach(self):
        """
        Advance the approach interpolation by one tick.
        Returns interpolated joint positions, or None if approach is finished.
        """
        if not self.approaching:
            return None

        elapsed = time.monotonic() - self.approach_start_time
        t = elapsed / self.approach_duration  # 0.0 → 1.0

        if t >= 1.0:
            # Approach finished — send final target
            self.send_follower_action(self.approach_target_pos)
            self.approaching = False
            self.replay_start_time = time.monotonic()
            print("[worker] Approach complete")
            return self.read_follower()

        # Smooth interpolation using ease-in-out (cosine)
        # t=0 → factor=0, t=1 → factor=1, smooth acceleration/deceleration
        factor = (1.0 - math.cos(t * math.pi)) / 2.0

        interpolated = {}
        for name in JOINT_NAMES:
            start_val = self.approach_start_pos.get(name, 0.0)
            end_val = self.approach_target_pos.get(name, 0.0)
            interpolated[name] = start_val + (end_val - start_val) * factor

        self.send_follower_action(interpolated)
        return self.read_follower()

    # -----------------------------------------------------------------------
    # Replay tick — called each iteration of the main loop
    # -----------------------------------------------------------------------

    def _tick_replay(self):
        """
        Advance replay by one tick. Handles approach phase, then frame playback.
        Returns the follower position after sending (for PUB).
        """
        # --- Approach phase: smooth transition to start position ---
        if self.approaching:
            return self._tick_approach()

        # --- Normal replay ---
        if not self.replay_frames or self.replay_index >= len(self.replay_frames):
            # Replay finished
            if self.replay_loop and self.replay_frames:
                # Loop: approach back to first frame before restarting
                first_frame_joints = self.replay_frames[0].get("joints", {})
                self.replay_index = 0
                self._start_approach(first_frame_joints)
                if self.approaching:
                    print(f"[worker] Replay loop — approaching start position")
                    return self.read_follower()
                else:
                    # Already close enough, restart immediately
                    print(f"[worker] Replay looping ({self.replay_filename})")
            else:
                self.state = "idle"
                print(f"[worker] Replay finished ({self.replay_filename})")
                return self.read_follower()

        elapsed = time.monotonic() - self.replay_start_time
        first_ts = self.replay_frames[0]["ts"]

        # Advance to the frame that matches current elapsed time
        while self.replay_index < len(self.replay_frames):
            frame = self.replay_frames[self.replay_index]
            frame_time = frame["ts"] - first_ts
            if frame_time <= elapsed:
                # Send this frame to follower
                joints = frame.get("joints", {})
                if joints:
                    self.send_follower_action(joints)
                self.replay_index += 1
            else:
                break  # wait for next tick

        # Re-read follower actual position
        return self.read_follower()

    def check_commands(self):
        """Non-blocking check for incoming REP commands."""
        events = dict(self.poller.poll(timeout=0))  # 0 = non-blocking
        if self.rep in events:
            try:
                raw = self.rep.recv_string(zmq.NOBLOCK)
                msg = json.loads(raw)
                self.last_cmd_time = time.monotonic()
                self.heartbeat_triggered = False
                response = self.handle_command(msg)
                self.rep.send_string(json.dumps(response))
            except zmq.Again:
                pass
            except Exception as e:
                # Must always send a reply on REP socket
                self.rep.send_string(json.dumps({
                    "status": "error", "reason": str(e)
                }))

    def run(self):
        """Main read loop at configured rate."""
        period = 1.0 / self.args.rate
        seq = 0
        teleop_count = 0
        print(f"[worker] Starting read loop at {self.args.rate} Hz "
              f"(period={period*1000:.1f}ms)")

        while self.running:
            t_start = time.monotonic()

            # --- Replaying mode: advance replay timeline ---
            if self.state == "replaying":
                follower_pos = self._tick_replay()
                leader_pos = self.read_leader()

            # --- Syncing mode: approach leader position, then activate teleop ---
            elif self.state == "syncing":
                if self.approaching:
                    follower_pos = self._tick_approach()
                else:
                    # Approach finished — activate teleop
                    self.state = "idle"
                    self.teleop = True
                    print("[worker] Teleop (sync) ON — approach complete")
                    follower_pos = self.read_follower()
                leader_pos = self.read_leader()

            else:
                # Read joint states
                follower_pos = self.read_follower()
                leader_pos = self.read_leader()

                # --- Teleop: send leader position to follower ---
                if self.teleop and leader_pos is not None:
                    self.send_follower_action(leader_pos)
                    teleop_count += 1
                    # Re-read follower after sending action to get updated position
                    follower_pos = self.read_follower()

                # --- Recording mode: buffer the current frame ---
                if self.state == "recording" and follower_pos is not None:
                    frame = {
                        "ts": time.monotonic() - self.recording_start_time,
                        "joints": dict(follower_pos),
                    }
                    self.recording_buffer.append(frame)

            # Build and publish message (degrees — bridge node converts to radians)
            msg = {
                "seq": seq,
                "ts": time.time(),
                "state": self.state,
                "teleop": self.teleop,
            }
            if follower_pos is not None:
                msg["follower"] = follower_pos
            if leader_pos is not None:
                msg["leader"] = leader_pos

            # Report device errors if threshold exceeded
            device_errors = {}
            if self.follower_error_count >= self.error_threshold:
                device_errors["follower"] = self.follower_error_msg
            if self.leader_error_count >= self.error_threshold:
                device_errors["leader"] = self.leader_error_msg
            if device_errors:
                msg["device_errors"] = device_errors

            # Add recording/replay metadata
            if self.state == "recording":
                msg["recording_frames"] = len(self.recording_buffer)
            elif self.state == "replaying":
                msg["replay_progress"] = {
                    "index": self.replay_index,
                    "total": len(self.replay_frames),
                    "filename": self.replay_filename,
                    "loop": self.replay_loop,
                    "approaching": self.approaching,
                }

            self.pub.send_string(json.dumps(msg))
            seq += 1

            # Check for commands (non-blocking)
            self.check_commands()

            # Heartbeat timeout: if no command from bridge for too long,
            # auto E-stop to prevent uncontrolled motion when the
            # Unreal→rosbridge→bridge path is broken.
            # Only triggers when actively doing something (not idle).
            if self.state != "idle" or self.teleop:
                since_last = time.monotonic() - self.last_cmd_time
                if since_last > self.heartbeat_timeout_sec and not self.heartbeat_triggered:
                    self.heartbeat_triggered = True
                    print(f"[worker] *** HEARTBEAT TIMEOUT ({since_last:.1f}s) "
                          f"— auto E-STOP ***")
                    self._handle_estop()

            # Rate limiting
            elapsed = time.monotonic() - t_start
            sleep_time = period - elapsed
            if sleep_time > 0:
                time.sleep(sleep_time)

    def shutdown(self):
        """Clean shutdown."""
        print("\n[worker] Shutting down...")
        self.running = False
        self.state = "idle"

        # Save any in-progress recording
        if self.recording_buffer:
            try:
                self._handle_stop_record()
                print("[worker] Saved in-progress recording before shutdown.")
            except Exception:
                pass

        if self.follower is not None:
            try:
                self.follower.disconnect()
                print("[worker] Follower disconnected.")
            except Exception:
                pass

        if self.leader is not None:
            try:
                self.leader.disconnect()
                print("[worker] Leader disconnected.")
            except Exception:
                pass

        self.pub.close()
        self.rep.close()
        self.ctx.term()
        print("[worker] ZMQ cleaned up. Bye.")


def main():
    args = build_args()
    worker = LeRobotWorker(args)

    # Graceful shutdown on Ctrl+C
    def sig_handler(sig, frame):
        worker.shutdown()
        sys.exit(0)

    signal.signal(signal.SIGINT, sig_handler)
    signal.signal(signal.SIGTERM, sig_handler)

    try:
        worker.connect_robots()
        worker.run()
    except Exception:
        traceback.print_exc()
        worker.shutdown()
        sys.exit(1)


if __name__ == "__main__":
    main()

변경 사항

 

  • reconnect_threshold = 30: 30회 연속 에러(약 1초) 후 reconnect 시도
  • reconnect_interval_sec = 5.0: reconnect 실패 시 5초 간격으로 재시도
  • last_follower_reconnect_time, last_leader_reconnect_time: 마지막 reconnect 시도 시각
  • _try_reconnect_follower() (신규): disconnect() → connect(), 성공 시 에러 카운터 리셋
  • _try_reconnect_leader() (신규): 동일
  • read_follower (수정): threshold 초과 시 reconnect 호출 후 read 시도
  • read_leader (수정): 동일
  • port_handler.is_open이 False가 되면 bus.is_connected가 False를 반환하고, connect()가 정상적으로 진행된다.

 

 

 

USB 강제 제거 후 재연결 동작 흐름

USB 뽑힘
  → 에러 카운터 증가 (5회 → 뷰포트 에러 표시, 30회 → reconnect 시도)
  → disconnect() → connect() 시도 → 실패 (USB 아직 없음)
  → 5초 후 재시도 → 실패 ...

USB 다시 꽂고 detach → attach
  → /dev/ttyACM* 복구
  → 다음 reconnect 시도에서 connect() 성공
  → 에러 카운터 리셋 → 정상 동작 복귀
  → 뷰포트 에러 메시지 자동 소멸

detach - attach는 스크립트로 대체할 예정.

 

 


 

 

2-5단계: 테스트 후 보완점5 - USB 재연결 시 언리얼 뷰포트에 Leader, Follower 재연결 표시

RobotVisualizer.h 수정

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "RobotVisualizer.generated.h"

class UStaticMesh;
class UStaticMeshComponent;
class USceneComponent;
class URosBridgeSubsystem;

/**
 * Visualizes the SO-ARM-101 follower arm in Unreal Engine and provides
 * MoveIt command interface via rosbridge.
 *
 * The component hierarchy mirrors the URDF link/joint structure:
 *   BaseLink -> ShoulderPanJoint -> ShoulderLink -> ShoulderLiftJoint -> ...
 *
 * Each "Joint" SceneComponent is where the ROS joint angle gets applied
 * as a local Z-axis rotation. All child links/meshes rotate with it.
 *
 * Phase 8 additions:
 *   - SendNamedTarget(): publish to /moveit_goal_named (std_msgs/String)
 *   - SendJointGoal(): publish to /moveit_goal_joints (sensor_msgs/JointState)
 *   - SendPoseGoal(): publish to /moveit_goal_pose (geometry_msgs/PoseStamped)
 *   - Blueprint-callable + editor-testable via UPROPERTY buttons
 *
 * Phase 9 additions (Record/Replay/E-Stop):
 *   - StartRecord(): begin teleop recording on worker
 *   - StopRecord(): stop recording, save trajectory
 *   - StartReplay(): replay most recent (or named) recording
 *   - StopReplay(): stop replay
 *   - EStop(): emergency stop all motion
 *   - All commands publish JSON to /robot_command topic
 *   - Worker state feedback via /robot_status subscription
 */
UCLASS()
class SO101_TWIN_API ARobotVisualizer : public AActor
{
	GENERATED_BODY()

public:
	ARobotVisualizer();

protected:
	virtual void BeginPlay() override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

	// =================================================================
	// Configuration
	// =================================================================

	UPROPERTY(EditAnywhere, Category = "ROS|Bridge")
	FString RosBridgeUrl = TEXT("ws://127.0.0.1:9090/?x=1");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateTopic = TEXT("/joint_states");

	UPROPERTY(EditAnywhere, Category = "ROS|Topics")
	FString JointStateType = TEXT("sensor_msgs/JointState");

	// =================================================================
	// MoveIt Command Interface (Phase 8)
	// =================================================================

	// --- Named target ---

	/** Named target to send (e.g. "home", "ready"). Set in Details panel, then call SendNamedTarget(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt")
	FString MoveItNamedTarget = TEXT("home");

	/** Send the named target to MoveIt via /moveit_goal_named topic. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt")
	void SendNamedTarget();

	// --- Joint goal ---

	/** Joint goal values in radians. Set in Details panel, then call SendJointGoal(). */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderPan = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalShoulderLift = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalElbowFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristFlex = 0.0f;

	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Joints")
	float GoalWristRoll = 0.0f;

	/** Send joint goal to MoveIt via /moveit_goal_joints topic. Values are in radians. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Joints")
	void SendJointGoal();

	// --- Pose goal (Cartesian, position-only for 5DOF) ---

	/** Target position in UE coordinates (cm). Converted to ROS (meters) on send. */
	UPROPERTY(EditAnywhere, Category = "ROS|MoveIt|Pose")
	FVector GoalPositionUE = FVector(10.0f, 0.0f, 15.0f);

	/** Send position-only goal to MoveIt via /moveit_goal_pose topic.
	 *  GoalPositionUE is in Unreal cm, auto-converted to ROS meters with Y flip. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|MoveIt|Pose")
	void SendPoseGoal();

	// =================================================================
	// Teleop Sync (Phase 9)
	// =================================================================

	/** Activate leader→follower sync (teleop). Must be ON before recording. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOn();

	/** Deactivate leader→follower sync. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Sync")
	void SyncOff();

	/** Whether teleop (sync) is currently active. Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	bool bSyncActive = false;

	// =================================================================
	// Record / Replay / E-Stop (Phase 9)
	// =================================================================

	/** Start recording: activates teleop on worker, buffers joint trajectory. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StartRecord();

	/** Stop recording: saves trajectory to file on worker. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Record")
	void StopRecord();

	/** Replay filename (empty = most recent recording). */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	FString ReplayFilename;

	/** Whether to loop the replay continuously. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay")
	bool bReplayLoop = false;

	/** Approach speed in degrees/sec. Controls how fast the robot moves
	 *  to the start position before replay begins. Lower = smoother. */
	UPROPERTY(EditAnywhere, Category = "ROS|Replay", meta = (ClampMin = "5.0", ClampMax = "300.0"))
	float ApproachSpeed = 45.0f;

	/** Start replaying a recorded trajectory on the follower arm. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StartReplay();

	/** Stop replay immediately. */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Replay")
	void StopReplay();

	/** Emergency stop: abort ALL motion immediately (recording, replay, teleop). */
	UFUNCTION(BlueprintCallable, CallInEditor, Category = "ROS|Safety")
	void EStop();

	/** Current worker state (idle/recording/replaying). Updated from /robot_status. */
	UPROPERTY(VisibleAnywhere, Category = "ROS|Status")
	FString WorkerState = TEXT("unknown");

private:
	// =================================================================
	// Component hierarchy
	// =================================================================

	UPROPERTY(VisibleAnywhere, Category = "Robot")
	TObjectPtr<USceneComponent> RobotRoot;

	// Link SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> BaseLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> ShoulderLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> UpperArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> LowerArmLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> WristLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> GripperLink;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Links")
	TObjectPtr<USceneComponent> MovingJawLink;

	// Joint SceneComponents
	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderPanJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ShoulderLiftJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> ElbowFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristFlexJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> WristRollJoint;

	UPROPERTY(VisibleAnywhere, Category = "Robot|Joints")
	TObjectPtr<USceneComponent> GripperJoint;

	// Joint name -> component mapping
	UPROPERTY()
	TMap<FName, TObjectPtr<USceneComponent>> JointComponentMap;

	// Mesh components
	UPROPERTY()
	TArray<TObjectPtr<UStaticMeshComponent>> AllMeshComponents;

	// =================================================================
	// ROS connection
	// =================================================================

	UFUNCTION()
	void OnRosBridgeConnected();

	UFUNCTION()
	void OnRosBridgeDisconnected();

	UFUNCTION()
	void OnRosMessage(const FString& Topic, const FString& MessageJson);

	/** Tracks rosbridge connection state for viewport warnings. */
	bool bRosBridgeConnected = false;

	void ParseAndApplyJointStates(const FString& MessageJson);

	// =================================================================
	// MoveIt publish helpers
	// =================================================================

	/** Advertise MoveIt command topics. Called once on connect. */
	void AdvertiseMoveItTopics();

	/** Whether MoveIt topics have been advertised in this connection session. */
	bool bMoveItTopicsAdvertised = false;

	// =================================================================
	// Record / Replay / E-Stop helpers
	// =================================================================

	/** Advertise /robot_command and subscribe /robot_status. Called once on connect. */
	void SetupRecordReplayTopics();

	/** Whether record/replay topics have been set up. */
	bool bRecordReplayTopicsSetup = false;

	/** Send a JSON command to /robot_command topic. */
	void PublishRobotCommand(const FString& JsonCmd);

	/** Handle /robot_status messages from the bridge node. */
	UFUNCTION()
	void OnRobotStatus(const FString& Topic, const FString& MessageJson);

	// =================================================================
	// Connection health monitoring (Unreal-side)
	// =================================================================

	/** Called periodically to check bridge and worker heartbeats. */
	void CheckConnectionHealth();

	/** Timer handle for health check. */
	FTimerHandle ConnectionHealthTimerHandle;

	/** Last time we received /bridge_heartbeat. */
	double LastBridgeHeartbeatTime = 0.0;

	/** Last time we received /joint_states (worker data via bridge). */
	double LastJointStatesTime = 0.0;

	/** Timeout in seconds for bridge heartbeat (bridge publishes every 1s). */
	float BridgeHeartbeatTimeoutSec = 4.0f;

	/** Timeout in seconds for worker data (/joint_states at 30Hz). */
	float WorkerDataTimeoutSec = 3.0f;

	/** Whether bridge heartbeat has been lost. */
	bool bBridgeHeartbeatLost = false;

	/** Whether worker data has been lost. */
	bool bWorkerDataLost = false;

	// =================================================================
	// Helpers (declared in original header, kept for compatibility)
	// =================================================================

	USceneComponent* CreateJointComponent(const FName& Name, USceneComponent* Parent,
		const FVector& Location, const FRotator& Rotation);

	USceneComponent* CreateLinkComponent(const FName& Name, USceneComponent* Parent);

	UStaticMeshComponent* AttachMesh(USceneComponent* Parent, UStaticMesh* Mesh,
		const FName& Name, const FVector& Location, const FRotator& Rotation,
		bool bIsMotor);
};

변경 사항

 

  • bFollowerDeviceError: follower USB 에러 상태 추적
  • bLeaderDeviceError: leader USB 에러 상태 추적

 

 

 

RobotVisualizer.cpp 수정

더보기
#include "RobotVisualizer.h"
#include "RosCoordConv.h"
#include "RosBridgeSubsystem.h"
#include "RosBridgeLog.h"

#include "Components/SceneComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
#include "UObject/ConstructorHelpers.h"

// =============================================================================
// Mesh asset path helper
// =============================================================================

static UStaticMesh* LoadMeshAsset(const TCHAR* AssetName)
{
	// All meshes live under /Game/Robot/Meshes/
	FString Path = FString::Printf(TEXT("/Game/Robot/Meshes/%s.%s"), AssetName, AssetName);
	UStaticMesh* Mesh = Cast<UStaticMesh>(StaticLoadObject(UStaticMesh::StaticClass(), nullptr, *Path));
	if (!Mesh)
	{
		UE_LOG(LogRosBridge, Warning, TEXT("Failed to load mesh: %s"), *Path);
	}
	return Mesh;
}

// =============================================================================
// Constructor — build the entire component hierarchy
// =============================================================================

ARobotVisualizer::ARobotVisualizer()
{
	PrimaryActorTick.bCanEverTick = false;

	// --- Root ---
	RobotRoot = CreateDefaultSubobject<USceneComponent>(TEXT("RobotRoot"));
	RootComponent = RobotRoot;

	// =========================================================================
	// URDF data converted to UE coordinates:
	//   Position: meters * 100 = cm, Y flipped
	//   Rotation: RPY radians -> FRotator degrees, pitch & yaw negated
	//
	// All values below are pre-computed from so101_follower.urdf.
	// =========================================================================

	// --- base_link (attached directly to root, no joint) ---
	BaseLink = CreateDefaultSubobject<USceneComponent>(TEXT("BaseLink"));
	BaseLink->SetupAttachment(RobotRoot);

	// --- shoulder_pan joint ---
	// URDF origin: xyz(0.0388353, 0, 0.0624) rpy(3.14159, 0, -3.14159)
	ShoulderPanJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderPanJoint"));
	ShoulderPanJoint->SetupAttachment(BaseLink);
	ShoulderPanJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0388353, 0.0, 0.0624));
	ShoulderPanJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159));

	ShoulderLink = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLink"));
	ShoulderLink->SetupAttachment(ShoulderPanJoint);

	// --- shoulder_lift joint ---
	// URDF origin: xyz(-0.0303992, -0.0182778, -0.0542) rpy(-1.5708, -1.5708, 0)
	ShoulderLiftJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ShoulderLiftJoint"));
	ShoulderLiftJoint->SetupAttachment(ShoulderLink);
	ShoulderLiftJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.0303992, -0.0182778, -0.0542));
	ShoulderLiftJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0));

	UpperArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("UpperArmLink"));
	UpperArmLink->SetupAttachment(ShoulderLiftJoint);

	// --- elbow_flex joint ---
	// URDF origin: xyz(-0.11257, -0.028, 0) rpy(0, 0, 1.5708)
	ElbowFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("ElbowFlexJoint"));
	ElbowFlexJoint->SetupAttachment(UpperArmLink);
	ElbowFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.11257, -0.028, 0.0));
	ElbowFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708));

	LowerArmLink = CreateDefaultSubobject<USceneComponent>(TEXT("LowerArmLink"));
	LowerArmLink->SetupAttachment(ElbowFlexJoint);

	// --- wrist_flex joint ---
	// URDF origin: xyz(-0.1349, 0.0052, 0) rpy(0, 0, -1.5708)
	WristFlexJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristFlexJoint"));
	WristFlexJoint->SetupAttachment(LowerArmLink);
	WristFlexJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(-0.1349, 0.0052, 0.0));
	WristFlexJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708));

	WristLink = CreateDefaultSubobject<USceneComponent>(TEXT("WristLink"));
	WristLink->SetupAttachment(WristFlexJoint);

	// --- wrist_roll joint ---
	// URDF origin: xyz(0, -0.0611, 0.0181) rpy(1.5708, 0.0486795, 3.14159)
	WristRollJoint = CreateDefaultSubobject<USceneComponent>(TEXT("WristRollJoint"));
	WristRollJoint->SetupAttachment(WristLink);
	WristRollJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0, -0.0611, 0.0181));
	WristRollJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159));

	GripperLink = CreateDefaultSubobject<USceneComponent>(TEXT("GripperLink"));
	GripperLink->SetupAttachment(WristRollJoint);

	// --- gripper joint ---
	// URDF origin: xyz(0.0202, 0.0188, -0.0234) rpy(1.5708, 0, 0)
	GripperJoint = CreateDefaultSubobject<USceneComponent>(TEXT("GripperJoint"));
	GripperJoint->SetupAttachment(GripperLink);
	GripperJoint->SetRelativeLocation(RosCoordConv::RosToUePosition(0.0202, 0.0188, -0.0234));
	GripperJoint->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0));

	MovingJawLink = CreateDefaultSubobject<USceneComponent>(TEXT("MovingJawLink"));
	MovingJawLink->SetupAttachment(GripperJoint);

	// --- Joint name mapping (matches ROS /joint_states names) ---
	JointComponentMap.Add(FName("shoulder_pan"),  ShoulderPanJoint);
	JointComponentMap.Add(FName("shoulder_lift"), ShoulderLiftJoint);
	JointComponentMap.Add(FName("elbow_flex"),    ElbowFlexJoint);
	JointComponentMap.Add(FName("wrist_flex"),    WristFlexJoint);
	JointComponentMap.Add(FName("wrist_roll"),    WristRollJoint);
	JointComponentMap.Add(FName("gripper"),       GripperJoint);
}

// =============================================================================
// BeginPlay — load meshes and attach, connect to ROS
// =============================================================================

void ARobotVisualizer::BeginPlay()
{
	Super::BeginPlay();

	// --- Load meshes and attach to links ---
	// Meshes are loaded at runtime (not in constructor) because
	// StaticLoadObject is safer to call here and allows hot-reload.

	// Helper lambda to reduce repetition
	auto Attach = [this](USceneComponent* Parent, const TCHAR* MeshName,
		double RosX, double RosY, double RosZ,
		double RosRoll, double RosPitch, double RosYaw,
		bool bIsMotor = false)
	{
		UStaticMesh* Mesh = LoadMeshAsset(MeshName);
		if (!Mesh) return;

		UStaticMeshComponent* SMC = NewObject<UStaticMeshComponent>(this);
		SMC->SetStaticMesh(Mesh);
		SMC->SetRelativeLocation(RosCoordConv::RosToUePosition(RosX, RosY, RosZ));
		SMC->SetRelativeRotation(RosCoordConv::RosRpyToUeRotator(RosRoll, RosPitch, RosYaw));
		SMC->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		SMC->AttachToComponent(Parent, FAttachmentTransformRules::KeepRelativeTransform);
		SMC->RegisterComponent();
		AllMeshComponents.Add(SMC);
	};

	// === base_link meshes ===
	Attach(BaseLink, TEXT("base_motor_holder_so101_v1"),
		-0.00636471, -0.0000994414, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("base_so101_v2"),
		-0.00636471, 0.0, -0.0024,
		1.5708, 0.0, 1.5708);
	Attach(BaseLink, TEXT("sts3215_03a_v1"),
		0.0263353, 0.0, 0.0437,
		0.0, 0.0, 0.0, true);
	Attach(BaseLink, TEXT("waveshare_mounting_plate_so101_v2"),
		-0.0309827, -0.000199441, 0.0474,
		1.5708, 0.0, 1.5708);

	// === shoulder_link meshes ===
	Attach(ShoulderLink, TEXT("sts3215_03a_v1"),
		-0.0303992, 0.000422241, -0.0417,
		1.5708, 1.5708, 0.0, true);
	Attach(ShoulderLink, TEXT("motor_holder_so101_base_v1"),
		-0.0675992, -0.000177759, 0.0158499,
		1.5708, -1.5708, 0.0);
	Attach(ShoulderLink, TEXT("rotation_pitch_so101_v1"),
		0.0122008, 0.0000222413, 0.0464,
		-1.5708, 0.0, 0.0);

	// === upper_arm_link meshes ===
	Attach(UpperArmLink, TEXT("sts3215_03a_v1"),
		-0.11257, -0.0155, 0.0187,
		-3.14159, 0.0, -1.5708, true);
	Attach(UpperArmLink, TEXT("upper_arm_so101_v1"),
		-0.065085, 0.012, 0.0182,
		3.14159, 0.0, 0.0);

	// === lower_arm_link meshes ===
	Attach(LowerArmLink, TEXT("under_arm_so101_v1"),
		-0.0648499, -0.032, 0.0182,
		3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("motor_holder_so101_wrist_v1"),
		-0.0648499, -0.032, 0.018,
		-3.14159, 0.0, 0.0);
	Attach(LowerArmLink, TEXT("sts3215_03a_v1"),
		-0.1224, 0.0052, 0.0187,
		-3.14159, 0.0, -3.14159, true);

	// === wrist_link meshes ===
	Attach(WristLink, TEXT("sts3215_03a_no_horn_v1"),
		0.0, -0.0424, 0.0306,
		1.5708, 1.5708, 0.0, true);
	Attach(WristLink, TEXT("wrist_roll_pitch_so101_v2"),
		0.0, -0.028, 0.0181,
		-1.5708, -1.5708, 0.0);

	// === gripper_link meshes ===
	Attach(GripperLink, TEXT("sts3215_03a_v1"),
		0.0077, 0.0001, -0.0234,
		-1.5708, 0.0, 0.0, true);
	Attach(GripperLink, TEXT("wrist_roll_follower_so101_v1"),
		0.0, -0.000218214, 0.000949706,
		-3.14159, 0.0, 0.0);

	// === moving_jaw_link meshes ===
	Attach(MovingJawLink, TEXT("moving_jaw_so101_v1"),
		0.0, 0.0, 0.0189,
		0.0, 0.0, 0.0);

	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: %d mesh components created"), AllMeshComponents.Num());

	// --- Connect to ROS via Subsystem ---
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Bind delegates.
	Ros->OnTopicMessage.AddDynamic(this, &ARobotVisualizer::OnRosMessage);
	Ros->OnConnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
	Ros->OnDisconnected.AddDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);

	// Subscribe is now queued even before connection — the subsystem will
	// send it automatically when connected (including on reconnect).
	Ros->Subscribe(JointStateTopic, JointStateType);

	// Queue MoveIt topic advertisements (sent on connect).
	AdvertiseMoveItTopics();

	// Queue record/replay/estop topics.
	SetupRecordReplayTopics();

	// Subscribe to bridge heartbeat for connection health monitoring.
	Ros->Subscribe(TEXT("/bridge_heartbeat"), TEXT("std_msgs/String"));

	// Start connection health monitor (checks every 2 seconds).
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;
	GetWorldTimerManager().SetTimer(
		ConnectionHealthTimerHandle, this,
		&ARobotVisualizer::CheckConnectionHealth, 2.0f, true);

	// Initiate connection if not already connected.
	if (!Ros->IsConnected())
	{
		Ros->Connect(RosBridgeUrl);
	}
	else
	{
		// Already connected (e.g. another actor already called Connect).
		UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: already connected, subscription sent."));
	}
}

// =============================================================================
// EndPlay
// =============================================================================

void ARobotVisualizer::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (UGameInstance* GI = UGameplayStatics::GetGameInstance(this))
	{
		if (URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>())
		{
			Ros->OnTopicMessage.RemoveDynamic(this, &ARobotVisualizer::OnRosMessage);
			Ros->OnConnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeConnected);
			Ros->OnDisconnected.RemoveDynamic(this, &ARobotVisualizer::OnRosBridgeDisconnected);
		}
	}

	bMoveItTopicsAdvertised = false;
	bRecordReplayTopicsSetup = false;

	GetWorldTimerManager().ClearTimer(ConnectionHealthTimerHandle);

	Super::EndPlay(EndPlayReason);
}

// =============================================================================
// ROS connection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeConnected()
{
	bRosBridgeConnected = true;
	UE_LOG(LogRosBridge, Log,
		TEXT("RobotVisualizer: rosbridge connected — subscriptions restored by subsystem."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
			TEXT("ROS Bridge: Connected"));
	}
}

// =============================================================================
// ROS disconnection callback
// =============================================================================

void ARobotVisualizer::OnRosBridgeDisconnected()
{
	bRosBridgeConnected = false;
	WorkerState = TEXT("disconnected");

	UE_LOG(LogRosBridge, Warning,
		TEXT("RobotVisualizer: rosbridge DISCONNECTED — cannot send commands. Auto-reconnect active."));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
			TEXT("*** ROS Bridge DISCONNECTED *** Cannot send commands. Reconnecting..."));
	}
}

// =============================================================================
// Connection health monitoring
// =============================================================================

void ARobotVisualizer::CheckConnectionHealth()
{
	// Only check when WebSocket is connected — if WebSocket is down,
	// OnRosBridgeDisconnected already shows a warning for that.
	if (!bRosBridgeConnected)
	{
		return;
	}

	const double Now = FPlatformTime::Seconds();

	// Check bridge heartbeat (published every 1s by bridge_node)
	const double BridgeElapsed = Now - LastBridgeHeartbeatTime;
	if (BridgeElapsed > BridgeHeartbeatTimeoutSec && !bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = true;
		bWorkerDataLost = true;  // if bridge is down, worker data can't reach us either
		WorkerState = TEXT("bridge lost");

		UE_LOG(LogRosBridge, Warning,
			TEXT("Bridge heartbeat lost (%.1fs). bridge_node may be down."), BridgeElapsed);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
				TEXT("*** Bridge Node: DOWN *** Check bridge_node terminal (terminal 2)."));
		}
		return;  // no need to check worker separately
	}

	// Check worker data (/joint_states at ~30Hz, flows through bridge)
	// Only meaningful if bridge is alive.
	if (!bBridgeHeartbeatLost)
	{
		const double WorkerElapsed = Now - LastJointStatesTime;
		if (WorkerElapsed > WorkerDataTimeoutSec && !bWorkerDataLost)
		{
			bWorkerDataLost = true;
			WorkerState = TEXT("worker lost");

			UE_LOG(LogRosBridge, Warning,
				TEXT("Worker data lost (%.1fs). lerobot_worker may be down."), WorkerElapsed);

			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
					TEXT("*** Worker: DOWN *** Check lerobot_worker terminal (terminal 1)."));
			}
		}
	}
}

// =============================================================================
// MoveIt topic advertisements
// =============================================================================

void ARobotVisualizer::AdvertiseMoveItTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// These are queued and sent automatically when connected.
	Ros->Advertise(TEXT("/moveit_goal_named"), TEXT("std_msgs/String"));
	Ros->Advertise(TEXT("/moveit_goal_joints"), TEXT("sensor_msgs/JointState"));
	Ros->Advertise(TEXT("/moveit_goal_pose"), TEXT("geometry_msgs/PoseStamped"));

	bMoveItTopicsAdvertised = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: MoveIt command topics advertised."));
}

// =============================================================================
// MoveIt commands — SendNamedTarget
// =============================================================================

void ARobotVisualizer::SendNamedTarget()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendNamedTarget: not connected to rosbridge."));
		return;
	}

	// std_msgs/String: {"data": "home"}
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *MoveItNamedTarget);
	Ros->Publish(TEXT("/moveit_goal_named"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("SendNamedTarget: published '%s' to /moveit_goal_named"), *MoveItNamedTarget);
}

// =============================================================================
// MoveIt commands — SendJointGoal
// =============================================================================

void ARobotVisualizer::SendJointGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendJointGoal: not connected to rosbridge."));
		return;
	}

	// sensor_msgs/JointState:
	// {
	//   "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": ""},
	//   "name": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"],
	//   "position": [0.0, -0.5, 0.5, 0.0, 0.0],
	//   "velocity": [],
	//   "effort": []
	// }

	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"\"},")
		TEXT("\"name\":[\"shoulder_pan\",\"shoulder_lift\",\"elbow_flex\",\"wrist_flex\",\"wrist_roll\"],")
		TEXT("\"position\":[%f,%f,%f,%f,%f],")
		TEXT("\"velocity\":[],\"effort\":[]}"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll
	);

	Ros->Publish(TEXT("/moveit_goal_joints"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendJointGoal: [%.3f, %.3f, %.3f, %.3f, %.3f] to /moveit_goal_joints"),
		GoalShoulderPan, GoalShoulderLift, GoalElbowFlex, GoalWristFlex, GoalWristRoll);
}

// =============================================================================
// MoveIt commands — SendPoseGoal
// =============================================================================

void ARobotVisualizer::SendPoseGoal()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("SendPoseGoal: not connected to rosbridge."));
		return;
	}

	// Convert UE position (cm, left-handed) to ROS (meters, right-handed)
	double RosX, RosY, RosZ;
	RosCoordConv::UeToRosPosition(GoalPositionUE, RosX, RosY, RosZ);

	// geometry_msgs/PoseStamped (orientation defaults to identity — position-only goal)
	FString MsgJson = FString::Printf(
		TEXT("{\"header\":{\"stamp\":{\"sec\":0,\"nanosec\":0},\"frame_id\":\"base_link\"},")
		TEXT("\"pose\":{\"position\":{\"x\":%f,\"y\":%f,\"z\":%f},")
		TEXT("\"orientation\":{\"x\":0.0,\"y\":0.0,\"z\":0.0,\"w\":1.0}}}"),
		RosX, RosY, RosZ
	);

	Ros->Publish(TEXT("/moveit_goal_pose"), MsgJson);

	UE_LOG(LogRosBridge, Log,
		TEXT("SendPoseGoal: UE(%.1f, %.1f, %.1f)cm -> ROS(%.4f, %.4f, %.4f)m to /moveit_goal_pose"),
		GoalPositionUE.X, GoalPositionUE.Y, GoalPositionUE.Z,
		RosX, RosY, RosZ);
}

// =============================================================================
// Message handling
// =============================================================================

void ARobotVisualizer::OnRosMessage(const FString& Topic, const FString& MessageJson)
{
	if (Topic == JointStateTopic)
	{
		// /joint_states arrives at ~30Hz — proves both bridge AND worker are alive.
		LastJointStatesTime = FPlatformTime::Seconds();
		LastBridgeHeartbeatTime = LastJointStatesTime; // joint_states flows through bridge
		if (bWorkerDataLost)
		{
			bWorkerDataLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Worker data restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Worker: Connection restored"));
			}
		}
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored (via joint_states)."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}

		ParseAndApplyJointStates(MessageJson);
	}
	else if (Topic == TEXT("/bridge_heartbeat"))
	{
		// Bridge heartbeat arrives every 1s — proves bridge is alive
		// (but worker may still be dead if /joint_states is not arriving).
		LastBridgeHeartbeatTime = FPlatformTime::Seconds();
		if (bBridgeHeartbeatLost)
		{
			bBridgeHeartbeatLost = false;
			UE_LOG(LogRosBridge, Log, TEXT("Bridge heartbeat restored."));
			if (GEngine)
			{
				GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
					TEXT("Bridge: Connection restored"));
			}
		}
	}
	else if (Topic == TEXT("/robot_status"))
	{
		OnRobotStatus(Topic, MessageJson);
	}
}

void ARobotVisualizer::ParseAndApplyJointStates(const FString& MessageJson)
{
	// Parse sensor_msgs/JointState JSON:
	// { "name": ["shoulder_pan", ...], "position": [0.1, ...], ... }

	TSharedPtr<FJsonObject> Json;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(Reader, Json) || !Json.IsValid())
	{
		return;
	}

	const TArray<TSharedPtr<FJsonValue>>* NameArray = nullptr;
	const TArray<TSharedPtr<FJsonValue>>* PosArray = nullptr;

	if (!Json->TryGetArrayField(TEXT("name"), NameArray) ||
		!Json->TryGetArrayField(TEXT("position"), PosArray))
	{
		return;
	}

	const int32 Count = FMath::Min(NameArray->Num(), PosArray->Num());
	for (int32 i = 0; i < Count; ++i)
	{
		const FName JointName(*(*NameArray)[i]->AsString());
		const double AngleRad = (*PosArray)[i]->AsNumber();

		TObjectPtr<USceneComponent>* JointComp = JointComponentMap.Find(JointName);
		if (JointComp && *JointComp)
		{
			const float AngleDeg = RosCoordConv::RosJointAngleToUeDegrees(AngleRad);

			FRotator BaseRot;
			if (JointName == FName("shoulder_pan"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(3.14159, 0.0, -3.14159);
			else if (JointName == FName("shoulder_lift"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(-1.5708, -1.5708, 0.0);
			else if (JointName == FName("elbow_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, 1.5708);
			else if (JointName == FName("wrist_flex"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(0.0, 0.0, -1.5708);
			else if (JointName == FName("wrist_roll"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0486795, 3.14159);
			else if (JointName == FName("gripper"))
				BaseRot = RosCoordConv::RosRpyToUeRotator(1.5708, 0.0, 0.0);

			const FQuat BaseQuat = BaseRot.Quaternion();
			const FQuat JointQuat = FQuat(FVector::UpVector, FMath::DegreesToRadians(AngleDeg));
			const FQuat FinalQuat = BaseQuat * JointQuat;

			(*JointComp)->SetRelativeRotation(FinalQuat.Rotator());
		}
	}
}

// =============================================================================
// Record / Replay / E-Stop — Topic setup
// =============================================================================

void ARobotVisualizer::SetupRecordReplayTopics()
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros) return;

	// Advertise the command topic
	Ros->Advertise(TEXT("/robot_command"), TEXT("std_msgs/String"));

	// Subscribe to status feedback
	Ros->Subscribe(TEXT("/robot_status"), TEXT("std_msgs/String"));

	bRecordReplayTopicsSetup = true;
	UE_LOG(LogRosBridge, Log, TEXT("RobotVisualizer: Record/Replay topics set up."));
}

// =============================================================================
// Record / Replay / E-Stop — Command publisher helper
// =============================================================================

void ARobotVisualizer::PublishRobotCommand(const FString& JsonCmd)
{
	UGameInstance* GI = UGameplayStatics::GetGameInstance(this);
	if (!GI) return;

	URosBridgeSubsystem* Ros = GI->GetSubsystem<URosBridgeSubsystem>();
	if (!Ros || !Ros->IsConnected())
	{
		UE_LOG(LogRosBridge, Warning, TEXT("PublishRobotCommand: not connected to rosbridge."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Red,
				TEXT("Robot: Not connected to rosbridge!"));
		}
		return;
	}

	// std_msgs/String: {"data": "<json_cmd>"}
	// The JSON command is nested inside the "data" field, with quotes escaped.
	FString EscapedCmd = JsonCmd.Replace(TEXT("\""), TEXT("\\\""));
	FString MsgJson = FString::Printf(TEXT("{\"data\":\"%s\"}"), *EscapedCmd);
	Ros->Publish(TEXT("/robot_command"), MsgJson);

	UE_LOG(LogRosBridge, Log, TEXT("PublishRobotCommand: %s"), *JsonCmd);
}

// =============================================================================
// Record / Replay / E-Stop — Button handlers
// =============================================================================

void ARobotVisualizer::StartRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Robot: Recording started (teleop active)"));
	}
}

void ARobotVisualizer::StopRecord()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_record\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Recording stopped, saving..."));
	}
}

void ARobotVisualizer::StartReplay()
{
	FString ArgsJson;
	if (ReplayFilename.IsEmpty())
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"loop\":%s,\"approach_speed\":%f}}"),
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	else
	{
		ArgsJson = FString::Printf(
			TEXT("{\"cmd\":\"start_replay\",\"args\":{\"filename\":\"%s\",\"loop\":%s,\"approach_speed\":%f}}"),
			*ReplayFilename,
			bReplayLoop ? TEXT("true") : TEXT("false"),
			ApproachSpeed);
	}
	PublishRobotCommand(ArgsJson);

	if (GEngine)
	{
		FString DisplayName = ReplayFilename.IsEmpty() ? TEXT("(most recent)") : ReplayFilename;
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Cyan,
			FString::Printf(TEXT("Robot: Replaying %s (loop=%s, speed=%.0f°/s)"),
				*DisplayName, bReplayLoop ? TEXT("yes") : TEXT("no"), ApproachSpeed));
	}
}

void ARobotVisualizer::StopReplay()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_replay\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Robot: Replay stopped"));
	}
}

void ARobotVisualizer::EStop()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"estop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
			TEXT("*** E-STOP *** All motion halted"));
	}
}

// =============================================================================
// Teleop Sync — SyncOn / SyncOff
// =============================================================================

void ARobotVisualizer::SyncOn()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"start_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Green,
			TEXT("Sync ON: leader -> follower active"));
	}
}

void ARobotVisualizer::SyncOff()
{
	PublishRobotCommand(TEXT("{\"cmd\":\"stop_teleop\"}"));

	if (GEngine)
	{
		GEngine->AddOnScreenDebugMessage(-1, 3.0f, FColor::Yellow,
			TEXT("Sync OFF: leader -> follower deactivated"));
	}
}

// =============================================================================
// Record / Replay — Status feedback handler
// =============================================================================

void ARobotVisualizer::OnRobotStatus(const FString& Topic, const FString& MessageJson)
{
	// /robot_status flows through bridge from worker — both are alive.
	const double Now = FPlatformTime::Seconds();
	LastBridgeHeartbeatTime = Now;
	LastJointStatesTime = Now;

	if (bBridgeHeartbeatLost)
	{
		bBridgeHeartbeatLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Bridge: Connection restored"));
		}
	}
	if (bWorkerDataLost)
	{
		bWorkerDataLost = false;
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Worker: Connection restored"));
		}
	}

	// rosbridge wraps std_msgs/String as: {"data": "..."}
	TSharedPtr<FJsonObject> OuterJson;
	TSharedRef<TJsonReader<>> OuterReader = TJsonReaderFactory<>::Create(MessageJson);
	if (!FJsonSerializer::Deserialize(OuterReader, OuterJson) || !OuterJson.IsValid())
	{
		return;
	}

	FString DataStr;
	if (!OuterJson->TryGetStringField(TEXT("data"), DataStr))
	{
		return;
	}

	// Parse the inner JSON status
	TSharedPtr<FJsonObject> StatusJson;
	TSharedRef<TJsonReader<>> StatusReader = TJsonReaderFactory<>::Create(DataStr);
	if (!FJsonSerializer::Deserialize(StatusReader, StatusJson) || !StatusJson.IsValid())
	{
		return;
	}

	FString State;
	if (StatusJson->TryGetStringField(TEXT("state"), State))
	{
		if (State != WorkerState)
		{
			WorkerState = State;
			UE_LOG(LogRosBridge, Log, TEXT("Worker state: %s"), *WorkerState);

			if (GEngine)
			{
				FColor Color = FColor::White;
				if (State == TEXT("recording")) Color = FColor::Green;
				else if (State == TEXT("replaying")) Color = FColor::Cyan;
				else if (State == TEXT("idle")) Color = FColor::Silver;

				GEngine->AddOnScreenDebugMessage(-1, 3.0f, Color,
					FString::Printf(TEXT("Robot state: %s"), *WorkerState));
			}
		}
	}

	// Update sync (teleop) status
	bool bTeleop = false;
	if (StatusJson->TryGetBoolField(TEXT("teleop"), bTeleop))
	{
		if (bTeleop != bSyncActive)
		{
			bSyncActive = bTeleop;
			UE_LOG(LogRosBridge, Log, TEXT("Sync (teleop): %s"),
				bSyncActive ? TEXT("ON") : TEXT("OFF"));
		}
	}

	// Log errors from commands
	FString Status;
	if (StatusJson->TryGetStringField(TEXT("status"), Status) && Status == TEXT("error"))
	{
		FString Reason;
		StatusJson->TryGetStringField(TEXT("reason"), Reason);
		UE_LOG(LogRosBridge, Warning, TEXT("Robot command error: %s"), *Reason);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red,
				FString::Printf(TEXT("Robot error: %s"), *Reason));
		}
	}

	// Log recording saved info
	FString Filename;
	if (StatusJson->TryGetStringField(TEXT("filename"), Filename) && !Filename.IsEmpty())
	{
		int32 Frames = 0;
		StatusJson->TryGetNumberField(TEXT("frames"), Frames);
		double Duration = 0.0;
		StatusJson->TryGetNumberField(TEXT("duration_sec"), Duration);

		UE_LOG(LogRosBridge, Log, TEXT("Recording saved: %s (%d frames, %.1fs)"),
			*Filename, Frames, Duration);

		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				FString::Printf(TEXT("Recording saved: %s (%d frames, %.1fs)"),
					*Filename, Frames, Duration));
		}
	}

	// Display device-level errors (USB disconnection, serial errors)
	const TSharedPtr<FJsonObject>* DeviceErrors = nullptr;
	bool bHasDeviceErrors = StatusJson->TryGetObjectField(TEXT("device_errors"), DeviceErrors) && DeviceErrors;

	// Check follower error
	FString FollowerErr;
	if (bHasDeviceErrors)
	{
		(*DeviceErrors)->TryGetStringField(TEXT("follower"), FollowerErr);
	}
	if (!FollowerErr.IsEmpty() && !bFollowerDeviceError)
	{
		bFollowerDeviceError = true;
		UE_LOG(LogRosBridge, Error, TEXT("Follower USB error: %s"), *FollowerErr);
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
				TEXT("*** Follower: USB/Serial ERROR *** Check USB connection."));
		}
	}
	else if (FollowerErr.IsEmpty() && bFollowerDeviceError)
	{
		bFollowerDeviceError = false;
		UE_LOG(LogRosBridge, Log, TEXT("Follower USB restored."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Follower: USB connection restored"));
		}
	}

	// Check leader error
	FString LeaderErr;
	if (bHasDeviceErrors)
	{
		(*DeviceErrors)->TryGetStringField(TEXT("leader"), LeaderErr);
	}
	if (!LeaderErr.IsEmpty() && !bLeaderDeviceError)
	{
		bLeaderDeviceError = true;
		UE_LOG(LogRosBridge, Error, TEXT("Leader USB error: %s"), *LeaderErr);
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 10.0f, FColor::Red,
				TEXT("*** Leader: USB/Serial ERROR *** Check USB connection."));
		}
	}
	else if (LeaderErr.IsEmpty() && bLeaderDeviceError)
	{
		bLeaderDeviceError = false;
		UE_LOG(LogRosBridge, Log, TEXT("Leader USB restored."));
		if (GEngine)
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Green,
				TEXT("Leader: USB connection restored"));
		}
	}
}

 

변경 사항

  • OnRobotStatus의 device_errors 처리를 상태 전환 방식으로 변경:
    • 에러 없음 → 에러 발생: 빨간색 "*** Follower/Leader: USB/Serial ERROR ***" (기존과 동일)
    • 에러 발생 → 에러 해소: 초록색 "Follower/Leader: USB connection restored"
    • 에러 지속 중: 중복 표시 안 함 (한 번만)

 

 

솔루션 빌드

 

 

복구 표시가 잘 나오는 것을 확인할 수 있다. → 어차피 두 USB 모두 detach 후 attach라서 둘 다 동시에 뜸.

 

 


 

 

이번 작업 내용

  • 오류 및 안전 처리 전체 완료. 실물 테스트 완료.
    • WebSocket 끊김 경고
    • bridge/worker 분리 진단
    • heartbeat timeout 자동 E-stop
    • MoveIt 플래닝 실패 피드백
    • USB 분리 시 follower/leader 개별 에러 표시
    • worker 자동 reconnect(터미널 재시작 없이 USB 복구)
    • ZMQ REQ 소켓 자동 복구(worker 재시작 시 bridge 재시작 불필요)

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

728x90
반응형