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/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!") diff --git a/pyolcb/interface.py b/pyolcb/interface.py index fed64f7..4b3b055 100644 --- a/pyolcb/interface.py +++ b/pyolcb/interface.py @@ -1,38 +1,105 @@ 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 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") - - def send(self, message:Message): + 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) - - def register_connected_device(self, address:Address): - if not address in self.network: + 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 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: + # 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='replace') + + # 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 (ConnectionError, OSError): + # Connection errors - stop listening + break + except Exception: + # Other errors - keep listening but continue + 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..6d6dc5c 100644 --- a/pyolcb/message.py +++ b/pyolcb/message.py @@ -1,12 +1,16 @@ from .address import Address from .message_types import MessageTypeIndicator, is_known_mti +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 @@ -15,38 +19,102 @@ 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 - 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: + return None + 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. + + 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, destination = cls._parse_frame_info(arbitration_id) if is_known_mti(mti): - return cls(mti,message.data, Address(alias=message.arbitration_id & 0xFFF), destination, frame_id) + 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..906842e 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) diff --git a/tests/test_tcp.py b/tests/test_tcp.py new file mode 100644 index 0000000..ad3b2fd --- /dev/null +++ b/tests/test_tcp.py @@ -0,0 +1,259 @@ +import socket +import threading +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 is 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 is 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 = [] + 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) + 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 accept_connection(): + nonlocal client_sock + client_sock, _ = server_socket.accept() + + 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_thread.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 + assert message_received.wait(timeout=2.0), "Message not received within timeout" + + # 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 = [] + 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) + 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 accept_connection(): + nonlocal client_sock + client_sock, _ = server_socket.accept() + + 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_thread.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) + pyolcb.Node(address, client_interface) + + # Wait for initialization message + 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" + 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!")