Skip to content

Commit 90bed6a

Browse files
committed
Merge branch 'dev'
Merge new feature replay mode and cleanup to main
2 parents 161a033 + 51a25ea commit 90bed6a

31 files changed

Lines changed: 962 additions & 468 deletions

Backend/communication_server.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ def start(self) -> None:
3434
while self.running:
3535
try:
3636
message = self.socket.recv_json()
37-
logger.debug(f"Received message: {message}")
37+
logger.debug("Received message: %s", message)
3838
response = self.handler.process_message(message=message)
3939
self.socket.send_json(response)
40-
logger.debug(f"Sent response: {response}")
40+
logger.debug("Sent response: %s", response)
4141
except zmq.Again:
4242
continue
4343
except zmq.ZMQError as e:
44-
logger.error(f"ZMQ error in server loop: {e}")
44+
logger.error("ZMQ error in server loop: %s", e)
4545
finally:
4646
self.close()
4747

@@ -52,6 +52,6 @@ def close(self) -> None:
5252
self.socket.close()
5353
self.context.term()
5454

55-
def _handle_signal(self, sig, frame: object):
55+
def _handle_signal(self, sig, frame: object) -> None:
5656
logger.info('Signal %s received, shutting down', sig)
5757
self.running = False

Backend/config_backend.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
BINDING_ADDRESS = "tcp://*:5555" # Binding address for backend ZeroMQ Server
22
ZMQ_TIMEOUT_MS = 1000 # Timeout for ZeroMQ
33
AVAILABLE_ROBOTS = ["mock", "franka", "ur"] # ur, franka, mock : Add new adapter here
4-
ALLOWED_COMMANDS = ["ping", "get_status", "execute_sequence"] # allowed main level commands for backend to accept
4+
ALLOWED_COMMANDS = ["ping", "get_status", "execute_sequence", # allowed main level commands for backend to accept
5+
"save_script", "run_script", "stop_script",
6+
"get_script_status", "delete_script"]
57
LOGGING_LEVEL = "INFO" # Log level for console, Options: DEBUG, INFO, WARNING, ERROR (for console)
68
LOGGING_LEVEL_FILE = "DEBUG" # Log level for file output in Backend/log/
79
PC_IP = "192.168.1.101" # your Backend PC IP as seen by the UR Robot. Needed for callback for finished pos

Backend/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
def main() -> None:
1212
"""Entry point for the robot backend server."""
1313
logger = setup_logging()
14-
logger.info(f"Starting backend server on {BINDING_ADDRESS}")
14+
logger.info("Starting backend server on %s", BINDING_ADDRESS)
1515

1616
server = ServerZeroMQ(BINDING_ADDRESS)
1717

1818
try:
1919
server.start()
2020
except Exception as e:
21-
logger.exception(f"Unhandled server error: {e}")
21+
logger.exception("Unhandled server error: %s", e)
2222

2323

2424
if __name__ == "__main__":

Backend/message_handler.py

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
import inspect
33
import logging
44
import time
5+
import threading
56
from typing import Dict, Optional, Type
67

78
from Backend.robot_controllers.base_robot_controller import BaseRobotController
8-
from Backend.config_backend import AVAILABLE_ROBOTS
9+
from Backend.config_backend import AVAILABLE_ROBOTS, ALLOWED_COMMANDS
910

1011
logger = logging.getLogger("cobot_backend")
1112

@@ -32,10 +33,11 @@ def _load_controllers() -> Dict[str, Type[BaseRobotController]]:
3233
class MessageHandler:
3334
"""Processes incoming commands and dispatches them to the robot controller."""
3435

35-
ALLOWED_COMMANDS = ["ping", "get_status", "execute_sequence"]
36-
37-
def __init__(self):
36+
def __init__(self) -> None:
3837
self.robot: Optional[BaseRobotController] = None
38+
self._script_stop_event: threading.Event = threading.Event()
39+
self._script_thread: Optional[threading.Thread] = None
40+
3941
for robot_type, cls in CONTROLLERS.items():
4042
logger.debug("Controller available: '%s' -> %s", robot_type, cls.__name__)
4143

@@ -54,13 +56,18 @@ def process_message(self, message: dict) -> dict:
5456
command = message.get("command", "")
5557
data = message.get("data", {})
5658

57-
if command not in self.ALLOWED_COMMANDS:
59+
if command not in ALLOWED_COMMANDS:
5860
return self._unknown_command(command)
5961

