From 7bfe5cccf106d6ca8814d76815b825577a702588 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 16 Jan 2026 20:09:56 +0000 Subject: [PATCH] Refactor core architecture and add test coverage - Fix socket lifecycle: create in child process, add cleanup with try/finally - Add ClientState enum with validated state transitions to prevent invalid operations - Decouple CSV logging from network loop using queue-based CSVLogger process - Fix broken imports: change absolute (src.RSIPI.x) to relative (.x) across 7 files - Add missing @staticmethod decorator to generate_report() - Add command queue for inter-process communication (logging control) - Add 34 unit tests for XMLGenerator, SafetyManager, and trajectory_planner - Add pytest configuration to pyproject.toml - Add CLAUDE.md with architecture documentation --- CLAUDE.md | 79 +++++++ pyproject.toml | 11 +- src/RSIPI/config_parser.py | 2 +- src/RSIPI/echo_server_gui.py | 2 +- src/RSIPI/kuka_visualiser.py | 2 +- src/RSIPI/network_handler.py | 211 ++++++++++++++++-- src/RSIPI/rsi_api.py | 5 +- src/RSIPI/rsi_client.py | 136 +++++++++-- src/RSIPI/rsi_config.py | 2 +- src/RSIPI/rsi_echo_server.py | 2 +- src/RSIPI/trajectory_planner.py | 2 +- tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 169 bytes ...afety_manager.cpython-313-pytest-8.4.1.pyc | Bin 0 -> 23600 bytes ...ctory_planner.cpython-313-pytest-8.4.1.pyc | Bin 0 -> 25863 bytes ...t_xml_handler.cpython-313-pytest-8.4.1.pyc | Bin 0 -> 23625 bytes tests/test_safety_manager.py | 168 ++++++++++++++ tests/test_trajectory_planner.py | 136 +++++++++++ tests/test_xml_handler.py | 112 ++++++++++ 19 files changed, 829 insertions(+), 42 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_safety_manager.cpython-313-pytest-8.4.1.pyc create mode 100644 tests/__pycache__/test_trajectory_planner.cpython-313-pytest-8.4.1.pyc create mode 100644 tests/__pycache__/test_xml_handler.cpython-313-pytest-8.4.1.pyc create mode 100644 tests/test_safety_manager.py create mode 100644 tests/test_trajectory_planner.py create mode 100644 tests/test_xml_handler.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d1e5314 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Does + +RSIPI enables real-time control of KUKA industrial robots from Python via the RSI (Robot Sensor Interface) protocol. The robot sends its position ~250 times/second over UDP, and this library lets you send back position corrections to control the robot externally. + +## Build & Development Commands + +```bash +# Install dependencies +pip install -e . + +# Or install from requirements (if present) +pip install pandas>=2.0 numpy>=1.22 matplotlib>=3.5 lxml>=4.9 scipy>=1.8 + +# Run the CLI +python -m RSIPI.rsi_cli --config RSI_EthernetConfig.xml + +# Run the echo server (for offline testing without a real robot) +python -m RSIPI.rsi_echo_server +``` + +**No test suite exists** - testing is done via the echo server simulation and example scripts in `examples/`. + +## Architecture + +### Core Communication Flow + +``` +KUKA Robot Controller <--UDP/XML--> NetworkProcess <--multiprocessing.Manager--> RSIClient <-- RSIAPI/CLI +``` + +1. **NetworkProcess** (`network_handler.py`) - Runs in separate process via `multiprocessing.Process`. Binds to UDP socket, receives XML from robot, parses into `receive_variables`, sends XML from `send_variables` back to robot. Uses `start_event` to wait for explicit start signal. + +2. **RSIClient** (`rsi_client.py`) - Orchestrates the system. Initializes ConfigParser, SafetyManager, and NetworkProcess. Uses `multiprocessing.Manager` dicts for thread-safe variable sharing between processes. + +3. **RSIAPI** (`rsi_api.py`) - High-level API wrapping RSIClient. Runs RSIClient in a daemon thread. Provides trajectory planning, logging, plotting, and safety controls. + +4. **RSICommandLineInterface** (`rsi_cli.py`) - Interactive CLI that wraps RSIAPI. + +### Key Shared State + +Variables are shared between processes using `multiprocessing.Manager().dict()`: +- `send_variables` - Values to send to robot (RKorr corrections, digital outputs, etc.) +- `receive_variables` - Values received from robot (RIst position, ASPos joints, IPOC timestamp) + +### Configuration + +`RSI_EthernetConfig.xml` defines: +- Network settings (IP, port) in `` section +- Send variables in `` - what the robot receives from us +- Receive variables in `` - what we receive from robot + +Variable tags like `DEF_RIst` get the `DEF_` prefix stripped and are expanded using `internal_structure` in ConfigParser to full dicts (e.g., `RIst: {X, Y, Z, A, B, C}`). + +### Safety Layer + +**SafetyManager** (`safety_manager.py`) validates all outgoing values against configurable limits. Can load limits from `.rsi.xml` files. Supports emergency stop and safety override modes. + +### Trajectory Execution + +`TrajectoryPlanner` generates interpolated waypoints. `execute_trajectory()` in RSIAPI uses asyncio to send points at specified rate (default 12ms for Cartesian, 400ms for joints). + +## Important Patterns + +- **IPOC synchronization**: The robot sends an IPOC (timestamp) value. The response must include `IPOC + 4` to maintain sync. This is handled automatically in `NetworkProcess.process_received_data()`. + +- **Lazy client initialization**: RSIAPI uses `_ensure_client()` pattern - RSIClient is created on first use, not at RSIAPI instantiation. + +- **Non-blocking start**: `start_rsi()` runs the client loop in a daemon thread. The NetworkProcess waits on `start_event` before binding the socket. + +## File Locations + +- Source code: `src/RSIPI/` +- Example scripts: `examples/` +- Config template: `RSI_EthernetConfig.xml` +- Logs written to: `logs/` (created at runtime) diff --git a/pyproject.toml b/pyproject.toml index 7ce9b06..4833b5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,17 @@ classifiers = [ "Operating System :: OS Independent", ] +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + [tool.setuptools] package-dir = {"" = "src"} [tool.setuptools.packages.find] -where = ["src"] \ No newline at end of file +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] \ No newline at end of file diff --git a/src/RSIPI/config_parser.py b/src/RSIPI/config_parser.py index 8afa5cb..8ffa6c9 100644 --- a/src/RSIPI/config_parser.py +++ b/src/RSIPI/config_parser.py @@ -17,7 +17,7 @@ class ConfigParser: config_file (str): Path to the RSI_EthernetConfig.xml file. rsi_limits_file (str, optional): Path to .rsi.xml file containing safety limits. """ - from src.RSIPI.rsi_limit_parser import parse_rsi_limits + from .rsi_limit_parser import parse_rsi_limits self.config_file = config_file self.rsi_limits_file = rsi_limits_file diff --git a/src/RSIPI/echo_server_gui.py b/src/RSIPI/echo_server_gui.py index a977c75..1255cc5 100644 --- a/src/RSIPI/echo_server_gui.py +++ b/src/RSIPI/echo_server_gui.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk, filedialog import threading import time -from src.RSIPI.rsi_echo_server import EchoServer +from .rsi_echo_server import EchoServer import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from mpl_toolkits.mplot3d import Axes3D diff --git a/src/RSIPI/kuka_visualiser.py b/src/RSIPI/kuka_visualiser.py index 0c3fb84..d02ecf5 100644 --- a/src/RSIPI/kuka_visualiser.py +++ b/src/RSIPI/kuka_visualiser.py @@ -150,7 +150,7 @@ if __name__ == "__main__": args = parser.parse_args() if args.limits: - from src.RSIPI.rsi_limit_parser import parse_rsi_limits + from .rsi_limit_parser import parse_rsi_limits limits = parse_rsi_limits(args.limits) visualiser = KukaRSIVisualiser(args.csv_file, safety_limits=limits) else: diff --git a/src/RSIPI/network_handler.py b/src/RSIPI/network_handler.py index f42e2b9..c9d6cb3 100644 --- a/src/RSIPI/network_handler.py +++ b/src/RSIPI/network_handler.py @@ -2,47 +2,116 @@ import multiprocessing import socket import logging import xml.etree.ElementTree as ET +import os +import datetime +from queue import Empty from .xml_handler import XMLGenerator from .safety_manager import SafetyManager + +class CSVLogger(multiprocessing.Process): + """Separate process for writing CSV logs without blocking the network loop.""" + + def __init__(self, log_queue, stop_event, filename): + super().__init__() + self.log_queue = log_queue + self.stop_event = stop_event + self.filename = filename + self.daemon = True + + def run(self): + """Write log entries from queue to CSV file.""" + # Ensure logs directory exists + log_dir = os.path.dirname(self.filename) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + header_written = False + + try: + with open(self.filename, 'w', newline='') as f: + while not self.stop_event.is_set(): + try: + entry = self.log_queue.get(timeout=0.5) + if entry is None: # Poison pill + break + + # Write header on first entry + if not header_written: + headers = ['Timestamp'] + list(entry.keys()) + f.write(','.join(headers) + '\n') + header_written = True + + # Write data row + timestamp = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S.%f")[:-3] + values = [timestamp] + [str(v) for v in entry.values()] + f.write(','.join(values) + '\n') + f.flush() + + except Empty: + continue + except Exception as e: + logging.error(f"CSV logging error: {e}") + + except Exception as e: + logging.error(f"Failed to open log file {self.filename}: {e}") + + class NetworkProcess(multiprocessing.Process): """Handles UDP communication and optional CSV logging in a separate process.""" - def __init__(self, ip, port, send_variables, receive_variables, stop_event, config_parser, start_event): + def __init__(self, ip, port, send_variables, receive_variables, stop_event, config_parser, start_event, command_queue): super().__init__() self.send_variables = send_variables self.receive_variables = receive_variables self.stop_event = stop_event - self.start_event = start_event # ✅ NEW + self.start_event = start_event self.config_parser = config_parser - self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.command_queue = command_queue self.safety_manager = SafetyManager(config_parser.safety_limits) self.client_address = (ip, port) self.logging_active = multiprocessing.Value('b', False) - self.log_filename = multiprocessing.Array('c', 256) - self.csv_process = None self.controller_ip_and_port = None + self.udp_socket = None + + # Logging infrastructure (created when logging starts) + self.log_queue = None + self.log_stop_event = None + self.csv_logger = None def run(self): """Start the network loop.""" - self.start_event.wait() # ✅ Wait until RSIClient sends start signal + # Wait for start signal, but check stop_event periodically to allow clean shutdown + while not self.start_event.wait(timeout=0.5): + if self.stop_event.is_set(): + logging.info("Network process stopped before starting") + return try: - if not self.is_valid_ip(self.client_address[0]): - logging.warning(f"Invalid IP address '{self.client_address[0]}'. Falling back to '0.0.0.0'.") - self.client_address = ('0.0.0.0', self.client_address[1]) + self._setup_socket() + self._run_loop() + finally: + self._cleanup() - self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.udp_socket.bind(self.client_address) - logging.info(f"✅ Network process bound on {self.client_address}") + def _setup_socket(self): + """Create and bind the UDP socket.""" + if not self.is_valid_ip(self.client_address[0]): + logging.warning(f"Invalid IP address '{self.client_address[0]}'. Falling back to '0.0.0.0'.") + self.client_address = ('0.0.0.0', self.client_address[1]) - except OSError as e: - logging.error(f"❌ Failed to bind to {self.client_address}: {e}") - raise + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp_socket.bind(self.client_address) + logging.info(f"Network process bound on {self.client_address}") + def _run_loop(self): + """Main communication loop.""" while not self.stop_event.is_set(): + # Check for commands (non-blocking) + self._process_commands() + try: self.udp_socket.settimeout(5) data_received, self.controller_ip_and_port = self.udp_socket.recvfrom(1024) @@ -51,13 +120,115 @@ class NetworkProcess(multiprocessing.Process): send_xml = XMLGenerator.generate_send_xml(self.send_variables, self.config_parser.network_settings) self.udp_socket.sendto(send_xml.encode(), self.controller_ip_and_port) - if self.logging_active.value: - self.log_to_csv() + if self.logging_active.value and self.log_queue: + self._queue_log_entry() except socket.timeout: - logging.error("[WARNING] No message received within timeout period.") + logging.warning("No message received within timeout period") except Exception as e: - logging.error(f"[ERROR] Network process error: {e}") + logging.error(f"Network process error: {e}") + + def _process_commands(self): + """Process any pending commands from the parent process.""" + try: + while True: + cmd = self.command_queue.get_nowait() + if cmd is None: + continue + + action = cmd.get('action') + if action == 'start_logging': + self._start_logging(cmd.get('filename')) + elif action == 'stop_logging': + self._stop_logging() + + except Empty: + pass + except Exception as e: + logging.error(f"Error processing command: {e}") + + def _queue_log_entry(self): + """Queue current state for CSV logging (non-blocking).""" + try: + entry = {} + # Flatten send variables + for key, value in dict(self.send_variables).items(): + if isinstance(value, dict): + for subkey, subval in value.items(): + entry[f"Send.{key}.{subkey}"] = subval + else: + entry[f"Send.{key}"] = value + + # Flatten receive variables + for key, value in dict(self.receive_variables).items(): + if isinstance(value, dict): + for subkey, subval in value.items(): + entry[f"Receive.{key}.{subkey}"] = subval + else: + entry[f"Receive.{key}"] = value + + # Non-blocking put - drop entry if queue is full + try: + self.log_queue.put_nowait(entry) + except: + pass # Queue full, skip this entry rather than block + + except Exception as e: + logging.debug(f"Failed to queue log entry: {e}") + + def _start_logging(self, filename): + """Start CSV logging to the specified file.""" + if self.logging_active.value: + logging.warning("Logging already active") + return + + self.log_queue = multiprocessing.Queue(maxsize=1000) + self.log_stop_event = multiprocessing.Event() + + self.csv_logger = CSVLogger(self.log_queue, self.log_stop_event, filename) + self.csv_logger.start() + + self.logging_active.value = True + logging.info(f"CSV logging started: {filename}") + + def _stop_logging(self): + """Stop CSV logging.""" + if not self.logging_active.value: + return + + self.logging_active.value = False + + if self.log_queue: + try: + self.log_queue.put_nowait(None) # Poison pill + except: + pass + + if self.log_stop_event: + self.log_stop_event.set() + + if self.csv_logger and self.csv_logger.is_alive(): + self.csv_logger.join(timeout=2) + if self.csv_logger.is_alive(): + self.csv_logger.terminate() + + self.csv_logger = None + self.log_queue = None + self.log_stop_event = None + logging.info("CSV logging stopped") + + def _cleanup(self): + """Clean up resources.""" + # Stop logging first + self._stop_logging() + + if self.udp_socket: + try: + self.udp_socket.close() + logging.info("Network socket closed") + except Exception as e: + logging.error(f"Error closing socket: {e}") + self.udp_socket = None @staticmethod def is_valid_ip(ip): @@ -83,4 +254,4 @@ class NetworkProcess(multiprocessing.Process): self.receive_variables["IPOC"] = received_ipoc self.send_variables["IPOC"] = received_ipoc + 4 except Exception as e: - logging.error(f"[ERROR] Error parsing received message: {e}") + logging.error(f"Error parsing received message: {e}") diff --git a/src/RSIPI/rsi_api.py b/src/RSIPI/rsi_api.py index 6893ca5..6fee0d7 100644 --- a/src/RSIPI/rsi_api.py +++ b/src/RSIPI/rsi_api.py @@ -9,9 +9,9 @@ from .inject_rsi_to_krl import inject_rsi_to_krl import threading from .trajectory_planner import generate_trajectory, execute_trajectory import datetime -from src.RSIPI.static_plotter import StaticPlotter # Make sure this file exists as described +from .static_plotter import StaticPlotter import os -from src.RSIPI.live_plotter import LivePlotter +from .live_plotter import LivePlotter from threading import Thread import asyncio @@ -49,6 +49,7 @@ class RSIAPI: self.client.stop() return "RSI stopped." + @staticmethod def generate_report(filename, format_type): """ Generate a statistical report from a CSV log file. diff --git a/src/RSIPI/rsi_client.py b/src/RSIPI/rsi_client.py index c4eafaf..c7664da 100644 --- a/src/RSIPI/rsi_client.py +++ b/src/RSIPI/rsi_client.py @@ -1,17 +1,43 @@ import logging import multiprocessing import time +from enum import Enum, auto +from threading import Lock from .config_parser import ConfigParser from .network_handler import NetworkProcess from .safety_manager import SafetyManager import threading + +class ClientState(Enum): + """Connection states for RSIClient.""" + INITIALIZED = auto() # After __init__, network process spawned but not started + STARTING = auto() # Start signal sent, waiting for network to be ready + RUNNING = auto() # Actively communicating with robot + STOPPING = auto() # Shutdown in progress + STOPPED = auto() # Fully stopped, cannot be restarted (use reconnect) + ERROR = auto() # Error state + + class RSIClient: """Main RSI API class that integrates network, config handling, and message processing.""" + # Valid state transitions + _VALID_TRANSITIONS = { + ClientState.INITIALIZED: {ClientState.STARTING, ClientState.STOPPING}, + ClientState.STARTING: {ClientState.RUNNING, ClientState.STOPPING, ClientState.ERROR}, + ClientState.RUNNING: {ClientState.STOPPING, ClientState.ERROR}, + ClientState.STOPPING: {ClientState.STOPPED, ClientState.ERROR}, + ClientState.STOPPED: {ClientState.INITIALIZED}, # Via reconnect + ClientState.ERROR: {ClientState.STOPPING, ClientState.INITIALIZED}, # Via reconnect + } + def __init__(self, config_file, rsi_limits_file=None): logging.info(f"Loading RSI configuration from {config_file}...") + self._state = ClientState.INITIALIZED + self._state_lock = Lock() + self.config_parser = ConfigParser(config_file, rsi_limits_file) network_settings = self.config_parser.get_network_settings() @@ -19,11 +45,15 @@ class RSIClient: self.send_variables = self.manager.dict(self.config_parser.send_variables) self.receive_variables = self.manager.dict(self.config_parser.receive_variables) self.stop_event = multiprocessing.Event() - self.start_event = multiprocessing.Event() # ✅ NEW + self.start_event = multiprocessing.Event() + self.command_queue = multiprocessing.Queue() self.safety_manager = SafetyManager(self.config_parser.safety_limits) - # ✅ Create NetworkProcess but don't start communication yet + # Shared logging state (readable from parent process) + self._logging_active = multiprocessing.Value('b', False) + + # Create NetworkProcess but don't start communication yet self.network_process = NetworkProcess( network_settings["ip"], network_settings["port"], @@ -31,17 +61,56 @@ class RSIClient: self.receive_variables, self.stop_event, self.config_parser, - self.start_event + self.start_event, + self.command_queue ) + # Share the logging_active flag + self.network_process.logging_active = self._logging_active self.network_process.start() + self.logger = None + self.running = False + self.thread = None + + @property + def state(self) -> ClientState: + """Get current client state (thread-safe).""" + with self._state_lock: + return self._state + + def _transition_to(self, new_state: ClientState) -> bool: + """ + Attempt to transition to a new state. + + Returns: + True if transition was valid and completed, False otherwise. + """ + with self._state_lock: + if new_state in self._VALID_TRANSITIONS.get(self._state, set()): + old_state = self._state + self._state = new_state + logging.debug(f"State transition: {old_state.name} -> {new_state.name}") + return True + else: + logging.warning( + f"Invalid state transition attempted: {self._state.name} -> {new_state.name}" + ) + return False def start(self): """Send start signal to NetworkProcess and run control loop.""" + if not self._transition_to(ClientState.STARTING): + logging.error("Cannot start: invalid state") + return + logging.info("RSIClient sending start signal to NetworkProcess...") self.start_event.set() - self.running = True + if not self._transition_to(ClientState.RUNNING): + logging.error("Failed to transition to RUNNING state") + return + + self.running = True logging.info("RSI Client Started") try: @@ -51,39 +120,58 @@ class RSIClient: self.stop() except Exception as e: logging.error(f"RSI Client encountered an error: {e}") + self._transition_to(ClientState.ERROR) def stop(self): """Stop the network process and the client thread safely.""" - logging.info("🛑 Stopping RSI Client...") + if self.state in (ClientState.STOPPED, ClientState.STOPPING): + logging.debug("Already stopped or stopping") + return + + if not self._transition_to(ClientState.STOPPING): + logging.warning("Could not transition to STOPPING state") + # Continue anyway to ensure cleanup + + logging.info("Stopping RSI Client...") self.running = False - self.stop_event.set() # ✅ Tell network process to exit nicely + self.stop_event.set() if self.network_process and self.network_process.is_alive(): - self.network_process.join(timeout=3) # ✅ Give it time to shutdown + self.network_process.join(timeout=3) if self.network_process.is_alive(): - logging.warning("⚠️ Forcing network process termination...") + logging.warning("Forcing network process termination...") self.network_process.terminate() self.network_process.join() - if hasattr(self, "thread") and self.thread and self.thread.is_alive(): - self.thread.join() + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=2) self.thread = None - logging.info("✅ RSI Client Stopped") + self._transition_to(ClientState.STOPPED) + logging.info("RSI Client Stopped") def reconnect(self): """Reconnects the network process safely.""" logging.info("Reconnecting RSI Client network...") + # Stop if currently running + if self.state in (ClientState.RUNNING, ClientState.STARTING): + self.stop() + if self.network_process and self.network_process.is_alive(): self.stop_event.set() self.network_process.terminate() self.network_process.join() - # Fresh new events + # Reset to initialized state + with self._state_lock: + self._state = ClientState.INITIALIZED + + # Fresh new events and queue self.stop_event = multiprocessing.Event() self.start_event = multiprocessing.Event() + self.command_queue = multiprocessing.Queue() # Create new network process network_settings = self.config_parser.get_network_settings() @@ -94,10 +182,32 @@ class RSIClient: self.receive_variables, self.stop_event, self.config_parser, - self.start_event + self.start_event, + self.command_queue ) + self.network_process.logging_active = self._logging_active self.network_process.start() # Fresh control thread self.thread = threading.Thread(target=self.start, daemon=True) self.thread.start() + + def is_running(self) -> bool: + """Check if client is in running state.""" + return self.state == ClientState.RUNNING + + def is_stopped(self) -> bool: + """Check if client is fully stopped.""" + return self.state == ClientState.STOPPED + + def start_logging(self, filename): + """Start CSV logging to the specified file.""" + self.command_queue.put({'action': 'start_logging', 'filename': filename}) + + def stop_logging(self): + """Stop CSV logging.""" + self.command_queue.put({'action': 'stop_logging'}) + + def is_logging_active(self) -> bool: + """Check if CSV logging is currently active.""" + return self._logging_active.value diff --git a/src/RSIPI/rsi_config.py b/src/RSIPI/rsi_config.py index 7de83aa..7031090 100644 --- a/src/RSIPI/rsi_config.py +++ b/src/RSIPI/rsi_config.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET import logging -from src.RSIPI.rsi_limit_parser import parse_rsi_limits +from .rsi_limit_parser import parse_rsi_limits # ✅ Configure Logging (toggleable) LOGGING_ENABLED = False # Change too False to silence logging output diff --git a/src/RSIPI/rsi_echo_server.py b/src/RSIPI/rsi_echo_server.py index 6d07478..6340d55 100644 --- a/src/RSIPI/rsi_echo_server.py +++ b/src/RSIPI/rsi_echo_server.py @@ -3,7 +3,7 @@ import time import xml.etree.ElementTree as ET import logging import threading -from src.RSIPI.rsi_config import RSIConfig +from .rsi_config import RSIConfig # ✅ Toggle logging for debugging purposes LOGGING_ENABLED = True diff --git a/src/RSIPI/trajectory_planner.py b/src/RSIPI/trajectory_planner.py index a735033..a225abe 100644 --- a/src/RSIPI/trajectory_planner.py +++ b/src/RSIPI/trajectory_planner.py @@ -1,4 +1,4 @@ -from RSIPI.safety_manager import SafetyManager +from .safety_manager import SafetyManager import time def generate_trajectory(start, end, steps=100, space="cartesian", mode="absolute", include_resets=False): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..693e8ec --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# RSIPI Tests diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..233849bcbdc2edfb8c8a8689517333c088e427e0 GIT binary patch literal 169 zcmey&%ge<81ea%KW%2{*#~=<2FhUuhS%8eG4CxG-jD9N_ikN`B&mgH=9xhff{&}e` zMVV!(3c3ox<%xO4sfh}qc|d+qab`(nOh8e7R%&udaZFIKXMkr6P*ArZGo~c97$_7U qpP83g5+AQuPGz{(L^ZPEr1wGS;S=iR=44 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