Files
Python/meshtastic-messenger.py
T

588 lines
26 KiB
Python
Raw Normal View History

2025-12-04 15:14:25 -07:00
#!/usr/bin/env python3
# Description: Meshtastic Bluetooth Messenger (CLI) - Connect to and message via Meshtastic radios
# Usage: python3 meshtastic-messenger.py
# Author: Justin Oros
# Source: https://github.com/JustinOros
2025-12-04 15:14:25 -07:00
import sys
import os # ← added
2025-12-04 15:14:25 -07:00
import time
import argparse
import signal # ← added
import atexit # ← added
import threading # ← added
2025-12-04 15:14:25 -07:00
from datetime import datetime, timedelta
2025-12-04 15:14:25 -07:00
import meshtastic.serial_interface
try:
import meshtastic.ble_interface
BLE_AVAILABLE = True
except ImportError:
BLE_AVAILABLE = False
2025-12-04 15:14:25 -07:00
class MeshtasticCLI:
"""CLI wrapper around the Meshtastic Python library."""
2025-12-04 15:14:25 -07:00
def __init__(self, debug_mode=False):
self.interface = None
self.nodes = {} # node_id -> info dict
self.messages = [] # stored message history
2025-12-04 15:14:25 -07:00
self.exit_flag = False
self.selected_node = None
self.debug_mode = debug_mode
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()
2025-12-04 15:14:25 -07:00
def setup_exit_handler(self):
"""Install SIGINT / SIGTERM handlers and atexit cleanup."""
# Catch Ctrl‑C from anywhere in the program.
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)
2025-12-04 15:14:25 -07:00
def exit_program(self):
"""Graceful termination used by `/quit` and signal handlers."""
# Prevent duplicate printing if we get called twice.
if self.exit_flag:
return
2025-12-04 15:14:25 -07:00
print("\n\nExiting...")
self.exit_flag = True
self._close_interface()
# ``os._exit`` stops the interpreter immediately – it does NOT
# execute ``finally`` blocks or other atexit handlers, which is exactly
# what we want when background threads misbehave.
os._exit(0)
# ------------------------------------------------------------------------
# Helper – return a printable name for a node (friendly or short id)
# ------------------------------------------------------------------------
def _friendly_name(self, node_id: int) -> str:
"""
Resolve a node id to a friendly name (if we have one) or the short
hex representation ``!deadbeef``.
"""
node = self.nodes.get(node_id)
if node:
# If we already stored a proper name, use it; otherwise fall back to short id.
if node.get("name") and node["name"] != node.get("short_id"):
return node["name"]
return node["short_id"]
# Look directly at the library’s node table (may have the name already)
if hasattr(self.interface, "nodesByNum"):
nbyn = self.interface.nodesByNum
if node_id in nbyn:
user = nbyn[node_id].get("user", {})
name = user.get("longName") or user.get("shortName")
if name:
return name
# If we have never seen this node, ask the mesh for its info.
try:
if hasattr(self.interface, "getRemoteNodeInfo"):
self.interface.getRemoteNodeInfo(node_id)
except Exception:
pass
return f"!{node_id:08x}"
# ------------------------------------------------------------------------
# INTERACTION METHODS (unchanged apart from exiting via exit_program)
# ------------------------------------------------------------------------
2025-12-04 15:14:25 -07:00
def scan_radios(self):
"""Scan for nearby Meshtastic radios via Bluetooth."""
2025-12-04 15:14:25 -07:00
print("Scanning for Meshtastic radios via Bluetooth (10 seconds)...")
print("Make sure your radio's Bluetooth is enabled!\n")
2025-12-04 15:14:25 -07:00
try:
import asyncio
from bleak import BleakScanner
2025-12-04 15:14:25 -07:00
async def scan():
print("Scanning all Bluetooth devices...")
devices = await BleakScanner.discover(timeout=10.0)
2025-12-04 15:14:25 -07:00
if self.debug_mode:
print(f"\nFound {len(devices)} total Bluetooth devices")
print("\nAll devices (for debugging):")
for d in devices:
print(f" - {d.name or 'Unknown'} ({d.address})")
2025-12-04 15:14:25 -07:00
# Filter for Meshtastic devices (very permissive)
meshtastic_devices = []
for d in devices:
name = d.name or ""
has_emoji = any(ord(c) > 127 for c in name) # simple emoji detection
2025-12-04 15:14:25 -07:00
if (name and (
'meshtastic' in name.lower() or
2025-12-04 15:14:25 -07:00
name.startswith('!') or
'mesh' in name.lower() or
name.startswith('Meshtastic') or
(has_emoji and len(name) <= 10 and '_' in name)
2025-12-04 15:14:25 -07:00
)):
meshtastic_devices.append({
'name': d.name,
'address': d.address
})
return meshtastic_devices, devices
2025-12-04 15:14:25 -07:00
meshtastic_devices, all_devices = asyncio.run(scan())
2025-12-04 15:14:25 -07:00
if not meshtastic_devices:
print("\nNo Meshtastic radios auto-detected.")
print("Would you like to manually select a device? (y/n): ", end='')
choice = input().strip().lower()
if choice == 'y':
print("\nAll available devices:")
for i, d in enumerate(all_devices, 1):
print(f" {i}. {d.name or 'Unknown'} - {d.address}")
return [{'name': d.name or 'Unknown', 'address': d.address}
2025-12-04 15:14:25 -07:00
for d in all_devices]
return None
2025-12-04 15:14:25 -07:00
print(f"\nFound {len(meshtastic_devices)} Meshtastic radio(s):")
for i, device in enumerate(meshtastic_devices, 1):
print(f" {i}. {device['name']} - {device['address']}")
return meshtastic_devices
2025-12-04 15:14:25 -07:00
except Exception as e:
print(f"Bluetooth scan error: {e}")
print("\nTroubleshooting tips:")
print(" - Ensure Bluetooth is enabled on your computer")
print(" - Make sure your Meshtastic radio's Bluetooth is on")
print(" - Try running with sudo on Linux/Mac")
return None
2025-12-04 15:14:25 -07:00
def connect_radio(self, devices):
"""Prompt user to select and connect to a radio."""
2025-12-04 15:14:25 -07:00
while True:
try:
choice = input("\nEnter number to connect ('r' to retry, 'q' to quit): ").strip()
2025-12-04 15:14:25 -07:00
if choice.lower() == 'q':
self.exit_program() # ← changed
elif choice.lower() == 'r':
return None # Signal to refresh scan
2025-12-04 15:14:25 -07:00
idx = int(choice) - 1
if 0 <= idx < len(devices):
device = devices[idx]
print(f"\nConnecting to {device['name']}...")
2025-12-04 15:14:25 -07:00
# Try BLE interface
if BLE_AVAILABLE:
try:
self.interface = meshtastic.ble_interface.BLEInterface(device['address'])
time.sleep(3)
print("Connected via BLE!")
return True
except Exception as e:
print(f"BLE connection failed: {e}")
return False
else:
print("BLE interface not available. Install bleak: pip install bleak")
return False
else:
print("Invalid selection. Try again.")
except ValueError:
print("Please enter a valid number.")
except Exception as e:
print(f"Connection failed: {e}")
return False
# ------------------------------------------------------------------------
# CALLBACK – PROCESS INCOMING PACKETS
# ------------------------------------------------------------------------
2025-12-04 15:14:25 -07:00
def on_receive(self, packet, interface=None):
"""Callback for received packets."""
2025-12-04 15:14:25 -07:00
if self.debug_mode:
print(f"\n[DEBUG] Packet received: {packet.keys() if isinstance(packet, dict) else type(packet)}")
2025-12-04 15:14:25 -07:00
try:
# ------------------------------------------------------------
# 1️⃣ Keep our node table up‑to‑date (but *don’t* overwrite a
# friendly name with a raw hex id)
# ------------------------------------------------------------
2025-12-04 15:14:25 -07:00
if 'from' in packet:
node_id = packet['from']
node_short_id = f"!{node_id:08x}"
# Gather the best name we can find right now
node_name_candidate = None
# a) fromId field (explicit friendly name)
if packet.get('fromId'):
node_name_candidate = packet['fromId']
else:
# b) look at the library’s node database
if hasattr(self.interface, 'nodesByNum') and node_id in self.interface.nodesByNum:
user = self.interface.nodesByNum[node_id].get('user', {})
node_name_candidate = user.get('longName') or user.get('shortName')
# Update our local cache – preserve an existing friendly name
2025-12-04 15:14:25 -07:00
if node_id not in self.nodes:
self.nodes[node_id] = {
'id': node_id,
'last_seen': datetime.now(),
'name': node_name_candidate or node_short_id,
2025-12-04 15:14:25 -07:00
'short_id': node_short_id
}
else:
self.nodes[node_id]['last_seen'] = datetime.now()
# Replace the stored name *only* if we have a new friendly name
if node_name_candidate and node_name_candidate != node_short_id:
self.nodes[node_id]['name'] = node_name_candidate
# If we still only have a short id, ask the mesh for full info
if self.nodes[node_id]['name'] == node_short_id:
try:
if hasattr(self.interface, 'getRemoteNodeInfo'):
self.interface.getRemoteNodeInfo(node_id)
except Exception:
pass
# ------------------------------------------------------------
# 2️⃣ Extract and display text messages
# ------------------------------------------------------------
2025-12-04 15:14:25 -07:00
if 'decoded' in packet:
decoded = packet['decoded']
if self.debug_mode:
print(f"[DEBUG] Decoded packet: portnum={decoded.get('portnum')}, has text={('text' in decoded)}")
# Identify a text message (covers several API variations)
if (decoded.get('portnum') == 'TEXT_MESSAGE_APP' or
decoded.get('portnum') == 1 or
2025-12-04 15:14:25 -07:00
'text' in decoded):
# ----------- extract the actual text -----------------
2025-12-04 15:14:25 -07:00
text_content = decoded.get('text', '')
if isinstance(text_content, bytes):
text_content = text_content.decode('utf-8', errors='ignore')
if not text_content and 'payload' in decoded:
payload = decoded['payload']
2025-12-04 15:14:25 -07:00
if isinstance(payload, bytes):
text_content = payload.decode('utf-8', errors='ignore')
elif isinstance(payload, dict):
text_content = payload.get('text', '')
if text_content:
# ----------- resolve friendly names -----------------
src_id = packet.get('from')
src_name = self._friendly_name(src_id) if src_id is not None else "Unknown"
src_short = f"!{src_id:08x}" if src_id is not None else "??"
dst_raw = packet.get('to')
if dst_raw is None or dst_raw == 0xffffffff:
dst_name = "Broadcast"
dst_short = "Broadcast"
else:
dst_name = self._friendly_name(dst_raw)
dst_short = f"!{dst_raw:08x}"
# ---------- store the message in history -------------
2025-12-04 15:14:25 -07:00
msg = {
'from': src_name,
'from_short': src_short,
'to': dst_name,
'to_short': dst_short,
2025-12-04 15:14:25 -07:00
'text': text_content,
'time': datetime.now()
}
self.messages.append(msg)
# ---------- live display if we are in the chat UI ---
if self.current_mode == "messaging":
time_str = msg['time'].strftime('%Y-%m-%d %H:%M:%S')
print(f"\n[{time_str}] {msg['from']} ({msg['from_short']}) -> "
f"{msg['to']} ({msg['to_short']}): {msg['text']}")
print("> ", end='', flush=True)
2025-12-04 15:14:25 -07:00
except Exception as e:
if self.debug_mode:
print(f"[DEBUG] Error processing packet: {e}")
# ------------------------------------------------------------------------
# UI HELPERS
# ------------------------------------------------------------------------
2025-12-04 15:14:25 -07:00
def list_recent_nodes(self):
"""List nodes seen in the last hour."""
2025-12-04 15:14:25 -07:00
one_hour_ago = datetime.now() - timedelta(hours=1)
recent_nodes = {k: v for k, v in self.nodes.items()
if v['last_seen'] > one_hour_ago}
2025-12-04 15:14:25 -07:00
if not recent_nodes:
print("\nNo nodes detected in the last hour.")
print("Waiting for node activity...")
return None
print(f"\nActive Nodes (Seen in the last hour) ({len(recent_nodes)}):")
2025-12-04 15:14:25 -07:00
node_list = list(recent_nodes.values())
node_list.sort(key=lambda x: x['last_seen'], reverse=True)
2025-12-04 15:14:25 -07:00
for i, node in enumerate(node_list, 1):
minutes_ago = (datetime.now() - node['last_seen']).seconds // 60
displayed = node['name'] if node['name'] and node['name'] != node['short_id'] else node['short_id']
print(f" {i}. {displayed} (seen {minutes_ago}m ago)")
2025-12-04 15:14:25 -07:00
return node_list
2025-12-04 15:14:25 -07:00
def select_contact(self, node_list):
"""Prompt user to select a contact."""
2025-12-04 15:14:25 -07:00
while True:
try:
choice = input("\nEnter number to message ('r' to refresh, 'b' for broadcast, 'q' to quit): ").strip()
if choice.lower() == 'q':
self.exit_program() # ← changed
elif choice.lower() == 'b':
2025-12-04 15:14:25 -07:00
self.selected_node = None
print("Broadcasting to all nodes")
return True
elif choice.lower() == 'r':
return False # refresh
2025-12-04 15:14:25 -07:00
idx = int(choice) - 1
if 0 <= idx < len(node_list):
self.selected_node = node_list[idx]
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'])
print(f"Messaging: {display_name}")
2025-12-04 15:14:25 -07:00
return True
else:
print("Invalid selection. Try again.")
except ValueError:
print("Please enter a valid number.")
2025-12-04 15:14:25 -07:00
def display_messages(self):
"""Display all past messages."""
2025-12-04 15:14:25 -07:00
if not self.messages:
print("\nNo messages yet.")
return
print(f"\n--- Message History ({len(self.messages)} messages) ---")
for msg in self.messages[-20:]:
t = msg['time'].strftime('%Y-%m-%d %H:%M:%S')
print(f"[{t}] {msg['from']} ({msg['from_short']}) -> {msg['to']} ({msg['to_short']}): {msg['text']}")
2025-12-04 15:14:25 -07:00
print("--- End of messages ---\n")
2025-12-04 15:14:25 -07:00
def send_message(self, text):
"""Send a message."""
2025-12-04 15:14:25 -07:00
try:
time_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if self.selected_node:
# Direct message
self.interface.sendText(text, destinationId=self.selected_node['id'])
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'])
to_display = f"{display_name} ({self.selected_node['short_id']})"
2025-12-04 15:14:25 -07:00
print(f"[{time_str}] You -> {to_display}: {text}")
else:
# Broadcast
self.interface.sendText(text)
print(f"[{time_str}] You -> Broadcast: {text}")
except Exception as e:
print(f"Failed to send message: {e}")
2025-12-04 15:14:25 -07:00
def message_loop(self):
"""Main messaging loop."""
2025-12-04 15:14:25 -07:00
print("\n=== Messaging Interface ===")
print("Type your message and press ENTER to send")
print("Type /back to return to node selection")
2025-12-04 15:14:25 -07:00
print("Type /quit to exit\n")
2025-12-04 15:14:25 -07:00
while not self.exit_flag:
try:
msg = input("> ").strip()
# Special commands
2025-12-04 15:14:25 -07:00
if msg.lower() == '/quit':
self.exit_program() # ← changed (os._exit will stop everything)
elif msg.lower() == '/back':
print("\nReturning to node selection...\n")
self.current_mode = "node_selection"
return False # go back to node selection
2025-12-04 15:14:25 -07:00
if msg:
self.send_message(msg)
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
# --------------------------------------------------------------------
2025-12-04 15:14:25 -07:00
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Meshtastic Bluetooth CLI Messenger')
parser.add_argument('-d', '--debug', action='store_true',
2025-12-04 15:14:25 -07:00
help='Enable debug mode to see packet information')
args = parser.parse_args()
2025-12-04 15:14:25 -07:00
try:
cli = MeshtasticCLI(debug_mode=args.debug)
cli.run()
except KeyboardInterrupt:
# If the user hits Ctrl‑C before we have installed our signal handler
# we still want a clean shutdown.
2025-12-04 15:14:25 -07:00
print("\n\nExiting...")
cli._cleanup()
2025-12-04 15:14:25 -07:00
sys.exit(0)
except Exception as e:
print(f"\nError: {e}")
sys.exit(1)