fix: replace sys.exit with graceful cleanup, signal handling and os._exit to prevent hanging on quit.

This commit is contained in:
Justin Oros
2026-01-26 15:29:01 -07:00
parent a00901efdc
commit 235b3d7be9
+220 -200
View File
@@ -5,9 +5,14 @@
# Source: https://github.com/JustinOros # Source: https://github.com/JustinOros
import sys import sys
import os # ← added
import time import time
import argparse import argparse
import signal # ← added
import atexit # ← added
import threading # ← added
from datetime import datetime, timedelta from datetime import datetime, timedelta
import meshtastic.serial_interface import meshtastic.serial_interface
try: try:
import meshtastic.ble_interface import meshtastic.ble_interface
@@ -15,7 +20,10 @@ try:
except ImportError: except ImportError:
BLE_AVAILABLE = False BLE_AVAILABLE = False
class MeshtasticCLI: class MeshtasticCLI:
"""CLI wrapper around the Meshtastic Python library."""
def __init__(self, debug_mode=False): def __init__(self, debug_mode=False):
self.interface = None self.interface = None
self.nodes = {} self.nodes = {}
@@ -23,26 +31,61 @@ class MeshtasticCLI:
self.exit_flag = False self.exit_flag = False
self.selected_node = None self.selected_node = None
self.debug_mode = debug_mode self.debug_mode = debug_mode
self.current_mode = "node_selection" # Can be "node_selection" or "messaging" self.current_mode = "node_selection" # "node_selection" or "messaging"
# ------------------------------------------------------------------------
# EXIT / CLEAN‑UP HELPERS
# ------------------------------------------------------------------------
def _close_interface(self):
"""Close the Meshtastic interface without blocking forever."""
if not self.interface:
return
try:
# Run ``close()`` in its own thread and give it a few seconds.
# If the library hangs we simply give up – the process will be
# terminated immediately after this call.
close_thread = threading.Thread(target=self.interface.close,
daemon=True)
close_thread.start()
close_thread.join(timeout=3) # ← 3 s timeout (adjustable)
except Exception as e: # pragma: no cover
if self.debug_mode:
print(f"[DEBUG] error while closing interface: {e}")
finally:
self.interface = None
def _cleanup(self):
"""Registered with ``atexit`` – guarantees the interface is closed."""
self._close_interface()
def setup_exit_handler(self): def setup_exit_handler(self):
"""Setup exit handler""" """Install SIGINT / SIGTERM handlers and atexit cleanup."""
# Exit via /quit command # Catch Ctrl‑C from anywhere in the program.
pass signal.signal(signal.SIGINT, lambda s, f: self.exit_program())
# Catch termination from the OS (e.g. `kill`).
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, lambda s, f: self.exit_program())
# Ensure a final cleanup runs even if we call ``sys.exit`` elsewhere.
atexit.register(self._cleanup)
def exit_program(self): def exit_program(self):
"""Exit the program gracefully""" """Graceful termination used by `/quit` and signal handlers."""
# Prevent duplicate printing if we get called twice.
if self.exit_flag:
return
print("\n\nExiting...") print("\n\nExiting...")
self.exit_flag = True self.exit_flag = True
if self.interface: self._close_interface()
try: # ``os._exit`` stops the interpreter immediately – it does NOT
self.interface.close() # execute ``finally`` blocks or other atexit handlers, which is exactly
except: # what we want when background threads misbehave.
pass os._exit(0)
sys.exit(0)
# ------------------------------------------------------------------------
# INTERACTION METHODS (unchanged apart from exiting via exit_program)
# ------------------------------------------------------------------------
def scan_radios(self): def scan_radios(self):
"""Scan for nearby Meshtastic radios via Bluetooth""" """Scan for nearby Meshtastic radios via Bluetooth."""
print("Scanning for Meshtastic radios via Bluetooth (10 seconds)...") print("Scanning for Meshtastic radios via Bluetooth (10 seconds)...")
print("Make sure your radio's Bluetooth is enabled!\n") print("Make sure your radio's Bluetooth is enabled!\n")
@@ -64,22 +107,18 @@ class MeshtasticCLI:
meshtastic_devices = [] meshtastic_devices = []
for d in devices: for d in devices:
name = d.name or "" name = d.name or ""
# Match Meshtastic patterns including emoji prefixes has_emoji = any(ord(c) > 127 for c in name) # simple emoji detection
# Common Meshtastic emojis: 📡 🏠 🚀 🌐 📻 and others
has_emoji = any(ord(c) > 127 for c in name) # Simple emoji detection
if (name and ( if (name and (
'meshtastic' in name.lower() or 'meshtastic' in name.lower() or
name.startswith('!') or name.startswith('!') or
'mesh' in name.lower() or 'mesh' in name.lower() or
name.startswith('Meshtastic') or name.startswith('Meshtastic') or
(has_emoji and len(name) <= 10 and '_' in name) # Emoji_XXXX pattern (has_emoji and len(name) <= 10 and '_' in name)
)): )):
meshtastic_devices.append({ meshtastic_devices.append({
'name': d.name, 'name': d.name,
'address': d.address 'address': d.address
}) })
return meshtastic_devices, devices return meshtastic_devices, devices
meshtastic_devices, all_devices = asyncio.run(scan()) meshtastic_devices, all_devices = asyncio.run(scan())
@@ -88,13 +127,10 @@ class MeshtasticCLI:
print("\nNo Meshtastic radios auto-detected.") print("\nNo Meshtastic radios auto-detected.")
print("Would you like to manually select a device? (y/n): ", end='') print("Would you like to manually select a device? (y/n): ", end='')
choice = input().strip().lower() choice = input().strip().lower()
if choice == 'y': if choice == 'y':
# Let user pick from all devices
print("\nAll available devices:") print("\nAll available devices:")
for i, d in enumerate(all_devices, 1): for i, d in enumerate(all_devices, 1):
print(f" {i}. {d.name or 'Unknown'} - {d.address}") print(f" {i}. {d.name or 'Unknown'} - {d.address}")
return [{'name': d.name or 'Unknown', 'address': d.address} return [{'name': d.name or 'Unknown', 'address': d.address}
for d in all_devices] for d in all_devices]
return None return None
@@ -102,7 +138,6 @@ class MeshtasticCLI:
print(f"\nFound {len(meshtastic_devices)} Meshtastic radio(s):") print(f"\nFound {len(meshtastic_devices)} Meshtastic radio(s):")
for i, device in enumerate(meshtastic_devices, 1): for i, device in enumerate(meshtastic_devices, 1):
print(f" {i}. {device['name']} - {device['address']}") print(f" {i}. {device['name']} - {device['address']}")
return meshtastic_devices return meshtastic_devices
except Exception as e: except Exception as e:
@@ -114,12 +149,12 @@ class MeshtasticCLI:
return None return None
def connect_radio(self, devices): def connect_radio(self, devices):
"""Prompt user to select and connect to a radio""" """Prompt user to select and connect to a radio."""
while True: while True:
try: try:
choice = input("\nEnter number to connect ('r' to retry, 'q' to quit): ").strip() choice = input("\nEnter number to connect ('r' to retry, 'q' to quit): ").strip()
if choice.lower() == 'q': if choice.lower() == 'q':
sys.exit(0) self.exit_program() # ← changed
elif choice.lower() == 'r': elif choice.lower() == 'r':
return None # Signal to refresh scan return None # Signal to refresh scan
@@ -150,7 +185,7 @@ class MeshtasticCLI:
return False return False
def on_receive(self, packet, interface=None): def on_receive(self, packet, interface=None):
"""Callback for received packets""" """Callback for received packets."""
if self.debug_mode: if self.debug_mode:
print(f"\n[DEBUG] Packet received: {packet.keys() if isinstance(packet, dict) else type(packet)}") print(f"\n[DEBUG] Packet received: {packet.keys() if isinstance(packet, dict) else type(packet)}")
@@ -164,7 +199,6 @@ class MeshtasticCLI:
if 'fromId' in packet and packet['fromId']: if 'fromId' in packet and packet['fromId']:
node_name = packet['fromId'] node_name = packet['fromId']
else: else:
# Try to get name from node database
node_name = node_short_id node_name = node_short_id
if hasattr(self.interface, 'nodesByNum') and node_id in self.interface.nodesByNum: if hasattr(self.interface, 'nodesByNum') and node_id in self.interface.nodesByNum:
node_info = self.interface.nodesByNum[node_id] node_info = self.interface.nodesByNum[node_id]
@@ -183,7 +217,6 @@ class MeshtasticCLI:
} }
else: else:
self.nodes[node_id]['last_seen'] = datetime.now() self.nodes[node_id]['last_seen'] = datetime.now()
# Update name if we got a better one
if 'fromId' in packet and packet['fromId']: if 'fromId' in packet and packet['fromId']:
self.nodes[node_id]['name'] = packet['fromId'] self.nodes[node_id]['name'] = packet['fromId']
@@ -195,29 +228,26 @@ class MeshtasticCLI:
# Check if it's a text message # Check if it's a text message
if (decoded.get('portnum') == 'TEXT_MESSAGE_APP' or if (decoded.get('portnum') == 'TEXT_MESSAGE_APP' or
decoded.get('portnum') == 1 or # TEXT_MESSAGE_APP enum value decoded.get('portnum') == 1 or
'text' in decoded): 'text' in decoded):
# Extract text - handle both string and bytes # Extract text – handle both string and bytes
text_content = decoded.get('text', '') text_content = decoded.get('text', '')
# If text is bytes, decode it
if isinstance(text_content, bytes): if isinstance(text_content, bytes):
text_content = text_content.decode('utf-8', errors='ignore') text_content = text_content.decode('utf-8', errors='ignore')
# If still no text, try payload
if not text_content and 'payload' in decoded: if not text_content and 'payload' in decoded:
payload = decoded.get('payload', '') payload = decoded['payload']
if isinstance(payload, bytes): if isinstance(payload, bytes):
text_content = payload.decode('utf-8', errors='ignore') text_content = payload.decode('utf-8', errors='ignore')
elif isinstance(payload, dict): elif isinstance(payload, dict):
text_content = payload.get('text', '') text_content = payload.get('text', '')
if text_content: # Only store if there's actual text if text_content:
from_name = packet.get('fromId', 'Unknown') from_name = packet.get('fromId', 'Unknown')
from_short = f"!{packet.get('from', 0):08x}" from_short = f"!{packet.get('from', 0):08x}"
to_name = packet.get('toId', 'Broadcast') to_name = packet.get('toId', 'Broadcast')
to_short = f"!{packet.get('to', 0):08x}" if packet.get('to') != 0xffffffff else 'Broadcast' to_short = (f"!{packet.get('to', 0):08x}"
if packet.get('to') != 0xffffffff else 'Broadcast')
msg = { msg = {
'from': from_name, 'from': from_name,
@@ -228,17 +258,18 @@ class MeshtasticCLI:
'time': datetime.now() 'time': datetime.now()
} }
self.messages.append(msg) self.messages.append(msg)
# Print new messages in real-time # Print new messages in real‑time
if self.current_mode == "messaging": if self.current_mode == "messaging":
time_str = msg['time'].strftime('%Y-%m-d %H:%M:%S') time_str = msg['time'].strftime('%Y-%m-%d %H:%M:%S')
print(f"\n[{time_str}] {msg['from']} ({msg['from_short']}) -> {msg['to']} ({msg['to_short']}): {msg['text']}") print(f"\n[{time_str}] {msg['from']} ({msg['from_short']}) -> "
f"{msg['to']} ({msg['to_short']}): {msg['text']}")
print("> ", end='', flush=True) print("> ", end='', flush=True)
except Exception as e: except Exception as e:
if self.debug_mode: if self.debug_mode:
print(f"[DEBUG] Error processing packet: {e}") print(f"[DEBUG] Error processing packet: {e}")
def list_recent_nodes(self): def list_recent_nodes(self):
"""List nodes seen in the last hour""" """List nodes seen in the last hour."""
one_hour_ago = datetime.now() - timedelta(hours=1) one_hour_ago = datetime.now() - timedelta(hours=1)
recent_nodes = {k: v for k, v in self.nodes.items() recent_nodes = {k: v for k, v in self.nodes.items()
if v['last_seen'] > one_hour_ago} if v['last_seen'] > one_hour_ago}
@@ -250,39 +281,34 @@ class MeshtasticCLI:
print(f"\nActive Nodes (Seen in the last hour) ({len(recent_nodes)}):") print(f"\nActive Nodes (Seen in the last hour) ({len(recent_nodes)}):")
node_list = list(recent_nodes.values()) node_list = list(recent_nodes.values())
# Sort by last seen (most recent first)
node_list.sort(key=lambda x: x['last_seen'], reverse=True) node_list.sort(key=lambda x: x['last_seen'], reverse=True)
for i, node in enumerate(node_list, 1): for i, node in enumerate(node_list, 1):
time_ago = (datetime.now() - node['last_seen']).seconds // 60 minutes_ago = (datetime.now() - node['last_seen']).seconds // 60
# Display the name, fall back to short ID if no name displayed = node['name'] if node['name'] and node['name'] != node['short_id'] else node['short_id']
display_name = node['name'] if node['name'] and node['name'] != node['short_id'] else node['short_id'] print(f" {i}. {displayed} (seen {minutes_ago}m ago)")
print(f" {i}. {display_name} (seen {time_ago}m ago)")
return node_list return node_list
def select_contact(self, node_list): def select_contact(self, node_list):
"""Prompt user to select a contact""" """Prompt user to select a contact."""
while True: while True:
try: try:
choice = input("\nEnter number to message ('r' to refresh, 'b' for broadcast, 'q' to quit): ").strip() choice = input("\nEnter number to message ('r' to refresh, 'b' for broadcast, 'q' to quit): ").strip()
if choice.lower() == 'q': if choice.lower() == 'q':
self.exit_program() self.exit_program() # ← changed
return False
elif choice.lower() == 'b': elif choice.lower() == 'b':
self.selected_node = None self.selected_node = None
print("Broadcasting to all nodes") print("Broadcasting to all nodes")
return True return True
elif choice.lower() == 'r': elif choice.lower() == 'r':
return False # Signal to refresh return False # refresh
idx = int(choice) - 1 idx = int(choice) - 1
if 0 <= idx < len(node_list): if 0 <= idx < len(node_list):
self.selected_node = node_list[idx] self.selected_node = node_list[idx]
# Use the display name for messaging display_name = (self.selected_node['name']
display_name = self.selected_node['name'] if self.selected_node['name'] and self.selected_node['name'] != self.selected_node['short_id'] else self.selected_node['short_id'] if self.selected_node['name'] and self.selected_node['name'] != self.selected_node['short_id']
else self.selected_node['short_id'])
print(f"Messaging: {display_name}") print(f"Messaging: {display_name}")
return True return True
else: else:
@@ -291,27 +317,26 @@ class MeshtasticCLI:
print("Please enter a valid number.") print("Please enter a valid number.")
def display_messages(self): def display_messages(self):
"""Display all past messages""" """Display all past messages."""
if not self.messages: if not self.messages:
print("\nNo messages yet.") print("\nNo messages yet.")
return return
print(f"\n--- Message History ({len(self.messages)} messages) ---") print(f"\n--- Message History ({len(self.messages)} messages) ---")
for msg in self.messages[-20:]: # Show last 20 messages for msg in self.messages[-20:]:
time_str = msg['time'].strftime('%Y-%m-%d %H:%M:%S') t = msg['time'].strftime('%Y-%m-%d %H:%M:%S')
print(f"[{time_str}] {msg['from']} ({msg['from_short']}) -> {msg['to']} ({msg['to_short']}): {msg['text']}") print(f"[{t}] {msg['from']} ({msg['from_short']}) -> {msg['to']} ({msg['to_short']}): {msg['text']}")
print("--- End of messages ---\n") print("--- End of messages ---\n")
def send_message(self, text): def send_message(self, text):
"""Send a message""" """Send a message."""
try: try:
time_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S') time_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if self.selected_node: if self.selected_node:
# Direct message # Direct message
self.interface.sendText(text, destinationId=self.selected_node['id']) self.interface.sendText(text, destinationId=self.selected_node['id'])
# Use display name for output display_name = (self.selected_node['name']
display_name = self.selected_node['name'] if self.selected_node['name'] and self.selected_node['name'] != self.selected_node['short_id'] else self.selected_node['short_id'] if self.selected_node['name'] and self.selected_node['name'] != self.selected_node['short_id']
else self.selected_node['short_id'])
to_display = f"{display_name} ({self.selected_node['short_id']})" to_display = f"{display_name} ({self.selected_node['short_id']})"
print(f"[{time_str}] You -> {to_display}: {text}") print(f"[{time_str}] You -> {to_display}: {text}")
else: else:
@@ -322,7 +347,7 @@ class MeshtasticCLI:
print(f"Failed to send message: {e}") print(f"Failed to send message: {e}")
def message_loop(self): def message_loop(self):
"""Main messaging loop""" """Main messaging loop."""
print("\n=== Messaging Interface ===") print("\n=== Messaging Interface ===")
print("Type your message and press ENTER to send") print("Type your message and press ENTER to send")
print("Type /back to return to node selection") print("Type /back to return to node selection")
@@ -332,163 +357,154 @@ class MeshtasticCLI:
try: try:
msg = input("> ").strip() msg = input("> ").strip()
# Check for special commands # Special commands
if msg.lower() == '/quit': if msg.lower() == '/quit':
self.exit_program() self.exit_program() # ← changed (os._exit will stop everything)
break
elif msg.lower() == '/back': elif msg.lower() == '/back':
print("\nReturning to node selection...\n") print("\nReturning to node selection...\n")
self.current_mode = "node_selection" self.current_mode = "node_selection"
return False # Signal to go back return False # go back to node selection
if self.exit_flag:
break
if msg: if msg:
self.send_message(msg) self.send_message(msg)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\nExiting...") self.exit_program() # ← changed
break
except EOFError: except EOFError:
print("\n\nExiting...") self.exit_program() # ← changed
break
# Clean up # The loop only exits when ``self.exit_flag`` is True – which only happens via
if self.interface: # ``exit_program`` and therefore never reaches the code below.
try: # Keeping it for completeness (no‑op if we already exited).
self.interface.close() self._close_interface()
except: return True
pass
return True # Signal to exit
# ------------------------------------------------------------------------
# MAIN PROGRAM FLOW
# ------------------------------------------------------------------------
def run(self): def run(self):
"""Main program flow""" """Main program flow."""
self.setup_exit_handler() self.setup_exit_handler()
# Step 1: Scan for radios with refresh option
while True:
devices = self.scan_radios()
if not devices:
print("\nNo devices found. Press Enter to scan again, or 'q' to quit: ", end='')
choice = input().strip().lower()
if choice == 'q':
return
continue
# Step 2: Connect to radio with refresh option
connection_result = self.connect_radio(devices)
if connection_result is None:
# User selected 'r' to refresh
continue
elif connection_result:
break # Successfully connected
else:
# Connection failed
print("\nConnection failed. Press Enter to try again, or 'q' to quit: ", end='')
choice = input().strip().lower()
if choice == 'q':
return
# Setup packet callback using the correct method
print("Setting up message listener...")
callback_set = False
try: try:
# Try the pub/sub method for newer meshtastic versions # ------------------------------------------------------------
from pubsub import pub # Step 1 – scan for radios (with refresh option)
# ------------------------------------------------------------
def on_receive_wrapper(packet, interface=None): while True:
self.on_receive(packet, interface) devices = self.scan_radios()
if not devices:
pub.subscribe(on_receive_wrapper, "meshtastic.receive") print("\nNo devices found. Press Enter to scan again, or 'q' to quit: ", end='')
print("✓ Message listener active (pub/sub)") choice = input().strip().lower()
callback_set = True if choice == 'q':
except Exception as e: return
if self.debug_mode:
print(f"Pub/sub setup failed: {e}")
if not callback_set:
# Fallback to old callback method
try:
self.interface.addReceiveCallback(self.on_receive)
print("✓ Message listener active (callback)")
callback_set = True
except AttributeError:
print("⚠ Warning: Could not set up message callback.")
# Step 3: Wait for node discovery
print("\nDiscovering nodes and syncing data...")
print("This may take 10-20 seconds...\n")
# Get nodedb to populate nodes with proper names
try:
if hasattr(self.interface, 'nodesByNum'):
for node_id, node_info in self.interface.nodesByNum.items():
user_info = node_info.get('user', {})
# Get the best available name
long_name = user_info.get('longName', '')
short_name = user_info.get('shortName', '')
# Prefer long name, then short name, then fall back to ID
if long_name:
display_name = long_name
elif short_name:
display_name = short_name
else:
display_name = f"!{node_id:08x}"
# Convert lastHeard timestamp if it exists
last_seen = datetime.now()
if node_info.get('lastHeard'):
try:
last_seen = datetime.fromtimestamp(node_info['lastHeard'])
except:
pass
self.nodes[node_id] = {
'id': node_id,
'last_seen': last_seen,
'name': display_name,
'short_id': f"!{node_id:08x}",
'long_name': long_name,
'short_name': short_name
}
print(f"Loaded {len(self.nodes)} nodes from radio memory")
if self.debug_mode:
print("\n[DEBUG] Loaded nodes:")
for node_id, node in list(self.nodes.items())[:5]: # Show first 5 for debugging
print(f" {node['short_id']}: '{node['name']}' (long: '{node.get('long_name', '')}', short: '{node.get('short_name', '')}')")
except Exception as e:
if self.debug_mode:
print(f"Note: Could not load node database: {e}")
time.sleep(10)
# Main loop that allows switching between modes
while not self.exit_flag:
if self.current_mode == "node_selection":
node_list = self.list_recent_nodes()
if node_list is None:
time.sleep(5)
continue continue
if self.select_contact(node_list): # --------------------------------------------------------
self.current_mode = "messaging" # Step 2 – connect to a radio (with refresh option)
self.display_messages() # --------------------------------------------------------
connection_result = self.connect_radio(devices)
if connection_result is None: # user asked for a refresh
continue
elif connection_result:
break # successfully connected
else: else:
continue # Refresh node list print("\nConnection failed. Press Enter to try again, or 'q' to quit: ", end='')
choice = input().strip().lower()
if choice == 'q':
return
elif self.current_mode == "messaging": # ------------------------------------------------------------
should_exit = self.message_loop() # Step 3 – set up packet receive callback
if should_exit: # ------------------------------------------------------------
break # Exit program print("Setting up message listener...")
# If we get here, user typed /back, so we'll loop again in node_selection mode callback_set = False
try:
# Newer Meshtastic versions use pub/sub
from pubsub import pub
def on_receive_wrapper(packet, interface=None):
self.on_receive(packet, interface)
pub.subscribe(on_receive_wrapper, "meshtastic.receive")
print("✓ Message listener active (pub/sub)")
callback_set = True
except Exception as e:
if self.debug_mode:
print(f"Pub/sub setup failed: {e}")
if not callback_set:
# Old callback API
try:
self.interface.addReceiveCallback(self.on_receive)
print("✓ Message listener active (callback)")
callback_set = True
except AttributeError:
print("⚠ Warning: Could not set up message callback.")
# ------------------------------------------------------------
# Step 4 – give the radio time to sync & pull its node database
# ------------------------------------------------------------
print("\nDiscovering nodes and syncing data...")
print("This may take 10‑20 seconds...\n")
try:
if hasattr(self.interface, 'nodesByNum'):
for node_id, node_info in self.interface.nodesByNum.items():
user = node_info.get('user', {})
long_name = user.get('longName', '')
short_name = user.get('shortName', '')
display_name = long_name or short_name or f"!{node_id:08x}"
last_seen = datetime.now()
if node_info.get('lastHeard'):
try:
last_seen = datetime.fromtimestamp(node_info['lastHeard'])
except Exception:
pass
self.nodes[node_id] = {
'id': node_id,
'last_seen': last_seen,
'name': display_name,
'short_id': f"!{node_id:08x}",
'long_name': long_name,
'short_name': short_name
}
print(f"Loaded {len(self.nodes)} nodes from radio memory")
except Exception as e:
if self.debug_mode:
print(f"Note: Could not load node database: {e}")
time.sleep(10) # give the receive thread a chance to pick up any pending msgs
# ------------------------------------------------------------
# Main UI loop – switch between node‑selection and messaging
# ------------------------------------------------------------
while not self.exit_flag:
if self.current_mode == "node_selection":
node_list = self.list_recent_nodes()
if node_list is None:
time.sleep(5)
continue
if self.select_contact(node_list):
self.current_mode = "messaging"
self.display_messages()
else:
continue # refresh node list
elif self.current_mode == "messaging":
should_exit = self.message_loop()
if should_exit:
break # (will never be reached – exit_program kills the process)
# If we get here the user typed /back → loop again in node_selection
finally:
# ----------------------------------------------------------------
# ALWAYS clean up the interface – even if we never called exit_program
# ----------------------------------------------------------------
self._cleanup()
# --------------------------------------------------------------------
# Entry‑point
# --------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
# Parse command line arguments
parser = argparse.ArgumentParser(description='Meshtastic Bluetooth CLI Messenger') parser = argparse.ArgumentParser(description='Meshtastic Bluetooth CLI Messenger')
parser.add_argument('-d', '--debug', action='store_true', parser.add_argument('-d', '--debug', action='store_true',
help='Enable debug mode to see packet information') help='Enable debug mode to see packet information')
@@ -498,8 +514,12 @@ if __name__ == "__main__":
cli = MeshtasticCLI(debug_mode=args.debug) cli = MeshtasticCLI(debug_mode=args.debug)
cli.run() cli.run()
except KeyboardInterrupt: except KeyboardInterrupt:
# If the user hits Ctrl‑C before we have installed our signal handler
# we still want a clean shutdown.
print("\n\nExiting...") print("\n\nExiting...")
cli._cleanup()
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"\nError: {e}") print(f"\nError: {e}")
sys.exit(1) sys.exit(1)