269 lines
14 KiB
Python
269 lines
14 KiB
Python
"""Isolated DDS-domain integration fixture. All hardware transports are fake."""
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# The caller assigns a private ROS_DOMAIN_ID before importing rclpy.
|
|
import rclpy
|
|
from rclpy.executors import MultiThreadedExecutor
|
|
from rclpy.node import Node
|
|
from arm_msgs.srv import GripperExecute
|
|
from arm_application.dh_pgea import GripperState
|
|
import arm_application.dh_pgea_node as driver
|
|
|
|
|
|
class FakeGripper:
|
|
writes=[]
|
|
def __init__(self,**kwargs):
|
|
self.state=GripperState(init_state=1,grip_state=1,position=300,target_position=300,target_force=33)
|
|
def open(self): pass
|
|
def close(self): pass
|
|
def probe(self): return 1
|
|
def get_run_states(self,**kwargs): return self.state
|
|
def initialize(self): self.writes.append('INIT')
|
|
def move(self,pos,force,speed):
|
|
self.writes.append((pos,force,speed));self.state.position=pos;self.state.target_position=pos;return True
|
|
|
|
|
|
def main():
|
|
driver.DhPgeaGripper=FakeGripper
|
|
rclpy.init(args=['--ros-args','-r','__ns:=/station/grippers/arm_89','-p','auto_init:=false'])
|
|
device=driver.DhPgeaNode()
|
|
assert device.get_namespace()=='/station/grippers/arm_89',device.get_namespace()
|
|
client_node=Node('station_test_client')
|
|
executor=MultiThreadedExecutor(num_threads=2);executor.add_node(device);executor.add_node(client_node)
|
|
thread=threading.Thread(target=executor.spin,daemon=True);thread.start()
|
|
try:
|
|
client=client_node.create_client(GripperExecute,'/station/grippers/arm_89/execute')
|
|
assert client.wait_for_service(timeout_sec=3)
|
|
def command(name,position=0,force=33,speed=25):
|
|
req=GripperExecute.Request();req.command.command=name;req.command.position=float(position)
|
|
req.command.force=float(force);req.command.speed=float(speed)
|
|
future=client.call_async(req);deadline=time.monotonic()+3
|
|
while not future.done() and time.monotonic()<deadline: time.sleep(.01)
|
|
assert future.done(),'service did not respond'
|
|
return future.result()
|
|
assert command('connect').success
|
|
assert FakeGripper.writes==[],FakeGripper.writes
|
|
assert not command('move',1,force=999).success
|
|
assert not command('move',float('nan')).success
|
|
assert not command('rotate_to',90).success
|
|
assert FakeGripper.writes==[]
|
|
assert command('move',10).success
|
|
assert FakeGripper.writes==[(500,33,25)],FakeGripper.writes
|
|
assert command('connect').status.finger_position_per_mille==500.
|
|
print('ROS typed service, namespace, bounds and no-auto-init PASS',flush=True)
|
|
finally:
|
|
executor.shutdown(timeout_sec=3);thread.join(timeout=3)
|
|
device.destroy_node();client_node.destroy_node();rclpy.shutdown()
|
|
# Camera subscriptions must follow consumers, not all configured cameras.
|
|
from station import operator_camera_view as camera
|
|
cams=[{'id':'left','role':'wrist'},{'id':'right','role':'wrist'},{'id':'sim_wrist','role':'wrist'}]
|
|
camera.watch(set(),set());node=camera._open_node(cams)
|
|
try:
|
|
node.sync_streams();assert node._streams.items=={}
|
|
camera.watch({'left_color'},set());node.sync_streams()
|
|
assert set(node._streams.items)=={'left_color'}
|
|
assert all(node._accept('left_color') for _ in range(10)), 'color preview must preserve input cadence'
|
|
from sensor_msgs.msg import Image
|
|
from rclpy.qos import qos_profile_sensor_data
|
|
publisher=node.create_publisher(Image,'/left/color/image_raw',qos_profile_sensor_data)
|
|
message=Image();message.width=4;message.height=4;message.step=12;message.encoding='rgb8';message.data=bytes([90,20,10])*16
|
|
deadline=time.monotonic()+3
|
|
while 'left_color' not in camera.previews() and time.monotonic()<deadline:
|
|
publisher.publish(message);rclpy.spin_once(node,timeout_sec=.05);camera.publish_latest()
|
|
blob,width,height=camera.previews()['left_color']
|
|
assert (width,height)==(4,4) and blob[:3]==bytes([90,20,10])
|
|
import tempfile
|
|
from station.cam_bridge import _export
|
|
shms={}
|
|
try:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
_export(Path(directory),shms,{})
|
|
assert shms
|
|
first=next(iter(shms.values()))[1]
|
|
assert camera._shm_read(first)[3]==blob
|
|
finally:
|
|
for _,shm in shms.values(): shm.close();shm.unlink()
|
|
camera.watch({'right_depth'},set());node.sync_streams()
|
|
assert set(node._streams.items)=={'right_depth'}
|
|
camera.watch(set(),{'left_color'});node.sync_streams()
|
|
assert set(node._streams.items)=={'left_color'}
|
|
camera.watch(set(),set());node.sync_streams()
|
|
assert not node._streams.items and not node._detections.items
|
|
print('ROS dynamic camera subscriptions PASS',flush=True)
|
|
finally:
|
|
node.destroy_node();rclpy.shutdown()
|
|
|
|
|
|
# Real-camera UI preview uses a compressed transport; perception stays raw.
|
|
import cv2,numpy as np
|
|
from sensor_msgs.msg import CompressedImage
|
|
camera.watch({'compressed_test_color'},set())
|
|
node=camera._open_node([{'id':'compressed_test','role':'wrist','preview_transport':'compressed'}])
|
|
try:
|
|
node.sync_streams()
|
|
publisher=node.create_publisher(CompressedImage,'/compressed_test/color/preview/compressed',qos_profile_sensor_data)
|
|
ok,jpg=cv2.imencode('.jpg',np.full((720,1280,3),(20,80,150),np.uint8));assert ok
|
|
sample=CompressedImage();sample.format='jpeg';sample.data=jpg.tobytes()
|
|
deadline=time.monotonic()+3
|
|
while 'compressed_test_color' not in camera.previews() and time.monotonic()<deadline:
|
|
publisher.publish(sample);rclpy.spin_once(node,timeout_sec=.05);camera.publish_latest()
|
|
blob,w,h=camera.previews()['compressed_test_color']
|
|
assert (w,h)==(1280,720),(w,h)
|
|
assert len(blob)==w*h*3
|
|
print('ROS compressed preview preserves camera resolution PASS',flush=True)
|
|
finally:
|
|
camera.watch(set(),set());node.destroy_node();rclpy.shutdown()
|
|
|
|
# Exercise native Action feedback/cancellation without a robot controller.
|
|
from station.ros_gateway import RosGateway
|
|
from arm_application.site_io import load_site_dict
|
|
from sensor_msgs.msg import JointState
|
|
from controller_manager_msgs.srv import ListControllers, SwitchController
|
|
from controller_manager_msgs.msg import ControllerState
|
|
from rclpy.action import ActionServer, CancelResponse
|
|
from rclpy.callback_groups import ReentrantCallbackGroup
|
|
from control_msgs.action import FollowJointTrajectory
|
|
from station.tasks import Cancelled
|
|
gateway=RosGateway(load_site_dict())
|
|
fake=Node('fake_controller')
|
|
controller_state = ['active']
|
|
switches = []
|
|
def controllers(req,resp):
|
|
state=ControllerState();state.name='rizon_arm_controller';state.state=controller_state[0]
|
|
state.claimed_interfaces=[f"{arm['sn']}_joint{i}/position" for i in range(1,8)] if state.state=='active' else []
|
|
resp.controller=[state];return resp
|
|
fake.create_service(ListControllers,'/controller_manager/list_controllers',controllers)
|
|
def switch(req,resp):
|
|
switches.append((list(req.activate_controllers),list(req.deactivate_controllers)))
|
|
controller_state[0]='inactive' if req.deactivate_controllers else 'active'
|
|
resp.ok=True;return resp
|
|
fake.create_service(SwitchController,'/controller_manager/switch_controller',switch)
|
|
group=ReentrantCallbackGroup()
|
|
def execute(handle):
|
|
end=time.monotonic()+.7
|
|
while time.monotonic()<end:
|
|
if handle.is_cancel_requested:
|
|
handle.canceled();return FollowJointTrajectory.Result()
|
|
time.sleep(.01)
|
|
handle.succeed();return FollowJointTrajectory.Result()
|
|
action=ActionServer(fake,FollowJointTrajectory,'/rizon_arm_controller/follow_joint_trajectory',
|
|
execute_callback=execute,cancel_callback=lambda request:CancelResponse.ACCEPT,callback_group=group)
|
|
gateway.executor.add_node(fake)
|
|
arm=next(a for a in gateway.arms if a['id']==gateway.active_arm)
|
|
msg=JointState();msg.name=[f"{arm['sn']}_joint{i}" for i in range(1,8)];msg.position=[0.]*7
|
|
try:
|
|
from unittest.mock import patch
|
|
from flexiv_msgs.msg import RobotStates
|
|
from station.operator_arm_state import tcp_display_pose
|
|
import math
|
|
assert tcp_display_pose((0.,0.,0.),(0.,0.,0.,0.)) is None
|
|
yaw_pose=tcp_display_pose((.1,.2,.3),(0.,0.,math.sqrt(.5),math.sqrt(.5)))
|
|
assert yaw_pose[:3]==[100.,200.,300.] and abs(yaw_pose[-1]-90.)<1e-8
|
|
native=RobotStates()
|
|
native.tcp_pose.pose.position.x=.5
|
|
native.tcp_pose.pose.orientation.w=1.
|
|
aid=arm['id']
|
|
with patch('station.ros_gateway.time.monotonic',return_value=100.):
|
|
gateway._joint(msg);native.robot_timestamp.sec=10;gateway._native_state(aid,native)
|
|
assert gateway.states()[0][aid]['enabled'] is None
|
|
with patch('station.ros_gateway.time.monotonic',return_value=100.1):
|
|
gateway._joint(msg);native.robot_timestamp.sec=11;gateway._native_state(aid,native)
|
|
assert gateway.states()[0][aid]['enabled'] is True
|
|
assert gateway.states()[0][aid]['tcp_pose']==[500.,0.,0.,0.,0.,0.]
|
|
with patch('station.ros_gateway.time.monotonic',return_value=102.):
|
|
gateway._joint(msg);gateway._native_state(aid,native)
|
|
assert gateway.states()[0][aid]['pose_live'] is True
|
|
assert gateway.states()[0][aid]['tcp_pose'] is None
|
|
assert gateway.states()[0][aid]['enabled'] is None, 'repeated cached hardware sample must not confirm enable'
|
|
gateway._robot_clock.clear()
|
|
gateway._last_joint_sample=0.
|
|
gateway._joint(msg)
|
|
assert gateway.move_joints(arm,[.01]*7,.15,threading.Event())['ok']
|
|
gateway._joint(msg)
|
|
cancel=threading.Event();timer=threading.Timer(.3,cancel.set);timer.start()
|
|
try:
|
|
gateway.move_joints(arm,[.01]*7,.15,cancel)
|
|
raise AssertionError('expected action cancellation')
|
|
except Cancelled: pass
|
|
timer.join()
|
|
assert not gateway._goals
|
|
joint_stop=threading.Event()
|
|
def joint_feedback():
|
|
while not joint_stop.wait(.03): gateway._joint(msg)
|
|
joint_thread=threading.Thread(target=joint_feedback);joint_thread.start()
|
|
try:
|
|
assert gateway.stop_arm(arm)['ok']
|
|
assert gateway.confirm_stopped(arm)['ok']
|
|
assert controller_state[0]=='inactive'
|
|
assert switches[-1]==([],['rizon_arm_controller'])
|
|
# Enable/readiness checks must not resume a stopped trajectory.
|
|
switch_count = len(switches)
|
|
gateway.require_arm(arm['id'],threading.Event(),resume=False)
|
|
assert controller_state[0]=='inactive'
|
|
assert len(switches)==switch_count
|
|
# Only the next explicit motion command restores the controller.
|
|
gateway.require_arm(arm['id'],threading.Event())
|
|
assert controller_state[0]=='active'
|
|
assert switches[-1]==(['rizon_arm_controller'],[])
|
|
wrong={**arm,'sn':'OtherRobot'}
|
|
try:
|
|
gateway.stop_arm(wrong)
|
|
raise AssertionError('wrong-arm stop accepted')
|
|
except RuntimeError as exc: assert '不属于' in str(exc)
|
|
finally:
|
|
joint_stop.set();joint_thread.join()
|
|
print('ROS stop deactivation, stationary confirmation, explicit resume and arm ownership PASS',flush=True)
|
|
gateway._motion_watch.add(arm['id'])
|
|
gateway._robot_clock[arm['id']] = (123, time.monotonic())
|
|
assert gateway.motion_interruption(arm['id']) is None
|
|
gateway._robot_clock[arm['id']] = (123, time.monotonic()-1.)
|
|
assert gateway.motion_interruption(arm['id'])
|
|
assert gateway.motion_interruption(arm['id']) is None
|
|
# A recovered Enable sample does not recreate the cancelled motion watch/task.
|
|
gateway._robot_clock[arm['id']] = (124, time.monotonic())
|
|
assert arm['id'] not in gateway._motion_watch
|
|
gateway._robot_clock.clear()
|
|
print('ROS operational feedback loss cancels old motion once; no automatic resume PASS',flush=True)
|
|
from std_msgs.msg import Float32MultiArray
|
|
saved_gripper=gateway._grippers.pop(gateway.active_arm,None)
|
|
try:
|
|
legacy=Float32MultiArray();legacy.data=[1.,1.,300.,300.,33.,0.]
|
|
gateway._legacy_grip(legacy)
|
|
assert gateway.states()[1][gateway.active_arm]['connected'] is False
|
|
finally:
|
|
if saved_gripper:gateway._grippers[gateway.active_arm]=saved_gripper
|
|
from arm_msgs.msg import TactileStatus
|
|
from station import site_catalog
|
|
force=TactileStatus();force.connected=True;force.fnormal=1.25;force.fshearx=-.5;force.fsheary=.75
|
|
gateway._tactile('left',force)
|
|
assert site_catalog._TACTILE['left']['fnormal']==1.25
|
|
assert site_catalog._TACTILE['left']['fshearx']==-.5
|
|
from geometry_msgs.msg import WrenchStamped
|
|
calibrated=WrenchStamped();calibrated.wrench.force.x=.2;calibrated.wrench.force.y=-.3;calibrated.wrench.force.z=.4
|
|
gateway._tactile_wrench('left',calibrated);gateway._tactile('left',force)
|
|
assert site_catalog._TACTILE['left']['force_n']=={'fnormal':.4,'fshearx':.2,'fsheary':-.3}
|
|
assert site_catalog._TACTILE['left']['fnormal']==1.25
|
|
assert gateway.tactile_history()['left'][-1]['fshearx']==.2
|
|
packet=gateway.tactile_sample_packet({})
|
|
assert packet['samples']['left'][-1]['fshearx']==.2
|
|
seq=packet['samples']['left'][-1]['seq']
|
|
assert gateway.tactile_sample_packet({'left':seq})['samples']['left']==[]
|
|
gateway.clear_tactile_reference('left')
|
|
assert gateway.tactile_history()['left']==[]
|
|
assert gateway.tactile_sample_packet({})['epochs']['left']>packet['epochs']['left']
|
|
assert site_catalog._TACTILE['left']['force_n'] is None
|
|
gateway.watch_tactile(True)
|
|
gateway._tactile_image('left_thermal',message)
|
|
assert 'left_thermal' in gateway.tactile_frames()
|
|
gateway.watch_tactile(False)
|
|
assert not gateway.tactile_frames() and not gateway._tactile_images.items
|
|
print('ROS tactile driver reuse, unchanged force values and preview lifetime PASS',flush=True)
|
|
print('ROS persistent gateway, arm identity and trajectory cancellation PASS',flush=True)
|
|
finally:
|
|
action.destroy();gateway.executor.remove_node(fake);fake.destroy_node();gateway.close()
|
|
|
|
|
|
if __name__=='__main__': main()
|