#!/usr/bin/env python3 """ MeshCore GUI - Threaded BLE Edition ==================================== A graphical user interface for MeshCore mesh network devices. Communicates via Bluetooth Low Energy (BLE) with a MeshCore companion device. Architecture: - BLE communication runs in a separate thread with its own asyncio event loop - NiceGUI web interface runs in the main thread - Thread-safe SharedData class for communication between threads - Command queue for GUI -> BLE communication Requirements: pip install meshcore nicegui bleak Usage: python meshcore_gui_v2.py python meshcore_gui_v2.py literal:AA:BB:CC:DD:EE:FF Author: PE1HVH Version: 2.0 SPDX-License-Identifier: MIT Copyright: (c) 2026 PE1HVH """ import asyncio import sys import threading import queue from datetime import datetime from typing import Optional, Dict, List from nicegui import ui, app try: from meshcore import MeshCore, EventType except ImportError: print("ERROR: meshcore library not found") print("Install with: pip install meshcore") sys.exit(1) # ============================================================================== # CONFIGURATION # ============================================================================== # Debug mode: set to True for verbose logging DEBUG = False # Hardcoded channels configuration # Determine your channels with meshcli: # meshcli -d # > get_channels # Output: 0: Public [...], 1: #test [...], etc. CHANNELS_CONFIG = [ {'idx': 0, 'name': 'Public'}, {'idx': 1, 'name': '#test'}, {'idx': 2, 'name': '#zwolle'}, {'idx': 3, 'name': 'RahanSom'}, ] def debug_print(msg: str) -> None: """ Print debug message if DEBUG mode is enabled. Args: msg: The message to print """ if DEBUG: print(f"DEBUG: {msg}") # ============================================================================== # SHARED DATA - Thread-safe data container # ============================================================================== class SharedData: """ Thread-safe container for shared data between BLE worker and GUI. All access to data goes through methods that use a threading.Lock to prevent race conditions. Attributes: lock: Threading lock for thread-safe access name: Device name public_key: Device public key radio_freq: Radio frequency in MHz radio_sf: Spreading factor radio_bw: Bandwidth in kHz tx_power: Transmit power in dBm adv_lat: Advertised latitude adv_lon: Advertised longitude firmware_version: Firmware version string connected: Boolean whether device is connected status: Status text for UI contacts: Dict of contacts {key: {adv_name, type, lat, lon, ...}} channels: List of channels [{idx, name}, ...] messages: List of messages rx_log: List of RX log entries """ def __init__(self): """Initialize SharedData with empty values and flags set to True.""" self.lock = threading.Lock() # Device info self.name: str = "" self.public_key: str = "" self.radio_freq: float = 0.0 self.radio_sf: int = 0 self.radio_bw: float = 0.0 self.tx_power: int = 0 self.adv_lat: float = 0.0 self.adv_lon: float = 0.0 self.firmware_version: str = "" # Connection status self.connected: bool = False self.status: str = "Starting..." # Data collections self.contacts: Dict = {} self.channels: List[Dict] = [] self.messages: List[Dict] = [] self.rx_log: List[Dict] = [] # Command queue (GUI -> BLE) self.cmd_queue: queue.Queue = queue.Queue() # Update flags - INITIALLY TRUE so first GUI render shows data self.device_updated: bool = True self.contacts_updated: bool = True self.channels_updated: bool = True self.rxlog_updated: bool = True # Flag to track if GUI has done first render self.gui_initialized: bool = False def update_from_appstart(self, payload: Dict) -> None: """ Update device info from send_appstart response. Args: payload: Response payload from send_appstart command """ with self.lock: self.name = payload.get('name', self.name) self.public_key = payload.get('public_key', self.public_key) self.radio_freq = payload.get('radio_freq', self.radio_freq) self.radio_sf = payload.get('radio_sf', self.radio_sf) self.radio_bw = payload.get('radio_bw', self.radio_bw) self.tx_power = payload.get('tx_power', self.tx_power) self.adv_lat = payload.get('adv_lat', self.adv_lat) self.adv_lon = payload.get('adv_lon', self.adv_lon) self.device_updated = True debug_print(f"Device info updated: {self.name}") def update_from_device_query(self, payload: Dict) -> None: """ Update firmware version from send_device_query response. Args: payload: Response payload from send_device_query command """ with self.lock: self.firmware_version = payload.get('ver', self.firmware_version) self.device_updated = True debug_print(f"Firmware version: {self.firmware_version}") def set_status(self, status: str) -> None: """ Update status text. Args: status: New status text """ with self.lock: self.status = status def set_contacts(self, contacts_dict: Dict) -> None: """ Update contacts dictionary. Args: contacts_dict: Dictionary with contacts {key: contact_data} """ with self.lock: self.contacts = contacts_dict.copy() self.contacts_updated = True debug_print(f"Contacts updated: {len(self.contacts)} contacts") def set_channels(self, channels: List[Dict]) -> None: """ Update channels list. Args: channels: List of channel dictionaries [{idx, name}, ...] """ with self.lock: self.channels = channels.copy() self.channels_updated = True debug_print(f"Channels updated: {[c['name'] for c in channels]}") def add_message(self, msg: Dict) -> None: """ Add a message to the messages list. Args: msg: Message dictionary with time, sender, text, channel, direction """ with self.lock: self.messages.append(msg) # Limit to last 100 messages if len(self.messages) > 100: self.messages.pop(0) debug_print(f"Message added: {msg.get('sender', '?')}: {msg.get('text', '')[:30]}") def add_rx_log(self, entry: Dict) -> None: """ Add an RX log entry. Args: entry: RX log entry with time, snr, rssi, payload_type """ with self.lock: self.rx_log.insert(0, entry) # Limit to last 50 entries if len(self.rx_log) > 50: self.rx_log.pop() self.rxlog_updated = True def get_snapshot(self) -> Dict: """ Create a snapshot of all data for the GUI. Returns: Dictionary with copies of all data and update flags """ with self.lock: return { 'name': self.name, 'public_key': self.public_key, 'radio_freq': self.radio_freq, 'radio_sf': self.radio_sf, 'radio_bw': self.radio_bw, 'tx_power': self.tx_power, 'adv_lat': self.adv_lat, 'adv_lon': self.adv_lon, 'firmware_version': self.firmware_version, 'connected': self.connected, 'status': self.status, 'contacts': self.contacts.copy(), 'channels': self.channels.copy(), 'messages': self.messages.copy(), 'rx_log': self.rx_log.copy(), 'device_updated': self.device_updated, 'contacts_updated': self.contacts_updated, 'channels_updated': self.channels_updated, 'rxlog_updated': self.rxlog_updated, 'gui_initialized': self.gui_initialized, } def clear_update_flags(self) -> None: """Reset all update flags to False.""" with self.lock: self.device_updated = False self.contacts_updated = False self.channels_updated = False self.rxlog_updated = False def mark_gui_initialized(self) -> None: """Mark that the GUI has completed its first render.""" with self.lock: self.gui_initialized = True debug_print("GUI marked as initialized") # ============================================================================== # BLE WORKER - Runs in separate thread # ============================================================================== class BLEWorker: """ BLE communication worker that runs in a separate thread. This class handles all Bluetooth Low Energy communication with the MeshCore device. It runs in a separate thread with its own asyncio event loop to avoid conflicts with NiceGUI's event loop. Attributes: address: BLE MAC address of the device shared: SharedData instance for thread-safe communication mc: MeshCore instance after connection running: Boolean to control the worker loop """ def __init__(self, address: str, shared: SharedData): """ Initialize the BLE worker. Args: address: BLE MAC address (e.g. "literal:AA:BB:CC:DD:EE:FF") shared: SharedData instance for data exchange """ self.address = address self.shared = shared self.mc: Optional[MeshCore] = None self.running = True def start(self) -> None: """Start the worker in a new daemon thread.""" thread = threading.Thread(target=self._run, daemon=True) thread.start() debug_print("BLE worker thread started") def _run(self) -> None: """Entry point for the worker thread. Starts asyncio event loop.""" asyncio.run(self._async_main()) async def _async_main(self) -> None: """ Main async loop of the worker. Connects to the device and then continuously processes commands from the GUI via the command queue. """ await self._connect() if self.mc: # Process commands from GUI in infinite loop while self.running: await self._process_commands() await asyncio.sleep(0.1) async def _connect(self) -> None: """ Connect to the BLE device and load initial data. Also subscribes to events for incoming messages and RX log. """ self.shared.set_status(f"🔄 Connecting to {self.address}...") try: print(f"BLE: Connecting to {self.address}...") self.mc = await MeshCore.create_ble(self.address) print("BLE: Connected!") # Wait for device to be ready await asyncio.sleep(1) # Subscribe to events self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_msg) self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_contact_msg) self.mc.subscribe(EventType.RX_LOG_DATA, self._on_rx_log) # Load initial data await self._load_data() # Start automatic message fetching await self.mc.start_auto_message_fetching() self.shared.connected = True self.shared.set_status("✅ Connected") print("BLE: Ready!") except Exception as e: print(f"BLE: Connection error: {e}") self.shared.set_status(f"❌ {e}") async def _load_data(self) -> None: """ Load device data with retry mechanism. Tries send_appstart and send_device_query each up to 5 times with 0.3 second pause between attempts. Channels are loaded from the hardcoded configuration. """ # send_appstart with retries self.shared.set_status("🔄 Device info...") for i in range(5): debug_print(f"send_appstart attempt {i+1}") r = await self.mc.commands.send_appstart() if r.type != EventType.ERROR: print(f"BLE: send_appstart OK: {r.payload.get('name')}") self.shared.update_from_appstart(r.payload) break await asyncio.sleep(0.3) # send_device_query with retries for i in range(5): debug_print(f"send_device_query attempt {i+1}") r = await self.mc.commands.send_device_query() if r.type != EventType.ERROR: print(f"BLE: send_device_query OK: {r.payload.get('ver')}") self.shared.update_from_device_query(r.payload) break await asyncio.sleep(0.3) # Channels from hardcoded config (BLE get_channel is unreliable) self.shared.set_status("🔄 Channels...") self.shared.set_channels(CHANNELS_CONFIG) print(f"BLE: Channels loaded: {[c['name'] for c in CHANNELS_CONFIG]}") # Fetch contacts self.shared.set_status("🔄 Contacts...") r = await self.mc.commands.get_contacts() if r.type != EventType.ERROR: self.shared.set_contacts(r.payload) print(f"BLE: Contacts loaded: {len(r.payload)} contacts") async def _process_commands(self) -> None: """Process all commands in the queue from the GUI.""" try: while not self.shared.cmd_queue.empty(): cmd = self.shared.cmd_queue.get_nowait() await self._handle_command(cmd) except queue.Empty: pass async def _handle_command(self, cmd: Dict) -> None: """ Process a single command from the GUI. Args: cmd: Command dictionary with 'action' and optional parameters Supported actions: - send_message: Send channel message - send_advert: Send advertisement - refresh: Reload all data """ action = cmd.get('action') if action == 'send_message': channel = cmd.get('channel', 0) text = cmd.get('text', '') if text and self.mc: await self.mc.commands.send_chan_msg(channel, text) self.shared.add_message({ 'time': datetime.now().strftime('%H:%M:%S'), 'sender': 'Me', 'text': text, 'channel': channel, 'direction': 'out' }) debug_print(f"Sent message to channel {channel}: {text[:30]}") elif action == 'send_advert': if self.mc: await self.mc.commands.send_advert(flood=True) self.shared.set_status("📢 Advert sent") debug_print("Advert sent") elif action == 'send_dm': pubkey = cmd.get('pubkey', '') text = cmd.get('text', '') contact_name = cmd.get('contact_name', pubkey[:8]) if text and pubkey and self.mc: await self.mc.commands.send_msg(pubkey, text) self.shared.add_message({ 'time': datetime.now().strftime('%H:%M:%S'), 'sender': 'Me', 'text': text, 'channel': None, # None = DM 'direction': 'out' }) debug_print(f"Sent DM to {contact_name}: {text[:30]}") elif action == 'refresh': if self.mc: debug_print("Refresh requested") await self._load_data() def _on_channel_msg(self, event) -> None: """ Callback for received channel messages. Args: event: MeshCore event with payload """ payload = event.payload sender = payload.get('sender_name') or payload.get('sender') or '' self.shared.add_message({ 'time': datetime.now().strftime('%H:%M:%S'), 'sender': sender[:15] if sender else '', 'text': payload.get('text', ''), 'channel': payload.get('channel_idx'), 'direction': 'in', 'snr': payload.get('snr') }) def _on_contact_msg(self, event) -> None: """ Callback for received DM (direct message) messages. Looks up the sender name in the contacts list via pubkey_prefix. Args: event: MeshCore event with payload """ payload = event.payload pubkey = payload.get('pubkey_prefix', '') sender = '' # Look up contact name based on pubkey prefix if pubkey: with self.shared.lock: for key, contact in self.shared.contacts.items(): if key.startswith(pubkey): sender = contact.get('adv_name', '') break # Fallback to pubkey prefix if not sender: sender = pubkey[:8] if pubkey else '' self.shared.add_message({ 'time': datetime.now().strftime('%H:%M:%S'), 'sender': sender[:15] if sender else '', 'text': payload.get('text', ''), 'channel': None, # None = DM 'direction': 'in', 'snr': payload.get('SNR') # Note: uppercase in DM payload }) debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") def _on_rx_log(self, event) -> None: """ Callback for RX log data. Args: event: MeshCore event with payload """ payload = event.payload self.shared.add_rx_log({ 'time': datetime.now().strftime('%H:%M:%S'), 'snr': payload.get('snr', 0), 'rssi': payload.get('rssi', 0), 'payload_type': payload.get('payload_type', '?'), 'hops': payload.get('path_len', 0) }) # ============================================================================== # GUI - NiceGUI Web Interface # ============================================================================== class MeshCoreGUI: """ NiceGUI web interface for MeshCore. Provides a real-time dashboard with: - Device information - Contacts list - Interactive map with markers - Send/receive messages with filtering - RX log Attributes: shared: SharedData instance for data access TYPE_ICONS: Mapping of contact type to emoji TYPE_NAMES: Mapping of contact type to name """ # Contact type mappings TYPE_ICONS = {0: "○", 1: "📱", 2: "📡", 3: "🏠"} TYPE_NAMES = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"} def __init__(self, shared: SharedData): """ Initialize the GUI. Args: shared: SharedData instance for data access """ self.shared = shared # UI element references self.status_label = None self.device_label = None self.channel_select = None self.channels_filter_container = None self.channel_filters: Dict = {} self.contacts_container = None self.map_widget = None self.messages_container = None self.rxlog_table = None self.msg_input = None # Map markers tracking self.markers: List = [] # Channel data for message display self.last_channels: List[Dict] = [] def render(self) -> None: """ Render the complete UI. Builds the layout with header, three columns, and starts the update timer for real-time data refresh. """ ui.dark_mode(False) # Header with ui.header().classes('bg-blue-600 text-white'): ui.label('🔗 MeshCore').classes('text-xl font-bold') ui.space() self.status_label = ui.label('Starting...').classes('text-sm') # Main layout: three columns with ui.row().classes('w-full h-full gap-2 p-2'): # Left column: Device info and Contacts with ui.column().classes('w-64 gap-2'): self._render_device_panel() self._render_contacts_panel() # Middle column: Map, Input, Filter, Messages with ui.column().classes('flex-grow gap-2'): self._render_map_panel() self._render_input_panel() self._render_channels_filter() self._render_messages_panel() # Right column: Actions and RX Log with ui.column().classes('w-64 gap-2'): self._render_actions_panel() self._render_rxlog_panel() # Start update timer (every 500ms) ui.timer(0.5, self._update_ui) def _render_device_panel(self) -> None: """Render the device info panel.""" with ui.card().classes('w-full'): ui.label('📡 Device').classes('font-bold text-gray-600') self.device_label = ui.label('Connecting...').classes( 'text-sm whitespace-pre-line' ) def _render_contacts_panel(self) -> None: """Render the contacts panel.""" with ui.card().classes('w-full'): ui.label('👥 Contacts').classes('font-bold text-gray-600') self.contacts_container = ui.column().classes( 'w-full gap-1 max-h-96 overflow-y-auto' ) def _render_map_panel(self) -> None: """Render the map panel with Leaflet.""" with ui.card().classes('w-full'): self.map_widget = ui.leaflet( center=(52.5, 6.0), # Default: Netherlands zoom=9 ).classes('w-full h-72') def _render_input_panel(self) -> None: """Render the message input panel.""" with ui.card().classes('w-full'): with ui.row().classes('w-full items-center gap-2'): self.msg_input = ui.input( placeholder='Message...' ).classes('flex-grow') self.channel_select = ui.select( options={0: '[0] Public'}, value=0 ).classes('w-32') ui.button( 'Send', on_click=self._send_message ).classes('bg-blue-500 text-white') def _render_channels_filter(self) -> None: """Render the channel filter panel with checkboxes.""" with ui.card().classes('w-full'): with ui.row().classes('w-full items-center gap-4 justify-center'): ui.label('📻 Filter:').classes('text-sm text-gray-600') self.channels_filter_container = ui.row().classes('gap-4') def _render_messages_panel(self) -> None: """Render the messages panel.""" with ui.card().classes('w-full'): ui.label('💬 Messages').classes('font-bold text-gray-600') self.messages_container = ui.column().classes( 'w-full h-40 overflow-y-auto gap-0 text-sm font-mono ' 'bg-gray-50 p-2 rounded' ) def _render_actions_panel(self) -> None: """Render the actions panel.""" with ui.card().classes('w-full'): ui.label('⚡ Actions').classes('font-bold text-gray-600') with ui.row().classes('gap-2'): ui.button('🔄 Refresh', on_click=self._refresh) ui.button('📢 Advert', on_click=self._send_advert) def _render_rxlog_panel(self) -> None: """Render the RX log panel.""" with ui.card().classes('w-full'): ui.label('📊 RX Log').classes('font-bold text-gray-600') self.rxlog_table = ui.table( columns=[ {'name': 'time', 'label': 'Time', 'field': 'time'}, {'name': 'snr', 'label': 'SNR', 'field': 'snr'}, {'name': 'type', 'label': 'Type', 'field': 'type'}, ], rows=[] ).props('dense flat').classes('text-xs max-h-48 overflow-y-auto') def _update_ui(self) -> None: """ Periodic UI update from shared data. Called every 500ms by the timer. Fetches a snapshot of the data and only updates UI elements that have changed. """ try: # Check if UI elements exist if not self.status_label or not self.device_label: return # Get data snapshot data = self.shared.get_snapshot() # Determine if this is the first GUI render is_first_render = not data['gui_initialized'] # Always update status self.status_label.text = data['status'] # Update device info if changed OR first render if data['device_updated'] or is_first_render: self._update_device_info(data) # Update channels if changed OR first render if data['channels_updated'] or is_first_render: self._update_channels(data) # Update contacts if changed OR first render if data['contacts_updated'] or is_first_render: self._update_contacts(data) # Update map if contacts changed OR no markers OR first render if data['contacts'] and (data['contacts_updated'] or not self.markers or is_first_render): self._update_map(data) # Always refresh messages (for filter functionality) self._refresh_messages(data) # Update RX Log if changed if data['rxlog_updated'] and self.rxlog_table: self._update_rxlog(data) # Clear flags and mark GUI as initialized self.shared.clear_update_flags() # Only mark GUI as initialized when there is actual data if is_first_render and data['channels'] and data['contacts']: self.shared.mark_gui_initialized() except Exception as e: # Only log relevant errors error_str = str(e).lower() if "deleted" not in error_str and "client" not in error_str: print(f"GUI update error: {e}") def _update_device_info(self, data: Dict) -> None: """ Update the device info panel. Args: data: Snapshot dictionary from SharedData """ lines = [] if data['name']: lines.append(f"📡 {data['name']}") if data['public_key']: lines.append(f"🔑 {data['public_key'][:16]}...") if data['radio_freq']: lines.append(f"📻 {data['radio_freq']:.3f} MHz") lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz") if data['tx_power']: lines.append(f"⚡ TX: {data['tx_power']} dBm") if data['adv_lat'] and data['adv_lon']: lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}") if data['firmware_version']: lines.append(f"🏷️ {data['firmware_version']}") self.device_label.text = "\n".join(lines) if lines else "Loading..." def _update_channels(self, data: Dict) -> None: """ Update the channel filter checkboxes and send select. Args: data: Snapshot dictionary from SharedData """ if not self.channels_filter_container or not data['channels']: return # Rebuild filter checkboxes self.channels_filter_container.clear() self.channel_filters = {} with self.channels_filter_container: # DM filter checkbox cb_dm = ui.checkbox('DM', value=True) self.channel_filters['DM'] = cb_dm # Channel filter checkboxes for ch in data['channels']: idx = ch['idx'] name = ch['name'] cb = ui.checkbox(f"[{idx}] {name}", value=True) self.channel_filters[idx] = cb # Save channels for message display self.last_channels = data['channels'] # Update send channel select if self.channel_select and data['channels']: options = {ch['idx']: f"[{ch['idx']}] {ch['name']}" for ch in data['channels']} self.channel_select.options = options if self.channel_select.value not in options: self.channel_select.value = list(options.keys())[0] self.channel_select.update() def _update_contacts(self, data: Dict) -> None: """ Update the contacts list. Args: data: Snapshot dictionary from SharedData """ if not self.contacts_container: return self.contacts_container.clear() with self.contacts_container: for key, contact in data['contacts'].items(): ctype = contact.get('type', 0) icon = self.TYPE_ICONS.get(ctype, '○') name = contact.get('adv_name', key[:12]) type_name = self.TYPE_NAMES.get(ctype, '-') lat = contact.get('adv_lat', 0) lon = contact.get('adv_lon', 0) has_loc = lat != 0 or lon != 0 # Tooltip with details tooltip = f"{name}\nType: {type_name}\nKey: {key[:16]}...\nClick to send DM" if has_loc: tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}" # Contact row - clickable for DM with ui.row().classes( 'w-full items-center gap-2 p-1 hover:bg-gray-100 rounded cursor-pointer' ).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)): ui.label(icon).classes('text-sm') ui.label(name[:15]).classes( 'text-sm flex-grow truncate' ).tooltip(tooltip) ui.label(type_name).classes('text-xs text-gray-500') if has_loc: ui.label('📍').classes('text-xs') def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None: """ Open a dialog to send a DM to a contact. Args: pubkey: Public key of the contact contact_name: Name of the contact for display """ with ui.dialog() as dialog, ui.card().classes('w-96'): ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg') msg_input = ui.input( placeholder='Type your message...' ).classes('w-full') with ui.row().classes('w-full justify-end gap-2 mt-4'): ui.button('Cancel', on_click=dialog.close).props('flat') def send_dm(): text = msg_input.value if text: self.shared.cmd_queue.put({ 'action': 'send_dm', 'pubkey': pubkey, 'text': text, 'contact_name': contact_name }) dialog.close() ui.button('Send', on_click=send_dm).classes('bg-blue-500 text-white') dialog.open() def _update_map(self, data: Dict) -> None: """ Update the map markers. Args: data: Snapshot dictionary from SharedData """ if not self.map_widget: return # Remove old markers for marker in self.markers: try: self.map_widget.remove_layer(marker) except: pass self.markers.clear() # Own position marker if data['adv_lat'] and data['adv_lon']: m = self.map_widget.marker(latlng=(data['adv_lat'], data['adv_lon'])) self.markers.append(m) self.map_widget.set_center((data['adv_lat'], data['adv_lon'])) # Contact markers for key, contact in data['contacts'].items(): lat = contact.get('adv_lat', 0) lon = contact.get('adv_lon', 0) if lat != 0 or lon != 0: m = self.map_widget.marker(latlng=(lat, lon)) self.markers.append(m) def _update_rxlog(self, data: Dict) -> None: """ Update the RX log table. Args: data: Snapshot dictionary from SharedData """ rows = [ { 'time': entry['time'], 'snr': f"{entry['snr']:.1f}", 'type': entry['payload_type'] } for entry in data['rx_log'][:20] ] self.rxlog_table.rows = rows self.rxlog_table.update() def _refresh_messages(self, data: Dict) -> None: """ Refresh the messages container with filter application. Shows messages filtered based on channel checkboxes. Most recent messages are shown at the top. Args: data: Snapshot dictionary from SharedData """ if not self.messages_container: return # Channel name lookup channel_names = {ch['idx']: ch['name'] for ch in self.last_channels} # Filter messages based on checkboxes filtered_messages = [] for msg in data['messages']: ch_idx = msg['channel'] if ch_idx is None: # DM message - check DM filter if self.channel_filters.get('DM') and not self.channel_filters['DM'].value: continue else: # Channel message - check channel filter if ch_idx in self.channel_filters: if not self.channel_filters[ch_idx].value: continue filtered_messages.append(msg) # Rebuild messages container self.messages_container.clear() with self.messages_container: # Last 50 messages, newest at top for msg in reversed(filtered_messages[-50:]): direction = '→' if msg['direction'] == 'out' else '←' ch_idx = msg['channel'] # Determine channel name if ch_idx is not None: ch_name = channel_names.get(ch_idx, f'ch{ch_idx}') ch_label = f"[{ch_name}]" else: ch_label = '[DM]' # Format message line sender = msg.get('sender', '') if sender: line = f"{msg['time']} {direction} {ch_label} {sender}: {msg['text']}" else: line = f"{msg['time']} {direction} {ch_label} {msg['text']}" ui.label(line).classes('text-xs leading-tight') def _send_message(self) -> None: """Handle send button click - send message via command queue.""" text = self.msg_input.value channel = self.channel_select.value if text: self.shared.cmd_queue.put({ 'action': 'send_message', 'channel': channel, 'text': text }) self.msg_input.value = '' def _send_advert(self) -> None: """Handle advert button click - send advertisement.""" self.shared.cmd_queue.put({'action': 'send_advert'}) def _refresh(self) -> None: """Handle refresh button click - reload all data.""" self.shared.cmd_queue.put({'action': 'refresh'}) # ============================================================================== # MAIN ENTRY POINT # ============================================================================== # Global instances shared_data: Optional[SharedData] = None gui: Optional[MeshCoreGUI] = None @ui.page('/') def main_page(): """NiceGUI page handler - render the GUI.""" global gui if gui: gui.render() def main(): """ Main entry point. Parses command line arguments, initializes SharedData and GUI, starts the BLE worker thread, and starts the NiceGUI server. """ global shared_data, gui # Parse command line arguments if len(sys.argv) < 2: print("MeshCore GUI - Threaded BLE Edition") print("=" * 40) print("Usage: python meshcore_gui_v2.py ") print("Example: python meshcore_gui_v2.py literal:AA:BB:CC:DD:EE:FF") print() print("Tip: Use 'bluetoothctl scan on' to find devices") sys.exit(1) ble_address = sys.argv[1] # Startup banner print("=" * 50) print("MeshCore GUI - Threaded BLE Edition") print("=" * 50) print(f"Device: {ble_address}") print(f"Debug mode: {'ON' if DEBUG else 'OFF'}") print("=" * 50) # Initialize shared data shared_data = SharedData() # Initialize GUI gui = MeshCoreGUI(shared_data) # Start BLE worker in separate thread worker = BLEWorker(ble_address, shared_data) worker.start() # Start NiceGUI server ui.run( title='MeshCore', port=8080, reload=False ) if __name__ == "__main__": main()