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

This commit is contained in:
justin committed 2026-01-26 15:29:01 -07:00
1 parent a00901efdc
commit 235b3d7be9
1 file changed
+274 -254
+274 -254
View File
@@ -2,12 +2,17 @@
# Description: Meshtastic Bluetooth Messenger (CLI) - Connect to and message via Meshtastic radios # Description: Meshtastic Bluetooth Messenger (CLI) - Connect to and message via Meshtastic radios
# Usage: python3 meshtastic-messenger.py # Usage: python3 meshtastic-messenger.py
# Author: Justin Oros # Author: Justin Oros
# 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,88 +31,115 @@ 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")
try: try:
import asyncio import asyncio
from bleak import BleakScanner from bleak import BleakScanner
async def scan(): async def scan():
print("Scanning all Bluetooth devices...") print("Scanning all Bluetooth devices...")
devices = await BleakScanner.discover(timeout=10.0) devices = await BleakScanner.discover(timeout=10.0)
if self.debug_mode: if self.debug_mode:
print(f"\nFound {len(devices)} total Bluetooth devices") print(f"\nFound {len(devices)} total Bluetooth devices")
print("\nAll devices (for debugging):") print("\nAll devices (for debugging):")
for d in devices: for d in devices:
print(f" - {d.name or 'Unknown'} ({d.address})") print(f" - {d.name or 'Unknown'} ({d.address})")
# Filter for Meshtastic devices (very permissive) # Filter for Meshtastic devices (very permissive)
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())
if not meshtastic_devices: if not meshtastic_devices:
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
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:
print(f"Bluetooth scan error: {e}") print(f"Bluetooth scan error: {e}")
print("\nTroubleshooting tips:") print("\nTroubleshooting tips:")
@@ -112,22 +147,22 @@ class MeshtasticCLI:
print(" - Make sure your Meshtastic radio's Bluetooth is on") print(" - Make sure your Meshtastic radio's Bluetooth is on")
print(" - Try running with sudo on Linux/Mac") print(" - Try running with sudo on Linux/Mac")
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
idx = int(choice) - 1 idx = int(choice) - 1
if 0 <= idx < len(devices): if 0 <= idx < len(devices):
device = devices[idx] device = devices[idx]
print(f"\nConnecting to {device['name']}...") print(f"\nConnecting to {device['name']}...")
# Try BLE interface # Try BLE interface
if BLE_AVAILABLE: if BLE_AVAILABLE:
try: try:
@@ -148,23 +183,22 @@ class MeshtasticCLI:
except Exception as e: except Exception as e:
print(f"Connection failed: {e}") print(f"Connection failed: {e}")
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)}")
try: try:
# Store node info # Store node info
if 'from' in packet: if 'from' in packet:
node_id = packet['from'] node_id = packet['from']
node_short_id = f"!{node_id:08x}" node_short_id = f"!{node_id:08x}"
# Get the best available name # Get the best available name
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]
@@ -173,7 +207,7 @@ class MeshtasticCLI:
node_name = user_info['longName'] node_name = user_info['longName']
elif 'shortName' in user_info and user_info['shortName']: elif 'shortName' in user_info and user_info['shortName']:
node_name = user_info['shortName'] node_name = user_info['shortName']
if node_id not in self.nodes: if node_id not in self.nodes:
self.nodes[node_id] = { self.nodes[node_id] = {
'id': node_id, 'id': node_id,
@@ -183,42 +217,38 @@ 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']
# Store and display messages # Store and display messages
if 'decoded' in packet: if 'decoded' in packet:
decoded = packet['decoded'] decoded = packet['decoded']
if self.debug_mode: if self.debug_mode:
print(f"[DEBUG] Decoded packet: portnum={decoded.get('portnum')}, has text={('text' in decoded)}") print(f"[DEBUG] Decoded packet: portnum={decoded.get('portnum')}, has text={('text' in decoded)}")
# 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,
'from_short': from_short, 'from_short': from_short,
@@ -228,90 +258,85 @@ 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}
if not recent_nodes: if not recent_nodes:
print("\nNo nodes detected in the last hour.") print("\nNo nodes detected in the last hour.")
print("Waiting for node activity...") print("Waiting for node activity...")
return None return None
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:
print("Invalid selection. Try again.") print("Invalid selection. Try again.")
except ValueError: except ValueError:
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:
@@ -320,186 +345,181 @@ class MeshtasticCLI:
print(f"[{time_str}] You -> Broadcast: {text}") print(f"[{time_str}] You -> Broadcast: {text}")
except Exception as e: except Exception as e:
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")
print("Type /quit to exit\n") print("Type /quit to exit\n")
while not self.exit_flag: while not self.exit_flag:
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:
print("\n\nExiting...")
break
except EOFError:
print("\n\nExiting...")
break
# Clean up
if self.interface:
try:
self.interface.close()
except:
pass
return True # Signal to exit
def run(self):
"""Main program flow"""
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 the pub/sub method for newer meshtastic versions
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:
# 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
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 # Exit program
# If we get here, user typed /back, so we'll loop again in node_selection mode
except KeyboardInterrupt:
self.exit_program() # ← changed
except EOFError:
self.exit_program() # ← changed
# The loop only exits when ``self.exit_flag`` is True – which only happens via
# ``exit_program`` and therefore never reaches the code below.
# Keeping it for completeness (no‑op if we already exited).
self._close_interface()
return True
# ------------------------------------------------------------------------
# MAIN PROGRAM FLOW
# ------------------------------------------------------------------------
def run(self):
"""Main program flow."""
self.setup_exit_handler()
try:
# ------------------------------------------------------------
# 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 a radio (with refresh option)
# --------------------------------------------------------
connection_result = self.connect_radio(devices)
if connection_result is None: # user asked for a refresh
continue
elif connection_result:
break # successfully connected
else:
print("\nConnection failed. Press Enter to try again, or 'q' to quit: ", end='')
choice = input().strip().lower()
if choice == 'q':
return
# ------------------------------------------------------------
# Step 3 – set up packet receive callback
# ------------------------------------------------------------
print("Setting up message listener...")
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')
args = parser.parse_args() args = parser.parse_args()
try: try:
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)