From 2183f8e24670baff149f605ccfc3afd9e97f6313 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 1 May 2026 21:32:54 -0700 Subject: [PATCH] refactor(message_handler): standardize string quotes and improve code readability - Updated string quotes from single to double quotes for consistency across the MessageHandler class. - Enhanced formatting of method parameters for better readability. - Minor adjustments to logging statements for clarity. --- modules/message_handler.py | 1532 ++++++++++++++++++++---------------- 1 file changed, 842 insertions(+), 690 deletions(-) diff --git a/modules/message_handler.py b/modules/message_handler.py index 72021ba..db5571f 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -47,9 +47,9 @@ class MessageHandler: self.rssi_cache: OrderedDict[str, float] = OrderedDict() # Load configuration for RF data correlation - self.rf_data_timeout = float(bot.config.get('Bot', 'rf_data_timeout', fallback='15.0')) - self.message_timeout = float(bot.config.get('Bot', 'message_correlation_timeout', fallback='10.0')) - self.enhanced_correlation = bot.config.getboolean('Bot', 'enable_enhanced_correlation', fallback=True) + self.rf_data_timeout = float(bot.config.get("Bot", "rf_data_timeout", fallback="15.0")) + self.message_timeout = float(bot.config.get("Bot", "message_correlation_timeout", fallback="10.0")) + self.enhanced_correlation = bot.config.getboolean("Bot", "enable_enhanced_correlation", fallback=True) # Time-based cache for recent RF log data self.recent_rf_data: list[dict[str, Any]] = [] @@ -59,7 +59,7 @@ class MessageHandler: # Enhanced RF data storage with better correlation self.rf_data_by_timestamp: dict[int | float, dict[str, Any]] = {} # Index by timestamp for faster lookup - self.rf_data_by_pubkey: dict[str, list[dict[str, Any]]] = {} # Index by pubkey for exact matches + self.rf_data_by_pubkey: dict[str, list[dict[str, Any]]] = {} # Index by pubkey for exact matches # Cache memory management self._max_rf_cache_size = 1000 # Maximum entries per cache @@ -75,8 +75,9 @@ class MessageHandler: self.logger.info(f"RF Data Correlation: timeout={self.rf_data_timeout}s, enhanced={self.enhanced_correlation}") @staticmethod - def _match_scope(transport_code: int, payload_type: int, pkt_payload: bytes, - scope_keys: dict[str, bytes]) -> str | None: + def _match_scope( + transport_code: int, payload_type: int, pkt_payload: bytes, scope_keys: dict[str, bytes] + ) -> str | None: """Return the scope name whose HMAC matches transport_code, or None. Mirrors the firmware's TransportKey::calcTransportCode: computes @@ -107,11 +108,11 @@ class MessageHandler: bool: True if message is from before connection, False otherwise. """ # If no connection time tracked, process all messages (backward compatibility) - if not hasattr(self.bot, 'connection_time') or self.bot.connection_time is None: + if not hasattr(self.bot, "connection_time") or self.bot.connection_time is None: return False # Handle invalid/unknown timestamps - process them (they might be current) - if timestamp is None or timestamp == 'unknown': + if timestamp is None or timestamp == "unknown": return False try: @@ -143,7 +144,8 @@ class MessageHandler: try: # Copy payload immediately to avoid segfault if event is freed import copy - payload = copy.deepcopy(event.payload) if hasattr(event, 'payload') else None + + payload = copy.deepcopy(event.payload) if hasattr(event, "payload") else None if payload is None: self.logger.warning("Contact message event has no payload") return @@ -153,28 +155,30 @@ class MessageHandler: self.logger.debug(f"Payload keys: {list(payload.keys())}") self.logger.debug(f"Event metadata: {event.metadata if hasattr(event, 'metadata') else 'None'}") - self.logger.info(f"Received DM from {sanitize_name(payload.get('pubkey_prefix', 'unknown'))}: {sanitize_name(payload.get('text', ''))}") + self.logger.info( + f"Received DM from {sanitize_name(payload.get('pubkey_prefix', 'unknown'))}: {sanitize_name(payload.get('text', ''))}" + ) # Extract path information from contacts using pubkey_prefix path_info = "Unknown" - path_len = payload.get('path_len', 255) + path_len = payload.get("path_len", 255) - if metadata and 'pubkey_prefix' in metadata: - pubkey_prefix = metadata.get('pubkey_prefix', '') + if metadata and "pubkey_prefix" in metadata: + pubkey_prefix = metadata.get("pubkey_prefix", "") if pubkey_prefix: self.logger.debug(f"Looking up path for pubkey_prefix: {pubkey_prefix}") # Look up the contact to get path information - if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + if hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(pubkey_prefix): - out_path = contact_data.get('out_path', '') - out_path_len = contact_data.get('out_path_len', -1) + if contact_data.get("public_key", "").startswith(pubkey_prefix): + out_path = contact_data.get("out_path", "") + out_path_len = contact_data.get("out_path_len", -1) if out_path and out_path_len > 0: # Chunk by bytes_per_hop (multi-byte path support); derive if not stored try: - bph = contact_data.get('out_bytes_per_hop') + bph = contact_data.get("out_bytes_per_hop") if bph is None and out_path_len > 0: byte_len = len(out_path) // 2 if byte_len > 0 and (byte_len % out_path_len) == 0: @@ -182,9 +186,14 @@ class MessageHandler: else: bph = 1 hex_chars = (bph or 1) * 2 - path_nodes = [out_path[i:i + hex_chars].lower() for i in range(0, len(out_path), hex_chars)] + path_nodes = [ + out_path[i : i + hex_chars].lower() + for i in range(0, len(out_path), hex_chars) + ] if (len(out_path) % hex_chars) != 0 or not path_nodes: - path_nodes = [out_path[i:i + 2].lower() for i in range(0, len(out_path), 2)] + path_nodes = [ + out_path[i : i + 2].lower() for i in range(0, len(out_path), 2) + ] path_info = f"{','.join(path_nodes)} ({out_path_len} hops)" self.logger.debug(f"Found path info: {path_info}") except Exception as e: @@ -214,34 +223,34 @@ class MessageHandler: routing_info = None # Look for raw packet data in recent RF data # Extract packet prefix from message raw_hex for correlation - message_raw_hex = payload.get('raw_hex', '') + message_raw_hex = payload.get("raw_hex", "") message_packet_prefix = message_raw_hex[:32] if message_raw_hex else None - message_pubkey = payload.get('pubkey_prefix', '') # Keep for contact lookup + message_pubkey = payload.get("pubkey_prefix", "") # Keep for contact lookup if message_packet_prefix: recent_rf_data = self.find_recent_rf_data(message_packet_prefix) elif message_pubkey: # Fallback to pubkey correlation if no raw_hex recent_rf_data = self.find_recent_rf_data(message_pubkey) - if recent_rf_data and recent_rf_data.get('raw_hex'): + if recent_rf_data and recent_rf_data.get("raw_hex"): # Use payload field if available, otherwise fall back to raw_hex - payload_hex = recent_rf_data.get('payload') - decoded_packet = self.decode_meshcore_packet(recent_rf_data['raw_hex'], payload_hex) + payload_hex = recent_rf_data.get("payload") + decoded_packet = self.decode_meshcore_packet(recent_rf_data["raw_hex"], payload_hex) if decoded_packet: self.logger.debug(f"Decoded packet for routing from RF data: {decoded_packet}") # Extract routing information - if recent_rf_data.get('routing_info'): - routing_info = recent_rf_data['routing_info'] + if recent_rf_data.get("routing_info"): + routing_info = recent_rf_data["routing_info"] self.logger.debug(f"Found routing info: {routing_info}") # If we have routing info, use it for path information if routing_info: - path_len = routing_info.get('path_length', 0) + path_len = routing_info.get("path_length", 0) if path_len > 0: - path_hex = routing_info.get('path_hex', '') - path_nodes = routing_info.get('path_nodes', []) - route_type = routing_info.get('route_type', 'Unknown') + path_hex = routing_info.get("path_hex", "") + path_nodes = routing_info.get("path_nodes", []) + route_type = routing_info.get("route_type", "Unknown") # Convert path to readable format if path_nodes: @@ -259,56 +268,56 @@ class MessageHandler: rssi: int | None = None # Try to get SNR from payload first - check multiple possible field names - if 'SNR' in payload: - _snr = payload.get('SNR') + if "SNR" in payload: + _snr = payload.get("SNR") snr = float(_snr) if _snr is not None else None - elif 'snr' in payload: - _snr = payload.get('snr') + elif "snr" in payload: + _snr = payload.get("snr") snr = float(_snr) if _snr is not None else None - elif 'signal_to_noise' in payload: - _snr = payload.get('signal_to_noise') + elif "signal_to_noise" in payload: + _snr = payload.get("signal_to_noise") snr = float(_snr) if _snr is not None else None - elif 'signal_noise_ratio' in payload: - _snr = payload.get('signal_noise_ratio') + elif "signal_noise_ratio" in payload: + _snr = payload.get("signal_noise_ratio") snr = float(_snr) if _snr is not None else None # Try to get SNR from event metadata if available elif metadata: - if 'snr' in metadata: - _snr = metadata.get('snr') + if "snr" in metadata: + _snr = metadata.get("snr") snr = float(_snr) if _snr is not None else None - elif 'SNR' in metadata: - _snr = metadata.get('SNR') + elif "SNR" in metadata: + _snr = metadata.get("SNR") snr = float(_snr) if _snr is not None else None # If still no SNR, try to get it from the cache using pubkey prefix from payload if snr is None: - pubkey_prefix = payload.get('pubkey_prefix', '') + pubkey_prefix = payload.get("pubkey_prefix", "") if pubkey_prefix and pubkey_prefix in self.snr_cache: snr = self.snr_cache[pubkey_prefix] self.logger.debug(f"Retrieved cached SNR {snr} for pubkey {pubkey_prefix}") # Try to get RSSI from payload first - if 'RSSI' in payload: - _rssi = payload.get('RSSI') + if "RSSI" in payload: + _rssi = payload.get("RSSI") rssi = int(_rssi) if _rssi is not None else None - elif 'rssi' in payload: - _rssi = payload.get('rssi') + elif "rssi" in payload: + _rssi = payload.get("rssi") rssi = int(_rssi) if _rssi is not None else None - elif 'signal_strength' in payload: - _rssi = payload.get('signal_strength') + elif "signal_strength" in payload: + _rssi = payload.get("signal_strength") rssi = int(_rssi) if _rssi is not None else None # Try to get RSSI from event metadata if available elif metadata: - if 'rssi' in metadata: - _rssi = metadata.get('rssi') + if "rssi" in metadata: + _rssi = metadata.get("rssi") rssi = int(_rssi) if _rssi is not None else None - elif 'RSSI' in metadata: - _rssi = metadata.get('RSSI') + elif "RSSI" in metadata: + _rssi = metadata.get("RSSI") rssi = int(_rssi) if _rssi is not None else None # If still no RSSI, try to get it from the cache using pubkey prefix from payload if rssi is None: - pubkey_prefix = payload.get('pubkey_prefix', '') + pubkey_prefix = payload.get("pubkey_prefix", "") if pubkey_prefix and pubkey_prefix in self.rssi_cache: rssi = int(self.rssi_cache[pubkey_prefix]) self.logger.debug(f"Retrieved cached RSSI {rssi} for pubkey {pubkey_prefix}") @@ -319,21 +328,21 @@ class MessageHandler: # DMs are encrypted with recipient's public key, so we can't decode the raw packet # But we can get SNR/RSSI from the message payload if available - if 'SNR' in payload: - _snr = payload.get('SNR') + if "SNR" in payload: + _snr = payload.get("SNR") snr = float(_snr) if _snr is not None else None self.logger.debug(f"Using SNR from DM payload: {snr}") - elif 'snr' in payload: - _snr = payload.get('snr') + elif "snr" in payload: + _snr = payload.get("snr") snr = float(_snr) if _snr is not None else None self.logger.debug(f"Using SNR from DM payload: {snr}") - if 'RSSI' in payload: - _rssi = payload.get('RSSI') + if "RSSI" in payload: + _rssi = payload.get("RSSI") rssi = int(_rssi) if _rssi is not None else None self.logger.debug(f"Using RSSI from DM payload: {rssi}") - elif 'rssi' in payload: - _rssi = payload.get('rssi') + elif "rssi" in payload: + _rssi = payload.get("rssi") rssi = int(_rssi) if _rssi is not None else None self.logger.debug(f"Using RSSI from DM payload: {rssi}") @@ -344,53 +353,53 @@ class MessageHandler: if recent_rf_data: self.logger.debug(f"Found recent RF data for DM: {recent_rf_data}") - if snr is None and recent_rf_data.get('snr') is not None: - snr = float(recent_rf_data['snr']) + if snr is None and recent_rf_data.get("snr") is not None: + snr = float(recent_rf_data["snr"]) self.logger.debug(f"Using SNR from recent RF data: {snr}") - if rssi is None and recent_rf_data.get('rssi') is not None: - rssi = int(recent_rf_data['rssi']) + if rssi is None and recent_rf_data.get("rssi") is not None: + rssi = int(recent_rf_data["rssi"]) self.logger.debug(f"Using RSSI from recent RF data: {rssi}") # For DMs, we can't determine the actual routing path from encrypted data # Use the path_len from the payload (255 means unknown/direct) - path_len = payload.get('path_len', 255) + path_len = payload.get("path_len", 255) path_info = "Direct (0 hops)" if path_len == 255 else f"Routed through {path_len} hops" self.logger.debug(f"DM path info: {path_info}") - timestamp = payload.get('sender_timestamp', 'unknown') + timestamp = payload.get("sender_timestamp", "unknown") # Look up contact name from pubkey prefix - sender_id = sanitize_name(payload.get('pubkey_prefix', '')) + sender_id = sanitize_name(payload.get("pubkey_prefix", "")) sender_name = sender_id # Default to sender_id - if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + if hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(sender_id): + if contact_data.get("public_key", "").startswith(sender_id): # Use the contact name if available, otherwise use adv_name - contact_name = sanitize_name(contact_data.get('name', contact_data.get('adv_name', sender_id))) + contact_name = sanitize_name(contact_data.get("name", contact_data.get("adv_name", sender_id))) sender_name = contact_name break # Get the full public key from contacts if available sender_pubkey = sender_id # Default to pubkey prefix (same value as sender_id at this point) - if sender_id and hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + if sender_id and hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(sender_id): + if contact_data.get("public_key", "").startswith(sender_id): # Use the full public key from the contact - sender_pubkey = contact_data.get('public_key', sender_id) + sender_pubkey = contact_data.get("public_key", sender_id) self.logger.debug(f"Found full public key for {sender_name}: {sender_pubkey[:16]}...") break # Sanitize message content to prevent injection attacks # Note: Firmware enforces 150-char limit at hardware level, so we disable length check # but still strip control characters for security - message_content = payload.get('text', '') + message_content = payload.get("text", "") message_content = sanitize_input(message_content, max_length=None, strip_controls=True) # Elapsed: "Nms" when device clock is valid, or "Sync Device Clock" when # invalid (e.g. T-Deck before GPS sync: 0, future, or far in the past). - translator = getattr(self.bot, 'translator', None) + translator = getattr(self.bot, "translator", None) elapsed_str = format_elapsed_display(timestamp, translator) # Convert to our message format @@ -404,7 +413,7 @@ class MessageHandler: rssi=rssi, elapsed=elapsed_str, hops=path_len if path_len != 255 else 0, - path=path_info + path=path_info, ) # Always decode and log path information for debugging (regardless of keywords) @@ -417,12 +426,12 @@ class MessageHandler: recent_rf_data = self.find_recent_rf_data() # If we have RF data with routing information, update the path with that instead - if recent_rf_data and recent_rf_data.get('routing_info'): - rf_routing = recent_rf_data['routing_info'] + if recent_rf_data and recent_rf_data.get("routing_info"): + rf_routing = recent_rf_data["routing_info"] message.routing_info = rf_routing # Path command uses this for multi-byte path (no re-parse) - if rf_routing.get('path_length', 0) > 0: - path_nodes = rf_routing.get('path_nodes', []) - route_type = rf_routing.get('route_type', 'Unknown') + if rf_routing.get("path_length", 0) > 0: + path_nodes = rf_routing.get("path_nodes", []) + route_type = rf_routing.get("route_type", "Unknown") if path_nodes: message.path = f"{','.join(path_nodes)} ({len(path_nodes)} hops via {route_type})" self.logger.info(f"🛣️ CONTACT USING RF ROUTING: {message.path}") @@ -440,7 +449,9 @@ class MessageHandler: # Check if this is an old cached message from before bot connection if self._is_old_cached_message(timestamp): - self.logger.debug(f"Skipping old cached message from {sender_name} (timestamp: {timestamp}, connection: {self.bot.connection_time})") + self.logger.debug( + f"Skipping old cached message from {sender_name} (timestamp: {timestamp}, connection: {self.bot.connection_time})" + ) return # Read the message to clear cache, but don't process it await self.process_message(message) @@ -461,7 +472,7 @@ class MessageHandler: try: # Copy payload immediately to avoid segfault if event is freed # Make a deep copy to ensure we have all the data we need - payload = copy.deepcopy(event.payload) if hasattr(event, 'payload') else None + payload = copy.deepcopy(event.payload) if hasattr(event, "payload") else None if payload is None: self.logger.warning("RAW_DATA event has no payload") return @@ -471,15 +482,15 @@ class MessageHandler: self.logger.info(f"📦 Metadata: {metadata}") # This should contain the full packet data we need - if hasattr(payload, 'data') or 'data' in payload: - raw_data = payload.get('data', payload.data if hasattr(payload, 'data') else None) + if hasattr(payload, "data") or "data" in payload: + raw_data = payload.get("data", payload.data if hasattr(payload, "data") else None) if raw_data: self.logger.info(f"🔍 FULL PACKET DATA: {raw_data}") # Try to decode this as a MeshCore packet if isinstance(raw_data, str): # Convert to hex if it's not already - if not raw_data.startswith('0x'): + if not raw_data.startswith("0x"): raw_hex = raw_data else: raw_hex = raw_data[2:] # Remove 0x prefix @@ -503,9 +514,12 @@ class MessageHandler: except Exception as e: self.logger.error(f"Error handling raw data event: {e}") import traceback + self.logger.error(traceback.format_exc()) - async def _process_advertisement_packet(self, packet_info: dict[str, Any], metadata: dict[str, Any] | None = None) -> None: + async def _process_advertisement_packet( + self, packet_info: dict[str, Any], metadata: dict[str, Any] | None = None + ) -> None: """Process advertisement packets for complete repeater tracking. Extracts node information, location data, and routing path from @@ -517,36 +531,40 @@ class MessageHandler: """ try: # Check if this is an advertisement packet - if (packet_info.get('payload_type') == 'ADVERT' or - packet_info.get('payload_type_name') == 'ADVERT' or - packet_info.get('type') == 'advert'): + if ( + packet_info.get("payload_type") == "ADVERT" + or packet_info.get("payload_type_name") == "ADVERT" + or packet_info.get("type") == "advert" + ): self.logger.debug(f"Processing advertisement packet: {packet_info}") # Parse the advert payload if we have it advert_data = {} - if 'payload_hex' in packet_info: + if "payload_hex" in packet_info: try: - payload_bytes = bytes.fromhex(packet_info['payload_hex']) + payload_bytes = bytes.fromhex(packet_info["payload_hex"]) parsed_advert = self.parse_advert(payload_bytes) if parsed_advert: advert_data = parsed_advert - self.logger.info(f"✅ Parsed ADVERT: {sanitize_name(advert_data.get('mode', 'Unknown'))} - {sanitize_name(advert_data.get('name', 'No name'))}") + self.logger.info( + f"✅ Parsed ADVERT: {sanitize_name(advert_data.get('mode', 'Unknown'))} - {sanitize_name(advert_data.get('name', 'No name'))}" + ) except Exception as e: self.logger.warning(f"Failed to parse ADVERT payload: {e}") # Fallback to basic information if parsing failed if not advert_data: advert_data = { - 'public_key': packet_info.get('sender_id', ''), - 'name': packet_info.get('name', packet_info.get('adv_name', 'Unknown')), - 'mode': 'Unknown' + "public_key": packet_info.get("sender_id", ""), + "name": packet_info.get("name", packet_info.get("adv_name", "Unknown")), + "mode": "Unknown", } # Add advert data to packet_info for web viewer if advert_data: - packet_info['advert_name'] = advert_data.get('name') - packet_info['advert_mode'] = advert_data.get('mode') - packet_info['advert_public_key'] = advert_data.get('public_key') + packet_info["advert_name"] = advert_data.get("name") + packet_info["advert_mode"] = advert_data.get("mode") + packet_info["advert_public_key"] = advert_data.get("public_key") # Extract signal information from metadata signal_info = {} @@ -554,85 +572,96 @@ class MessageHandler: signal_info.update(metadata) # Add hop count if available - if 'hops' in packet_info: - signal_info['hops'] = packet_info['hops'] + if "hops" in packet_info: + signal_info["hops"] = packet_info["hops"] # Extract packet_hash and path information if available (from routing_info or packet_info) packet_hash = None - out_path = '' + out_path = "" out_path_len = -1 - if 'routing_info' in packet_info and packet_info['routing_info']: - routing_info = packet_info['routing_info'] - packet_hash = routing_info.get('packet_hash') + if "routing_info" in packet_info and packet_info["routing_info"]: + routing_info = packet_info["routing_info"] + packet_hash = routing_info.get("packet_hash") # Extract path information from routing_info - path_hex = routing_info.get('path_hex', '') - path_length = routing_info.get('path_length', 0) + path_hex = routing_info.get("path_hex", "") + path_length = routing_info.get("path_length", 0) if path_hex and path_length > 0: out_path = path_hex out_path_len = path_length elif path_length == 0: # Direct connection - out_path = '' + out_path = "" out_path_len = 0 - elif 'packet_hash' in packet_info: - packet_hash = packet_info['packet_hash'] + elif "packet_hash" in packet_info: + packet_hash = packet_info["packet_hash"] # Also check packet_info directly for path information (fallback) if out_path_len == -1: - if 'path_hex' in packet_info: - out_path = packet_info.get('path_hex', '') - out_path_len = packet_info.get('path_len', -1) - elif 'path_len' in packet_info: - out_path_len = packet_info.get('path_len', -1) + if "path_hex" in packet_info: + out_path = packet_info.get("path_hex", "") + out_path_len = packet_info.get("path_len", -1) + elif "path_len" in packet_info: + out_path_len = packet_info.get("path_len", -1) if out_path_len == 0: - out_path = '' + out_path = "" # Add path information to advert_data so it gets saved to the database if out_path_len >= 0: - advert_data['out_path'] = out_path - advert_data['out_path_len'] = out_path_len - advert_data['out_bytes_per_hop'] = packet_info.get('bytes_per_hop', 1) + advert_data["out_path"] = out_path + advert_data["out_path_len"] = out_path_len + advert_data["out_bytes_per_hop"] = packet_info.get("bytes_per_hop", 1) # Update mesh graph with edges from the advert path (one edge per hop). # This can trigger many send_mesh_edge_update() calls in quick succession; # if the web viewer is down, that produces a wave of connection-refused logs. - path_byte_length = packet_info.get('path_byte_length') or (len(out_path) // 2 if out_path else 0) - if (out_path and out_path_len > 0 - and hasattr(self.bot, 'mesh_graph') and self.bot.mesh_graph - and self.bot.mesh_graph.capture_enabled): + path_byte_length = packet_info.get("path_byte_length") or (len(out_path) // 2 if out_path else 0) + if ( + out_path + and out_path_len > 0 + and hasattr(self.bot, "mesh_graph") + and self.bot.mesh_graph + and self.bot.mesh_graph.capture_enabled + ): self._update_mesh_graph_from_advert(advert_data, out_path, path_byte_length, packet_info) # Store complete path in observed_paths table if out_path and out_path_len > 0: - self._store_observed_path(advert_data, out_path, path_byte_length, 'advert', packet_hash=packet_hash, bytes_per_hop=packet_info.get('bytes_per_hop', 1)) + self._store_observed_path( + advert_data, + out_path, + path_byte_length, + "advert", + packet_hash=packet_hash, + bytes_per_hop=packet_info.get("bytes_per_hop", 1), + ) # Track this advertisement in the complete database - if hasattr(self.bot, 'repeater_manager'): + if hasattr(self.bot, "repeater_manager"): # Track all advertisements regardless of type success = await self.bot.repeater_manager.track_contact_advertisement( advert_data, signal_info, packet_hash=packet_hash ) if success: # Log rich advert information - mode = advert_data.get('mode', 'Unknown') - name = advert_data.get('name', 'No name') + mode = advert_data.get("mode", "Unknown") + name = advert_data.get("name", "No name") location = "" - if 'lat' in advert_data and 'lon' in advert_data: + if "lat" in advert_data and "lon" in advert_data: # Try to get resolved location from database if available try: - if hasattr(self.bot, 'repeater_manager'): + if hasattr(self.bot, "repeater_manager"): # Look up the contact to get resolved location - public_key = advert_data.get('public_key') + public_key = advert_data.get("public_key") if public_key: contact_query = self.bot.db_manager.execute_query( - 'SELECT city, state, country FROM complete_contact_tracking WHERE public_key = ?', - (public_key,) + "SELECT city, state, country FROM complete_contact_tracking WHERE public_key = ?", + (public_key,), ) if contact_query: contact = contact_query[0] - city = contact.get('city') - state = contact.get('state') + city = contact.get("city") + state = contact.get("state") if city and state: location = f" at {city}, {state}" elif city: @@ -655,12 +684,14 @@ class MessageHandler: location = f" at {advert_data['lat']:.4f},{advert_data['lon']:.4f}" # Show hop count in log - hop_count = signal_info.get('hops', 0) + hop_count = signal_info.get("hops", 0) hop_info = f" ({hop_count} hop{'s' if hop_count != 1 else ''})" if hop_count is not None else "" self.logger.info(f"📡 Tracked {mode}: {name}{location}{hop_info}") else: - self.logger.warning(f"Failed to track contact advertisement: {sanitize_name(advert_data.get('name', 'Unknown'))}") + self.logger.warning( + f"Failed to track contact advertisement: {sanitize_name(advert_data.get('name', 'Unknown'))}" + ) except Exception as e: self.logger.error(f"Error processing advertisement packet: {e}") @@ -678,17 +709,18 @@ class MessageHandler: try: # Copy payload immediately to avoid segfault if event is freed import copy - payload = copy.deepcopy(event.payload) if hasattr(event, 'payload') else None + + payload = copy.deepcopy(event.payload) if hasattr(event, "payload") else None if payload is None: self.logger.warning("RF log data event has no payload") return # Extract SNR from payload - if 'snr' in payload: - snr_value = payload.get('snr') + if "snr" in payload: + snr_value = payload.get("snr") # Use raw_hex prefix for correlation instead of trying to extract pubkey - raw_hex = payload.get('raw_hex', '') + raw_hex = payload.get("raw_hex", "") packet_prefix = None if raw_hex: @@ -699,8 +731,8 @@ class MessageHandler: # Keep pubkey_prefix for contact lookup (from metadata if available) pubkey_prefix = None - if metadata and 'pubkey_prefix' in metadata: - pubkey_prefix = metadata.get('pubkey_prefix') + if metadata and "pubkey_prefix" in metadata: + pubkey_prefix = metadata.get("pubkey_prefix") if isinstance(pubkey_prefix, str): self.logger.debug(f"Got pubkey_prefix from metadata: {pubkey_prefix[:16]}...") @@ -713,8 +745,8 @@ class MessageHandler: self.logger.debug(f"Cached SNR {snr_value} for packet prefix {packet_prefix}") # Extract and cache RSSI if available - if 'rssi' in payload: - rssi_value = payload.get('rssi') + if "rssi" in payload: + rssi_value = payload.get("rssi") if packet_prefix and rssi_value is not None: # Cache the RSSI value for this packet prefix (LRU-bounded) self.rssi_cache[packet_prefix] = rssi_value @@ -726,12 +758,13 @@ class MessageHandler: # Store recent RF data with timestamp for SNR/RSSI matching only if packet_prefix: import time + current_time = time.time() # Store both raw packet data and extracted payload for analysis - raw_hex = payload.get('raw_hex', '') - extracted_payload = payload.get('payload', '') - payload_length = payload.get('payload_length', 0) + raw_hex = payload.get("raw_hex", "") + extracted_payload = payload.get("payload", "") + payload_length = payload.get("payload_length", 0) # Extract routing information from raw packet if available routing_info = None @@ -744,50 +777,54 @@ class MessageHandler: # Use extracted_payload if available (actual MeshCore packet), otherwise use raw_hex # This matches the logic in decode_meshcore_packet which prefers extracted_payload # extracted_payload is the actual MeshCore packet without RF wrapper, so use it if available - packet_hex_for_hash = extracted_payload if (extracted_payload and len(extracted_payload) > 0) else raw_hex + packet_hex_for_hash = ( + extracted_payload if (extracted_payload and len(extracted_payload) > 0) else raw_hex + ) # Ensure we use the numeric payload_type value (not enum or string) - payload_type_value = decoded_packet.get('payload_type', None) + payload_type_value = decoded_packet.get("payload_type", None) if payload_type_value is not None: # Handle enum.value if it's an enum - if hasattr(payload_type_value, 'value'): + if hasattr(payload_type_value, "value"): payload_type_value = payload_type_value.value payload_type_value = int(payload_type_value) packet_hash = calculate_packet_hash(packet_hex_for_hash, payload_type_value) - is_trace = decoded_packet.get('payload_type') == PayloadType.TRACE.value + is_trace = decoded_packet.get("payload_type") == PayloadType.TRACE.value # Check if this is a repeat of one of our transmissions - if (hasattr(self.bot, 'transmission_tracker') and - self.bot.transmission_tracker and - packet_hash and packet_hash != "0000000000000000"): - + if ( + hasattr(self.bot, "transmission_tracker") + and self.bot.transmission_tracker + and packet_hash + and packet_hash != "0000000000000000" + ): # TRACE: RF path bytes are per-hop SNR×4, not repeater hashes — do not # extract prefixes or record repeats from them. if not is_trace: # Extract repeater prefixes from path - try multiple field names # decode_meshcore_packet returns 'path' not 'path_nodes' - path_nodes = decoded_packet.get('path', []) + path_nodes = decoded_packet.get("path", []) # Also try 'path_nodes' field (from routing_info) if not path_nodes: - path_nodes = decoded_packet.get('path_nodes', []) + path_nodes = decoded_packet.get("path_nodes", []) - path_hex = decoded_packet.get('path_hex', '') + path_hex = decoded_packet.get("path_hex", "") # If we don't have path_nodes but have path_hex, convert it if not path_nodes and path_hex and len(path_hex) >= 2: path_nodes = self._path_hex_to_nodes(path_hex) - path_string = ','.join(path_nodes) if path_nodes else None + path_string = ",".join(path_nodes) if path_nodes else None # Debug logging if path_nodes: - self.logger.debug(f"📡 Extracting prefixes from path_nodes: {path_nodes}, path_hex: {path_hex}, bot_prefix: {self.bot.transmission_tracker.bot_prefix}") + self.logger.debug( + f"📡 Extracting prefixes from path_nodes: {path_nodes}, path_hex: {path_hex}, bot_prefix: {self.bot.transmission_tracker.bot_prefix}" + ) # Try to match this packet hash to a transmission - record = self.bot.transmission_tracker.match_packet_hash( - packet_hash, current_time - ) + record = self.bot.transmission_tracker.match_packet_hash(packet_hash, current_time) if record: # This is one of our transmissions - check for repeats @@ -799,9 +836,13 @@ class MessageHandler: # Log for debugging if prefixes: - self.logger.info(f"📡 Found {len(prefixes)} repeater prefix(es) in repeat: {', '.join(prefixes)}") + self.logger.info( + f"📡 Found {len(prefixes)} repeater prefix(es) in repeat: {', '.join(prefixes)}" + ) elif path_nodes or path_hex: - self.logger.debug(f"📡 Repeat detected but no repeater prefixes extracted (path_nodes: {path_nodes}, path_hex: {path_hex}, bot_prefix: {self.bot.transmission_tracker.bot_prefix})") + self.logger.debug( + f"📡 Repeat detected but no repeater prefixes extracted (path_nodes: {path_nodes}, path_hex: {path_hex}, bot_prefix: {self.bot.transmission_tracker.bot_prefix})" + ) # Record the repeat for prefix in prefixes: @@ -813,74 +854,87 @@ class MessageHandler: # Still count as a repeat (heard by our radio) self.bot.transmission_tracker.record_repeat(packet_hash, None) else: - record = self.bot.transmission_tracker.match_packet_hash( - packet_hash, current_time - ) + record = self.bot.transmission_tracker.match_packet_hash(packet_hash, current_time) if record: self.logger.debug( "📡 TRACE packet matched our transmission; skipping repeater prefix " "extraction (RF path holds SNR bytes, not node hashes)" ) - pi = decoded_packet.get('path_info') or {} - trace_route_hashes = list(pi.get('path_hashes') or pi.get('path') or []) - trace_snr_db = list(pi.get('snr_data') or []) + pi = decoded_packet.get("path_info") or {} + trace_route_hashes = list(pi.get("path_hashes") or pi.get("path") or []) + trace_snr_db = list(pi.get("snr_data") or []) if is_trace: routing_info = { - 'path_length': len(trace_route_hashes) if trace_route_hashes else decoded_packet.get('path_len', 0), - 'path_len_byte': decoded_packet.get('path_len_byte'), - 'path_byte_length': decoded_packet.get('path_byte_length'), - 'bytes_per_hop': decoded_packet.get('bytes_per_hop', 1), - 'path_hex': decoded_packet.get('path_hex', ''), - 'path_nodes': trace_route_hashes, - 'trace_route_hashes': trace_route_hashes, - 'trace_snr_db': trace_snr_db, - 'trace_snr_path_hex': decoded_packet.get('path_hex', ''), - 'route_type': decoded_packet.get('route_type_name', 'Unknown'), - 'payload_length': payload_length, - 'payload_type': decoded_packet.get('payload_type_name', 'Unknown'), - 'packet_hash': packet_hash, + "path_length": len(trace_route_hashes) + if trace_route_hashes + else decoded_packet.get("path_len", 0), + "path_len_byte": decoded_packet.get("path_len_byte"), + "path_byte_length": decoded_packet.get("path_byte_length"), + "bytes_per_hop": decoded_packet.get("bytes_per_hop", 1), + "path_hex": decoded_packet.get("path_hex", ""), + "path_nodes": trace_route_hashes, + "trace_route_hashes": trace_route_hashes, + "trace_snr_db": trace_snr_db, + "trace_snr_path_hex": decoded_packet.get("path_hex", ""), + "route_type": decoded_packet.get("route_type_name", "Unknown"), + "payload_length": payload_length, + "payload_type": decoded_packet.get("payload_type_name", "Unknown"), + "packet_hash": packet_hash, } else: routing_info = { - 'path_length': decoded_packet.get('path_len', 0), - 'path_len_byte': decoded_packet.get('path_len_byte'), - 'path_byte_length': decoded_packet.get('path_byte_length'), - 'bytes_per_hop': decoded_packet.get('bytes_per_hop', 1), - 'path_hex': decoded_packet.get('path_hex', ''), - 'path_nodes': decoded_packet.get('path', []), - 'route_type': decoded_packet.get('route_type_name', 'Unknown'), - 'payload_length': payload_length, - 'payload_type': decoded_packet.get('payload_type_name', 'Unknown'), - 'packet_hash': packet_hash, + "path_length": decoded_packet.get("path_len", 0), + "path_len_byte": decoded_packet.get("path_len_byte"), + "path_byte_length": decoded_packet.get("path_byte_length"), + "bytes_per_hop": decoded_packet.get("bytes_per_hop", 1), + "path_hex": decoded_packet.get("path_hex", ""), + "path_nodes": decoded_packet.get("path", []), + "route_type": decoded_packet.get("route_type_name", "Unknown"), + "payload_length": payload_length, + "payload_type": decoded_packet.get("payload_type_name", "Unknown"), + "packet_hash": packet_hash, } # Validate path consistency (path_byte_length, path_hex, path_nodes, bytes_per_hop) if not is_trace: - path_len = routing_info['path_length'] - path_byte_len = routing_info.get('path_byte_length') - path_hex_str = routing_info.get('path_hex', '') - path_nodes_list = routing_info.get('path_nodes') or [] - bph = routing_info.get('bytes_per_hop', 1) or 1 - expected_hex_len = (path_byte_len * 2) if path_byte_len is not None else (path_len * bph * 2) + path_len = routing_info["path_length"] + path_byte_len = routing_info.get("path_byte_length") + path_hex_str = routing_info.get("path_hex", "") + path_nodes_list = routing_info.get("path_nodes") or [] + bph = routing_info.get("bytes_per_hop", 1) or 1 + expected_hex_len = ( + (path_byte_len * 2) if path_byte_len is not None else (path_len * bph * 2) + ) if path_len > 0 and path_hex_str: if len(path_hex_str) != expected_hex_len: self.logger.warning( "Path length mismatch: path_hex has %d hex chars, expected %d (path_byte_length=%s, path_length=%s, bytes_per_hop=%s)", - len(path_hex_str), expected_hex_len, path_byte_len, path_len, bph + len(path_hex_str), + expected_hex_len, + path_byte_len, + path_len, + bph, ) if path_nodes_list and len(path_nodes_list) != path_len: self.logger.warning( "Path nodes count mismatch: %d nodes, path_length=%d", - len(path_nodes_list), path_len + len(path_nodes_list), + path_len, ) - if path_nodes_list and bph >= 1 and any(len(str(n)) != bph * 2 for n in path_nodes_list): + if ( + path_nodes_list + and bph >= 1 + and any(len(str(n)) != bph * 2 for n in path_nodes_list) + ): self.logger.warning( "Path node width mismatch: bytes_per_hop=%d expects %d hex chars per node, nodes=%s", - bph, bph * 2, path_nodes_list[:5] + bph, + bph * 2, + path_nodes_list[:5], ) # Log the routing information for analysis - rf_path_bytes = decoded_packet.get('path_byte_length') or 0 + rf_path_bytes = decoded_packet.get("path_byte_length") or 0 trace_has_route = bool(trace_route_hashes) trace_has_snr_path = rf_path_bytes > 0 @@ -894,28 +948,30 @@ class MessageHandler: if trace_snr_db: snr_fmt = ",".join(f"{v:.2f}" for v in trace_snr_db) snr_part = f" | Trace SNR (dB): {snr_fmt}" - elif routing_info.get('trace_snr_path_hex'): + elif routing_info.get("trace_snr_path_hex"): snr_part = ( f" | Trace SNR path (raw hex, int8×4 per hop): " f"{routing_info['trace_snr_path_hex']}" ) - hops_display = len(trace_route_hashes) if trace_route_hashes else decoded_packet.get('path_len', 0) + hops_display = ( + len(trace_route_hashes) if trace_route_hashes else decoded_packet.get("path_len", 0) + ) log_message = ( f"🛣️ ROUTING INFO: {routing_info['route_type']} | {route_part}{snr_part} " f"({hops_display} route hops, {rf_path_bytes} RF path bytes) | " f"Payload: {routing_info['payload_length']} bytes | Type: {routing_info['payload_type']}" ) self.logger.info(log_message) - elif routing_info['path_length'] > 0: + elif routing_info["path_length"] > 0: # Use path_nodes when present (multi-byte); else chunk path_hex - path_nodes_list = routing_info.get('path_nodes') or [] + path_nodes_list = routing_info.get("path_nodes") or [] if path_nodes_list: - formatted_path = ','.join(str(n).lower() for n in path_nodes_list) + formatted_path = ",".join(str(n).lower() for n in path_nodes_list) else: - path_hex = routing_info['path_hex'] + path_hex = routing_info["path_hex"] path_nodes_fmt = self._path_hex_to_nodes(path_hex) - formatted_path = ','.join(path_nodes_fmt) - path_bytes_str = decoded_packet.get('path_byte_length', routing_info['path_length']) + formatted_path = ",".join(path_nodes_fmt) + path_bytes_str = decoded_packet.get("path_byte_length", routing_info["path_length"]) log_message = f"🛣️ ROUTING INFO: {routing_info['route_type']} | Path: {formatted_path} ({routing_info['path_length']} hops, {path_bytes_str} bytes) | Payload: {routing_info['payload_length']} bytes | Type: {routing_info['payload_type']}" self.logger.info(log_message) else: @@ -923,38 +979,40 @@ class MessageHandler: self.logger.info(log_message) # Capture full packet data for web viewer (for all packets) - if (hasattr(self.bot, 'web_viewer_integration') and - self.bot.web_viewer_integration and - self.bot.web_viewer_integration.bot_integration): - decoded_packet['routing_info'] = routing_info + if ( + hasattr(self.bot, "web_viewer_integration") + and self.bot.web_viewer_integration + and self.bot.web_viewer_integration.bot_integration + ): + decoded_packet["routing_info"] = routing_info if is_trace and trace_route_hashes: - decoded_packet['path'] = list(trace_route_hashes) - decoded_packet['path_len'] = len(trace_route_hashes) + decoded_packet["path"] = list(trace_route_hashes) + decoded_packet["path_len"] = len(trace_route_hashes) # Use extracted_payload which is the full MeshCore packet # (header + path_len + path + payload, without RF wrapper) - decoded_packet['raw_packet_hex'] = extracted_payload if extracted_payload else raw_hex - decoded_packet['packet_hash'] = packet_hash + decoded_packet["raw_packet_hex"] = extracted_payload if extracted_payload else raw_hex + decoded_packet["packet_hash"] = packet_hash self.bot.web_viewer_integration.bot_integration.capture_full_packet_data(decoded_packet) # Process ADVERT packets for contact tracking (regardless of path length) - if routing_info['payload_type'] == 'ADVERT': + if routing_info["payload_type"] == "ADVERT": # Add routing_info to decoded_packet so it's available in _process_advertisement_packet - decoded_packet['routing_info'] = routing_info + decoded_packet["routing_info"] = routing_info # Create signal info from available data signal_info = { - 'snr': snr_value, - 'rssi': payload.get('rssi') if 'rssi' in payload else None, - 'hops': routing_info['path_length'] + "snr": snr_value, + "rssi": payload.get("rssi") if "rssi" in payload else None, + "hops": routing_info["path_length"], } await self._process_advertisement_packet(decoded_packet, signal_info) # Prefer library-provided scope fields (already parsed by meshcore-py). # The library's parsePacketPayload populates these directly from the # inner MeshCore packet, avoiding any raw_hex prefix/offset issues. - _lib_route_type = payload.get('route_type') # int: 0=TC_FLOOD, 1=FLOOD - _lib_tc_hex = payload.get('transport_code') # hex str e.g. "26f10000" - _lib_payload_type = payload.get('payload_type') # int - _lib_pkt_payload = payload.get('pkt_payload') # bytes after path + _lib_route_type = payload.get("route_type") # int: 0=TC_FLOOD, 1=FLOOD + _lib_tc_hex = payload.get("transport_code") # hex str e.g. "26f10000" + _lib_payload_type = payload.get("payload_type") # int + _lib_pkt_payload = payload.get("pkt_payload") # bytes after path # Compute transport code1 (uint16 LE) from library hex string _lib_tc_code1 = None @@ -972,27 +1030,36 @@ class MessageHandler: _lib_pkt_hex = _lib_pkt_payload rf_data = { - 'timestamp': current_time, - 'packet_prefix': packet_prefix, # Use packet prefix for correlation - 'pubkey_prefix': pubkey_prefix, # Keep for contact lookup - 'snr': snr_value, - 'rssi': payload.get('rssi') if 'rssi' in payload else None, - 'raw_hex': raw_hex, # Full packet data - 'payload': extracted_payload, # Extracted payload - 'payload_length': payload_length, # Payload length - 'routing_info': routing_info, # Extracted routing information - 'packet_hash': packet_hash, # Packet hash for tracking same message via different paths + "timestamp": current_time, + "packet_prefix": packet_prefix, # Use packet prefix for correlation + "pubkey_prefix": pubkey_prefix, # Keep for contact lookup + "snr": snr_value, + "rssi": payload.get("rssi") if "rssi" in payload else None, + "raw_hex": raw_hex, # Full packet data + "payload": extracted_payload, # Extracted payload + "payload_length": payload_length, # Payload length + "routing_info": routing_info, # Extracted routing information + "packet_hash": packet_hash, # Packet hash for tracking same message via different paths # Fields for TC_FLOOD scope matching — use library values first, decoded_packet as fallback - 'route_type_int': _lib_route_type if _lib_route_type is not None else (decoded_packet.get('route_type') if decoded_packet else None), - 'transport_code1': _lib_tc_code1 if _lib_tc_code1 is not None else ((decoded_packet.get('transport_codes') or {}).get('code1') if decoded_packet else None), - 'payload_type_int': _lib_payload_type if _lib_payload_type is not None else (decoded_packet.get('payload_type') if decoded_packet else None), - 'scope_payload_hex': _lib_pkt_hex if _lib_pkt_hex else (decoded_packet.get('payload_hex') if decoded_packet else None), + "route_type_int": _lib_route_type + if _lib_route_type is not None + else (decoded_packet.get("route_type") if decoded_packet else None), + "transport_code1": _lib_tc_code1 + if _lib_tc_code1 is not None + else ((decoded_packet.get("transport_codes") or {}).get("code1") if decoded_packet else None), + "payload_type_int": _lib_payload_type + if _lib_payload_type is not None + else (decoded_packet.get("payload_type") if decoded_packet else None), + "scope_payload_hex": _lib_pkt_hex + if _lib_pkt_hex + else (decoded_packet.get("payload_hex") if decoded_packet else None), } - if rf_data.get('route_type_int') == 0: + if rf_data.get("route_type_int") == 0: self.logger.debug( "TC_FLOOD scope fields: tc_code1=%s payload_type=%s payload_hex_prefix=%s", - rf_data.get('transport_code1'), rf_data.get('payload_type_int'), - (rf_data.get('scope_payload_hex') or '')[:16], + rf_data.get("transport_code1"), + rf_data.get("payload_type_int"), + (rf_data.get("scope_payload_hex") or "")[:16], ) self.recent_rf_data.append(rf_data) @@ -1049,7 +1116,7 @@ class MessageHandler: # Try different positions where path might be located, but avoid header area path_positions = [ - (8, 14), # Position 8-13 (3 bytes) + (8, 14), # Position 8-13 (3 bytes) (10, 16), # Position 10-15 (3 bytes) (12, 18), # Position 12-17 (3 bytes) (14, 20), # Position 14-19 (3 bytes) @@ -1063,23 +1130,23 @@ class MessageHandler: path_nodes = [] for i in range(0, len(path_hex), 2): if i + 1 < len(path_hex): - node_hex = path_hex[i:i+2] + node_hex = path_hex[i : i + 2] path_nodes.append(node_hex) if len(path_nodes) == expected_hops: - path_string = ','.join(path_nodes) + path_string = ",".join(path_nodes) self.logger.debug(f"Found path at position {start}-{end}: {path_string}") return path_string # If no exact match, try to find any 3-byte pattern that looks like a path # But skip the header area for i in range(min_start, len(raw_hex) - 6, 2): - path_hex = raw_hex[i:i+6] + path_hex = raw_hex[i : i + 6] if len(path_hex) == 6: # Check if this looks like a valid path (all hex chars) - if all(c in '0123456789abcdef' for c in path_hex.lower()): - path_nodes = [path_hex[j:j+2] for j in range(0, 6, 2)] - path_string = ','.join(path_nodes) + if all(c in "0123456789abcdef" for c in path_hex.lower()): + path_nodes = [path_hex[j : j + 2] for j in range(0, 6, 2)] + path_string = ",".join(path_nodes) self.logger.debug(f"Found potential path at position {i}: {path_string}") return path_string @@ -1104,21 +1171,24 @@ class MessageHandler: cutoff_time = current_time - self.rf_data_timeout # Clean timestamp-indexed cache (timeout only) - stale_timestamps = [ts for ts in self.rf_data_by_timestamp - if ts < cutoff_time] + stale_timestamps = [ts for ts in self.rf_data_by_timestamp if ts < cutoff_time] for ts in stale_timestamps: del self.rf_data_by_timestamp[ts] # Clean pubkey-indexed cache (timeout only) for pubkey in list(self.rf_data_by_pubkey.keys()): - self.rf_data_by_pubkey[pubkey] = [data for data in self.rf_data_by_pubkey[pubkey] - if current_time - data['timestamp'] < self.rf_data_timeout] + self.rf_data_by_pubkey[pubkey] = [ + data + for data in self.rf_data_by_pubkey[pubkey] + if current_time - data["timestamp"] < self.rf_data_timeout + ] if not self.rf_data_by_pubkey[pubkey]: del self.rf_data_by_pubkey[pubkey] # Clean recent_rf_data list (timeout only) - self.recent_rf_data = [data for data in self.recent_rf_data - if current_time - data['timestamp'] < self.rf_data_timeout] + self.recent_rf_data = [ + data for data in self.recent_rf_data if current_time - data["timestamp"] < self.rf_data_timeout + ] return # Full cleanup with size enforcement @@ -1126,22 +1196,24 @@ class MessageHandler: cutoff_time = current_time - self.rf_data_timeout # Clean timestamp-indexed cache - stale_timestamps = [ts for ts in self.rf_data_by_timestamp - if ts < cutoff_time] + stale_timestamps = [ts for ts in self.rf_data_by_timestamp if ts < cutoff_time] for ts in stale_timestamps: del self.rf_data_by_timestamp[ts] # Enforce maximum size on timestamp cache (keep most recent) if len(self.rf_data_by_timestamp) > self._max_rf_cache_size: - sorted_items = sorted(self.rf_data_by_timestamp.items(), - key=lambda x: x[1].get('timestamp', 0), - reverse=True) - self.rf_data_by_timestamp = dict(sorted_items[:self._max_rf_cache_size]) + sorted_items = sorted( + self.rf_data_by_timestamp.items(), key=lambda x: x[1].get("timestamp", 0), reverse=True + ) + self.rf_data_by_timestamp = dict(sorted_items[: self._max_rf_cache_size]) # Clean pubkey-indexed cache for pubkey in list(self.rf_data_by_pubkey.keys()): - self.rf_data_by_pubkey[pubkey] = [data for data in self.rf_data_by_pubkey[pubkey] - if current_time - data['timestamp'] < self.rf_data_timeout] + self.rf_data_by_pubkey[pubkey] = [ + data + for data in self.rf_data_by_pubkey[pubkey] + if current_time - data["timestamp"] < self.rf_data_timeout + ] if not self.rf_data_by_pubkey[pubkey]: del self.rf_data_by_pubkey[pubkey] @@ -1153,23 +1225,24 @@ class MessageHandler: for pubkey, entries in self.rf_data_by_pubkey.items(): for entry in entries: all_pubkey_entries.append((pubkey, entry)) - all_pubkey_entries.sort(key=lambda x: x[1].get('timestamp', 0), reverse=True) + all_pubkey_entries.sort(key=lambda x: x[1].get("timestamp", 0), reverse=True) # Rebuild pubkey cache with only the most recent entries self.rf_data_by_pubkey = {} - for pubkey, entry in all_pubkey_entries[:self._max_rf_cache_size]: + for pubkey, entry in all_pubkey_entries[: self._max_rf_cache_size]: if pubkey not in self.rf_data_by_pubkey: self.rf_data_by_pubkey[pubkey] = [] self.rf_data_by_pubkey[pubkey].append(entry) # Clean recent_rf_data list - self.recent_rf_data = [data for data in self.recent_rf_data - if current_time - data['timestamp'] < self.rf_data_timeout] + self.recent_rf_data = [ + data for data in self.recent_rf_data if current_time - data["timestamp"] < self.rf_data_timeout + ] # Enforce maximum size on recent_rf_data (keep most recent) if len(self.recent_rf_data) > self._max_rf_cache_size: - self.recent_rf_data.sort(key=lambda x: x.get('timestamp', 0), reverse=True) - self.recent_rf_data = self.recent_rf_data[:self._max_rf_cache_size] + self.recent_rf_data.sort(key=lambda x: x.get("timestamp", 0), reverse=True) + self.recent_rf_data = self.recent_rf_data[: self._max_rf_cache_size] def find_recent_rf_data( self, correlation_key: str | None = None, max_age_seconds: float | None = None @@ -1182,6 +1255,7 @@ class MessageHandler: - pubkey_prefix (from message payload) for message correlation """ import time + current_time = time.time() # Use default timeout if not specified @@ -1189,8 +1263,7 @@ class MessageHandler: max_age_seconds = self.rf_data_timeout # Filter recent RF data by age - recent_data = [data for data in self.recent_rf_data - if current_time - data['timestamp'] < max_age_seconds] + recent_data = [data for data in self.recent_rf_data if current_time - data["timestamp"] < max_age_seconds] if not recent_data: self.logger.debug(f"No recent RF data found within {max_age_seconds}s window") @@ -1199,7 +1272,7 @@ class MessageHandler: # Strategy 1: Try exact packet prefix match first (for RF data correlation) if correlation_key: for data in recent_data: - rf_packet_prefix = data.get('packet_prefix', '') or '' + rf_packet_prefix = data.get("packet_prefix", "") or "" if rf_packet_prefix == correlation_key: self.logger.debug(f"Found exact packet prefix match: {rf_packet_prefix}") return data @@ -1207,7 +1280,7 @@ class MessageHandler: # Strategy 2: Try pubkey prefix match (for message correlation) if correlation_key: for data in recent_data: - rf_pubkey_prefix = data.get('pubkey_prefix', '') or '' + rf_pubkey_prefix = data.get("pubkey_prefix", "") or "" if rf_pubkey_prefix == correlation_key: self.logger.debug(f"Found exact pubkey prefix match: {rf_pubkey_prefix}") return data @@ -1215,17 +1288,19 @@ class MessageHandler: # Strategy 3: Try partial packet prefix matches if correlation_key: for data in recent_data: - rf_packet_prefix = data.get('packet_prefix', '') or '' + rf_packet_prefix = data.get("packet_prefix", "") or "" # Check for partial match (at least 16 characters) min_length = min(len(rf_packet_prefix), len(correlation_key), 16) - if (rf_packet_prefix[:min_length] == correlation_key[:min_length] and min_length >= 16): - self.logger.debug(f"Found partial packet prefix match: {rf_packet_prefix[:16]}... matches {correlation_key[:16]}...") + if rf_packet_prefix[:min_length] == correlation_key[:min_length] and min_length >= 16: + self.logger.debug( + f"Found partial packet prefix match: {rf_packet_prefix[:16]}... matches {correlation_key[:16]}..." + ) return data # Strategy 4: Use most recent data (fallback for timing issues) if recent_data: - most_recent = max(recent_data, key=lambda x: x['timestamp']) - packet_prefix = most_recent.get('packet_prefix', 'unknown') + most_recent = max(recent_data, key=lambda x: x["timestamp"]) + packet_prefix = most_recent.get("packet_prefix", "unknown") self.logger.debug(f"Using most recent RF data (fallback): {packet_prefix} at {most_recent['timestamp']}") return most_recent @@ -1234,11 +1309,8 @@ class MessageHandler: def store_message_for_correlation(self, message_id: str, message_data: dict[str, Any]) -> None: """Store a message temporarily to wait for RF data correlation""" import time - self.pending_messages[message_id] = { - 'data': message_data, - 'timestamp': time.time(), - 'processed': False - } + + self.pending_messages[message_id] = {"data": message_data, "timestamp": time.time(), "processed": False} self.logger.debug(f"Stored message {message_id} for RF data correlation") def correlate_message_with_rf_data(self, message_id: str) -> dict[str, Any] | None: @@ -1247,15 +1319,15 @@ class MessageHandler: return None message_info = self.pending_messages[message_id] - message_data = message_info['data'] + message_data = message_info["data"] # Try to find RF data for this message - pubkey_prefix = message_data.get('pubkey_prefix', '') + pubkey_prefix = message_data.get("pubkey_prefix", "") rf_data = self.find_recent_rf_data(pubkey_prefix) if rf_data: self.logger.debug(f"Successfully correlated message {message_id} with RF data") - message_info['processed'] = True + message_info["processed"] = True return rf_data return None @@ -1263,11 +1335,12 @@ class MessageHandler: def cleanup_old_messages(self) -> None: """Clean up old pending messages that couldn't be correlated""" import time + current_time = time.time() to_remove = [] for message_id, message_info in self.pending_messages.items(): - if current_time - message_info['timestamp'] > self.message_timeout: + if current_time - message_info["timestamp"] > self.message_timeout: to_remove.append(message_id) for message_id in to_remove: @@ -1276,24 +1349,22 @@ class MessageHandler: def try_correlate_pending_messages(self, rf_data: dict[str, Any]) -> None: """Try to correlate new RF data with any pending messages""" - pubkey_prefix = rf_data.get('pubkey_prefix', '') or '' + pubkey_prefix = rf_data.get("pubkey_prefix", "") or "" for message_id, message_info in self.pending_messages.items(): - if message_info['processed']: + if message_info["processed"]: continue - message_pubkey = message_info['data'].get('pubkey_prefix', '') or '' + message_pubkey = message_info["data"].get("pubkey_prefix", "") or "" # Check if this RF data matches the pending message - if (pubkey_prefix == message_pubkey or - (len(pubkey_prefix) >= 16 and len(message_pubkey) >= 16 and - pubkey_prefix[:16] == message_pubkey[:16])): + if pubkey_prefix == message_pubkey or ( + len(pubkey_prefix) >= 16 and len(message_pubkey) >= 16 and pubkey_prefix[:16] == message_pubkey[:16] + ): self.logger.debug(f"Correlated RF data with pending message {message_id}") - message_info['processed'] = True + message_info["processed"] = True break - - def decode_meshcore_packet(self, raw_hex: str, payload_hex: str | None = None) -> dict | None: """ Decode a MeshCore packet from raw hex data - matches Packet.cpp exactly @@ -1321,7 +1392,7 @@ class MessageHandler: return None # Remove 0x prefix if present (like in your other project) - if hex_data.startswith('0x'): + if hex_data.startswith("0x"): hex_data = hex_data[2:] byte_data = bytes.fromhex(hex_data) @@ -1352,19 +1423,19 @@ class MessageHandler: # Decode per firmware: low 6 bits = hop count, high 2 bits = size code (bytes_per_hop = code+1) path_parts = decode_path_len_byte(path_len_byte) if path_parts is None: - self.logger.debug( - "decode_meshcore_packet: invalid path_len byte (firmware would reject)" - ) + self.logger.debug("decode_meshcore_packet: invalid path_len byte (firmware would reject)") return None path_byte_length, bytes_per_hop = path_parts # Check if we have enough data for the full path if len(byte_data) < offset + path_byte_length: - self.logger.error(f"Packet too short for path (need {offset + path_byte_length}, have {len(byte_data)})") + self.logger.error( + f"Packet too short for path (need {offset + path_byte_length}, have {len(byte_data)})" + ) return None # Extract path - path_bytes = byte_data[offset:offset + path_byte_length] + path_bytes = byte_data[offset : offset + path_byte_length] offset += path_byte_length # Remaining data is payload @@ -1375,7 +1446,9 @@ class MessageHandler: # Only accept VER_1 (version 0) if payload_version != PayloadVersion.VER_1: - self.logger.warning(f"Encountered an unknown packet version. Version: {payload_version.value} RAW: {hex_data}") + self.logger.warning( + f"Encountered an unknown packet version. Version: {payload_version.value} RAW: {hex_data}" + ) return None # Extract payload type (bits 2-5) @@ -1385,51 +1458,48 @@ class MessageHandler: path_hex, path_values = self._path_bytes_to_nodes(path_bytes, prefix_hex_chars=bytes_per_hop * 2) # Process path based on packet type - path_info = self._process_packet_path( - path_bytes, - payload, - route_type, - payload_type - ) + path_info = self._process_packet_path(path_bytes, payload, route_type, payload_type) # Extract transport codes if present (only for TRANSPORT_FLOOD and TRANSPORT_DIRECT) transport_codes = None if has_transport and len(byte_data) >= 5: # header(1) + transport(4) transport_bytes = byte_data[1:5] transport_codes = { - 'code1': int.from_bytes(transport_bytes[0:2], byteorder='little'), - 'code2': int.from_bytes(transport_bytes[2:4], byteorder='little'), - 'hex': transport_bytes.hex() + "code1": int.from_bytes(transport_bytes[0:2], byteorder="little"), + "code2": int.from_bytes(transport_bytes[2:4], byteorder="little"), + "hex": transport_bytes.hex(), } packet_info = { - 'header': f"0x{header:02x}", + "header": f"0x{header:02x}", # Raw values for backward compatibility - 'route_type': route_type.value, - 'route_type_name': route_type.name, - 'payload_type': payload_type.value, - 'payload_type_name': payload_type.name, - 'payload_version': payload_version.value, + "route_type": route_type.value, + "route_type_name": route_type.name, + "payload_type": payload_type.value, + "payload_type_name": payload_type.name, + "payload_version": payload_version.value, # Enum objects for improved type safety - 'route_type_enum': route_type, - 'payload_type_enum': payload_type, - 'payload_version_enum': payload_version, + "route_type_enum": route_type, + "payload_type_enum": payload_type, + "payload_version_enum": payload_version, # Transport and path information - 'has_transport_codes': has_transport, - 'transport_codes': transport_codes, - 'transport_size': 4 if has_transport else 0, - 'path_len': len(path_values), # Hop count for display / routing_info - 'path_len_byte': path_len_byte, # Raw wire byte (same as firmware Packet path_len) - 'path_byte_length': path_byte_length, # Path bytes (for logs showing "X bytes") - 'bytes_per_hop': bytes_per_hop, # For multi-byte path storage/retrieval - 'path_info': path_info, - 'path': path_values, # For backward compatibility - 'path_hex': path_hex, - 'payload_hex': payload.hex(), - 'payload_bytes': len(payload) + "has_transport_codes": has_transport, + "transport_codes": transport_codes, + "transport_size": 4 if has_transport else 0, + "path_len": len(path_values), # Hop count for display / routing_info + "path_len_byte": path_len_byte, # Raw wire byte (same as firmware Packet path_len) + "path_byte_length": path_byte_length, # Path bytes (for logs showing "X bytes") + "bytes_per_hop": bytes_per_hop, # For multi-byte path storage/retrieval + "path_info": path_info, + "path": path_values, # For backward compatibility + "path_hex": path_hex, + "payload_hex": payload.hex(), + "payload_bytes": len(payload), } - self.logger.debug(f"Successfully decoded: route={packet_info.get('route_type_name')}, type={packet_info.get('payload_type_name')}") + self.logger.debug( + f"Successfully decoded: route={packet_info.get('route_type_name')}, type={packet_info.get('payload_type_name')}" + ) return packet_info except Exception as e: @@ -1448,8 +1518,8 @@ class MessageHandler: # advert header pub_key = payload[0:32] - timestamp = int.from_bytes(payload[32:32+4], "little") - signature = payload[36:36+64] + timestamp = int.from_bytes(payload[32 : 32 + 4], "little") + signature = payload[36 : 36 + 64] # appdata - parse according to C++ AdvertDataParser app_data = payload[100:] @@ -1460,7 +1530,7 @@ class MessageHandler: flags_byte = app_data[0] # Log the full flag byte for debugging - if hasattr(self, 'debug') and self.debug: + if hasattr(self, "debug") and self.debug: self.logger.debug(f"ADVERT flags: 0x{flags_byte:02X} (binary: {flags_byte:08b})") # Create flags object with the full byte value @@ -1494,8 +1564,8 @@ class MessageHandler: self.logger.error(f"ADVERT with location flag too short: {len(app_data)} bytes") return advert - lat = int.from_bytes(app_data[i:i+4], 'little', signed=True) - lon = int.from_bytes(app_data[i+4:i+8], 'little', signed=True) + lat = int.from_bytes(app_data[i : i + 4], "little", signed=True) + lon = int.from_bytes(app_data[i + 4 : i + 8], "little", signed=True) advert.update({"lat": round(lat / 1000000.0, 6), "lon": round(lon / 1000000.0, 6)}) i += 8 @@ -1504,7 +1574,7 @@ class MessageHandler: if len(app_data) < i + 2: self.logger.error(f"ADVERT with feat1 flag too short: {len(app_data)} bytes") return advert - feat1 = int.from_bytes(app_data[i:i+2], 'little') + feat1 = int.from_bytes(app_data[i : i + 2], "little") advert.update({"feat1": feat1}) i += 2 @@ -1513,7 +1583,7 @@ class MessageHandler: if len(app_data) < i + 2: self.logger.error(f"ADVERT with feat2 flag too short: {len(app_data)} bytes") return advert - feat2 = int.from_bytes(app_data[i:i+2], 'little') + feat2 = int.from_bytes(app_data[i : i + 2], "little") advert.update({"feat2": feat2}) i += 2 @@ -1523,7 +1593,7 @@ class MessageHandler: if name_len > 0: try: # Decode name and handle potential null terminators - name = app_data[i:].decode('utf-8', errors='ignore').rstrip('\x00') + name = app_data[i:].decode("utf-8", errors="ignore").rstrip("\x00") advert.update({"name": name}) except Exception as e: self.logger.warning(f"Failed to decode ADVERT name: {e}") @@ -1544,14 +1614,14 @@ class MessageHandler: Returns: Tuple of (path_hex_str, path_nodes_list). """ - n = prefix_hex_chars if prefix_hex_chars is not None else getattr(self.bot, 'prefix_hex_chars', 2) + n = prefix_hex_chars if prefix_hex_chars is not None else getattr(self.bot, "prefix_hex_chars", 2) if n <= 0: n = 2 path_hex = path_bytes.hex() - nodes = [path_hex[i:i + n].upper() for i in range(0, len(path_hex), n)] + nodes = [path_hex[i : i + n].upper() for i in range(0, len(path_hex), n)] # Legacy fallback: if remainder or no nodes, treat as 1-byte-per-hop if (len(path_hex) % n) != 0 or not nodes: - nodes = [path_hex[i:i + 2].upper() for i in range(0, len(path_hex), 2)] + nodes = [path_hex[i : i + 2].upper() for i in range(0, len(path_hex), 2)] return path_hex, nodes def _path_hex_to_nodes(self, path_hex: str) -> list[str]: @@ -1561,19 +1631,16 @@ class MessageHandler: """ if not path_hex or len(path_hex) < 2: return [] - n = getattr(self.bot, 'prefix_hex_chars', 2) + n = getattr(self.bot, "prefix_hex_chars", 2) if n <= 0: n = 2 - nodes = [path_hex[i:i + n].lower() for i in range(0, len(path_hex), n)] + nodes = [path_hex[i : i + n].lower() for i in range(0, len(path_hex), n)] if (len(path_hex) % n) != 0 or not nodes: - nodes = [path_hex[i:i + 2].lower() for i in range(0, len(path_hex), 2)] + nodes = [path_hex[i : i + 2].lower() for i in range(0, len(path_hex), 2)] return nodes def _get_path_from_rf_data( - self, - rf_data: dict[str, Any], - payload_hex: str | None = None, - packet_info: dict[str, Any] | None = None + self, rf_data: dict[str, Any], payload_hex: str | None = None, packet_info: dict[str, Any] | None = None ) -> tuple[str | None, list[str] | None, int]: """Get path string, path nodes, and hop count from RF data (single source for path extraction). @@ -1584,42 +1651,43 @@ class MessageHandler: Returns: (path_string, path_nodes, hops). path_nodes is a list for mesh graph; hops is path_length or 255. """ - routing_info = rf_data.get('routing_info') or {} - path_nodes_list = routing_info.get('path_nodes') + routing_info = rf_data.get("routing_info") or {} + path_nodes_list = routing_info.get("path_nodes") if path_nodes_list: - path_str = ','.join(str(n).lower() for n in path_nodes_list) + path_str = ",".join(str(n).lower() for n in path_nodes_list) return (path_str, list(path_nodes_list), len(path_nodes_list)) - raw_hex = rf_data.get('raw_hex') + raw_hex = rf_data.get("raw_hex") if not raw_hex: return (None, None, 255) if packet_info is None: - payload = payload_hex or rf_data.get('payload') or None + payload = payload_hex or rf_data.get("payload") or None packet_info = self.decode_meshcore_packet(raw_hex, str(payload) if payload is not None else None) if not packet_info: return (None, None, 255) - hops = packet_info.get('path_len', 255) - path_nodes_list = packet_info.get('path_nodes') or packet_info.get('path') or [] + hops = packet_info.get("path_len", 255) + path_nodes_list = packet_info.get("path_nodes") or packet_info.get("path") or [] if path_nodes_list: - path_str = ','.join(str(n).lower() for n in path_nodes_list) + path_str = ",".join(str(n).lower() for n in path_nodes_list) return (path_str, list(path_nodes_list), len(path_nodes_list)) - path_hex = packet_info.get('path_hex', '') + path_hex = packet_info.get("path_hex", "") if path_hex and len(path_hex) >= 2: - bytes_per_hop = packet_info.get('bytes_per_hop', 1) + bytes_per_hop = packet_info.get("bytes_per_hop", 1) n = (bytes_per_hop * 2) if bytes_per_hop and bytes_per_hop >= 1 else 2 - path_nodes_list = [path_hex[i:i + n].lower() for i in range(0, len(path_hex), n)] + path_nodes_list = [path_hex[i : i + n].lower() for i in range(0, len(path_hex), n)] if (len(path_hex) % n) != 0: - path_nodes_list = [path_hex[i:i + 2].lower() for i in range(0, len(path_hex), 2)] + path_nodes_list = [path_hex[i : i + 2].lower() for i in range(0, len(path_hex), 2)] if path_nodes_list: - return (','.join(path_nodes_list), path_nodes_list, len(path_nodes_list)) - path_info = packet_info.get('path_info') or {} - path_nodes_list = path_info.get('path') or [] + return (",".join(path_nodes_list), path_nodes_list, len(path_nodes_list)) + path_info = packet_info.get("path_info") or {} + path_nodes_list = path_info.get("path") or [] if path_nodes_list: - path_str = ','.join(str(n).lower() for n in path_nodes_list) + path_str = ",".join(str(n).lower() for n in path_nodes_list) return (path_str, list(path_nodes_list), len(path_nodes_list)) return (None, None, hops) - def _process_packet_path(self, path_bytes: bytes, payload: bytes, - route_type: RouteType, payload_type: PayloadType) -> dict: + def _process_packet_path( + self, path_bytes: bytes, payload: bytes, route_type: RouteType, payload_type: PayloadType + ) -> dict: """ Process the path field based on packet and route type @@ -1660,7 +1728,7 @@ class MessageHandler: path_hash_len = 1 if len(path_hashes_bytes) % path_hash_len == 0: path_hashes = [ - path_hashes_bytes[i:i + path_hash_len].hex().upper() + path_hashes_bytes[i : i + path_hash_len].hex().upper() for i in range(0, len(path_hashes_bytes), path_hash_len) ] else: @@ -1671,12 +1739,12 @@ class MessageHandler: path_hashes = [f"{b:02x}".upper() for b in payload[9:]] return { - 'type': 'trace', - 'snr_data': snr_values, - 'snr_path': path_nodes, # SNR data as hex for reference - 'path': path_hashes, # Actual routing path from payload pathHashes - 'path_hashes': path_hashes, # Explicit field for pathHashes - 'description': f"TRACE packet with {len(snr_values)} SNR readings and {len(path_hashes)} path nodes" + "type": "trace", + "snr_data": snr_values, + "snr_path": path_nodes, # SNR data as hex for reference + "path": path_hashes, # Actual routing path from payload pathHashes + "path_hashes": path_hashes, # Explicit field for pathHashes + "description": f"TRACE packet with {len(snr_values)} SNR readings and {len(path_hashes)} path nodes", } # Regular packets - determine path type based on route type @@ -1686,30 +1754,26 @@ class MessageHandler: # Direct routing: path contains routing instructions # Bytes are stripped at each hop return { - 'type': 'routing_instructions', - 'path': path_nodes, - 'meaning': 'bytes_stripped_at_each_hop', - 'description': f"Direct route via {','.join(path_nodes)} ({len(path_nodes)} hops)" + "type": "routing_instructions", + "path": path_nodes, + "meaning": "bytes_stripped_at_each_hop", + "description": f"Direct route via {','.join(path_nodes)} ({len(path_nodes)} hops)", } else: # Flood routing: path contains historical route # Bytes are added as packet floods through network return { - 'type': 'historical_route', - 'path': path_nodes, - 'meaning': 'bytes_added_as_packet_floods', - 'description': f"Flooded through {','.join(path_nodes)} ({len(path_nodes)} hops)" + "type": "historical_route", + "path": path_nodes, + "meaning": "bytes_added_as_packet_floods", + "description": f"Flooded through {','.join(path_nodes)} ({len(path_nodes)} hops)", } except Exception as e: self.logger.error(f"Error processing packet path: {e}") # Return basic path info as fallback (legacy 1-byte-per-hop) _, path_nodes = self._path_bytes_to_nodes(path_bytes, prefix_hex_chars=2) - return { - 'type': 'unknown', - 'path': path_nodes, - 'description': f"Path: {','.join(path_nodes)}" - } + return {"type": "unknown", "path": path_nodes, "description": f"Path: {','.join(path_nodes)}"} def _get_route_type_name(self, route_type: int) -> str: """Get human-readable name for route type""" @@ -1717,7 +1781,7 @@ class MessageHandler: 0x00: "ROUTE_TYPE_TRANSPORT_FLOOD", 0x01: "ROUTE_TYPE_FLOOD", 0x02: "ROUTE_TYPE_DIRECT", - 0x03: "ROUTE_TYPE_TRANSPORT_DIRECT" + 0x03: "ROUTE_TYPE_TRANSPORT_DIRECT", } return route_types.get(route_type, f"UNKNOWN_ROUTE_{route_type:02x}") @@ -1740,7 +1804,7 @@ class MessageHandler: 0x0C: "UNKNOWN_0c", # Not defined in official spec 0x0D: "UNKNOWN_0d", # Not defined in official spec 0x0E: "UNKNOWN_0e", # Not defined in official spec - 0x0F: "RAW_CUSTOM" + 0x0F: "RAW_CUSTOM", } return payload_types.get(payload_type, f"UNKNOWN_{payload_type:02x}") @@ -1749,25 +1813,26 @@ class MessageHandler: try: # Copy payload immediately to avoid segfault if event is freed import copy - payload = copy.deepcopy(event.payload) if hasattr(event, 'payload') else None + + payload = copy.deepcopy(event.payload) if hasattr(event, "payload") else None if payload is None: self.logger.warning("Channel message event has no payload") return - channel_idx = payload.get('channel_idx', 0) + channel_idx = payload.get("channel_idx", 0) # Debug: Log the full payload structure self.logger.debug(f"Channel message payload: {payload}") self.logger.debug(f"Payload keys: {list(payload.keys())}") # Get sender information from text field if it's in "SENDER: message" format - text = payload.get('text', '') + text = payload.get("text", "") sender_id = "Channel User" # Default fallback # Try to extract sender from text field (e.g., "HOWL: Test" -> "HOWL") message_content = text # Default to full text - if ':' in text and not text.startswith(':'): - parts = text.split(':', 1) + if ":" in text and not text.startswith(":"): + parts = text.split(":", 1) if len(parts) == 2 and parts[0].strip(): sender_id = parts[0].strip() message_content = parts[1].strip() # Use the part after the colon for keyword processing @@ -1787,50 +1852,50 @@ class MessageHandler: rssi: int | None = None # Try to get SNR from payload first - if 'SNR' in payload: - _snr = payload.get('SNR') + if "SNR" in payload: + _snr = payload.get("SNR") snr = float(_snr) if _snr is not None else None - elif 'snr' in payload: - _snr = payload.get('snr') + elif "snr" in payload: + _snr = payload.get("snr") snr = float(_snr) if _snr is not None else None # Try to get SNR from event metadata if available elif metadata: - if 'snr' in metadata: - _snr = metadata.get('snr') + if "snr" in metadata: + _snr = metadata.get("snr") snr = float(_snr) if _snr is not None else None - elif 'SNR' in metadata: - _snr = metadata.get('SNR') + elif "SNR" in metadata: + _snr = metadata.get("SNR") snr = float(_snr) if _snr is not None else None # If still no SNR, try to get it from the cache using pubkey prefix from payload if snr is None: - pubkey_prefix = payload.get('pubkey_prefix', '') + pubkey_prefix = payload.get("pubkey_prefix", "") if pubkey_prefix and pubkey_prefix in self.snr_cache: snr = self.snr_cache[pubkey_prefix] self.logger.debug(f"Retrieved cached SNR {snr} for pubkey {pubkey_prefix}") # Try to get RSSI from payload first - if 'RSSI' in payload: - _rssi = payload.get('RSSI') + if "RSSI" in payload: + _rssi = payload.get("RSSI") rssi = int(_rssi) if _rssi is not None else None - elif 'rssi' in payload: - _rssi = payload.get('rssi') + elif "rssi" in payload: + _rssi = payload.get("rssi") rssi = int(_rssi) if _rssi is not None else None - elif 'signal_strength' in payload: - _rssi = payload.get('signal_strength') + elif "signal_strength" in payload: + _rssi = payload.get("signal_strength") rssi = int(_rssi) if _rssi is not None else None # Try to get RSSI from event metadata if available elif metadata: - if 'rssi' in metadata: - _rssi = metadata.get('rssi') + if "rssi" in metadata: + _rssi = metadata.get("rssi") rssi = int(_rssi) if _rssi is not None else None - elif 'RSSI' in metadata: - _rssi = metadata.get('RSSI') + elif "RSSI" in metadata: + _rssi = metadata.get("RSSI") rssi = int(_rssi) if _rssi is not None else None # If still no RSSI, try to get it from the cache using pubkey prefix from payload if rssi is None: - pubkey_prefix = payload.get('pubkey_prefix', '') + pubkey_prefix = payload.get("pubkey_prefix", "") if pubkey_prefix and pubkey_prefix in self.rssi_cache: rssi = int(self.rssi_cache[pubkey_prefix]) self.logger.debug(f"Retrieved cached RSSI {rssi} for pubkey {pubkey_prefix}") @@ -1838,10 +1903,12 @@ class MessageHandler: # For channel messages, we can decode the packet since they use shared channel keys # This gives us access to the actual routing information # Extract packet prefix from message raw_hex for correlation - message_raw_hex = payload.get('raw_hex', '') + message_raw_hex = payload.get("raw_hex", "") 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}") + 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 @@ -1856,6 +1923,7 @@ class MessageHandler: # 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) @@ -1877,62 +1945,76 @@ class MessageHandler: 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'] + 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'] + 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'] + if recent_rf_data.get("rssi"): + rssi = recent_rf_data["rssi"] self.logger.debug(f"Using RSSI from RF data: {rssi}") # Single path source: prefer routing_info, else decode/fallback via helper path_string = None - hops = payload.get('path_len', 255) - payload_hex = recent_rf_data.get('payload') + hops = payload.get("path_len", 255) + payload_hex = recent_rf_data.get("payload") packet_info = self.decode_meshcore_packet(raw_hex, payload_hex) - packet_hash = recent_rf_data.get('packet_hash') + 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: - hops = packet_info.get('path_len', 0) - if packet_info.get('payload_type') == 9: # TRACE packet - path_info = packet_info.get('path_info', {}) - path_hashes = path_info.get('path_hashes') or path_info.get('path', []) + packet_info["packet_hash"] = packet_hash + if packet_info and packet_info.get("path_len") is not None: + hops = packet_info.get("path_len", 0) + if packet_info.get("payload_type") == 9: # TRACE packet + path_info = packet_info.get("path_info", {}) + path_hashes = path_info.get("path_hashes") or path_info.get("path", []) if path_hashes: - path_string = ','.join(path_hashes) + path_string = ",".join(path_hashes) self.logger.debug(f"Path from TRACE packet: {path_string} ({len(path_hashes)} hops)") - if hasattr(self.bot, 'mesh_graph') and self.bot.mesh_graph and self.bot.mesh_graph.capture_enabled: + if ( + hasattr(self.bot, "mesh_graph") + and self.bot.mesh_graph + and self.bot.mesh_graph.capture_enabled + ): 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.debug(f"Path from TRACE packet: {path_string}") else: - had_routing_nodes = bool((recent_rf_data.get('routing_info') or {}).get('path_nodes')) + had_routing_nodes = bool((recent_rf_data.get("routing_info") or {}).get("path_nodes")) path_string, path_nodes, hops = self._get_path_from_rf_data( recent_rf_data, payload_hex=payload_hex, packet_info=packet_info ) - if path_string and path_nodes and hasattr(self.bot, 'mesh_graph') and self.bot.mesh_graph and self.bot.mesh_graph.capture_enabled: + if ( + path_string + and path_nodes + and hasattr(self.bot, "mesh_graph") + and self.bot.mesh_graph + and self.bot.mesh_graph.capture_enabled + ): self._update_mesh_graph(path_nodes, packet_info) if path_string and not had_routing_nodes: self.logger.debug(f"Path from fallback decode: {path_string} ({hops} hops)") else: self.logger.debug("Packet decoding failed, trying direct hex or routing_info fallback") path_string = self.extract_path_from_raw_hex(raw_hex, hops) - if not path_string and recent_rf_data.get('routing_info') and recent_rf_data['routing_info'].get('path_nodes'): - routing_info = recent_rf_data['routing_info'] - path_nodes = routing_info['path_nodes'] + if ( + not path_string + and recent_rf_data.get("routing_info") + and recent_rf_data["routing_info"].get("path_nodes") + ): + routing_info = recent_rf_data["routing_info"] + path_nodes = routing_info["path_nodes"] hops = len(path_nodes) - path_string = ','.join(str(n).lower() for n in path_nodes) + path_string = ",".join(str(n).lower() for n in path_nodes) self.logger.debug(f"Path from RF routing_info fallback: {path_string} ({hops} hops)") else: self.logger.warning("❌ NO RF DATA found for channel message after all correlation attempts") - hops = payload.get('path_len', 255) + hops = payload.get("path_len", 255) path_string = None # Scope matching: if the RF data is a TC_FLOOD, check whether its transport @@ -1944,16 +2026,15 @@ class MessageHandler: tc_code1 = recent_rf_data.get("transport_code1") scope_payload_type = recent_rf_data.get("payload_type_int") scope_payload_hex = recent_rf_data.get("scope_payload_hex") or "" - scope_keys = getattr(getattr(self.bot, 'command_manager', None), - 'flood_scope_keys', {}) - if (rt == 0 # TRANSPORT_FLOOD (TC_FLOOD) - and tc_code1 is not None - and scope_payload_type is not None - and scope_payload_hex): + scope_keys = getattr(getattr(self.bot, "command_manager", None), "flood_scope_keys", {}) + if ( + rt == 0 # TRANSPORT_FLOOD (TC_FLOOD) + and tc_code1 is not None + and scope_payload_type is not None + and scope_payload_hex + ): pkt_payload_bytes = bytes.fromhex(scope_payload_hex) - reply_scope = self._match_scope( - tc_code1, scope_payload_type, pkt_payload_bytes, scope_keys - ) + reply_scope = self._match_scope(tc_code1, scope_payload_type, pkt_payload_bytes, scope_keys) if reply_scope: self.logger.info(f"Incoming TC_FLOOD matched scope '{reply_scope}'; reply will use same scope") elif scope_keys: @@ -1966,10 +2047,10 @@ class MessageHandler: # Allowlist enforcement: when flood_scopes is configured, only reply to # messages whose scope matched an entry. Unscoped FLOOD is allowed only # when '*' (or equivalent) is explicitly listed. - cmd_mgr = getattr(self.bot, 'command_manager', None) - scope_keys = getattr(cmd_mgr, 'flood_scope_keys', {}) + cmd_mgr = getattr(self.bot, "command_manager", None) + scope_keys = getattr(cmd_mgr, "flood_scope_keys", {}) if scope_keys and reply_scope is None: - allow_global = getattr(cmd_mgr, 'flood_scope_allow_global', False) + allow_global = getattr(cmd_mgr, "flood_scope_allow_global", False) rt_for_check = recent_rf_data.get("route_type_int") if recent_rf_data else None if rt_for_check == 0: self.logger.info("Ignoring TC_FLOOD: scope not in flood_scopes allowlist") @@ -1979,18 +2060,18 @@ class MessageHandler: return # Get the full public key from contacts if available - sender_pubkey = payload.get('pubkey_prefix', '') - if sender_pubkey and hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + sender_pubkey = payload.get("pubkey_prefix", "") + if sender_pubkey and hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(sender_pubkey): + if contact_data.get("public_key", "").startswith(sender_pubkey): # Use the full public key from the contact - sender_pubkey = contact_data.get('public_key', sender_pubkey) + sender_pubkey = contact_data.get("public_key", sender_pubkey) self.logger.debug(f"Found full public key for {sender_id}: {sender_pubkey[:16]}...") break # Elapsed: "Nms" when device clock is valid, or "Sync Device Clock" when invalid. - _translator = getattr(self.bot, 'translator', None) - _elapsed = format_elapsed_display(payload.get('sender_timestamp'), _translator) + _translator = getattr(self.bot, "translator", None) + _elapsed = format_elapsed_display(payload.get("sender_timestamp"), _translator) # Convert to our message format message = MeshMessage( @@ -1998,7 +2079,7 @@ class MessageHandler: sender_id=sender_id, sender_pubkey=sender_pubkey, channel=channel_name, - timestamp=payload.get('sender_timestamp', 0), + timestamp=payload.get("sender_timestamp", 0), snr=snr, rssi=rssi, hops=hops, @@ -2007,8 +2088,8 @@ class MessageHandler: is_dm=False, reply_scope=reply_scope, ) - if recent_rf_data and recent_rf_data.get('routing_info'): - message.routing_info = recent_rf_data['routing_info'] + if recent_rf_data and recent_rf_data.get("routing_info"): + message.routing_info = recent_rf_data["routing_info"] # Path information is now set directly in the MeshMessage constructor from RF data # No need for additional path extraction since we're using the actual routing data @@ -2024,18 +2105,22 @@ class MessageHandler: await self._debug_decode_packet_for_message(message, sender_id, recent_rf_data) # Check if this is an old cached message from before bot connection - timestamp = payload.get('sender_timestamp', 0) + timestamp = payload.get("sender_timestamp", 0) if self._is_old_cached_message(timestamp): - self.logger.debug(f"Skipping old cached channel message from {sender_id} (timestamp: {timestamp}, connection: {self.bot.connection_time})") + self.logger.debug( + f"Skipping old cached channel message from {sender_id} (timestamp: {timestamp}, connection: {self.bot.connection_time})" + ) return # Read the message to clear cache, but don't process it # Process the message await self.process_message(message) # Capture for web viewer live monitor - if (hasattr(self.bot, 'web_viewer_integration') and - self.bot.web_viewer_integration and - self.bot.web_viewer_integration.bot_integration): + if ( + hasattr(self.bot, "web_viewer_integration") + and self.bot.web_viewer_integration + and self.bot.web_viewer_integration.bot_integration + ): try: self.bot.web_viewer_integration.bot_integration.capture_channel_message(message) except Exception: @@ -2044,6 +2129,7 @@ class MessageHandler: except Exception as e: self.logger.error(f"Error handling channel message: {e}") import traceback + self.logger.error(traceback.format_exc()) def _update_mesh_graph(self, path_nodes: list[str], packet_info: dict[str, Any]) -> None: @@ -2061,7 +2147,7 @@ class MessageHandler: self.logger.debug(f"Mesh graph: Skipping path with < 2 nodes: {path_nodes}") return # Need at least 2 nodes to form an edge - if not hasattr(self.bot, 'mesh_graph') or not self.bot.mesh_graph: + if not hasattr(self.bot, "mesh_graph") or not self.bot.mesh_graph: self.logger.debug("Mesh graph: Graph not initialized, skipping update") return # Graph not initialized @@ -2069,7 +2155,7 @@ class MessageHandler: self.logger.debug(f"Mesh graph: Updating graph with path: {path_nodes} ({len(path_nodes)} nodes)") # Get recency window from config (default 7 days) - recency_days = self.bot.config.getint('Path_Command', 'graph_edge_expiration_days', fallback=7) + recency_days = self.bot.config.getint("Path_Command", "graph_edge_expiration_days", fallback=7) # Get public keys if available from database # Note: We don't check device contacts because repeaters aren't stored on the device @@ -2081,19 +2167,19 @@ class MessageHandler: for node_prefix in path_nodes: try: # First check if prefix is unique in database (within recency window) - count_query = f''' + count_query = f""" SELECT COUNT(DISTINCT public_key) as count FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') - ''' + """ prefix_pattern = f"{node_prefix}%" count_results = self.bot.db_manager.execute_query(count_query, (prefix_pattern,)) - if count_results and count_results[0].get('count', 0) == 1: + if count_results and count_results[0].get("count", 0) == 1: # Prefix is unique within recency window - safe to use database lookup - query = f''' + query = f""" SELECT public_key FROM complete_contact_tracking WHERE public_key LIKE ? @@ -2101,15 +2187,19 @@ class MessageHandler: AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 - ''' + """ results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) - if results and results[0].get('public_key'): - node_keys[node_prefix] = results[0]['public_key'] - self.logger.debug(f"Mesh graph: Found unique public key for prefix {node_prefix} from database: {results[0]['public_key'][:16]}...") + if results and results[0].get("public_key"): + node_keys[node_prefix] = results[0]["public_key"] + self.logger.debug( + f"Mesh graph: Found unique public key for prefix {node_prefix} from database: {results[0]['public_key'][:16]}..." + ) else: # Prefix collision or no recent matches - don't use database lookup (would risk wrong public key) - count = count_results[0].get('count', 0) if count_results else 0 - self.logger.debug(f"Mesh graph: Prefix {node_prefix} has {count} recent matches in database, skipping public key lookup (not unique or stale)") + count = count_results[0].get("count", 0) if count_results else 0 + self.logger.debug( + f"Mesh graph: Prefix {node_prefix} has {count} recent matches in database, skipping public key lookup (not unique or stale)" + ) except Exception as e: self.logger.debug(f"Error looking up public key for prefix {node_prefix}: {e}") @@ -2148,7 +2238,9 @@ class MessageHandler: # Try to get to_location first to use as reference # Use bot location as fallback reference to ensure distance-based selection bot_location_ref = self._get_bot_location_fallback() - to_location_result = _get_node_location_from_db(self.bot, to_prefix, bot_location_ref, recency_days) + to_location_result = _get_node_location_from_db( + self.bot, to_prefix, bot_location_ref, recency_days + ) if to_location_result: to_location_temp, temp_key = to_location_result if not to_key and temp_key: @@ -2159,7 +2251,9 @@ class MessageHandler: reference_for_from = to_location_temp if to_location_temp else self._get_bot_location_fallback() # Capture the selected public key when distance-based selection is used # Apply recency window to avoid using stale repeaters - from_location_result = _get_node_location_from_db(self.bot, from_prefix, reference_for_from, recency_days) + from_location_result = _get_node_location_from_db( + self.bot, from_prefix, reference_for_from, recency_days + ) if from_location_result: from_location, selected_from_key = from_location_result if not from_key and selected_from_key: @@ -2181,8 +2275,7 @@ class MessageHandler: if from_location and to_location: geographic_distance = calculate_distance( - from_location[0], from_location[1], - to_location[0], to_location[1] + from_location[0], from_location[1], to_location[0], to_location[1] ) except Exception as e: self.logger.debug(f"Could not calculate distance for edge {from_prefix}->{to_prefix}: {e}") @@ -2194,9 +2287,9 @@ class MessageHandler: from_prefix=from_prefix, to_prefix=to_prefix, from_public_key=from_key, # Only if prefix was unique (certain) - to_public_key=to_key, # Only if prefix was unique (certain) + to_public_key=to_key, # Only if prefix was unique (certain) hop_position=hop_position, - geographic_distance=geographic_distance + geographic_distance=geographic_distance, ) def _store_observed_path( @@ -2225,9 +2318,9 @@ class MessageHandler: # Parse path to extract from_prefix and to_prefix (use bytes_per_hop when provided for multi-byte paths) hex_chars = (bytes_per_hop or 1) * 2 if bytes_per_hop is not None and bytes_per_hop > 0: - path_nodes = [path_hex[i:i + hex_chars].lower() for i in range(0, len(path_hex), hex_chars)] + path_nodes = [path_hex[i : i + hex_chars].lower() for i in range(0, len(path_hex), hex_chars)] if (len(path_hex) % hex_chars) != 0 or not path_nodes: - path_nodes = [path_hex[i:i + 2].lower() for i in range(0, len(path_hex), 2)] + path_nodes = [path_hex[i : i + 2].lower() for i in range(0, len(path_hex), 2)] else: path_nodes = self._path_hex_to_nodes(path_hex) @@ -2238,67 +2331,76 @@ class MessageHandler: to_prefix = path_nodes[-1] # Last hop in path (last repeater that forwarded to bot) # Get public_key for adverts (NULL for messages) - public_key = advert_data.get('public_key', '') if packet_type == 'advert' else None + public_key = advert_data.get("public_key", "") if packet_type == "advert" else None # Check if path already exists if public_key: # For adverts: check by public_key, path_hex, packet_type - query = ''' + query = """ SELECT id, observation_count, last_seen FROM observed_paths WHERE public_key = ? AND path_hex = ? AND packet_type = ? - ''' + """ existing = self.bot.db_manager.execute_query(query, (public_key, path_hex, packet_type)) else: # For messages: check by from_prefix, to_prefix, path_hex, packet_type - query = ''' + query = """ SELECT id, observation_count, last_seen FROM observed_paths WHERE from_prefix = ? AND to_prefix = ? AND path_hex = ? AND packet_type = ? AND public_key IS NULL - ''' + """ existing = self.bot.db_manager.execute_query(query, (from_prefix, to_prefix, path_hex, packet_type)) from datetime import datetime + now = datetime.now() if existing and len(existing) > 0: # Path exists - update observation count and last_seen - path_id = existing[0]['id'] - current_count = existing[0].get('observation_count', 1) - update_query = ''' + path_id = existing[0]["id"] + current_count = existing[0].get("observation_count", 1) + update_query = """ UPDATE observed_paths SET observation_count = ?, last_seen = ? WHERE id = ? - ''' + """ self.bot.db_manager.execute_update(update_query, (current_count + 1, now.isoformat(), path_id)) - self.logger.debug(f"Updated observed_paths entry for {packet_type} path {path_hex[:20]}... (count: {current_count + 1})") + self.logger.debug( + f"Updated observed_paths entry for {packet_type} path {path_hex[:20]}... (count: {current_count + 1})" + ) else: # New path - insert - insert_query = ''' + insert_query = """ INSERT INTO observed_paths (public_key, packet_hash, from_prefix, to_prefix, path_hex, path_length, bytes_per_hop, packet_type, first_seen, last_seen, observation_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) - ''' + """ # Only store packet_hash if it's valid (not None and not the default invalid hash) stored_packet_hash = packet_hash if (packet_hash and packet_hash != "0000000000000000") else None - self.bot.db_manager.execute_update(insert_query, ( - public_key, - stored_packet_hash, - from_prefix, - to_prefix, - path_hex, - path_length, - bytes_per_hop, - packet_type, - now.isoformat(), - now.isoformat() - )) - self.logger.debug(f"Stored new {packet_type} path in observed_paths: {from_prefix}->{to_prefix} ({path_length} bytes)") + self.bot.db_manager.execute_update( + insert_query, + ( + public_key, + stored_packet_hash, + from_prefix, + to_prefix, + path_hex, + path_length, + bytes_per_hop, + packet_type, + now.isoformat(), + now.isoformat(), + ), + ) + self.logger.debug( + f"Stored new {packet_type} path in observed_paths: {from_prefix}->{to_prefix} ({path_length} bytes)" + ) except Exception as e: self.logger.warning(f"Error storing observed path: {e}") import traceback + self.logger.debug(traceback.format_exc()) def _get_bot_location_fallback(self) -> tuple[float, float] | None: @@ -2308,8 +2410,8 @@ class MessageHandler: Optional[Tuple[float, float]]: (latitude, longitude) or None if not configured. """ try: - lat = self.bot.config.getfloat('Bot', 'bot_latitude', fallback=None) - lon = self.bot.config.getfloat('Bot', 'bot_longitude', fallback=None) + lat = self.bot.config.getfloat("Bot", "bot_latitude", fallback=None) + lon = self.bot.config.getfloat("Bot", "bot_longitude", fallback=None) if lat is not None and lon is not None: # Validate coordinates @@ -2332,7 +2434,7 @@ class MessageHandler: Optional[Tuple[float, float]]: (latitude, longitude) or None. """ try: - query = ''' + query = """ SELECT latitude, longitude FROM complete_contact_tracking WHERE public_key = ? @@ -2341,12 +2443,12 @@ class MessageHandler: AND role IN ('repeater', 'roomserver') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 - ''' + """ results = self.bot.db_manager.execute_query(query, (public_key,)) if results: row = results[0] - lat = row.get('latitude') - lon = row.get('longitude') + lat = row.get("latitude") + lon = row.get("longitude") if lat is not None and lon is not None: return (float(lat), float(lon)) except Exception as e: @@ -2370,25 +2472,25 @@ class MessageHandler: if not out_path or out_path_len < 2: return # Need at least 2 bytes (1 node) to form an edge - if not hasattr(self.bot, 'mesh_graph') or not self.bot.mesh_graph: + if not hasattr(self.bot, "mesh_graph") or not self.bot.mesh_graph: return # Graph not initialized mesh_graph = self.bot.mesh_graph # Get advertiser's public key - advertiser_key = advert_data.get('public_key', '') + advertiser_key = advert_data.get("public_key", "") if not advertiser_key: self.logger.debug("Mesh graph: No public key in advert data, skipping graph update") return - advertiser_prefix = advertiser_key[:self.bot.prefix_hex_chars].lower() + advertiser_prefix = advertiser_key[: self.bot.prefix_hex_chars].lower() # Parse path from hex string (use bytes_per_hop from packet for multi-byte paths) - hex_chars = (packet_info.get('bytes_per_hop') or 1) * 2 + hex_chars = (packet_info.get("bytes_per_hop") or 1) * 2 path_nodes = [] for i in range(0, len(out_path), hex_chars): if i + hex_chars <= len(out_path): - path_nodes.append(out_path[i:i + hex_chars].lower()) + path_nodes.append(out_path[i : i + hex_chars].lower()) if len(path_nodes) == 0: return # No valid path nodes @@ -2396,7 +2498,7 @@ class MessageHandler: self.logger.debug(f"Mesh graph: Updating graph from advert path: {advertiser_prefix} -> {path_nodes}") # Get recency window from config (default 7 days) - recency_days = self.bot.config.getint('Path_Command', 'graph_edge_expiration_days', fallback=7) + recency_days = self.bot.config.getint("Path_Command", "graph_edge_expiration_days", fallback=7) # Calculate geographic distances if locations are available from .utils import _get_node_location_from_db, calculate_distance @@ -2410,19 +2512,19 @@ class MessageHandler: # For the first hop, we can only be certain if the prefix is unique (and recent) try: # Check if first_hop prefix is unique within recency window (only then can we be certain of the public key) - count_query = f''' + count_query = f""" SELECT COUNT(DISTINCT public_key) as count FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') - ''' + """ prefix_pattern = f"{first_hop}%" count_results = self.bot.db_manager.execute_query(count_query, (prefix_pattern,)) - if count_results and count_results[0].get('count', 0) == 1: + if count_results and count_results[0].get("count", 0) == 1: # Prefix is unique within recency window - safe to use database lookup - query = f''' + query = f""" SELECT public_key FROM complete_contact_tracking WHERE public_key LIKE ? @@ -2430,14 +2532,18 @@ class MessageHandler: AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 - ''' + """ results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) - if results and results[0].get('public_key'): - first_hop_key = results[0]['public_key'] - self.logger.debug(f"Mesh graph: Found unique public key for first hop {first_hop}: {first_hop_key[:16]}...") + if results and results[0].get("public_key"): + first_hop_key = results[0]["public_key"] + self.logger.debug( + f"Mesh graph: Found unique public key for first hop {first_hop}: {first_hop_key[:16]}..." + ) else: - count = count_results[0].get('count', 0) if count_results else 0 - self.logger.debug(f"Mesh graph: First hop prefix {first_hop} has {count} recent matches, cannot be certain of public key") + count = count_results[0].get("count", 0) if count_results else 0 + self.logger.debug( + f"Mesh graph: First hop prefix {first_hop} has {count} recent matches, cannot be certain of public key" + ) except Exception as e: self.logger.debug(f"Error checking uniqueness for first hop {first_hop}: {e}") @@ -2458,7 +2564,9 @@ class MessageHandler: first_hop_location_temp = bot_location_ref # Use bot location as fallback if first_hop_location_temp: - advertiser_result = _get_node_location_from_db(self.bot, advertiser_prefix, first_hop_location_temp, recency_days) + advertiser_result = _get_node_location_from_db( + self.bot, advertiser_prefix, first_hop_location_temp, recency_days + ) if advertiser_result: advertiser_location, _ = advertiser_result @@ -2473,8 +2581,7 @@ class MessageHandler: if advertiser_location and first_hop_location: geographic_distance = calculate_distance( - advertiser_location[0], advertiser_location[1], - first_hop_location[0], first_hop_location[1] + advertiser_location[0], advertiser_location[1], first_hop_location[0], first_hop_location[1] ) except Exception as e: self.logger.debug(f"Could not calculate distance for advert edge {advertiser_prefix}->{first_hop}: {e}") @@ -2486,9 +2593,9 @@ class MessageHandler: from_prefix=advertiser_prefix, to_prefix=first_hop, from_public_key=advertiser_key, # 100% certain - from NEW_CONTACT event - to_public_key=first_hop_key, # Only if prefix was unique (certain) + to_public_key=first_hop_key, # Only if prefix was unique (certain) hop_position=1, # First hop in path - geographic_distance=geographic_distance + geographic_distance=geographic_distance, ) # Create edges between subsequent hops in the path @@ -2496,7 +2603,7 @@ class MessageHandler: # Start with first_hop_location (if available) or advertiser_location as reference previous_location = None try: - if 'first_hop_location' in locals() and first_hop_location: + if "first_hop_location" in locals() and first_hop_location: previous_location = first_hop_location elif advertiser_location: previous_location = advertiser_location @@ -2514,18 +2621,18 @@ class MessageHandler: # Check if from_node prefix is unique within recency window try: - count_query = f''' + count_query = f""" SELECT COUNT(DISTINCT public_key) as count FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') - ''' + """ prefix_pattern = f"{from_node}%" count_results = self.bot.db_manager.execute_query(count_query, (prefix_pattern,)) - if count_results and count_results[0].get('count', 0) == 1: - query = f''' + if count_results and count_results[0].get("count", 0) == 1: + query = f""" SELECT public_key FROM complete_contact_tracking WHERE public_key LIKE ? @@ -2533,28 +2640,30 @@ class MessageHandler: AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 - ''' + """ results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) - if results and results[0].get('public_key'): - from_node_key = results[0]['public_key'] - self.logger.debug(f"Mesh graph: Found unique public key for {from_node}: {from_node_key[:16]}...") + if results and results[0].get("public_key"): + from_node_key = results[0]["public_key"] + self.logger.debug( + f"Mesh graph: Found unique public key for {from_node}: {from_node_key[:16]}..." + ) except Exception as e: self.logger.debug(f"Error checking uniqueness for {from_node}: {e}") # Check if to_node prefix is unique within recency window try: - count_query = f''' + count_query = f""" SELECT COUNT(DISTINCT public_key) as count FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') - ''' + """ prefix_pattern = f"{to_node}%" count_results = self.bot.db_manager.execute_query(count_query, (prefix_pattern,)) - if count_results and count_results[0].get('count', 0) == 1: - query = f''' + if count_results and count_results[0].get("count", 0) == 1: + query = f""" SELECT public_key FROM complete_contact_tracking WHERE public_key LIKE ? @@ -2562,10 +2671,10 @@ class MessageHandler: AND COALESCE(last_advert_timestamp, last_heard) >= datetime('now', '-{recency_days} days') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 - ''' + """ results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) - if results and results[0].get('public_key'): - to_node_key = results[0]['public_key'] + if results and results[0].get("public_key"): + to_node_key = results[0]["public_key"] self.logger.debug(f"Mesh graph: Found unique public key for {to_node}: {to_node_key[:16]}...") except Exception as e: self.logger.debug(f"Error checking uniqueness for {to_node}: {e}") @@ -2622,8 +2731,7 @@ class MessageHandler: if from_location and to_location: geographic_distance = calculate_distance( - from_location[0], from_location[1], - to_location[0], to_location[1] + from_location[0], from_location[1], to_location[0], to_location[1] ) except Exception as e: self.logger.debug(f"Could not calculate distance for edge {from_node}->{to_node}: {e}") @@ -2633,9 +2741,9 @@ class MessageHandler: from_prefix=from_node, to_prefix=to_node, from_public_key=from_node_key, # Only if prefix was unique (certain) - to_public_key=to_node_key, # Only if prefix was unique (certain) + to_public_key=to_node_key, # Only if prefix was unique (certain) hop_position=hop_position, - geographic_distance=geographic_distance + geographic_distance=geographic_distance, ) def _update_mesh_graph_from_trace(self, path_hashes: list[str], packet_info: dict[str, Any]) -> None: @@ -2656,34 +2764,34 @@ class MessageHandler: """ try: # First try to find the contact by name - if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + if hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: contact = None - pubkey_prefix = rf_data.get('pubkey_prefix', '') + pubkey_prefix = rf_data.get("pubkey_prefix", "") # Look for contact by name first for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('adv_name') == sender_id: + if contact_data.get("adv_name") == sender_id: contact = contact_data break # If not found by name, try by pubkey prefix if not contact and pubkey_prefix: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(pubkey_prefix): + if contact_data.get("public_key", "").startswith(pubkey_prefix): contact = contact_data break if contact: # Use the stored path information if available - out_path = contact.get('out_path', '') - out_path_len = contact.get('out_path_len', -1) + out_path = contact.get("out_path", "") + out_path_len = contact.get("out_path_len", -1) if out_path_len == 0: self.logger.debug(f"Direct connection to {sender_id}") return 0, "Direct" elif out_path_len > 0: # Format the path string (use stored bytes_per_hop for multi-byte paths) - bph = contact.get('out_bytes_per_hop') + bph = contact.get("out_bytes_per_hop") if bph is None and out_path_len > 0 and out_path: byte_len = len(out_path) // 2 if byte_len > 0 and (byte_len % out_path_len) == 0: @@ -2725,36 +2833,36 @@ class MessageHandler: self.logger.debug(f"No RF data for {sender_id}") return - pubkey_prefix = rf_data.get('pubkey_prefix', '') + pubkey_prefix = rf_data.get("pubkey_prefix", "") if not pubkey_prefix: self.logger.debug(f"No pubkey prefix for {sender_id}") return # Try to find the contact to get stored path information - if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts: + if hasattr(self.bot.meshcore, "contacts") and self.bot.meshcore.contacts: contact = None # Look for contact by name first for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('adv_name') == sender_id: + if contact_data.get("adv_name") == sender_id: contact = contact_data break # If not found by name, try by pubkey prefix if not contact: for _contact_key, contact_data in self.bot.meshcore.contacts.items(): - if contact_data.get('public_key', '').startswith(pubkey_prefix): + if contact_data.get("public_key", "").startswith(pubkey_prefix): contact = contact_data break if contact: - out_path = contact.get('out_path', '') - out_path_len = contact.get('out_path_len', -1) + out_path = contact.get("out_path", "") + out_path_len = contact.get("out_path_len", -1) if out_path_len == 0: self.logger.info(f"📡 {sender_id} → Direct connection") elif out_path_len > 0: - bph = contact.get('out_bytes_per_hop') + bph = contact.get("out_bytes_per_hop") if bph is None and out_path_len > 0 and out_path: byte_len = len(out_path) // 2 if byte_len > 0 and (byte_len % out_path_len) == 0: @@ -2788,7 +2896,7 @@ class MessageHandler: self.logger.debug(f"No RF data available for {sender_id}") return - raw_hex = rf_data.get('raw_hex', '') + raw_hex = rf_data.get("raw_hex", "") if not raw_hex: self.logger.debug(f"No raw_hex in RF data for {sender_id}") return @@ -2796,8 +2904,8 @@ class MessageHandler: self.logger.debug(f"Decoding packet for {sender_id} ({len(raw_hex)} chars)") # Log basic payload info if available - extracted_payload = rf_data.get('payload', '') - payload_length = rf_data.get('payload_length', 0) + extracted_payload = rf_data.get("payload", "") + payload_length = rf_data.get("payload_length", 0) if extracted_payload: self.logger.debug(f"Payload: {payload_length} bytes") @@ -2824,9 +2932,9 @@ class MessageHandler: if bytes_per_hop is not None and bytes_per_hop > 0: hex_chars = bytes_per_hop * 2 - path_nodes = [hex_path[i:i + hex_chars].lower() for i in range(0, len(hex_path), hex_chars)] + path_nodes = [hex_path[i : i + hex_chars].lower() for i in range(0, len(hex_path), hex_chars)] if (len(hex_path) % hex_chars) != 0 or not path_nodes: - path_nodes = [hex_path[i:i + 2].lower() for i in range(0, len(hex_path), 2)] + path_nodes = [hex_path[i : i + 2].lower() for i in range(0, len(hex_path), 2)] if path_nodes: return ",".join(path_nodes) return "Direct" @@ -2862,16 +2970,16 @@ class MessageHandler: # Record all messages in stats database FIRST (before any filtering) # This ensures we collect stats for all channels, not just monitored ones - if 'stats' in self.bot.command_manager.commands: - stats_command = self.bot.command_manager.commands['stats'] + if "stats" in self.bot.command_manager.commands: + stats_command = self.bot.command_manager.commands["stats"] if stats_command: stats_command.record_message(message) stats_command.record_path_stats(message) # Check greeter command for public channel messages (BEFORE general message filtering) # This allows greeter to work on its own configured channels even if not in monitor_channels - if self._channel_responses_allowed(message) and 'greeter' in self.bot.command_manager.commands: - greeter_command = self.bot.command_manager.commands['greeter'] + if self._channel_responses_allowed(message) and "greeter" in self.bot.command_manager.commands: + greeter_command = self.bot.command_manager.commands["greeter"] # First, check if this message should cancel a pending greeting (human greeting detection) if greeter_command: greeter_command.check_message_for_human_greeting(message) @@ -2885,14 +2993,19 @@ class MessageHandler: # Determine if a response was sent response_sent = False - if hasattr(greeter_command, 'last_response') and greeter_command.last_response or hasattr(self.bot.command_manager, '_last_response') and self.bot.command_manager._last_response: + if ( + hasattr(greeter_command, "last_response") + and greeter_command.last_response + or hasattr(self.bot.command_manager, "_last_response") + and self.bot.command_manager._last_response + ): response_sent = True # Record command execution in stats database - if 'stats' in self.bot.command_manager.commands: - stats_command = self.bot.command_manager.commands['stats'] + if "stats" in self.bot.command_manager.commands: + stats_command = self.bot.command_manager.commands["stats"] if stats_command: - stats_command.record_command(message, 'greeter', response_sent) + stats_command.record_command(message, "greeter", response_sent) except Exception as e: self.logger.error(f"Error executing greeter command: {e}") @@ -2902,19 +3015,22 @@ class MessageHandler: # Handle respond_to_mentions for channel messages if not message.is_dm: - _mention_mode = self.bot.config.get('Bot', 'respond_to_mentions', fallback='also').strip().lower() - if _mention_mode in ('also', 'only'): + _mention_mode = self.bot.config.get("Bot", "respond_to_mentions", fallback="also").strip().lower() + if _mention_mode in ("also", "only"): import re - _bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot') - _mention = f'@[{_bot_name}]' + + _bot_name = self.bot.config.get("Bot", "bot_name", fallback="Bot") + _mention = f"@[{_bot_name}]" _has_mention = _mention.lower() in message.content.lower() - if _mention_mode == 'only' and not _has_mention: + if _mention_mode == "only" and not _has_mention: self.logger.debug(f"Ignoring channel message (respond_to_mentions=only, no mention of {_mention})") return if _has_mention: - message.content = re.sub(re.escape(_mention), '', message.content, flags=re.IGNORECASE).strip() + message.content = re.sub(re.escape(_mention), "", message.content, flags=re.IGNORECASE).strip() - self.logger.info(f"Processing message: '{message.content}' from {message.sender_id} in {'DM' if message.is_dm else message.channel}") + self.logger.info( + f"Processing message: '{message.content}' from {message.sender_id} in {'DM' if message.is_dm else message.channel}" + ) # Check for advert command (DM only) if message.is_dm and message.content.strip().lower() == "advert": @@ -2929,14 +3045,14 @@ class MessageHandler: if keyword_matches: for keyword, response in keyword_matches: # Use translator if available for logging - if hasattr(self.bot, 'translator'): - log_msg = self.bot.translator.translate('messages.keyword_matched', keyword=keyword) + if hasattr(self.bot, "translator"): + log_msg = self.bot.translator.translate("messages.keyword_matched", keyword=keyword) self.logger.info(log_msg) else: self.logger.info(f"Keyword '{keyword}' matched, responding") # Track if this is a help response - if keyword == 'help': + if keyword == "help": help_response_sent = True # Track if this is a plugin command that has a response format @@ -2950,14 +3066,15 @@ class MessageHandler: # Record command execution in stats database for keyword-matched commands with responses # Commands without responses (response is None) are recorded in execute_commands to avoid double-counting - if 'stats' in self.bot.command_manager.commands: - stats_command = self.bot.command_manager.commands['stats'] + if "stats" in self.bot.command_manager.commands: + stats_command = self.bot.command_manager.commands["stats"] if stats_command: # response is not None here, so we know a response will be sent stats_command.record_command(message, keyword, True) # Generate command_id for repeat tracking (before sending) import time + command_id = f"keyword_{keyword}_{message.sender_id}_{int(time.time())}" try: @@ -2972,15 +3089,19 @@ class MessageHandler: ) if not success: - self.logger.warning(f"Failed to send keyword response for '{keyword}' to {message.sender_id if message.is_dm else message.channel}") + self.logger.warning( + f"Failed to send keyword response for '{keyword}' to {message.sender_id if message.is_dm else message.channel}" + ) except Exception as e: self.logger.error(f"Error sending keyword response for '{keyword}': {e}", exc_info=True) success = False # Capture keyword command data for web viewer - if (hasattr(self.bot, 'web_viewer_integration') and - self.bot.web_viewer_integration and - self.bot.web_viewer_integration.bot_integration): + if ( + hasattr(self.bot, "web_viewer_integration") + and self.bot.web_viewer_integration + and self.bot.web_viewer_integration.bot_integration + ): try: self.bot.web_viewer_integration.bot_integration.capture_command( message, keyword, response, success, command_id @@ -2998,6 +3119,7 @@ class MessageHandler: key, response = randomline_match plugin_command_with_response_matched = True import time + command_id = f"randomline_{key}_{message.sender_id}_{int(time.time())}" try: @@ -3027,7 +3149,7 @@ class MessageHandler: def should_process_message(self, message: MeshMessage) -> bool: """Check if message should be processed by the bot""" # Check if bot is enabled - if not self.bot.config.getboolean('Bot', 'enabled'): + if not self.bot.config.getboolean("Bot", "enabled"): return False # Check if sender is banned (starts-with matching) @@ -3041,7 +3163,7 @@ class MessageHandler: return False # Don't reply to messages from so far away the sender wont see response - max_response_hops = max(1, self.bot.config.getint('Channels', 'max_response_hops', fallback=64)) + max_response_hops = max(1, self.bot.config.getint("Channels", "max_response_hops", fallback=64)) if message.hops is not None: try: if int(message.hops) > max_response_hops: @@ -3061,18 +3183,20 @@ class MessageHandler: # Check if ANY command allows this channel (for selective access) for command_name, command in self.bot.command_manager.commands.items(): - if hasattr(command, 'is_channel_allowed') and callable(command.is_channel_allowed): + if hasattr(command, "is_channel_allowed") and callable(command.is_channel_allowed): if command.is_channel_allowed(message): # At least one command allows this channel self.logger.debug(f"Channel {message.channel} allowed by command '{command_name}' override") return True # Channel not in global list and no command allows it - self.logger.debug(f"Channel {message.channel} not in monitored channels: {self.bot.command_manager.monitor_channels}") + self.logger.debug( + f"Channel {message.channel} not in monitored channels: {self.bot.command_manager.monitor_channels}" + ) return False # Check if DMs are enabled - if message.is_dm and not self.bot.config.getboolean('Channels', 'respond_to_dms'): + if message.is_dm and not self.bot.config.getboolean("Channels", "respond_to_dms"): self.logger.debug("DMs are disabled") return False @@ -3091,20 +3215,20 @@ class MessageHandler: non-negative hop count produces a negative int and OverflowError on unsigned to_bytes. """ try: - hash_mode = int(contact_data.get('out_path_hash_mode', 0)) + hash_mode = int(contact_data.get("out_path_hash_mode", 0)) except (TypeError, ValueError): return if hash_mode != -1: return opl: int | None - raw_opl = contact_data.get('out_path_len') + raw_opl = contact_data.get("out_path_len") try: opl = None if raw_opl is None else int(raw_opl) except (TypeError, ValueError): opl = None - bph_raw = contact_data.get('out_bytes_per_hop', 1) or 1 + bph_raw = contact_data.get("out_bytes_per_hop", 1) or 1 try: bph = int(bph_raw) except (TypeError, ValueError): @@ -3115,7 +3239,7 @@ class MessageHandler: # Some NEW_CONTACT payloads omit out_path_len but include out_path + bytes_per_hop. # Derive hop count here so meshcore doesn't combine a non-flood path with hash_mode=-1. if opl is None: - out_path_hex = contact_data.get('out_path') or '' + out_path_hex = contact_data.get("out_path") or "" if not isinstance(out_path_hex, str) or not out_path_hex: return if (len(out_path_hex) % 2) != 0: @@ -3135,15 +3259,15 @@ class MessageHandler: pb = encode_path_len_byte(opl, bph) except ValueError: pb = encode_path_len_byte(min(opl, 0x3F), 1) - contact_data['out_path_hash_mode'] = (pb >> 6) & 0x03 - contact_data['out_path_len'] = pb & 0x3F + contact_data["out_path_hash_mode"] = (pb >> 6) & 0x03 + contact_data["out_path_len"] = pb & 0x3F async def handle_new_contact(self, event: Any, metadata: dict[str, Any] | None = None) -> None: """Handle NEW_CONTACT events for automatic contact management""" try: # Copy payload immediately to avoid segfault if event is freed # Make a deep copy to ensure we have all the data we need - if hasattr(event, 'payload'): + if hasattr(event, "payload"): contact_data = copy.deepcopy(event.payload) else: # Fallback: try to copy the event itself if it's a dict-like object @@ -3158,8 +3282,8 @@ class MessageHandler: self.logger.info(f"📦 Event payload: {contact_data}") # Get contact details - contact_name = sanitize_name(contact_data.get('name', contact_data.get('adv_name', 'Unknown'))) - public_key = contact_data.get('public_key', '') + contact_name = sanitize_name(contact_data.get("name", contact_data.get("adv_name", "Unknown"))) + public_key = contact_data.get("public_key", "") self.logger.info(f"Processing new contact: {contact_name} (key: {public_key[:16]}...)") @@ -3177,65 +3301,80 @@ class MessageHandler: if recent_rf_data: # Find RF data that might match this contact's public key for rf_entry in recent_rf_data[-10:]: # Check last 10 RF entries - if 'routing_info' in rf_entry: - routing_info = rf_entry['routing_info'] + if "routing_info" in rf_entry: + routing_info = rf_entry["routing_info"] # Extract packet_hash if available - packet_hash = routing_info.get('packet_hash') or rf_entry.get('packet_hash') + packet_hash = routing_info.get("packet_hash") or rf_entry.get("packet_hash") # Extract path information from routing_info - path_hex = routing_info.get('path_hex', '') - path_length = routing_info.get('path_length', 0) + path_hex = routing_info.get("path_hex", "") + path_length = routing_info.get("path_length", 0) # Add path information to contact_data if not already present - if 'out_path' not in contact_data or not contact_data.get('out_path'): + if "out_path" not in contact_data or not contact_data.get("out_path"): if path_hex and path_length > 0: - contact_data['out_path'] = path_hex - contact_data['out_bytes_per_hop'] = routing_info.get('bytes_per_hop', 1) or 1 - bph = contact_data['out_bytes_per_hop'] - pb = routing_info.get('path_len_byte') + contact_data["out_path"] = path_hex + contact_data["out_bytes_per_hop"] = routing_info.get("bytes_per_hop", 1) or 1 + bph = contact_data["out_bytes_per_hop"] + pb = routing_info.get("path_len_byte") if pb is None or pb == 255: try: pb = encode_path_len_byte(path_length, bph) except ValueError: pb = encode_path_len_byte(path_length, 1) - contact_data['out_path_hash_mode'] = (pb >> 6) & 0x03 - contact_data['out_path_len'] = pb & 0x3F + contact_data["out_path_hash_mode"] = (pb >> 6) & 0x03 + contact_data["out_path_len"] = pb & 0x3F elif path_length == 0: - contact_data['out_path'] = '' - contact_data['out_path_len'] = 0 - contact_data['out_path_hash_mode'] = 0 + contact_data["out_path"] = "" + contact_data["out_path_len"] = 0 + contact_data["out_path_hash_mode"] = 0 # Update mesh graph with this NEW_CONTACT event's path information # This captures public keys for edges that we might not see in regular message paths if path_hex and path_length > 0 and public_key: try: packet_info = { - 'routing_info': routing_info, - 'packet_hash': packet_hash, - 'bytes_per_hop': routing_info.get('bytes_per_hop', 1) + "routing_info": routing_info, + "packet_hash": packet_hash, + "bytes_per_hop": routing_info.get("bytes_per_hop", 1), } - path_byte_len = routing_info.get('path_byte_length') or (len(path_hex) // 2) - self._update_mesh_graph_from_advert(contact_data, path_hex, path_byte_len, packet_info) - self.logger.debug(f"Mesh graph: Updated from NEW_CONTACT event for {contact_name} (key: {public_key[:16]}...)") + path_byte_len = routing_info.get("path_byte_length") or (len(path_hex) // 2) + self._update_mesh_graph_from_advert( + contact_data, path_hex, path_byte_len, packet_info + ) + self.logger.debug( + f"Mesh graph: Updated from NEW_CONTACT event for {contact_name} (key: {public_key[:16]}...)" + ) # Store complete path in observed_paths table - self._store_observed_path(contact_data, path_hex, path_byte_len, 'advert', packet_hash=packet_hash, bytes_per_hop=routing_info.get('bytes_per_hop', 1)) + self._store_observed_path( + contact_data, + path_hex, + path_byte_len, + "advert", + packet_hash=packet_hash, + bytes_per_hop=routing_info.get("bytes_per_hop", 1), + ) except Exception as e: self.logger.debug(f"Error updating mesh graph from NEW_CONTACT: {e}") # Only collect signal data for direct (zero-hop) advertisements if path_length == 0: # Direct advertisement - collect signal data - if 'snr' in rf_entry: - signal_info['snr'] = rf_entry['snr'] - if 'rssi' in rf_entry: - signal_info['rssi'] = rf_entry['rssi'] - signal_info['hops'] = 0 - self.logger.debug(f"📡 Direct advertisement - collecting signal data: SNR={rf_entry.get('snr')}, RSSI={rf_entry.get('rssi')}") + if "snr" in rf_entry: + signal_info["snr"] = rf_entry["snr"] + if "rssi" in rf_entry: + signal_info["rssi"] = rf_entry["rssi"] + signal_info["hops"] = 0 + self.logger.debug( + f"📡 Direct advertisement - collecting signal data: SNR={rf_entry.get('snr')}, RSSI={rf_entry.get('rssi')}" + ) else: # Multi-hop advertisement - only collect hop count, not signal data - signal_info['hops'] = path_length - self.logger.debug(f"📡 Multi-hop advertisement ({path_length} hops) - skipping signal data collection") + signal_info["hops"] = path_length + self.logger.debug( + f"📡 Multi-hop advertisement ({path_length} hops) - skipping signal data collection" + ) break except Exception as e: self.logger.debug(f"Could not correlate RF data: {e}") @@ -3247,7 +3386,7 @@ class MessageHandler: self.logger.info("📡 No signal data available") # Check if this is a repeater or companion - if hasattr(self.bot, 'repeater_manager'): + if hasattr(self.bot, "repeater_manager"): is_repeater = self.bot.repeater_manager._is_repeater_device(contact_data) if is_repeater: @@ -3255,18 +3394,22 @@ class MessageHandler: self.logger.info(f"📡 New repeater discovered: {contact_name} - tracking in database only") # Track repeater in complete database with signal info - await self.bot.repeater_manager.track_contact_advertisement(contact_data, signal_info, packet_hash=packet_hash) + await self.bot.repeater_manager.track_contact_advertisement( + contact_data, signal_info, packet_hash=packet_hash + ) # Notify web viewer of new node - if (hasattr(self.bot, 'web_viewer_integration') and - self.bot.web_viewer_integration and - self.bot.web_viewer_integration.bot_integration): + if ( + hasattr(self.bot, "web_viewer_integration") + and self.bot.web_viewer_integration + and self.bot.web_viewer_integration.bot_integration + ): try: node_data = { - 'public_key': public_key, - 'prefix': public_key[:self.bot.prefix_hex_chars].lower() if public_key else '', - 'name': contact_name, - 'role': 'repeater' + "public_key": public_key, + "prefix": public_key[: self.bot.prefix_hex_chars].lower() if public_key else "", + "name": contact_name, + "role": "repeater", } self.bot.web_viewer_integration.bot_integration.send_mesh_node_update(node_data) except Exception as e: @@ -3279,7 +3422,7 @@ class MessageHandler: return else: # COMPANION: track in DB; device add behaviour depends on auto_manage_contacts - auto_manage_setting = self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() + auto_manage_setting = self.bot.config.get("Bot", "auto_manage_contacts", fallback="false").lower() self.logger.info( "👤 New companion discovered: %s — auto_manage_contacts=%s", contact_name, @@ -3290,21 +3433,21 @@ class MessageHandler: contact_data, signal_info, packet_hash=packet_hash ) - if auto_manage_setting == 'false': + if auto_manage_setting == "false": self.logger.info( "Manual mode — companion %s tracked in database only (not added to device)", contact_name, ) - elif auto_manage_setting == 'device': + elif auto_manage_setting == "device": self.logger.info( "Device mode — companion %s tracked; firmware handles addition; bot may manage capacity", contact_name, ) status = await self.bot.repeater_manager.get_contact_list_status() - if status and status.get('is_near_limit', False): + if status and status.get("is_near_limit", False): self.logger.warning( "Contact list near limit (%.1f%%) — managing capacity", - status['usage_percentage'], + status["usage_percentage"], ) await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True) else: @@ -3312,7 +3455,7 @@ class MessageHandler: "New companion %s — contact list has adequate space", contact_name, ) - elif auto_manage_setting == 'bot': + elif auto_manage_setting == "bot": self.logger.info( "Bot mode — adding companion %s to device with capacity management", contact_name, @@ -3331,10 +3474,10 @@ class MessageHandler: self.logger.error("Error adding companion %s to device: %s", contact_name, e) status = await self.bot.repeater_manager.get_contact_list_status() - if status and status.get('is_near_limit', False): + if status and status.get("is_near_limit", False): self.logger.warning( "Contact list near limit (%.1f%%) — managing capacity after add", - status['usage_percentage'], + status["usage_percentage"], ) await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True) else: @@ -3358,36 +3501,40 @@ class MessageHandler: return # Fallback: Track in database for unknown contact types (no repeater_manager) - if hasattr(self.bot, 'repeater_manager'): + if hasattr(self.bot, "repeater_manager"): await self.bot.repeater_manager.track_contact_advertisement(contact_data, packet_hash=packet_hash) await self.bot.repeater_manager.check_and_auto_purge() # For unknown contact types, handle based on auto_manage_contacts setting - if hasattr(self.bot, 'repeater_manager'): - auto_manage_setting = self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() + if hasattr(self.bot, "repeater_manager"): + auto_manage_setting = self.bot.config.get("Bot", "auto_manage_contacts", fallback="false").lower() - if auto_manage_setting == 'device': + if auto_manage_setting == "device": # Device mode: Let device handle auto-addition, bot manages capacity - self.logger.info(f"Device auto-addition mode - new contact '{contact_name}' will be handled by device") + self.logger.info( + f"Device auto-addition mode - new contact '{contact_name}' will be handled by device" + ) # Check contact list capacity and manage if needed status = await self.bot.repeater_manager.get_contact_list_status() - if status and status.get('is_near_limit', False): - self.logger.warning(f"Contact list near limit ({status['usage_percentage']:.1f}%) - managing capacity") + if status and status.get("is_near_limit", False): + self.logger.warning( + f"Contact list near limit ({status['usage_percentage']:.1f}%) - managing capacity" + ) await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True) else: self.logger.info(f"New contact '{contact_name}' - contact list has adequate space") - elif auto_manage_setting == 'bot': + elif auto_manage_setting == "bot": # Bot mode: Bot automatically adds companion contacts to device and manages capacity - self.logger.info(f"Bot auto-addition mode - automatically adding new companion contact '{contact_name}' to device") + self.logger.info( + f"Bot auto-addition mode - automatically adding new companion contact '{contact_name}' to device" + ) # Add the contact to the device's contact list success = await self.bot.repeater_manager.add_discovered_contact( - contact_name, - public_key, - "Auto-added companion contact discovered via NEW_CONTACT event" + contact_name, public_key, "Auto-added companion contact discovered via NEW_CONTACT event" ) if success: @@ -3398,18 +3545,22 @@ class MessageHandler: # Check contact list capacity and manage if needed status = await self.bot.repeater_manager.get_contact_list_status() - if status and status.get('is_near_limit', False): - self.logger.warning(f"Contact list near limit ({status['usage_percentage']:.1f}%) - managing capacity") + if status and status.get("is_near_limit", False): + self.logger.warning( + f"Contact list near limit ({status['usage_percentage']:.1f}%) - managing capacity" + ) await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True) else: self.logger.info(f"New contact '{contact_name}' - contact list has adequate space") else: # false or any other value # Manual mode: Just log the discovery, no automatic actions - self.logger.info(f"Manual mode - new companion contact '{contact_name}' discovered (not auto-added)") + self.logger.info( + f"Manual mode - new companion contact '{contact_name}' discovered (not auto-added)" + ) # Log the new contact discovery - if hasattr(self.bot, 'repeater_manager'): + if hasattr(self.bot, "repeater_manager"): self.bot.repeater_manager.log_purging_action( "new_contact_discovered", f"New contact discovered: {contact_name} (key: {public_key[:16]}...)", @@ -3418,4 +3569,5 @@ class MessageHandler: except Exception as e: self.logger.error(f"Error handling new contact event: {e}") import traceback + self.logger.error(traceback.format_exc())