From 3a686eb56a20901958738d70b846669a75cc514d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:19:04 +0000 Subject: [PATCH 1/8] Initial plan From 54b4fd92cd98b61fb1278faeae95bf8a95f717b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:23:33 +0000 Subject: [PATCH 2/8] Add TCP/IP support with GridConnect format Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- pyolcb/interface.py | 65 +++++++++++++++++++++++++++++++++++- pyolcb/message.py | 57 ++++++++++++++++++++++++++++++++ pyolcb/node.py | 8 ++++- pyolcb/utilities.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) diff --git a/pyolcb/interface.py b/pyolcb/interface.py index fed64f7..0bd5268 100644 --- a/pyolcb/interface.py +++ b/pyolcb/interface.py @@ -1,6 +1,7 @@ import can import socket import asyncio +import threading from .message import Message from .address import Address from enum import Enum @@ -13,17 +14,28 @@ class Interface: network = [] phy = None connection = None + _tcp_listener_thread = None + _tcp_running = False + _tcp_buffer = "" + def __init__(self, connection: can.BusABC | socket.socket) -> None: if isinstance(connection, can.BusABC): self.connection = connection self.phy = InterfaceType.CAN + elif isinstance(connection, socket.socket): + self.connection = connection + self.phy = InterfaceType.TCP + self._tcp_buffer = "" else: - raise NotImplementedError("TCP/IP support is not yet implemented") + raise TypeError("Connection must be either can.BusABC or socket.socket") def send(self, message:Message): if self.phy == InterfaceType.CAN: can_message = can.Message(arbitration_id=message.get_can_header(), data=message.data, is_extended_id=True) return self.connection.send(can_message) + elif self.phy == InterfaceType.TCP: + gridconnect_frame = message.to_gridconnect() + return self.connection.sendall(gridconnect_frame.encode('ascii')) def register_connected_device(self, address:Address): if not address in self.network: @@ -33,6 +45,57 @@ def register_connected_device(self, address:Address): def register_listener(self, function:callable): if self.phy == InterfaceType.CAN: can.Notifier(self.connection, [function]) + elif self.phy == InterfaceType.TCP: + # Start TCP listener thread + if self._tcp_listener_thread is None or not self._tcp_listener_thread.is_alive(): + self._tcp_running = True + self._tcp_listener_thread = threading.Thread( + target=self._tcp_listener_loop, + args=(function,), + daemon=True + ) + self._tcp_listener_thread.start() + + def _tcp_listener_loop(self, callback:callable): + """ + Internal method to listen for TCP messages and call the callback function. + """ + while self._tcp_running: + try: + # Receive data from socket + data = self.connection.recv(4096) + if not data: + # Connection closed + break + + # Add to buffer and decode + self._tcp_buffer += data.decode('ascii', errors='ignore') + + # Process complete frames (ending with ';') + while ';' in self._tcp_buffer: + frame_end = self._tcp_buffer.index(';') + frame = self._tcp_buffer[:frame_end+1] + self._tcp_buffer = self._tcp_buffer[frame_end+1:] + + # Parse and callback + message = Message.from_gridconnect(frame) + if message is not None: + callback(message) + + except socket.timeout: + continue + except Exception as e: + # Handle errors but keep listening + continue + + def stop_listener(self): + """ + Stop the TCP listener thread if running. + """ + if self.phy == InterfaceType.TCP and self._tcp_running: + self._tcp_running = False + if self._tcp_listener_thread is not None: + self._tcp_listener_thread.join(timeout=1.0) def list_connected_devices(self): return self.network diff --git a/pyolcb/message.py b/pyolcb/message.py index 0f8608a..6f45fd6 100644 --- a/pyolcb/message.py +++ b/pyolcb/message.py @@ -1,5 +1,6 @@ from .address import Address from .message_types import MessageTypeIndicator, is_known_mti +from . import utilities import can class Message: @@ -49,4 +50,60 @@ def from_can_message(cls, message:can.Message): return None else: return None + + def to_gridconnect(self) -> str: + """ + Convert this Message to GridConnect ASCII format. + + Returns + ------- + str + GridConnect formatted string + """ + if self.source is None: + raise Exception("No source node set") + + arbitration_id = self.get_can_header() + data = self.data if self.data is not None else bytes() + return utilities.to_gridconnect(arbitration_id, data, is_extended=True) + + @classmethod + def from_gridconnect(cls, frame: str): + """ + Create a Message from a GridConnect ASCII format string. + + Parameters + ---------- + frame : str + GridConnect formatted string + Returns + ------- + Message | None + A Message object or None if parsing fails + """ + parsed = utilities.from_gridconnect(frame) + if parsed is None: + return None + + arbitration_id, data, is_extended = parsed + + if is_extended: + mti = MessageTypeIndicator.from_can_header(arbitration_id) + frame_id = None + destination = None + match (arbitration_id >> 24): + case 0x1A: + frame_id = None + case 0x1B: + frame_id = 1 + case 0x1D: + frame_id = -1 + case 0x1C: + frame_id = 2 + if is_known_mti(mti): + return cls(mti, data, Address(alias=arbitration_id & 0xFFF), destination, frame_id) + else: + return None + else: + return None diff --git a/pyolcb/node.py b/pyolcb/node.py index 3b72973..13ac5be 100644 --- a/pyolcb/node.py +++ b/pyolcb/node.py @@ -321,8 +321,14 @@ def set_unknown_message_processor(self, function: callable): def process_message(self, message): if isinstance(message, can.Message): converted_message = Message.from_can_message(message) + elif isinstance(message, Message): + # Message already converted (e.g., from TCP) + converted_message = message else: - raise NotImplementedError() + raise NotImplementedError("Unsupported message type") + + if converted_message is None: + return match converted_message.message_type: case message_types.Verify_Node_ID_Number_Addressed: diff --git a/pyolcb/utilities.py b/pyolcb/utilities.py index 20112ec..20dbbb5 100644 --- a/pyolcb/utilities.py +++ b/pyolcb/utilities.py @@ -20,3 +20,83 @@ def process_bytes(n: int | float, x: str | list[int] | int | bytes | bytearray): byte_options = str | list[int] | int | bytes | bytearray + + +def to_gridconnect(arbitration_id: int, data: bytes, is_extended: bool = True) -> str: + """ + Convert a CAN message to GridConnect ASCII format. + + Parameters + ---------- + arbitration_id : int + The CAN arbitration ID + data : bytes + The message data payload + is_extended : bool + Whether this is an extended CAN frame (default: True) + + Returns + ------- + str + GridConnect formatted string (e.g., ':X195B4123N0102030405060708;') + """ + frame_type = 'X' if is_extended else 'S' + id_width = 8 if is_extended else 3 + id_hex = format(arbitration_id, f'0{id_width}X') + data_hex = ''.join(format(b, '02X') for b in data) if data else '' + return f':{frame_type}{id_hex}N{data_hex};' + + +def from_gridconnect(frame: str) -> tuple[int, bytes, bool] | None: + """ + Parse a GridConnect ASCII format frame into CAN message components. + + Parameters + ---------- + frame : str + GridConnect formatted string + + Returns + ------- + tuple[int, bytes, bool] | None + A tuple of (arbitration_id, data, is_extended) or None if parsing fails + """ + frame = frame.strip() + + # Check basic frame structure + if not frame.startswith(':') or not frame.endswith(';'): + return None + + # Remove delimiters + frame = frame[1:-1] + + # Check frame type + if not frame or frame[0] not in ['X', 'S']: + return None + + is_extended = (frame[0] == 'X') + frame = frame[1:] + + # Find the 'N' separator + n_pos = frame.find('N') + if n_pos == -1: + return None + + # Parse arbitration ID + id_str = frame[:n_pos] + try: + arbitration_id = int(id_str, 16) + except ValueError: + return None + + # Parse data bytes + data_str = frame[n_pos+1:] + if len(data_str) % 2 != 0: + return None + + try: + data = bytes(int(data_str[i:i+2], 16) for i in range(0, len(data_str), 2)) + except ValueError: + return None + + return (arbitration_id, data, is_extended) From 91fbdc2b345d98263ddc1fa7ed199a573313439b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:26:41 +0000 Subject: [PATCH 3/8] Add TCP/IP tests and update documentation Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- README.md | 21 +++- tests/test_tcp.py | 254 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 tests/test_tcp.py diff --git a/README.md b/README.md index 7e3626c..03bf155 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # pyOLCB -An easy to use python implementation of OpenLCB (LCC) protocols, designed to interface with CAN (and TCP/IP via gridconnect and the native OpenLCB specification in a future release). +An easy to use python implementation of OpenLCB (LCC) protocols, designed to interface with CAN and TCP/IP via GridConnect format. This is very much a **work in progress**, please don't expect it to function fully for a while. Documentation is available at [https://www.uncommonmodels.com/pyOLCB](https://www.uncommonmodels.com/pyOLCB) +## CAN Example ```python from pyolcb import Node, Address, Event, Interface @@ -15,5 +16,23 @@ interface = Interface(can.Bus(interface='socketcan', channel='vcan0', bitrate=12 node = Node(address, interface) +node.produce(Event(125)) +``` + +## TCP/IP Example + +```python +from pyolcb import Node, Address, Event, Interface +import socket + +# Connect to an OpenLCB hub via TCP/IP +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.connect(('localhost', 12021)) # Connect to GridConnect hub + +address = Address('05.01.01.01.8C.00') +interface = Interface(sock) + +node = Node(address, interface) + node.produce(Event(125)) ``` \ No newline at end of file diff --git a/tests/test_tcp.py b/tests/test_tcp.py new file mode 100644 index 0000000..195760b --- /dev/null +++ b/tests/test_tcp.py @@ -0,0 +1,254 @@ +import socket +import threading +import time +import pyolcb + +TEST_ADDRESS = '05.01.01.01.8C.00' +TEST_OTHER_ADDRESS = '05.01.01.01.8C.01' +TEST_PORT = 12021 + + +def test_gridconnect_encoding(): + """ + Test GridConnect format encoding. + """ + # Test encoding a message to GridConnect format + arbitration_id = 0x195B4123 + data = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) + + result = pyolcb.utilities.to_gridconnect(arbitration_id, data, is_extended=True) + expected = ':X195B4123N0102030405060708;' + assert result == expected, f"Expected {expected}, got {result}" + + +def test_gridconnect_decoding(): + """ + Test GridConnect format decoding. + """ + frame = ':X195B4123N0102030405060708;' + result = pyolcb.utilities.from_gridconnect(frame) + + assert result is not None, "Failed to parse GridConnect frame" + arbitration_id, data, is_extended = result + + assert arbitration_id == 0x195B4123, f"Expected ID 0x195B4123, got {hex(arbitration_id)}" + assert data == bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) + assert is_extended == True + + +def test_gridconnect_empty_data(): + """ + Test GridConnect format with empty data. + """ + arbitration_id = 0x19100123 + data = bytes() + + result = pyolcb.utilities.to_gridconnect(arbitration_id, data, is_extended=True) + expected = ':X19100123N;' + assert result == expected + + +def test_gridconnect_roundtrip(): + """ + Test encoding and decoding roundtrip. + """ + # Test multiple frames + test_cases = [ + (0x19100123, bytes([0x05, 0x01, 0x01, 0x01, 0x8C, 0x00])), + (0x195B4123, bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08])), + (0x19490123, bytes()), + ] + + for arb_id, data in test_cases: + encoded = pyolcb.utilities.to_gridconnect(arb_id, data, is_extended=True) + decoded = pyolcb.utilities.from_gridconnect(encoded) + + assert decoded is not None, f"Failed to decode {encoded}" + dec_id, dec_data, dec_ext = decoded + + assert dec_id == arb_id, f"ID mismatch: {hex(arb_id)} != {hex(dec_id)}" + assert dec_data == data, f"Data mismatch: {data.hex()} != {dec_data.hex()}" + assert dec_ext == True + + +def test_message_to_gridconnect(): + """ + Test Message to GridConnect conversion. + """ + address = pyolcb.Address(TEST_ADDRESS) + address.set_alias(0x123) # Set an alias + message = pyolcb.Message( + pyolcb.message_types.Initialization_Complete, + pyolcb.utilities.process_bytes(6, TEST_ADDRESS), + address + ) + + gridconnect = message.to_gridconnect() + + # Should start with : and end with ; + assert gridconnect.startswith(':') + assert gridconnect.endswith(';') + assert 'X' in gridconnect # Extended frame + assert 'N' in gridconnect # Data separator + + +def test_message_from_gridconnect(): + """ + Test creating Message from GridConnect string. + """ + # Initialization Complete message + frame = ':X19100123N050101018C00;' + message = pyolcb.Message.from_gridconnect(frame) + + assert message is not None, "Failed to create message from GridConnect" + assert message.message_type == pyolcb.message_types.Initialization_Complete + assert message.source is not None + assert message.source.get_alias() == 0x123 + + +def test_tcp_interface_creation(): + """ + Test creating a TCP Interface. + """ + # Create a socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + # Create interface + interface = pyolcb.Interface(sock) + + assert interface.phy == pyolcb.interface.InterfaceType.TCP + assert interface.connection == sock + + sock.close() + + +def test_tcp_send_receive(): + """ + Test sending and receiving messages over TCP. + """ + received_messages = [] + + def message_handler(message): + received_messages.append(message) + + # Create server socket + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(('127.0.0.1', TEST_PORT)) + server_socket.listen(1) + + # Server thread to accept connection + client_sock = None + def server_thread(): + nonlocal client_sock + client_sock, _ = server_socket.accept() + + server_t = threading.Thread(target=server_thread) + server_t.start() + + # Create client socket and connect + client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client_socket.connect(('127.0.0.1', TEST_PORT)) + + # Wait for server to accept + server_t.join(timeout=2) + + # Create interfaces + client_interface = pyolcb.Interface(client_socket) + server_interface = pyolcb.Interface(client_sock) + + # Register listener on server side + server_interface.register_listener(message_handler) + + # Create and send a message from client + address = pyolcb.Address(TEST_ADDRESS) + address.set_alias(0x123) # Set an alias + message = pyolcb.Message( + pyolcb.message_types.Initialization_Complete, + pyolcb.utilities.process_bytes(6, TEST_ADDRESS), + address + ) + + client_interface.send(message) + + # Wait for message to be received + time.sleep(0.5) + + # Verify message was received + assert len(received_messages) > 0, "No messages received" + received = received_messages[0] + assert received.message_type == pyolcb.message_types.Initialization_Complete + + # Clean up + server_interface.stop_listener() + client_socket.close() + client_sock.close() + server_socket.close() + + +def test_tcp_node_initialization(): + """ + Test Node initialization with TCP interface. + """ + received_messages = [] + + def message_handler(message): + received_messages.append(message) + + # Create server socket + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(('127.0.0.1', TEST_PORT + 1)) + server_socket.listen(1) + + # Server thread + client_sock = None + def server_thread(): + nonlocal client_sock + client_sock, _ = server_socket.accept() + + server_t = threading.Thread(target=server_thread) + server_t.start() + + # Create client socket + client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client_socket.connect(('127.0.0.1', TEST_PORT + 1)) + + server_t.join(timeout=2) + + # Create server interface with listener + server_interface = pyolcb.Interface(client_sock) + server_interface.register_listener(message_handler) + + # Create node with TCP interface + client_interface = pyolcb.Interface(client_socket) + address = pyolcb.Address(TEST_ADDRESS) + node = pyolcb.Node(address, client_interface) + + # Wait for initialization message + time.sleep(0.5) + + # Verify initialization message was sent + assert len(received_messages) > 0, "No initialization message received" + init_msg = received_messages[0] + assert init_msg.message_type == pyolcb.message_types.Initialization_Complete + + # Clean up + server_interface.stop_listener() + client_socket.close() + client_sock.close() + server_socket.close() + + +if __name__ == '__main__': + # Run tests + test_gridconnect_encoding() + test_gridconnect_decoding() + test_gridconnect_empty_data() + test_gridconnect_roundtrip() + test_message_to_gridconnect() + test_message_from_gridconnect() + test_tcp_interface_creation() + test_tcp_send_receive() + test_tcp_node_initialization() + print("All TCP tests passed!") From 4a7ab4da9cce1d11b802491a7935218ddce8684e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:30:21 +0000 Subject: [PATCH 4/8] Fix code style issues per flake8 Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- pyolcb/interface.py | 33 +++++++++--------- pyolcb/message.py | 33 +++++++++--------- pyolcb/utilities.py | 26 +++++++------- tests/test_tcp.py | 82 +++++++++++++++++++++++---------------------- 4 files changed, 90 insertions(+), 84 deletions(-) diff --git a/pyolcb/interface.py b/pyolcb/interface.py index 0bd5268..35ebbc6 100644 --- a/pyolcb/interface.py +++ b/pyolcb/interface.py @@ -1,15 +1,16 @@ import can import socket -import asyncio import threading from .message import Message from .address import Address from enum import Enum + class InterfaceType(Enum): CAN = 0 TCP = 1 + class Interface: network = [] phy = None @@ -17,7 +18,7 @@ class Interface: _tcp_listener_thread = None _tcp_running = False _tcp_buffer = "" - + def __init__(self, connection: can.BusABC | socket.socket) -> None: if isinstance(connection, can.BusABC): self.connection = connection @@ -28,21 +29,21 @@ def __init__(self, connection: can.BusABC | socket.socket) -> None: self._tcp_buffer = "" else: raise TypeError("Connection must be either can.BusABC or socket.socket") - - def send(self, message:Message): + + def send(self, message: Message): if self.phy == InterfaceType.CAN: can_message = can.Message(arbitration_id=message.get_can_header(), data=message.data, is_extended_id=True) return self.connection.send(can_message) elif self.phy == InterfaceType.TCP: gridconnect_frame = message.to_gridconnect() return self.connection.sendall(gridconnect_frame.encode('ascii')) - - def register_connected_device(self, address:Address): - if not address in self.network: + + def register_connected_device(self, address: Address): + if address not in self.network: self.network.append(address) return self.network - def register_listener(self, function:callable): + def register_listener(self, function: callable): if self.phy == InterfaceType.CAN: can.Notifier(self.connection, [function]) elif self.phy == InterfaceType.TCP: @@ -50,13 +51,13 @@ def register_listener(self, function:callable): if self._tcp_listener_thread is None or not self._tcp_listener_thread.is_alive(): self._tcp_running = True self._tcp_listener_thread = threading.Thread( - target=self._tcp_listener_loop, + target=self._tcp_listener_loop, args=(function,), daemon=True ) self._tcp_listener_thread.start() - def _tcp_listener_loop(self, callback:callable): + def _tcp_listener_loop(self, callback: callable): """ Internal method to listen for TCP messages and call the callback function. """ @@ -67,27 +68,27 @@ def _tcp_listener_loop(self, callback:callable): if not data: # Connection closed break - + # Add to buffer and decode self._tcp_buffer += data.decode('ascii', errors='ignore') - + # Process complete frames (ending with ';') while ';' in self._tcp_buffer: frame_end = self._tcp_buffer.index(';') frame = self._tcp_buffer[:frame_end+1] self._tcp_buffer = self._tcp_buffer[frame_end+1:] - + # Parse and callback message = Message.from_gridconnect(frame) if message is not None: callback(message) - + except socket.timeout: continue - except Exception as e: + except Exception: # Handle errors but keep listening continue - + def stop_listener(self): """ Stop the TCP listener thread if running. diff --git a/pyolcb/message.py b/pyolcb/message.py index 6f45fd6..96ba066 100644 --- a/pyolcb/message.py +++ b/pyolcb/message.py @@ -3,11 +3,14 @@ from . import utilities import can + class Message: source = None destination = None data = bytes(8) - def __init__(self, message_type:MessageTypeIndicator, data:bytes | bytearray = None, source:Address = None, destination:Address = None, frame_id:int = None) -> None: + + def __init__(self, message_type: MessageTypeIndicator, data: bytes | bytearray = None, + source: Address = None, destination: Address = None, frame_id: int = None) -> None: self.source = source self.destination = destination self.data = data @@ -16,21 +19,21 @@ def __init__(self, message_type:MessageTypeIndicator, data:bytes | bytearray = N def get_can_header(self) -> int: if self.source is None: - raise Exception("No source node set") + raise Exception("No source node set") else: return self.message_type.get_can_header(self.source, self.destination, self.frame_id) - + def get_can_header_bytes(self) -> bytes: if self.source is None: - raise Exception("No source node set") + raise Exception("No source node set") else: return self.message_type.get_can_header_bytes(self.source, self.destination, self.frame_id) - + def get_mti(self) -> bytes: return self.message_type.get_mti() @classmethod - def from_can_message(cls, message:can.Message): + def from_can_message(cls, message: can.Message): if message.is_extended_id: mti = MessageTypeIndicator.from_can_header(message.arbitration_id) frame_id = None @@ -45,16 +48,16 @@ def from_can_message(cls, message:can.Message): case 0x1C: frame_id = 2 if is_known_mti(mti): - return cls(mti,message.data, Address(alias=message.arbitration_id & 0xFFF), destination, frame_id) + return cls(mti, message.data, Address(alias=message.arbitration_id & 0xFFF), destination, frame_id) else: return None else: return None - + def to_gridconnect(self) -> str: """ Convert this Message to GridConnect ASCII format. - + Returns ------- str @@ -62,21 +65,21 @@ def to_gridconnect(self) -> str: """ if self.source is None: raise Exception("No source node set") - + arbitration_id = self.get_can_header() data = self.data if self.data is not None else bytes() return utilities.to_gridconnect(arbitration_id, data, is_extended=True) - + @classmethod def from_gridconnect(cls, frame: str): """ Create a Message from a GridConnect ASCII format string. - + Parameters ---------- frame : str GridConnect formatted string - + Returns ------- Message | None @@ -85,9 +88,9 @@ def from_gridconnect(cls, frame: str): parsed = utilities.from_gridconnect(frame) if parsed is None: return None - + arbitration_id, data, is_extended = parsed - + if is_extended: mti = MessageTypeIndicator.from_can_header(arbitration_id) frame_id = None diff --git a/pyolcb/utilities.py b/pyolcb/utilities.py index 20dbbb5..9b5b24d 100644 --- a/pyolcb/utilities.py +++ b/pyolcb/utilities.py @@ -25,7 +25,7 @@ def process_bytes(n: int | float, x: str | list[int] | int | bytes | bytearray): def to_gridconnect(arbitration_id: int, data: bytes, is_extended: bool = True) -> str: """ Convert a CAN message to GridConnect ASCII format. - + Parameters ---------- arbitration_id : int @@ -34,7 +34,7 @@ def to_gridconnect(arbitration_id: int, data: bytes, is_extended: bool = True) - The message data payload is_extended : bool Whether this is an extended CAN frame (default: True) - + Returns ------- str @@ -50,53 +50,53 @@ def to_gridconnect(arbitration_id: int, data: bytes, is_extended: bool = True) - def from_gridconnect(frame: str) -> tuple[int, bytes, bool] | None: """ Parse a GridConnect ASCII format frame into CAN message components. - + Parameters ---------- frame : str GridConnect formatted string - + Returns ------- tuple[int, bytes, bool] | None A tuple of (arbitration_id, data, is_extended) or None if parsing fails """ frame = frame.strip() - + # Check basic frame structure if not frame.startswith(':') or not frame.endswith(';'): return None - + # Remove delimiters frame = frame[1:-1] - + # Check frame type if not frame or frame[0] not in ['X', 'S']: return None - + is_extended = (frame[0] == 'X') frame = frame[1:] - + # Find the 'N' separator n_pos = frame.find('N') if n_pos == -1: return None - + # Parse arbitration ID id_str = frame[:n_pos] try: arbitration_id = int(id_str, 16) except ValueError: return None - + # Parse data bytes data_str = frame[n_pos+1:] if len(data_str) % 2 != 0: return None - + try: data = bytes(int(data_str[i:i+2], 16) for i in range(0, len(data_str), 2)) except ValueError: return None - + return (arbitration_id, data, is_extended) diff --git a/tests/test_tcp.py b/tests/test_tcp.py index 195760b..39b6372 100644 --- a/tests/test_tcp.py +++ b/tests/test_tcp.py @@ -15,7 +15,7 @@ def test_gridconnect_encoding(): # Test encoding a message to GridConnect format arbitration_id = 0x195B4123 data = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) - + result = pyolcb.utilities.to_gridconnect(arbitration_id, data, is_extended=True) expected = ':X195B4123N0102030405060708;' assert result == expected, f"Expected {expected}, got {result}" @@ -27,13 +27,13 @@ def test_gridconnect_decoding(): """ frame = ':X195B4123N0102030405060708;' result = pyolcb.utilities.from_gridconnect(frame) - + assert result is not None, "Failed to parse GridConnect frame" arbitration_id, data, is_extended = result - + assert arbitration_id == 0x195B4123, f"Expected ID 0x195B4123, got {hex(arbitration_id)}" assert data == bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) - assert is_extended == True + assert is_extended is True def test_gridconnect_empty_data(): @@ -42,7 +42,7 @@ def test_gridconnect_empty_data(): """ arbitration_id = 0x19100123 data = bytes() - + result = pyolcb.utilities.to_gridconnect(arbitration_id, data, is_extended=True) expected = ':X19100123N;' assert result == expected @@ -58,17 +58,17 @@ def test_gridconnect_roundtrip(): (0x195B4123, bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08])), (0x19490123, bytes()), ] - + for arb_id, data in test_cases: encoded = pyolcb.utilities.to_gridconnect(arb_id, data, is_extended=True) decoded = pyolcb.utilities.from_gridconnect(encoded) - + assert decoded is not None, f"Failed to decode {encoded}" dec_id, dec_data, dec_ext = decoded - + assert dec_id == arb_id, f"ID mismatch: {hex(arb_id)} != {hex(dec_id)}" assert dec_data == data, f"Data mismatch: {data.hex()} != {dec_data.hex()}" - assert dec_ext == True + assert dec_ext is True def test_message_to_gridconnect(): @@ -82,9 +82,9 @@ def test_message_to_gridconnect(): pyolcb.utilities.process_bytes(6, TEST_ADDRESS), address ) - + gridconnect = message.to_gridconnect() - + # Should start with : and end with ; assert gridconnect.startswith(':') assert gridconnect.endswith(';') @@ -99,7 +99,7 @@ def test_message_from_gridconnect(): # Initialization Complete message frame = ':X19100123N050101018C00;' message = pyolcb.Message.from_gridconnect(frame) - + assert message is not None, "Failed to create message from GridConnect" assert message.message_type == pyolcb.message_types.Initialization_Complete assert message.source is not None @@ -112,13 +112,13 @@ def test_tcp_interface_creation(): """ # Create a socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - + # Create interface interface = pyolcb.Interface(sock) - + assert interface.phy == pyolcb.interface.InterfaceType.TCP assert interface.connection == sock - + sock.close() @@ -127,39 +127,40 @@ def test_tcp_send_receive(): Test sending and receiving messages over TCP. """ received_messages = [] - + def message_handler(message): received_messages.append(message) - + # Create server socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind(('127.0.0.1', TEST_PORT)) server_socket.listen(1) - + # Server thread to accept connection client_sock = None + def server_thread(): nonlocal client_sock client_sock, _ = server_socket.accept() - + server_t = threading.Thread(target=server_thread) server_t.start() - + # Create client socket and connect client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect(('127.0.0.1', TEST_PORT)) - + # Wait for server to accept server_t.join(timeout=2) - + # Create interfaces client_interface = pyolcb.Interface(client_socket) server_interface = pyolcb.Interface(client_sock) - + # Register listener on server side server_interface.register_listener(message_handler) - + # Create and send a message from client address = pyolcb.Address(TEST_ADDRESS) address.set_alias(0x123) # Set an alias @@ -168,17 +169,17 @@ def server_thread(): pyolcb.utilities.process_bytes(6, TEST_ADDRESS), address ) - + client_interface.send(message) - + # Wait for message to be received time.sleep(0.5) - + # Verify message was received assert len(received_messages) > 0, "No messages received" received = received_messages[0] assert received.message_type == pyolcb.message_types.Initialization_Complete - + # Clean up server_interface.stop_listener() client_socket.close() @@ -191,48 +192,49 @@ def test_tcp_node_initialization(): Test Node initialization with TCP interface. """ received_messages = [] - + def message_handler(message): received_messages.append(message) - + # Create server socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind(('127.0.0.1', TEST_PORT + 1)) server_socket.listen(1) - + # Server thread client_sock = None + def server_thread(): nonlocal client_sock client_sock, _ = server_socket.accept() - + server_t = threading.Thread(target=server_thread) server_t.start() - + # Create client socket client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect(('127.0.0.1', TEST_PORT + 1)) - + server_t.join(timeout=2) - + # Create server interface with listener server_interface = pyolcb.Interface(client_sock) server_interface.register_listener(message_handler) - + # Create node with TCP interface client_interface = pyolcb.Interface(client_socket) address = pyolcb.Address(TEST_ADDRESS) - node = pyolcb.Node(address, client_interface) - + pyolcb.Node(address, client_interface) + # Wait for initialization message time.sleep(0.5) - + # Verify initialization message was sent assert len(received_messages) > 0, "No initialization message received" init_msg = received_messages[0] assert init_msg.message_type == pyolcb.message_types.Initialization_Complete - + # Clean up server_interface.stop_listener() client_socket.close() From b57b58f64b529dc1b14ee3657a3b2b55d0ef234f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:32:15 +0000 Subject: [PATCH 5/8] Address code review feedback Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- pyolcb/interface.py | 7 +++++-- pyolcb/utilities.py | 2 +- tests/test_tcp.py | 8 ++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/pyolcb/interface.py b/pyolcb/interface.py index 35ebbc6..4b3b055 100644 --- a/pyolcb/interface.py +++ b/pyolcb/interface.py @@ -70,7 +70,7 @@ def _tcp_listener_loop(self, callback: callable): break # Add to buffer and decode - self._tcp_buffer += data.decode('ascii', errors='ignore') + self._tcp_buffer += data.decode('ascii', errors='replace') # Process complete frames (ending with ';') while ';' in self._tcp_buffer: @@ -85,8 +85,11 @@ def _tcp_listener_loop(self, callback: callable): except socket.timeout: continue + except (ConnectionError, OSError): + # Connection errors - stop listening + break except Exception: - # Handle errors but keep listening + # Other errors - keep listening but continue continue def stop_listener(self): diff --git a/pyolcb/utilities.py b/pyolcb/utilities.py index 9b5b24d..906842e 100644 --- a/pyolcb/utilities.py +++ b/pyolcb/utilities.py @@ -95,7 +95,7 @@ def from_gridconnect(frame: str) -> tuple[int, bytes, bool] | None: return None try: - data = bytes(int(data_str[i:i+2], 16) for i in range(0, len(data_str), 2)) + data = bytes([int(data_str[i:i+2], 16) for i in range(0, len(data_str), 2)]) except ValueError: return None diff --git a/tests/test_tcp.py b/tests/test_tcp.py index 39b6372..d84c5dd 100644 --- a/tests/test_tcp.py +++ b/tests/test_tcp.py @@ -127,9 +127,11 @@ def test_tcp_send_receive(): Test sending and receiving messages over TCP. """ received_messages = [] + message_received = threading.Event() def message_handler(message): received_messages.append(message) + message_received.set() # Create server socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -173,7 +175,7 @@ def server_thread(): client_interface.send(message) # Wait for message to be received - time.sleep(0.5) + assert message_received.wait(timeout=2.0), "Message not received within timeout" # Verify message was received assert len(received_messages) > 0, "No messages received" @@ -192,9 +194,11 @@ def test_tcp_node_initialization(): Test Node initialization with TCP interface. """ received_messages = [] + message_received = threading.Event() def message_handler(message): received_messages.append(message) + message_received.set() # Create server socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -228,7 +232,7 @@ def server_thread(): pyolcb.Node(address, client_interface) # Wait for initialization message - time.sleep(0.5) + assert message_received.wait(timeout=2.0), "Initialization message not received within timeout" # Verify initialization message was sent assert len(received_messages) > 0, "No initialization message received" From 756e5a11861f474e86bcf990d282d76648a434e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:36:02 +0000 Subject: [PATCH 6/8] Add TCP/IP example script Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- examples/tcp_example.py | 241 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100755 examples/tcp_example.py diff --git a/examples/tcp_example.py b/examples/tcp_example.py new file mode 100755 index 0000000..358db57 --- /dev/null +++ b/examples/tcp_example.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Example: OpenLCB Node over TCP/IP using GridConnect format + +This example demonstrates how to create an OpenLCB node that communicates +over TCP/IP using the GridConnect ASCII protocol. This is useful for +connecting to OpenLCB hubs, simulators, or other TCP-based tools. + +To run this example, you'll need: +1. An OpenLCB hub or simulator listening on TCP port 12021 +2. Or you can test with two instances of this script (one as server, one as client) +""" + +import socket +import pyolcb +import time +import threading + + +def simple_tcp_client_example(): + """ + Simple example of connecting to an OpenLCB hub over TCP/IP + """ + print("=== Simple TCP Client Example ===") + + # Connect to an OpenLCB hub + # Replace 'localhost' and 12021 with your hub's address and port + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect(('localhost', 12021)) + print("Connected to OpenLCB hub at localhost:12021") + except ConnectionRefusedError: + print("Could not connect to hub at localhost:12021") + print("Make sure an OpenLCB hub is running on that port") + sock.close() + return + + # Create an interface + interface = pyolcb.Interface(sock) + + # Create a node with a unique address + address = pyolcb.Address('05.01.01.01.8C.00') + node = pyolcb.Node(address, interface) + + print(f"Created node with address: {address}") + + # Produce an event + print("Producing event 125...") + node.produce(pyolcb.Event(125, address)) + + # Give some time for the message to be sent + time.sleep(0.5) + + # Clean up + sock.close() + print("Disconnected from hub") + + +def tcp_listener_example(): + """ + Example of setting up a listener for incoming messages + """ + print("\n=== TCP Listener Example ===") + + def message_handler(message): + """Called when a message is received""" + print(f"Received message: MTI={hex(message.message_type.value)}, " + f"Source={message.source.get_alias() if message.source else 'unknown'}") + if message.data: + print(f" Data: {message.data.hex()}") + + # Connect to hub + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect(('localhost', 12021)) + print("Connected to OpenLCB hub at localhost:12021") + except ConnectionRefusedError: + print("Could not connect to hub at localhost:12021") + sock.close() + return + + # Create interface + interface = pyolcb.Interface(sock) + + # Register the listener + interface.register_listener(message_handler) + + # Create a node + address = pyolcb.Address('05.01.01.01.8C.01') + node = pyolcb.Node(address, interface) + + print(f"Created node with address: {address}") + print("Listening for messages for 5 seconds...") + + # Listen for a while + time.sleep(5) + + # Clean up + interface.stop_listener() + sock.close() + print("Stopped listening and disconnected") + + +def peer_to_peer_example(): + """ + Example of two nodes communicating directly over TCP + """ + print("\n=== Peer-to-Peer Example ===") + + received_events = [] + event_received = threading.Event() + + def event_consumer(message): + """Called when an event is received""" + print(f"Node 2 received event!") + received_events.append(message) + event_received.set() + + # Create a simple server socket + server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_sock.bind(('127.0.0.1', 12022)) + server_sock.listen(1) + + print("Server listening on 127.0.0.1:12022") + + # Accept connection in a thread + client_sock = None + + def accept_connection(): + nonlocal client_sock + client_sock, addr = server_sock.accept() + print(f"Accepted connection from {addr}") + + accept_thread = threading.Thread(target=accept_connection) + accept_thread.start() + + # Connect from client + client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client_socket.connect(('127.0.0.1', 12022)) + print("Client connected to server") + + # Wait for accept to complete + accept_thread.join(timeout=2) + + # Create interfaces + client_interface = pyolcb.Interface(client_socket) + server_interface = pyolcb.Interface(client_sock) + + # Create nodes + node1_address = pyolcb.Address('05.01.01.01.8C.10') + node1 = pyolcb.Node(node1_address, client_interface) + print(f"Node 1 created with address: {node1_address}") + + node2_address = pyolcb.Address('05.01.01.01.8C.20') + node2 = pyolcb.Node(node2_address, server_interface) + print(f"Node 2 created with address: {node2_address}") + + # Register event consumer on node 2 + event = pyolcb.Event(100, node1_address) + node2.add_consumer(event, event_consumer) + print(f"Node 2 registered consumer for event {event.id.hex()}") + + # Produce event from node 1 + print("Node 1 producing event 100...") + node1.produce(100) + + # Wait for event to be received + if event_received.wait(timeout=2.0): + print("Event successfully received by Node 2!") + else: + print("Event not received within timeout") + + # Clean up + server_interface.stop_listener() + client_interface.stop_listener() + client_socket.close() + client_sock.close() + server_sock.close() + print("Peer-to-peer communication complete") + + +def gridconnect_format_example(): + """ + Example showing the GridConnect ASCII format + """ + print("\n=== GridConnect Format Example ===") + + # Create a message + address = pyolcb.Address('05.01.01.01.8C.00') + address.set_alias(0x123) + + message = pyolcb.Message( + pyolcb.message_types.Initialization_Complete, + pyolcb.utilities.process_bytes(6, '05.01.01.01.8C.00'), + address + ) + + # Convert to GridConnect format + gridconnect = message.to_gridconnect() + print(f"Message in GridConnect format: {gridconnect}") + + # Parse it back + parsed = pyolcb.Message.from_gridconnect(gridconnect) + print(f"Parsed message type: {hex(parsed.message_type.value)}") + print(f"Parsed source alias: {hex(parsed.source.get_alias())}") + print(f"Parsed data: {parsed.data.hex()}") + + # Show the format breakdown + print("\nGridConnect format breakdown:") + print(" : - Start delimiter") + print(" X - Extended frame") + print(" 19100123 - CAN arbitration ID (hex)") + print(" N - Data separator") + print(" 050101018C00 - Message data (hex)") + print(" ; - End delimiter") + + +if __name__ == '__main__': + print("OpenLCB over TCP/IP Examples\n") + + # Show GridConnect format + gridconnect_format_example() + + # Try peer-to-peer (always works) + peer_to_peer_example() + + # These require an external hub + print("\n" + "="*60) + print("The following examples require an OpenLCB hub at localhost:12021") + print("You can skip them if you don't have a hub running") + print("="*60) + + response = input("\nRun hub-dependent examples? (y/n): ") + if response.lower() == 'y': + simple_tcp_client_example() + tcp_listener_example() + else: + print("Skipping hub-dependent examples") + + print("\nExamples complete!") From 204f087076bf28daa9fc951d54698cabdf683f7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:38:58 +0000 Subject: [PATCH 7/8] Refactor to reduce code duplication and improve consistency Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- pyolcb/message.py | 52 +++++++++++++++++++++++++++-------------------- tests/test_tcp.py | 16 +++++++-------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/pyolcb/message.py b/pyolcb/message.py index 96ba066..6d6dc5c 100644 --- a/pyolcb/message.py +++ b/pyolcb/message.py @@ -36,17 +36,7 @@ def get_mti(self) -> bytes: def from_can_message(cls, message: can.Message): if message.is_extended_id: mti = MessageTypeIndicator.from_can_header(message.arbitration_id) - frame_id = None - destination = None - match (message.arbitration_id >> 24): - case 0x1A: - frame_id = None - case 0x1B: - frame_id = 1 - case 0x1D: - frame_id = -1 - case 0x1C: - frame_id = 2 + frame_id, destination = cls._parse_frame_info(message.arbitration_id) if is_known_mti(mti): return cls(mti, message.data, Address(alias=message.arbitration_id & 0xFFF), destination, frame_id) else: @@ -54,6 +44,34 @@ def from_can_message(cls, message: can.Message): else: return None + @staticmethod + def _parse_frame_info(arbitration_id: int) -> tuple[int | None, None]: + """ + Parse frame ID from CAN arbitration ID. + + Parameters + ---------- + arbitration_id : int + The CAN arbitration ID + + Returns + ------- + tuple[int | None, None] + A tuple of (frame_id, destination) + """ + frame_id = None + destination = None + match (arbitration_id >> 24): + case 0x1A: + frame_id = None + case 0x1B: + frame_id = 1 + case 0x1D: + frame_id = -1 + case 0x1C: + frame_id = 2 + return frame_id, destination + def to_gridconnect(self) -> str: """ Convert this Message to GridConnect ASCII format. @@ -93,17 +111,7 @@ def from_gridconnect(cls, frame: str): if is_extended: mti = MessageTypeIndicator.from_can_header(arbitration_id) - frame_id = None - destination = None - match (arbitration_id >> 24): - case 0x1A: - frame_id = None - case 0x1B: - frame_id = 1 - case 0x1D: - frame_id = -1 - case 0x1C: - frame_id = 2 + frame_id, destination = cls._parse_frame_info(arbitration_id) if is_known_mti(mti): return cls(mti, data, Address(alias=arbitration_id & 0xFFF), destination, frame_id) else: diff --git a/tests/test_tcp.py b/tests/test_tcp.py index d84c5dd..c9136c6 100644 --- a/tests/test_tcp.py +++ b/tests/test_tcp.py @@ -142,19 +142,19 @@ def message_handler(message): # Server thread to accept connection client_sock = None - def server_thread(): + def accept_connection(): nonlocal client_sock client_sock, _ = server_socket.accept() - server_t = threading.Thread(target=server_thread) - server_t.start() + server_thread = threading.Thread(target=accept_connection) + server_thread.start() # Create client socket and connect client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect(('127.0.0.1', TEST_PORT)) # Wait for server to accept - server_t.join(timeout=2) + server_thread.join(timeout=2) # Create interfaces client_interface = pyolcb.Interface(client_socket) @@ -209,18 +209,18 @@ def message_handler(message): # Server thread client_sock = None - def server_thread(): + def accept_connection(): nonlocal client_sock client_sock, _ = server_socket.accept() - server_t = threading.Thread(target=server_thread) - server_t.start() + server_thread = threading.Thread(target=accept_connection) + server_thread.start() # Create client socket client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.connect(('127.0.0.1', TEST_PORT + 1)) - server_t.join(timeout=2) + server_thread.join(timeout=2) # Create server interface with listener server_interface = pyolcb.Interface(client_sock) From af81cf84a53b8e2701b3cc143a9b7cbe326b5aa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:39:54 +0000 Subject: [PATCH 8/8] Remove unused import Co-authored-by: ngpaladi <8539865+ngpaladi@users.noreply.github.com> --- tests/test_tcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_tcp.py b/tests/test_tcp.py index c9136c6..ad3b2fd 100644 --- a/tests/test_tcp.py +++ b/tests/test_tcp.py @@ -1,6 +1,5 @@ import socket import threading -import time import pyolcb TEST_ADDRESS = '05.01.01.01.8C.00'