6062
commands = {
6163
"ping": self._answer_ping,
6264
"get_status": self._send_status,
6365
"execute_sequence": lambda: self._execute_sequence(data),
66+
"save_script": lambda: self._save_script(data),
67+
"run_script": lambda: self._run_script(data),
68+
"stop_script": self._stop_script,
69+
"get_script_status": self._get_script_status,
70+
"delete_script": lambda: self._delete_script(data),
6471
}
6572

6673
return commands[command]()
@@ -69,6 +76,8 @@ def process_message(self, message: dict) -> dict:
6976
logger.exception("Error processing message: %s", message)
7077
return self._formatted_response("error", {"error message": str(e)})
7178

79+
80+
7281
def _ensure_robot_ready(self, robot_type: str) -> dict:
7382
self._current_robot_type = robot_type
7483
expected_class = CONTROLLERS[robot_type]
@@ -187,7 +196,7 @@ def _process_command(self, command: dict, robot: BaseRobotController) -> dict:
187196
offset = None
188197
if target.get("type") == "offset_from_pose":
189198
raw = target.get("offset", {})
190-
offset = [raw["x_mm"], raw["y_mm"], raw["z_mm"]]
199+
offset = [raw.get("x_mm", 0.0), raw.get("y_mm", 0.0), raw.get("z_mm", 0.0)]
191200

192201
if motion_type == "moveJ":
193202
return robot.move_joint(pose, speed, offset)
@@ -230,4 +239,114 @@ def _process_command(self, command: dict, robot: BaseRobotController) -> dict:
230239
return self._ensure_robot_ready(self._current_robot_type)
231240

232241
else:
233-
return {"success": False, "message": f"Unknown action: {action}"}
242+
return {"success": False, "message": f"Unknown action: {action}"}
243+
244+
def _save_script(self, data: dict) -> dict:
245+
robot_type = data.get("robot")
246+
script_name = data.get("script_name")
247+
commands = data.get("commands", [])
248+
249+
if not robot_type or robot_type not in CONTROLLERS:
250+
return self._formatted_response("rejected", {"message": f"Unsupported robot type: {robot_type}"})
251+
if not script_name or not isinstance(commands, list):
252+
return self._formatted_response("rejected", {"message": "Missing script_name or commands"})
253+
254+
result = self._ensure_robot_ready(robot_type)
255+
if not result["success"]:
256+
return self._formatted_response("rejected",
257+
{"message": f"Could not connect to {robot_type}: {result['message']}"})
258+
259+
result = self.robot.save_script(script_name, commands)
260+
if not result["success"]:
261+
return self._formatted_response("error", {"message": result["message"]})
262+
263+
logger.info("Script '%s' saved for robot '%s' with %d command(s)", script_name, robot_type, len(commands))
264+
return self._formatted_response("success", {"message": f"Script '{script_name}' saved"})
265+
266+
def _run_script(self, data: dict) -> dict:
267+
robot_type = data.get("robot")
268+
script_name = data.get("script_name")
269+
loop = data.get("loop", 1)
270+
271+
if not robot_type or robot_type not in CONTROLLERS:
272+
return self._formatted_response("rejected", {"message": f"Unsupported robot type: {robot_type}"})
273+
274+
result = self._ensure_robot_ready(robot_type)
275+
if not result["success"]:
276+
return self._formatted_response("rejected",
277+
{"message": f"Could not connect to {robot_type}: {result['message']}"})
278+
279+
script = self.robot.get_script(script_name)
280+
if script is None:
281+
return self._formatted_response("rejected", {"message": f"Unknown script: '{script_name}'"})
282+
283+
if self._script_thread and self._script_thread.is_alive():
284+
return self._formatted_response("rejected", {"message": "A script is already running"})
285+
286+
validation_errors = [
287+
error
288+
for cmd in script
289+
if (error := self._validate_command(cmd, self.robot)) is not None
290+
]
291+
if validation_errors:
292+
logger.warning("Script '%s' rejected: %s", script_name, validation_errors)
293+
return self._formatted_response("rejected", {"reasons": validation_errors})
294+
295+
self._script_stop_event.clear()
296+
self._script_thread = threading.Thread(
297+
target=self._run_script_loop,
298+
args=(script_name, loop, script),
299+
daemon=True,
300+
name="thread_script_loop"
301+
)
302+
self._script_thread.start()
303+
logger.info("Script '%s' started for robot '%s', loop=%d", script_name, robot_type, loop)
304+
return self._formatted_response("success", {"message": f"Script '{script_name}' started"})
305+
306+
def _stop_script(self) -> dict:
307+
"""Signal the running script loop to stop after the current command."""
308+
self._script_stop_event.set()
309+
logger.info("Stop signal sent to script loop")
310+
return self._formatted_response("success", {"message": "Stop signal sent"})
311+
312+
def _run_script_loop(self, script_name: str, loop: int, commands: list) -> None:
313+
"""Execute script commands in a loop. Checks stop event between commands."""
314+
iteration = 0
315+
while not self._script_stop_event.is_set():
316+
logger.info("Script '%s' — iteration %d", script_name, iteration + 1)
317+
for cmd in commands:
318+
if self._script_stop_event.is_set():
319+
break
320+
result = self._process_command(cmd, self.robot)
321+
if not result["success"]:
322+
logger.error("Script '%s' aborted at command: %s", script_name, result["message"])
323+
return
324+
325+
iteration += 1
326+
if loop != -1 and iteration >= loop:
327+
break
328+
329+
logger.info("Script '%s' finished after %d iteration(s)", script_name, iteration)
330+
331+
def _get_script_status(self) -> dict:
332+
is_running = self._script_thread is not None and self._script_thread.is_alive()
333+
return self._formatted_response("success", {"is_running": is_running})
334+
335+
def _delete_script(self, data: dict) -> dict:
336+
robot_type = data.get("robot")
337+
script_name = data.get("script_name")
338+
339+
if not robot_type or robot_type not in CONTROLLERS:
340+
return self._formatted_response("rejected", {"message": f"Unsupported robot type: {robot_type}"})
341+
342+
result = self._ensure_robot_ready(robot_type)
343+
if not result["success"]:
344+
return self._formatted_response("rejected",
345+
{"message": f"Could not connect to {robot_type}: {result['message']}"})
346+
347+
result = self.robot.delete_script(script_name)
348+
if not result["success"]:
349+
return self._formatted_response("error", {"message": result["message"]})
350+
351+
logger.info("Script '%s' deleted", script_name)
352+
return self._formatted_response("success", {"message": f"Script '{script_name}' deleted"})

