From 9563a871f143177ac49d928ab9e9d0af4704c2c1 Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Mon, 12 May 2025 21:51:25 -0700 Subject: [PATCH 1/5] Fix issue where transports were not disconnecting --- src/meshcore/ble_cx.py | 6 ++++++ src/meshcore/meshcore.py | 22 ++++++++++++++-------- src/meshcore/serial_cx.py | 9 ++++++++- src/meshcore/tcp_cx.py | 7 +++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/meshcore/ble_cx.py b/src/meshcore/ble_cx.py index fbdca89..9a9b321 100644 --- a/src/meshcore/ble_cx.py +++ b/src/meshcore/ble_cx.py @@ -91,3 +91,9 @@ class BLEConnection: logger.error("RX characteristic not found") return False await self.client.write_gatt_char(self.rx_char, bytes(data), response=False) + + async def disconnect(self): + """Disconnect from the BLE device.""" + if self.client and self.client.is_connected: + await self.client.disconnect() + logger.info("BLE Connection closed") diff --git a/src/meshcore/meshcore.py b/src/meshcore/meshcore.py index e52959d..ac335cd 100644 --- a/src/meshcore/meshcore.py +++ b/src/meshcore/meshcore.py @@ -5,7 +5,9 @@ from typing import Optional, Dict, Any, Union from .events import EventDispatcher, EventType from .reader import MessageReader from .commands import CommandHandler - +from .ble_cx import BLEConnection +from .tcp_cx import TCPConnection +from .serial_cx import SerialConnection # Setup default logger logger = logging.getLogger("meshcore") @@ -45,9 +47,7 @@ class MeshCore: @classmethod async def create_tcp(cls, host: str, port: int, debug: bool = False, default_timeout=None) -> 'MeshCore': - """Create and connect a MeshCore instance using TCP connection""" - from .tcp_cx import TCPConnection - + """Create and connect a MeshCore instance using TCP connection""" connection = TCPConnection(host, port) await connection.connect() @@ -58,9 +58,6 @@ class MeshCore: @classmethod async def create_serial(cls, port: str, baudrate: int = 115200, debug: bool = False, default_timeout=None) -> 'MeshCore': """Create and connect a MeshCore instance using serial connection""" - from .serial_cx import SerialConnection - import asyncio - connection = SerialConnection(port, baudrate) await connection.connect() await asyncio.sleep(0.1) # Time for transport to establish @@ -75,7 +72,6 @@ class MeshCore: If address is None, it will scan for and connect to the first available MeshCore device. """ - from .ble_cx import BLEConnection connection = BLEConnection(address) result = await connection.connect() @@ -91,7 +87,17 @@ class MeshCore: return await self.commands.send_appstart() async def disconnect(self): + """Disconnect from the device and clean up resources.""" + # First stop the dispatcher to prevent any new events await self.dispatcher.stop() + + # Stop auto message fetching if it's running + if hasattr(self, '_auto_fetch_subscription') and self._auto_fetch_subscription: + await self.stop_auto_message_fetching() + + # Disconnect the connection object + if self.cx: + await self.cx.disconnect() def stop(self): """Synchronously stop the event dispatcher task""" diff --git a/src/meshcore/serial_cx.py b/src/meshcore/serial_cx.py index b7b07c6..b2284e2 100644 --- a/src/meshcore/serial_cx.py +++ b/src/meshcore/serial_cx.py @@ -86,4 +86,11 @@ class SerialConnection: size = len(data) pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data logger.debug(f"sending pkt : {pkt}") - self.transport.write(pkt) \ No newline at end of file + self.transport.write(pkt) + + async def disconnect(self): + """Close the serial connection.""" + if self.transport: + self.transport.close() + self.transport = None + logger.info("Serial Connection closed") \ No newline at end of file diff --git a/src/meshcore/tcp_cx.py b/src/meshcore/tcp_cx.py index e90fb2a..3bd4694 100644 --- a/src/meshcore/tcp_cx.py +++ b/src/meshcore/tcp_cx.py @@ -85,3 +85,10 @@ class TCPConnection: pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + data logger.debug(f"sending pkt : {pkt}") self.transport.write(pkt) + + async def disconnect(self): + """Close the TCP connection.""" + if self.transport: + self.transport.close() + self.transport = None + logger.info("TCP Connection closed") From b3cf2ba7b425b9beb452108d23c33a718cc35537 Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Mon, 12 May 2025 21:52:28 -0700 Subject: [PATCH 2/5] Fix typing on MC top level subscribe method --- src/meshcore/meshcore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/meshcore/meshcore.py b/src/meshcore/meshcore.py index ac335cd..ad55b34 100644 --- a/src/meshcore/meshcore.py +++ b/src/meshcore/meshcore.py @@ -105,7 +105,7 @@ class MeshCore: self.dispatcher.running = False self.dispatcher._task.cancel() - def subscribe(self, event_type: EventType, callback, attribute_filters: Optional[Dict[str, Any]] = None): + def subscribe(self, event_type: Union[EventType, None], callback, attribute_filters: Optional[Dict[str, Any]] = None): """ Subscribe to events using EventType enum with optional attribute filtering From 8d805de78d3c0f27b4b43e1e5bbd055f78a93abe Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Mon, 12 May 2025 22:35:53 -0700 Subject: [PATCH 3/5] add the ability to subscribe to LOGIN_SUCCESS event --- examples/tcp_login_status.py | 91 ++++++++++++++++++++++++++++++++++++ src/meshcore/ble_cx.py | 2 +- src/meshcore/reader.py | 29 +++++++++--- src/meshcore/serial_cx.py | 2 +- src/meshcore/tcp_cx.py | 2 +- 5 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 examples/tcp_login_status.py diff --git a/examples/tcp_login_status.py b/examples/tcp_login_status.py new file mode 100644 index 0000000..97e4069 --- /dev/null +++ b/examples/tcp_login_status.py @@ -0,0 +1,91 @@ +#!/usr/bin/python + +import asyncio +import argparse +import logging +from math import log + +from meshcore import MeshCore +from meshcore.events import EventType + +# Set up logging +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +async def main(): + # Parse command line arguments + parser = argparse.ArgumentParser(description='Get status from a repeater via TCP connection') + parser.add_argument('-host', '--hostname', required=True, help='TCP hostname or IP address') + parser.add_argument('-port', '--port', type=int, required=True, help='TCP port number') + parser.add_argument('-r', '--repeater', required=True, help='Repeater name') + parser.add_argument('-pw', '--password', required=True, help='Password for login') + args = parser.parse_args() + + # Connect to the device + print(f"Connecting to TCP {args.hostname}:{args.port}...") + mc = await MeshCore.create_tcp(args.hostname, args.port, debug=True) + + try: + # Set up a simple event handler to log all events + async def log_event(event): + print(f"EVENT: {event.type.name} - Payload: {event.payload}") + + # Subscribe to login events + mc.subscribe(EventType.LOGIN_SUCCESS, log_event) + mc.subscribe(EventType.LOGIN_FAILED, log_event) + mc.subscribe(EventType.STATUS_RESPONSE, log_event) + + # Get contacts + await mc.ensure_contacts() + + repeater = mc.get_contact_by_name(args.repeater) + + if repeater is None: + print(f"Repeater '{args.repeater}' not found in contacts.") + print(f"Available contacts: {mc.contacts}") + return + + print(f"Found repeater: {repeater}") + + # Send login request + print(f"Sending login request to '{args.repeater}'...") + login_cmd = await mc.commands.send_login(repeater, args.password) + if login_cmd.type == EventType.ERROR: + print(f"Login failed: {login_cmd.payload}") + return + + filter = {"pubkey_prefix": repeater["public_key"][0:12]} + login_result = await mc.wait_for_event(EventType.LOGIN_SUCCESS, filter, timeout=10) + print(f"Login result: {login_result}") + + + # Wait a bit for the login response + print("Waiting for login events...") + await asyncio.sleep(3) + + # Send status request + print("Sending status request...") + await mc.commands.send_statusreq(repeater) + + # Wait for status response + print("Waiting for status response event...") + status_event = await mc.wait_for_event(EventType.STATUS_RESPONSE, timeout=5.0) + + if status_event: + print(f"Status response received: {status_event.payload}") + else: + print("No status response received within timeout") + + finally: + # Always disconnect properly + print("Disconnecting...") + await mc.disconnect() + print("Disconnected from device") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nOperation cancelled by user") + except Exception as e: + print(f"Error: {e}") \ No newline at end of file diff --git a/src/meshcore/ble_cx.py b/src/meshcore/ble_cx.py index 9a9b321..72e363f 100644 --- a/src/meshcore/ble_cx.py +++ b/src/meshcore/ble_cx.py @@ -96,4 +96,4 @@ class BLEConnection: """Disconnect from the BLE device.""" if self.client and self.client.is_connected: await self.client.disconnect() - logger.info("BLE Connection closed") + logger.debug("BLE Connection closed") diff --git a/src/meshcore/reader.py b/src/meshcore/reader.py index bedb0ae..ee5e4bb 100644 --- a/src/meshcore/reader.py +++ b/src/meshcore/reader.py @@ -251,14 +251,31 @@ class MessageReader: await self.dispatcher.dispatch(Event(EventType.RAW_DATA, res)) elif packet_type_value == PacketType.LOGIN_SUCCESS.value: - logger.debug("Login success") - # TODO: Read login attributes - await self.dispatcher.dispatch(Event(EventType.LOGIN_SUCCESS, {})) + res = {} + if len(data) > 1: + res["permissions"] = data[1] + res["is_admin"] = (data[1] & 1) == 1 # Check if admin bit is set + + if len(data) > 7: + res["pubkey_prefix"] = data[2:8].hex() + + attributes = { + "pubkey_prefix": res.get("pubkey_prefix") + } + + await self.dispatcher.dispatch(Event(EventType.LOGIN_SUCCESS, res, attributes)) elif packet_type_value == PacketType.LOGIN_FAILED.value: - logger.debug("Login failed") - # TODO: Read login attributes - await self.dispatcher.dispatch(Event(EventType.LOGIN_FAILED, {})) + res = {} + + if len(data) > 7: + res["pubkey_prefix"] = data[2:8].hex() + + attributes = { + "pubkey_prefix": res.get("pubkey_prefix") + } + + await self.dispatcher.dispatch(Event(EventType.LOGIN_FAILED, res, attributes)) elif packet_type_value == PacketType.STATUS_RESPONSE.value: res = {} diff --git a/src/meshcore/serial_cx.py b/src/meshcore/serial_cx.py index b2284e2..61931b5 100644 --- a/src/meshcore/serial_cx.py +++ b/src/meshcore/serial_cx.py @@ -93,4 +93,4 @@ class SerialConnection: if self.transport: self.transport.close() self.transport = None - logger.info("Serial Connection closed") \ No newline at end of file + logger.debug("Serial Connection closed") \ No newline at end of file diff --git a/src/meshcore/tcp_cx.py b/src/meshcore/tcp_cx.py index 3bd4694..8d0aff9 100644 --- a/src/meshcore/tcp_cx.py +++ b/src/meshcore/tcp_cx.py @@ -91,4 +91,4 @@ class TCPConnection: if self.transport: self.transport.close() self.transport = None - logger.info("TCP Connection closed") + logger.debug("TCP Connection closed") From 68dda3aba2c173db4aa8e1d555a52dbc42fb644d Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Mon, 12 May 2025 22:52:13 -0700 Subject: [PATCH 4/5] update login script and drastically reduce chattiness of debug logs for setups with a lot of subscriptions --- examples/tcp_login_status.py | 11 +++++++---- src/meshcore/events.py | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/tcp_login_status.py b/examples/tcp_login_status.py index 97e4069..698de64 100644 --- a/examples/tcp_login_status.py +++ b/examples/tcp_login_status.py @@ -58,10 +58,13 @@ async def main(): login_result = await mc.wait_for_event(EventType.LOGIN_SUCCESS, filter, timeout=10) print(f"Login result: {login_result}") - - # Wait a bit for the login response - print("Waiting for login events...") - await asyncio.sleep(3) + send_ver = await mc.commands.send_cmd(repeater, "ver") + if send_ver.type == EventType.ERROR: + print(f"Error sending version command: {send_ver.payload}") + return + await mc.wait_for_event(EventType.MESSAGES_WAITING) + ver_msg = await mc.commands.get_msg() + print(f"Version message: {ver_msg.payload}") # Send status request print("Sending status request...") diff --git a/src/meshcore/events.py b/src/meshcore/events.py index 94da1e3..525615f 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -118,7 +118,6 @@ class EventDispatcher: event = await self.queue.get() logger.debug(f"Dispatching event: {event.type}, {event.payload}, {event.attributes}") for subscription in self.subscriptions.copy(): - logger.debug(f"Checking subscription: {subscription.event_type}, {subscription.attribute_filters}") # Check if event type matches if subscription.event_type is None or subscription.event_type == event.type: # Check if all attribute filters match From fda20e623e9e85b273a6022379e530b7a9e1fb8d Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Thu, 15 May 2025 11:52:15 -0700 Subject: [PATCH 5/5] Add copy to event handling to avoid cross mutations --- src/meshcore/events.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/meshcore/events.py b/src/meshcore/events.py index 525615f..e263899 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -64,6 +64,15 @@ class Event: # Add any keyword arguments to the attributes dictionary if kwargs: self.attributes.update(kwargs) + def clone(self): + """ + Create a copy of the event. + + Returns: + A new Event object with the same type, payload, and attributes. + """ + copied_payload = self.payload.copy() if isinstance(self.payload, dict) else self.payload + return Event(self.type, copied_payload, self.attributes.copy()) class Subscription: @@ -79,7 +88,7 @@ class Subscription: class EventDispatcher: def __init__(self): - self.queue = asyncio.Queue() + self.queue: asyncio.Queue[Event] = asyncio.Queue() self.subscriptions: List[Subscription] = [] self.running = False self._task = None @@ -127,7 +136,7 @@ class EventDispatcher: for key, value in subscription.attribute_filters.items()): continue try: - result = subscription.callback(event) + result = subscription.callback(event.clone()) if asyncio.iscoroutine(result): await result except Exception as e: