diff --git a/modules/command_manager.py b/modules/command_manager.py index c275759..50cc1ec 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -291,13 +291,13 @@ class CommandManager: def _handle_send_result(self, result, operation_name: str, target: str, used_retry_method: bool = False) -> bool: """Handle result from message send operations. - + Args: result: Result object from meshcore send operation. operation_name: Name of the operation ("DM" or "Channel message"). target: Recipient name or channel name for logging. used_retry_method: True if send_msg_with_retry was used (affects logging). - + Returns: bool: True if send succeeded (ACK received or sent successfully), False otherwise. """ @@ -307,14 +307,17 @@ class CommandManager: else: self.logger.error(f"❌ {operation_name} to {target} failed - no result returned") return False - + if hasattr(result, 'type'): - if result.type == EventType.ERROR: + # Get event type name for comparison (handles different EventType enums) + event_type_name = getattr(result.type, 'name', str(result.type)).upper() + + if event_type_name == 'ERROR': error_payload = result.payload if hasattr(result, 'payload') else {} self.logger.error(f"❌ {operation_name} failed to {target}: {error_payload if error_payload else 'Unknown error'}") return False - - if result.type in (EventType.MSG_SENT, EventType.OK): + + if event_type_name in ('MSG_SENT', 'OK', 'MESSAGE_SENT'): if used_retry_method and operation_name == "DM": self.logger.info(f"✅ {operation_name} sent and ACK received from {target}") else: @@ -732,16 +735,8 @@ class CommandManager: return False try: - # Get channel number from channel name - channel_num = self.bot.channel_manager.get_channel_number(channel) - - # Check if channel was found (None indicates channel name not found) - if channel_num is None: - self.logger.error(f"Channel '{channel}' not found. Cannot send message.") - return False - - self.logger.info(f"Sending channel message to {channel} (channel {channel_num}): {content}") - + self.logger.info(f"Sending channel message to {channel}: {content}") + # Record transmission for repeat tracking (don't let this block sending) try: if hasattr(self.bot, 'transmission_tracker') and self.bot.transmission_tracker: @@ -756,13 +751,27 @@ class CommandManager: except Exception as e: self.logger.debug(f"Error recording transmission for repeat tracking: {e}") # Don't fail the send if transmission tracking fails - - # Use meshcore-cli send_chan_msg function - from meshcore_cli.meshcore_cli import send_chan_msg - result = await send_chan_msg(self.bot.meshcore, channel_num, content) - + + # Check if using pymc mode (has send_channel_msg on commands) + if hasattr(self.bot.meshcore, 'commands') and hasattr(self.bot.meshcore.commands, 'send_channel_msg'): + # pymc mode - use channel name directly + result = await self.bot.meshcore.commands.send_channel_msg(channel, content) + target = channel + else: + # Standard meshcore mode - need channel number + channel_num = self.bot.channel_manager.get_channel_number(channel) + + # Check if channel was found (None indicates channel name not found) + if channel_num is None: + self.logger.error(f"Channel '{channel}' not found. Cannot send message.") + return False + + # Use meshcore-cli send_chan_msg function + from meshcore_cli.meshcore_cli import send_chan_msg + result = await send_chan_msg(self.bot.meshcore, channel_num, content) + target = f"{channel} (channel {channel_num})" + # Handle result using unified handler - target = f"{channel} (channel {channel_num})" return self._handle_send_result(result, "Channel message", target) except Exception as e: diff --git a/modules/message_handler.py b/modules/message_handler.py index 481acf5..90ff202 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -1472,10 +1472,16 @@ class MessageHandler: # Always strip trailing whitespace/newlines from message content to handle cases like "Wx 98104\n" message_content = message_content.strip() - - # Get channel name from channel number - channel_name = self.bot.channel_manager.get_channel_name(channel_idx) - + + # Get channel name - prefer the one from payload (pymc mode), fall back to channel_manager lookup + channel_name = payload.get('channel_name') or payload.get('channel', '') + if not channel_name and hasattr(self.bot, 'channel_manager') and self.bot.channel_manager: + channel_name = self.bot.channel_manager.get_channel_name(channel_idx) + + # Get sender name from payload if available (pymc mode provides this directly) + if payload.get('sender_name') and payload.get('sender_name') != 'Unknown': + sender_id = payload.get('sender_name') + self.logger.info(f"Received channel message ({channel_name}) from {sender_id}: {text}") # Get SNR and RSSI using the same logic as contact messages @@ -1529,147 +1535,157 @@ class MessageHandler: message_packet_prefix = message_raw_hex[:32] if message_raw_hex else None message_pubkey = payload.get('pubkey_prefix', '') # Keep for contact lookup self.logger.debug(f"Processing channel message from packet prefix: {message_packet_prefix}, pubkey: {message_pubkey}") - - # Enhanced RF data correlation with multiple strategies - recent_rf_data = None - - # Strategy 1: Try immediate correlation using packet prefix - if message_packet_prefix: - recent_rf_data = self.find_recent_rf_data(message_packet_prefix) - elif message_pubkey: - # Fallback to pubkey correlation - recent_rf_data = self.find_recent_rf_data(message_pubkey) - - # Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly - if not recent_rf_data and self.enhanced_correlation: - import time - correlation_key = message_packet_prefix or message_pubkey - message_id = f"{correlation_key}_{int(time.time() * 1000)}" - self.store_message_for_correlation(message_id, payload) - - # Wait a short time for RF data to arrive (non-blocking) - await asyncio.sleep(0.1) # 100ms wait - recent_rf_data = self.correlate_message_with_rf_data(message_id) - - # Strategy 3: Try with extended timeout if still no match - if not recent_rf_data: - extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout + + # Check if we already have path info from the payload (pymc mode) + # In pymc mode, path info comes directly from the packet, no RF correlation needed + if payload.get('path') is not None: + # pymc mode - use data directly from payload + path_string = payload.get('path') + hops = payload.get('path_len', 0) + self.logger.debug(f"Using path from payload (pymc mode): {path_string} ({hops} hops)") + recent_rf_data = None # Skip RF correlation + else: + # meshcore mode - need RF data correlation + # Enhanced RF data correlation with multiple strategies + recent_rf_data = None + + # Strategy 1: Try immediate correlation using packet prefix if message_packet_prefix: - recent_rf_data = self.find_recent_rf_data(message_packet_prefix, max_age_seconds=extended_timeout) + recent_rf_data = self.find_recent_rf_data(message_packet_prefix) elif message_pubkey: - recent_rf_data = self.find_recent_rf_data(message_pubkey, max_age_seconds=extended_timeout) + # Fallback to pubkey correlation + recent_rf_data = self.find_recent_rf_data(message_pubkey) - # Strategy 4: Use most recent RF data as last resort - if not recent_rf_data: - extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout - recent_rf_data = self.find_recent_rf_data(max_age_seconds=extended_timeout) + # Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly + if not recent_rf_data and self.enhanced_correlation: + import time + correlation_key = message_packet_prefix or message_pubkey + message_id = f"{correlation_key}_{int(time.time() * 1000)}" + self.store_message_for_correlation(message_id, payload) + + # Wait a short time for RF data to arrive (non-blocking) + await asyncio.sleep(0.1) # 100ms wait + recent_rf_data = self.correlate_message_with_rf_data(message_id) + + # Strategy 3: Try with extended timeout if still no match + if not recent_rf_data: + extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout + if message_packet_prefix: + recent_rf_data = self.find_recent_rf_data(message_packet_prefix, max_age_seconds=extended_timeout) + elif message_pubkey: + recent_rf_data = self.find_recent_rf_data(message_pubkey, max_age_seconds=extended_timeout) + + # Strategy 4: Use most recent RF data as last resort + if not recent_rf_data: + extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout + recent_rf_data = self.find_recent_rf_data(max_age_seconds=extended_timeout) - if recent_rf_data and recent_rf_data.get('raw_hex'): - raw_hex = recent_rf_data['raw_hex'] - self.logger.info(f"🔍 FOUND RF DATA: {len(raw_hex)} chars, starts with: {raw_hex[:32]}...") - self.logger.debug(f"Full RF data: {raw_hex}") - - # Extract SNR/RSSI from the RF data - if recent_rf_data.get('snr'): - snr = recent_rf_data['snr'] - self.logger.debug(f"Using SNR from RF data: {snr}") - - if recent_rf_data.get('rssi'): - rssi = recent_rf_data['rssi'] - self.logger.debug(f"Using RSSI from RF data: {rssi}") - - # Try to extract path information from raw hex directly - path_string = None - hops = payload.get('path_len', 255) - - # First try the packet decoder - # Use payload field if available, otherwise use raw_hex - payload_hex = recent_rf_data.get('payload') - packet_info = self.decode_meshcore_packet(raw_hex, payload_hex) - - # Get packet_hash from recent_rf_data if available (for trace correlation) - packet_hash = recent_rf_data.get('packet_hash') - if packet_hash and packet_info: - packet_info['packet_hash'] = packet_hash - - if packet_info and packet_info.get('path_len') is not None: - # Valid packet decoded - use the results even if path is empty (0 hops = direct) - hops = packet_info.get('path_len', 0) - - # Check if this is a TRACE packet with SNR data - if packet_info.get('payload_type') == 9: # TRACE packet - # For TRACE packets, extract routing path from payload pathHashes - # The path field contains SNR data, but the actual routing path is in payload - path_info = packet_info.get('path_info', {}) - path_hashes = path_info.get('path_hashes') or path_info.get('path', []) - - if path_hashes: - # Convert pathHashes to path string - path_string = ','.join(path_hashes) - self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string} ({len(path_hashes)} hops)") - - # Update mesh graph with trace path - bot is the destination, so we can confirm these edges - # Since the bot received this trace packet, it's the destination node - self._update_mesh_graph_from_trace(path_hashes, packet_info) + if recent_rf_data and recent_rf_data.get('raw_hex'): + raw_hex = recent_rf_data['raw_hex'] + self.logger.info(f"🔍 FOUND RF DATA: {len(raw_hex)} chars, starts with: {raw_hex[:32]}...") + self.logger.debug(f"Full RF data: {raw_hex}") + + # Extract SNR/RSSI from the RF data + if recent_rf_data.get('snr'): + snr = recent_rf_data['snr'] + self.logger.debug(f"Using SNR from RF data: {snr}") + + if recent_rf_data.get('rssi'): + rssi = recent_rf_data['rssi'] + self.logger.debug(f"Using RSSI from RF data: {rssi}") + + # Try to extract path information from raw hex directly + path_string = None + hops = payload.get('path_len', 255) + + # First try the packet decoder + # Use payload field if available, otherwise use raw_hex + payload_hex = recent_rf_data.get('payload') + packet_info = self.decode_meshcore_packet(raw_hex, payload_hex) + + # Get packet_hash from recent_rf_data if available (for trace correlation) + packet_hash = recent_rf_data.get('packet_hash') + if packet_hash and packet_info: + packet_info['packet_hash'] = packet_hash + + if packet_info and packet_info.get('path_len') is not None: + # Valid packet decoded - use the results even if path is empty (0 hops = direct) + hops = packet_info.get('path_len', 0) + + # Check if this is a TRACE packet with SNR data + if packet_info.get('payload_type') == 9: # TRACE packet + # For TRACE packets, extract routing path from payload pathHashes + # The path field contains SNR data, but the actual routing path is in payload + path_info = packet_info.get('path_info', {}) + path_hashes = path_info.get('path_hashes') or path_info.get('path', []) + + if path_hashes: + # Convert pathHashes to path string + path_string = ','.join(path_hashes) + self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string} ({len(path_hashes)} hops)") + + # Update mesh graph with trace path - bot is the destination, so we can confirm these edges + # Since the bot received this trace packet, it's the destination node + self._update_mesh_graph_from_trace(path_hashes, packet_info) + else: + path_string = "Direct" if hops == 0 else f"Unknown routing ({hops} hops)" + self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string}") else: - path_string = "Direct" if hops == 0 else f"Unknown routing ({hops} hops)" - self.logger.info(f"🎯 EXTRACTED PATH FROM TRACE PACKET: {path_string}") - else: - # For all other packet types, try multiple methods to get the path - path_string = None - - # Method 1: Try path_nodes field first - path_nodes = packet_info.get('path_nodes', []) - if path_nodes: - path_string = ','.join(path_nodes) - self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)") - # Update mesh graph with path edges - self._update_mesh_graph(path_nodes, packet_info) - else: - # Method 2: Try path_hex field - path_hex = packet_info.get('path_hex', '') - if path_hex and len(path_hex) >= 2: - # Convert hex string to node list (every 2 characters = 1 node) - path_nodes = [path_hex[i:i+2] for i in range(0, len(path_hex), 2)] + # For all other packet types, try multiple methods to get the path + path_string = None + + # Method 1: Try path_nodes field first + path_nodes = packet_info.get('path_nodes', []) + if path_nodes: path_string = ','.join(path_nodes) - self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET HEX: {path_string} ({hops} hops)") + self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)") # Update mesh graph with path edges self._update_mesh_graph(path_nodes, packet_info) else: - # Method 3: Try path_info.path field - path_info = packet_info.get('path_info', {}) - if path_info and path_info.get('path'): - path_nodes = path_info['path'] + # Method 2: Try path_hex field + path_hex = packet_info.get('path_hex', '') + if path_hex and len(path_hex) >= 2: + # Convert hex string to node list (every 2 characters = 1 node) + path_nodes = [path_hex[i:i+2] for i in range(0, len(path_hex), 2)] path_string = ','.join(path_nodes) - self.logger.info(f"🎯 EXTRACTED PATH FROM PATH_INFO: {path_string} ({hops} hops)") + self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET HEX: {path_string} ({hops} hops)") # Update mesh graph with path edges self._update_mesh_graph(path_nodes, packet_info) else: - # No path found - this is truly unknown - path_string = "Direct" if hops == 0 else "Unknown routing" - self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)") - else: - # Packet decoding failed - try to extract path directly from raw hex - self.logger.debug("Packet decoding failed, trying direct hex parsing") - path_string = self.extract_path_from_raw_hex(raw_hex, hops) - if path_string: - self.logger.info(f"🎯 EXTRACTED PATH FROM RAW HEX: {path_string} ({hops} hops)") + # Method 3: Try path_info.path field + path_info = packet_info.get('path_info', {}) + if path_info and path_info.get('path'): + path_nodes = path_info['path'] + path_string = ','.join(path_nodes) + self.logger.info(f"🎯 EXTRACTED PATH FROM PATH_INFO: {path_string} ({hops} hops)") + # Update mesh graph with path edges + self._update_mesh_graph(path_nodes, packet_info) + else: + # No path found - this is truly unknown + path_string = "Direct" if hops == 0 else "Unknown routing" + self.logger.info(f"🎯 EXTRACTED PATH FROM PACKET: {path_string} ({hops} hops)") else: - # Try to use routing info from RF data as fallback - if recent_rf_data.get('routing_info') and recent_rf_data['routing_info'].get('path_nodes'): - routing_info = recent_rf_data['routing_info'] - hops = len(routing_info['path_nodes']) - path_string = ','.join(routing_info['path_nodes']) - self.logger.info(f"🎯 EXTRACTED PATH FROM RF ROUTING INFO: {path_string} ({hops} hops)") + # Packet decoding failed - try to extract path directly from raw hex + self.logger.debug("Packet decoding failed, trying direct hex parsing") + path_string = self.extract_path_from_raw_hex(raw_hex, hops) + if path_string: + self.logger.info(f"🎯 EXTRACTED PATH FROM RAW HEX: {path_string} ({hops} hops)") else: - # Final fallback to basic path info - self.logger.debug("No path info available, using basic path info") - path_string = None - else: - self.logger.warning("❌ NO RF DATA found for channel message after all correlation attempts") - hops = payload.get('path_len', 255) - path_string = None - + # Try to use routing info from RF data as fallback + if recent_rf_data.get('routing_info') and recent_rf_data['routing_info'].get('path_nodes'): + routing_info = recent_rf_data['routing_info'] + hops = len(routing_info['path_nodes']) + path_string = ','.join(routing_info['path_nodes']) + self.logger.info(f"🎯 EXTRACTED PATH FROM RF ROUTING INFO: {path_string} ({hops} hops)") + else: + # Final fallback to basic path info + self.logger.debug("No path info available, using basic path info") + path_string = None + else: + self.logger.warning("❌ NO RF DATA found for channel message after all correlation attempts") + hops = payload.get('path_len', 255) + path_string = None + # Get the full public key from contacts if available sender_pubkey = payload.get('pubkey_prefix', '') if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: diff --git a/modules/pymc_connection.py b/modules/pymc_connection.py index 1e8c714..cff97a8 100644 --- a/modules/pymc_connection.py +++ b/modules/pymc_connection.py @@ -161,9 +161,6 @@ class ChannelDatabaseAdapter: # Well-known Public channel secret (from MeshCore spec) PUBLIC_CHANNEL_SECRET = "d8ee687c9be53be08d24a7f7aede4dac5de3168dea03c12e7b9c96c5511e807f" - # Common hashtag channels to always include - COMMON_HASHTAG_CHANNELS = ['#Public', '#general', '#test'] - def __init__(self, bot): self.bot = bot self.logger = logging.getLogger("ChannelDatabaseAdapter") @@ -171,24 +168,33 @@ class ChannelDatabaseAdapter: self._cache_time = 0 self._cache_ttl = 30 # Refresh cache every 30 seconds + # Pre-load channels and log what we have + self.logger.info("ChannelDatabaseAdapter initialized - loading channels...") + channels = self.get_channels() + self.logger.info(f"Initialized with {len(channels)} channels:") + for ch in channels: + ch_hash = self.get_channel_hash(ch['secret']) + self.logger.info(f" - {ch['name']}: hash=0x{ch_hash:02X}") + @staticmethod def derive_hashtag_key(channel_name: str) -> str: """ Derive channel key from a hashtag channel name. - For channels starting with #, the key is derived by SHA256 hashing - the channel name (including the #). + MeshCore uses the first 16 bytes of SHA256(channel_name) as the + encryption key for hashtag channels. Args: channel_name: Channel name (e.g., "#howltest") Returns: - Hex string of the 32-byte derived key + Hex string of the 16-byte derived key (32 hex chars) """ import hashlib - # Hash the channel name (including #) to get the key - key = hashlib.sha256(channel_name.encode('utf-8')).digest() - return key.hex() + # Hash the channel name, take first 16 bytes as the key + full_hash = hashlib.sha256(channel_name.encode('utf-8')).digest() + key_16 = full_hash[:16] + return key_16.hex() @staticmethod def get_channel_hash(secret_hex: str) -> int: @@ -211,8 +217,13 @@ class ChannelDatabaseAdapter: Includes: - Public channel (well-known key) - Hashtag channels from config (derived keys) + - Private channels from config (name:key pairs) - Custom channels from database + Config format in [Channels] section: + monitor_channels = #howltest, #mychannel + private_channels = mynet:abc123..., othernet:def456... + Returns: List of channel dicts with 'name' and 'secret' keys """ @@ -236,27 +247,16 @@ class ChannelDatabaseAdapter: }) self.logger.info(f"Added Public channel with hash 0x{public_hash:02X}") - # Add common hashtag channels - for ch_name in self.COMMON_HASHTAG_CHANNELS: - derived_key = self.derive_hashtag_key(ch_name) - ch_hash = self.get_channel_hash(derived_key) - if not any(c['name'] == ch_name for c in channels): - channels.append({ - 'name': ch_name, - 'secret': derived_key, - 'idx': len(channels) - }) - self.logger.info(f"Added common channel {ch_name} with hash 0x{ch_hash:02X}") - - # Add hashtag channels from config + # Add channels from config try: if hasattr(self.bot, 'config'): + # Hashtag channels (key derived from name) monitor_channels = self.bot.config.get('Channels', 'monitor_channels', fallback='') if monitor_channels: for ch_name in monitor_channels.split(','): ch_name = ch_name.strip() - if ch_name.startswith('#') and not any(c['name'] == ch_name for c in channels): - # Derive key for hashtag channel + if ch_name and not any(c['name'] == ch_name for c in channels): + # Derive key from channel name derived_key = self.derive_hashtag_key(ch_name) ch_hash = self.get_channel_hash(derived_key) channels.append({ @@ -264,11 +264,29 @@ class ChannelDatabaseAdapter: 'secret': derived_key, 'idx': len(channels) }) - self.logger.debug(f"Added config channel {ch_name} with hash 0x{ch_hash:02X}") - except Exception as e: - self.logger.warning(f"Error loading hashtag channels from config: {e}") + self.logger.info(f"Added channel {ch_name} with hash 0x{ch_hash:02X}") - # Add channels from database + # Private channels (explicit name:key pairs) + private_channels = self.bot.config.get('Channels', 'private_channels', fallback='') + if private_channels: + for pair in private_channels.split(','): + pair = pair.strip() + if ':' in pair: + ch_name, ch_key = pair.split(':', 1) + ch_name = ch_name.strip() + ch_key = ch_key.strip() + if ch_name and ch_key and not any(c['name'] == ch_name for c in channels): + ch_hash = self.get_channel_hash(ch_key) + channels.append({ + 'name': ch_name, + 'secret': ch_key, + 'idx': len(channels) + }) + self.logger.info(f"Added private channel {ch_name} with hash 0x{ch_hash:02X}") + except Exception as e: + self.logger.warning(f"Error loading channels from config: {e}") + + # Add channels from database (added via web viewer) try: if hasattr(self.bot, 'db_manager'): query = """ @@ -284,13 +302,15 @@ class ChannelDatabaseAdapter: channel_key_hex = row.get('channel_key_hex', '') if channel_name and channel_key_hex: - # Don't duplicate if already added + # Don't duplicate if already added from config if not any(c['name'] == channel_name for c in channels): + ch_hash = self.get_channel_hash(channel_key_hex) channels.append({ 'name': channel_name, 'secret': channel_key_hex, 'idx': row.get('channel_idx', 0) }) + self.logger.info(f"Added database channel {channel_name} with hash 0x{ch_hash:02X}") except Exception as e: self.logger.warning(f"Error loading channels from database: {e}") @@ -737,30 +757,38 @@ class PyMCConnection: async def _handle_advert_packet(self, pkt, snr: float, rssi: float) -> None: """Handle advertisement packet from dispatcher.""" try: - # Extract advertisement info from packet - # The advert handler in pymc_core parses this + # Use pymc_core's advert parsing utilities + from pymc_core.protocol.utils import parse_advert_payload, decode_appdata + payload = pkt.payload if hasattr(pkt, 'payload') else b'' - # Basic info we can extract + # Default event payload event_payload = { - 'public_key': payload[2:34].hex() if len(payload) >= 34 else '', - 'name': '', # Name is in the advert data after pubkey + 'public_key': '', + 'name': '', 'latitude': None, 'longitude': None, - 'flags': payload[1] if len(payload) > 1 else 0, + 'flags': 0, 'SNR': snr, 'RSSI': rssi, 'raw_hex': payload.hex() if payload else '' } - # Try to extract name from advert - if len(payload) > 34: - try: - name_end = payload.find(b'\x00', 34) - if name_end > 34: - event_payload['name'] = payload[34:name_end].decode('utf-8', errors='ignore') - except Exception: - pass + # Parse the advert packet properly + try: + parsed = parse_advert_payload(payload) + event_payload['public_key'] = parsed.get('pubkey', '') + + # Decode the appdata to get name, location, flags + appdata = parsed.get('appdata', b'') + if appdata: + decoded = decode_appdata(appdata) + event_payload['name'] = decoded.get('node_name', '') or decoded.get('name', '') + event_payload['latitude'] = decoded.get('latitude') + event_payload['longitude'] = decoded.get('longitude') + event_payload['flags'] = decoded.get('flags', 0) + except Exception as e: + self.logger.warning(f"Error parsing advert payload: {e}") self.logger.info(f"Advertisement from {event_payload.get('name', 'unknown')}") @@ -806,14 +834,34 @@ class PyMCConnection: # The GroupTextHandler in pymc_core stores decrypted data in pkt.decrypted group_data = getattr(pkt, 'decrypted', {}).get('group_text_data', {}) - text = group_data.get('text', '') + # Strip null bytes from text (padding from decryption) + text = group_data.get('text', '').rstrip('\x00') channel_name = group_data.get('channel_name', '') sender_name = group_data.get('sender_name', 'Unknown') channel_hash = group_data.get('channel_hash', 0) # If group_data is empty, the message wasn't decrypted (missing channel key) if not group_data: - self.logger.warning(f"Channel message not decrypted - missing channel key? hash=0x{payload[0]:02X}") + ch_hash = payload[0] if payload else 0 + self.logger.warning(f"Channel message not decrypted - unknown channel hash=0x{ch_hash:02X}") + # Log what channels we DO know about for debugging + if hasattr(self, '_channel_db') and self._channel_db: + known_hashes = [] + for ch in self._channel_db.get_channels(): + h = self._channel_db.get_channel_hash(ch['secret']) + known_hashes.append(f"{ch['name']}=0x{h:02X}") + self.logger.debug(f"Known channels: {', '.join(known_hashes)}") + + # Extract path information from the packet + path_len = getattr(pkt, 'path_len', 0) + path_bytes = getattr(pkt, 'path', b'') + path_string = None + if path_len > 0 and path_bytes: + # Convert path bytes to hex string (each byte is a node hash) + path_nodes = [f"{b:02x}" for b in path_bytes[:path_len]] + path_string = f"{','.join(path_nodes)} ({path_len} hops)" + elif path_len == 0: + path_string = "Direct" event_payload = { 'text': text, @@ -824,6 +872,8 @@ class PyMCConnection: 'sender_name': sender_name, 'SNR': snr, 'RSSI': rssi, + 'path': path_string, + 'path_len': path_len, 'raw_hex': payload.hex() if payload else '' } @@ -930,7 +980,41 @@ class PyMCCommands: except Exception as e: self.logger.error(f"Error sending advertisement: {e}") return Event(type=EventType.ERROR, payload={'error': str(e)}) - + + async def send_channel_msg(self, channel_name: str, content: str) -> Event: + """ + Send a message to a channel. + + Args: + channel_name: Name of the channel (e.g., "#howltest") + content: Message content to send + + Returns: + Event indicating success or failure + """ + try: + if not self.connection._mesh_node: + return Event(type=EventType.ERROR, payload={'error': 'Not connected'}) + + self.logger.info(f"Sending channel message to {channel_name}: {content[:50]}...") + + # Use pymc_core's send_group_text method + result = await self.connection._mesh_node.send_group_text(channel_name, content) + + if result.get('success'): + self.logger.info(f"Channel message sent to {channel_name}") + return Event(type=EventType.MSG_SENT, payload={'sent': True, 'channel': channel_name}) + else: + error = result.get('error', 'Unknown error') + self.logger.error(f"Failed to send channel message: {error}") + return Event(type=EventType.ERROR, payload={'error': error}) + + except Exception as e: + self.logger.error(f"Error sending channel message: {e}") + import traceback + self.logger.error(traceback.format_exc()) + return Event(type=EventType.ERROR, payload={'error': str(e)}) + async def get_time(self) -> Event: """ Get device time (not applicable for pyMC, return current time). diff --git a/modules/scheduler.py b/modules/scheduler.py index 18b9975..afbbad6 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -382,10 +382,13 @@ class MessageScheduler: # Process pending channel operations from web viewer (every 5 seconds) if not hasattr(self, 'last_channel_ops_check_time'): self.last_channel_ops_check_time = 0 - + if time.time() - self.last_channel_ops_check_time >= 5: # Every 5 seconds - if (hasattr(self.bot, 'channel_manager') and self.bot.channel_manager and - hasattr(self.bot, 'connected') and self.bot.connected): + # Process if connected - works for both meshcore (channel_manager) and pymc modes + can_process = (hasattr(self.bot, 'connected') and self.bot.connected and + (hasattr(self.bot, 'channel_manager') and self.bot.channel_manager or + self._is_pymc_connection())) + if can_process: import asyncio if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running(): # Schedule coroutine in the running main event loop @@ -508,16 +511,24 @@ class MessageScheduler: except Exception as e: self.logger.error(f"Error sending interval-based advert: {e}") + def _is_pymc_connection(self) -> bool: + """Check if we're using pymc connection mode""" + try: + from .pymc_connection import PyMCConnection + return isinstance(self.bot.meshcore, PyMCConnection) + except ImportError: + return False + async def _process_channel_operations(self): """Process pending channel operations from the web viewer""" try: db_path = str(self.bot.db_manager.db_path) # Ensure string, not Path object - + # Get pending operations with sqlite3.connect(db_path, timeout=30.0) as conn: conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute(''' SELECT id, operation_type, channel_idx, channel_name, channel_key_hex FROM channel_operations @@ -525,52 +536,63 @@ class MessageScheduler: ORDER BY created_at ASC LIMIT 10 ''') - + operations = cursor.fetchall() - + if not operations: return - + self.logger.info(f"Processing {len(operations)} pending channel operation(s)") - + + # Check if using pymc connection (channels stored in DB only, no radio) + is_pymc = self._is_pymc_connection() + for op in operations: op_id = op['id'] op_type = op['operation_type'] channel_idx = op['channel_idx'] channel_name = op['channel_name'] channel_key_hex = op['channel_key_hex'] - + try: success = False error_msg = None - - if op_type == 'add': - # Add channel - if channel_key_hex: - # Custom channel with key - channel_secret = bytes.fromhex(channel_key_hex) - success = await self.bot.channel_manager.add_channel( - channel_idx, channel_name, channel_secret=channel_secret - ) - else: - # Hashtag channel (firmware generates key) - success = await self.bot.channel_manager.add_channel( - channel_idx, channel_name - ) - - if success: - self.logger.info(f"Successfully processed channel add operation: {channel_name} at index {channel_idx}") - else: - error_msg = "Failed to add channel" - - elif op_type == 'remove': - # Remove channel - success = await self.bot.channel_manager.remove_channel(channel_idx) - - if success: - self.logger.info(f"Successfully processed channel remove operation: index {channel_idx}") - else: - error_msg = "Failed to remove channel" + + if is_pymc: + # For pymc mode, store directly in database + # The ChannelDatabaseAdapter will pick up channels from the DB + success, error_msg = await self._process_pymc_channel_operation( + op_type, channel_idx, channel_name, channel_key_hex + ) + else: + # Standard meshcore mode - use channel_manager to talk to radio + if op_type == 'add': + # Add channel + if channel_key_hex: + # Custom channel with key + channel_secret = bytes.fromhex(channel_key_hex) + success = await self.bot.channel_manager.add_channel( + channel_idx, channel_name, channel_secret=channel_secret + ) + else: + # Hashtag channel (firmware generates key) + success = await self.bot.channel_manager.add_channel( + channel_idx, channel_name + ) + + if success: + self.logger.info(f"Successfully processed channel add operation: {channel_name} at index {channel_idx}") + else: + error_msg = "Failed to add channel" + + elif op_type == 'remove': + # Remove channel + success = await self.bot.channel_manager.remove_channel(channel_idx) + + if success: + self.logger.info(f"Successfully processed channel remove operation: index {channel_idx}") + else: + error_msg = "Failed to remove channel" # Update operation status with sqlite3.connect(db_path, timeout=30.0) as conn: @@ -623,3 +645,73 @@ class MessageScheduler: self.logger.error(f"Parent directory: {parent} (exists: {parent.exists()}, writable: {os.access(str(parent), os.W_OK) if parent.exists() else False})") else: self.logger.error(f"Database path: {db_path_str}") + + async def _process_pymc_channel_operation(self, op_type: str, channel_idx: int, + channel_name: str, channel_key_hex: str) -> tuple: + """ + Process channel operation for pymc mode by storing directly in database. + + For pymc mode, channels are stored in the database and the ChannelDatabaseAdapter + reads from there. There's no radio firmware to configure. + + Args: + op_type: 'add' or 'remove' + channel_idx: Channel index + channel_name: Channel name + channel_key_hex: Channel key as hex string (for private channels) + + Returns: + Tuple of (success: bool, error_msg: str or None) + """ + import hashlib + + try: + db_path = str(self.bot.db_manager.db_path) + + if op_type == 'add': + # Derive key if not provided (hashtag channel) + if not channel_key_hex and channel_name: + # Derive key from channel name using SHA256 + key_bytes = hashlib.sha256(channel_name.encode('utf-8')).digest() + channel_key_hex = key_bytes.hex() + + # Determine channel type + channel_type = 'hashtag' if channel_name.startswith('#') else 'custom' + + # Insert/update channel in database + with sqlite3.connect(db_path, timeout=30.0) as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO channels + (channel_idx, channel_name, channel_type, channel_key_hex, last_updated) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ''', (channel_idx, channel_name, channel_type, channel_key_hex)) + conn.commit() + + # Invalidate channel cache in the adapter + if hasattr(self.bot.meshcore, '_channel_db'): + self.bot.meshcore._channel_db._cache_time = 0 + + self.logger.info(f"[pymc] Added channel {channel_idx}: {channel_name} (type={channel_type})") + return True, None + + elif op_type == 'remove': + # Remove channel from database + with sqlite3.connect(db_path, timeout=30.0) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM channels WHERE channel_idx = ?', (channel_idx,)) + conn.commit() + + # Invalidate channel cache in the adapter + if hasattr(self.bot.meshcore, '_channel_db'): + self.bot.meshcore._channel_db._cache_time = 0 + + self.logger.info(f"[pymc] Removed channel {channel_idx}") + return True, None + + else: + return False, f"Unknown operation type: {op_type}" + + except Exception as e: + self.logger.error(f"Error in pymc channel operation: {e}") + return False, str(e)