Backend/poses/mock_poses.jsonl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
{"name": "home", "pos": [0.0, 0.0, 0.0], "quat": [1.0, 0.0, 0.0, 0.0], "joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
22
{"name": "test_pose", "pos": [0.0, 0.0, 0.0], "quat": [1.0, 0.0, 0.0, 0.0], "joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
3+
{"name": "position_1", "pos": [0.0, 0.0, 0.0], "quat": [1.0, 0.0, 0.0, 0.0], "joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
4+
{"name": "position_2", "pos": [0.0, 0.0, 0.0], "quat": [1.0, 0.0, 0.0, 0.0], "joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
5+
{"name": "p1", "pos": [20.0, 0.0, 0.0], "quat": [1.0, 0.0, 0.0, 0.0], "joints": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Backend robot controllers — package placeholder

Backend/robot_controllers/base_robot_controller.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
class BaseRobotController(ABC):
1111
"""Abstract base class defining common robot controller interface."""
1212

13-
def __init__(self, poses_file: str):
13+
def __init__(self, poses_file: str) -> None:
1414
self.poses_file: str = poses_file
1515
self.poses: dict = self._load_poses()
16+
self._scripts_file: str = poses_file.replace("_poses.jsonl", "_scripts.jsonl")
17+
self.scripts: dict = self._load_scripts()
1618
self.connected: bool = False
1719
self.gripper_state: Optional[str] = None
1820

@@ -35,19 +37,38 @@ def save_pose(self, name: str, overwrite: bool = False) -> dict:
3537
}
3638
self.poses[name] = entry
3739
self._write_poses()
38-
logger.info(f"Saved position '{name}'")
40+
logger.info("Saved position '%s'", name)
3941
return {"success": True, "message": f"Pose '{name}' saved"}
4042

4143
def delete_pose(self, name: str) -> dict:
4244
"""Delete a named pose."""
4345
if name not in self.poses:
44-
logger.info(f"Pose '{name}' unknown")
46+
logger.info("Pose '%s' unknown", name)
4547
return {"success": False, "message": f"Pose '{name}' not found"}
4648
del self.poses[name]
4749
self._write_poses()
48-
logger.info(f"Deleted position '{name}'")
50+
logger.info("Deleted position '%s'", name)
4951
return {"success": True, "message": f"Pose '{name}' deleted"}
5052

53+
def save_script(self, name: str, commands: list) -> dict:
54+
"""Save a named command sequence for later replay."""
55+
self.scripts[name] = commands
56+
self._write_scripts()
57+
logger.info("Saved script '%s' with %d command(s)", name, len(commands))
58+
return {"success": True, "message": f"Script '{name}' saved"}
59+
60+
def get_script(self, name: str) -> Optional[list]:
61+
"""Return the command list for a named script, or None if not found."""
62+
return self.scripts.get(name)
63+
64+
def delete_script(self, name: str) -> dict:
65+
if name not in self.scripts:
66+
return {"success": False, "message": f"Script '{name}' not found"}
67+
del self.scripts[name]
68+
self._write_scripts()
69+
logger.info("Deleted script '%s'", name)
70+
return {"success": True, "message": f"Script '{name}' deleted"}
71+
5172
def is_ready(self) -> bool:
5273
"""Returns connected status if not overridden by robot controller."""
5374
return self.connected
@@ -128,4 +149,22 @@ def _load_poses(self) -> dict:
128149
def _write_poses(self) -> None:
129150
with open(self.poses_file, 'w') as f:
130151
for entry in self.poses.values():
131-
f.write(json.dumps(entry) + '\n')
152+
f.write(json.dumps(entry) + '\n')
153+
154+
def _load_scripts(self) -> dict:
155+
if not os.path.exists(self._scripts_file):
156+
os.makedirs(os.path.dirname(self._scripts_file), exist_ok=True)
157+
with open(self._scripts_file, 'w'):
158+
pass
159+
return {}
160+
scripts = {}
161+
with open(self._scripts_file, 'r') as f:
162+
for line in f:
163+
entry = json.loads(line.strip())
164+
scripts[entry["name"]] = entry["commands"]
165+
return scripts
166+
167+
def _write_scripts(self) -> None:
168+
with open(self._scripts_file, 'w') as f:
169+
for name, commands in self.scripts.items():
170+
f.write(json.dumps({"name": name, "commands": commands}) + '\n')

Backend/robot_controllers/franka_controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060

6161
class FrankaController(BaseRobotController):
6262

63-
def __init__(self):
63+
def __init__(self) -> None:
6464
super().__init__(POSES_FILE)
6565
self._robot: Optional[FrankaRobot] = None
6666
self._ros_process: Optional[subprocess.Popen] = None

Backend/robot_controllers/franka_robot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class FrankaRobot:
1717
and forwards them to MoveIt. Has no knowledge of named poses or files.
1818
"""
1919

20-
def __init__(self, arm_name: str, hand_name: str, moveit_commander):
20+
def __init__(self, arm_name: str, hand_name: str, moveit_commander) -> None:
2121
self.arm = moveit_commander.MoveGroupCommander(arm_name)
2222
self.gripper = moveit_commander.MoveGroupCommander(hand_name)
2323

Backend/robot_controllers/mock_controller.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class MockRobotController(BaseRobotController):
1111
"""Mock robot controller for testing without hardware."""
1212

13-
def __init__(self, poses_file: str = "Backend/poses/mock_poses.jsonl"):
13+
def __init__(self, poses_file: str = "Backend/poses/mock_poses.jsonl") -> None:
1414
super().__init__(poses_file)
1515
self._joint_angles: list = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
1616
self._tcp_pose: list = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0] # pos + identity quat
@@ -38,6 +38,7 @@ def move_joint(self, pose: dict, speed: Optional[float] = None, offset: Optional
3838
target_pos = list(pose["pos"])
3939
if offset:
4040
target_pos = [p + o for p, o in zip(target_pos, offset)]
41+
logger.info("Offset used: '%s'", offset)
4142
time.sleep(2)
4243
self._joint_angles = list(pose["joints"])
4344
self._tcp_pose = target_pos + list(pose["quat"])
@@ -49,6 +50,7 @@ def move_linear(self, pose: dict, speed: Optional[float] = None, offset: Optiona
4950
target_pos = list(pose["pos"])
5051
if offset:
5152
target_pos = [p + o for p, o in zip(target_pos, offset)]
53+
logger.info("Offset used: '%s'", offset)
5254
time.sleep(2)
5355
self._joint_angles = list(pose["joints"])
5456
self._tcp_pose = target_pos + list(pose["quat"])

0 commit comments

Comments
 (0)