"""Manual bounded real-SDK/JTC integration check over loopback only.
Run after building/sourcing iiwa_controller; never connects physical hardware.
"""
import csv
import sys
import time
import os
import signal
import subprocess
import tempfile
from pathlib import Path
import xacro
import yaml
root = Path(__file__).resolve().parents[3]
work = Path(tempfile.mkdtemp(prefix='iiwa-fri-smoke-'))
env = dict(os.environ, ROS_DOMAIN_ID='83', ROS_LOCALHOST_ONLY='1', ROS_LOG_DIR=str(work / 'logs'), ROS_HOME=str(work / 'ros'))
env['CYCLONEDDS_URI'] = 'false'
xml = xacro.process_file(str(root / 'src/iiwa_description/urdf/iiwa7.urdf.xacro'), mappings={'robot_ip': '127.0.0.2', 'fri_port': '30283', 'fri_cycle_ms': '10'}).toxml()
assert 'false' in xml
assert '127.0.0.2' in xml
assert '30283' in xml
build = Path(sys.argv[1]) if len(sys.argv) > 1 else root / 'build/iiwa_controller'
server = build / 'fake_fri_server'
shim = build / 'libfri_loopback_bind.so'
assert server.is_file() and shim.is_file(), 'Build the test harness first'
trace = work / 'commands.csv'
params = work / 'robot.yaml'
params.write_text(yaml.safe_dump({'/**': {'ros__parameters': {'robot_description': xml}}}))
processes = []
handles = []
try:
server_log = (work / 'fake_fri_server.log').open('w')
handles.append(server_log)
processes.append(subprocess.Popen([str(server), str(trace)], stdout=server_log, stderr=subprocess.STDOUT, start_new_session=True))
time.sleep(0.1)
assert processes[0].poll() is None, 'Fake peer failed to bind'
for package, executable, extra in [
('robot_state_publisher', 'robot_state_publisher', []),
('controller_manager', 'ros2_control_node', ['--params-file', str(root / 'src/iiwa_config/config/moveit/iiwa_controller.yaml'), '-p', 'update_rate:=100', '-p', 'hardware_synchronization.expect_blocking_read_write:=true']),
]:
log = (work / (executable + '.log')).open('w')
handles.append(log)
processes.append(subprocess.Popen(['ros2', 'run', package, executable, '--ros-args', '--params-file', str(params)] + extra,
env=dict(env, **({'LD_PRELOAD': str(shim)} if executable == 'ros2_control_node' else {})), stdout=log, stderr=subprocess.STDOUT, start_new_session=True))
for name in ['joint_state_broadcaster', 'iiwa_arm_controller']:
result = subprocess.run(['ros2', 'run', 'controller_manager', 'spawner', name, '--controller-manager-timeout', '15'],
env=env, text=True, capture_output=True, timeout=25)
print(result.stdout, result.stderr, flush=True)
assert result.returncode == 0, name
result = subprocess.run(['ros2', 'control', 'list_controllers'], env=env, text=True, capture_output=True, timeout=10)
print(result.stdout, result.stderr, flush=True)
assert result.returncode == 0 and 'iiwa_arm_controller' in result.stdout and 'active' in result.stdout
initial = yaml.safe_load((root / 'src/iiwa_config/config/moveit/initial_positions.yaml').read_text())['initial_positions']
positions = [float(initial[f'joint{i}']) for i in range(1, 8)]
positions[0] += 0.02
goal = {'trajectory': {'joint_names': [f'joint{i}' for i in range(1, 8)],
'points': [{'positions': positions, 'time_from_start': {'sec': 2, 'nanosec': 0}}]}}
result = subprocess.run(['ros2', 'action', 'send_goal', '/iiwa_arm_controller/follow_joint_trajectory',
'control_msgs/action/FollowJointTrajectory', yaml.safe_dump(goal)],
env=env, text=True, capture_output=True, timeout=15)
print(result.stdout, result.stderr, flush=True)
assert result.returncode == 0 and 'SUCCEEDED' in result.stdout
rows = list(csv.DictReader(trace.open()))
assert len(rows) > 100 and processes[0].poll() is None, 'FRI stream stopped'
active = [r for r in rows if int(r['session']) == 4]
assert active and abs(float(active[-1]['position']) - 0.02) < 0.001
deltas = [abs(float(r['delta'])) for r in active]
intervals = [float(b['receive_seconds']) - float(a['receive_seconds']) for a, b in zip(active, active[1:])]
assert max(deltas) <= 1.71 * 0.01 + 1e-6, 'Command exceeds per-frame velocity limit'
print(f'SMOKE PASS: real SDK loopback + actual CM/JTC action succeeded; {len(active)} active frames, max delta={max(deltas):.8f} rad; receive interval min/max={min(intervals):.6f}/{max(intervals):.6f} s.', flush=True)
finally:
for process in processes:
if process.poll() is None:
os.killpg(process.pid, signal.SIGINT)
for process in processes:
try: process.wait(timeout=5)
except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL)
for handle in handles: handle.close()
for log in work.glob('*.log'):
print('\n' + log.name + '\n' + log.read_text()[-4500:])
print('Logs:', work)