diff --git a/config.ini.example b/config.ini.example index 251dcb2..4fbee27 100644 --- a/config.ini.example +++ b/config.ini.example @@ -186,14 +186,16 @@ advert_interval_hours = 0 [Keywords] # Keyword-response pairs (keyword = response format) -# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path} +# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance} # {sender}: Name/ID of message sender # {connection_info}: "Direct connection (0 hops)" or "Routed through X hops" # {snr}: Signal-to-noise ratio in dB # {timestamp}: Message timestamp in HH:MM:SS format # {path}: Message routing path (e.g., "01,5f (2 hops)") # {rssi}: Received Signal Strength Indicator in dBm -test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}" +# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") +# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) +test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" pong = "Ping!" help = "Bot Help: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | Use 'help ' for details" @@ -250,8 +252,10 @@ meshcore_log_level = INFO [Custom_Syntax] # Custom syntax patterns for special message formats # Format: pattern = "response_format" -# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path} +# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance} # {phrase}: The text after the trigger (for custom syntax patterns) +# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") +# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) # # Note: The "t" command is now handled by the test command as an alias # "t phrase" works the same as "test phrase" - both use the test response format diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index 4ac5e56..1ab554e 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -28,6 +28,7 @@ class MultitestCommand(BaseCommand): self.listening_start_time = 0 self.listening_duration = 6.0 # 6 seconds listening window self.target_packet_hash: Optional[str] = None # Hash of the message we're tracking + self.triggering_timestamp: float = 0.0 # Timestamp of the triggering message def get_help_text(self) -> str: return self.translate('commands.multitest.help', fallback="Listens for 6 seconds and collects all unique paths from incoming messages") @@ -179,19 +180,30 @@ class MultitestCommand(BaseCommand): rf_data = self.get_rf_data_for_message(message) if not rf_data: # Can't get RF data, skip this message + self.logger.debug(f"Skipping message - no RF data found (sender: {message.sender_id})") return # Use pre-calculated packet hash if available, otherwise calculate it message_hash = rf_data.get('packet_hash') if not message_hash and rf_data.get('raw_hex'): # Fallback: calculate hash if not stored (for older RF data) - message_hash = calculate_packet_hash(rf_data['raw_hex']) + try: + payload_type = None + routing_info = rf_data.get('routing_info', {}) + if routing_info: + # Try to get payload type from routing_info if available + payload_type = routing_info.get('payload_type') + message_hash = calculate_packet_hash(rf_data['raw_hex'], payload_type) + except Exception as e: + self.logger.debug(f"Error calculating packet hash: {e}") + message_hash = None if not message_hash: # Can't determine hash, skip this message + self.logger.debug(f"Skipping message - could not determine packet hash (sender: {message.sender_id})") return - # Only collect paths if this message has the same hash as the target + # CRITICAL: Only collect paths if this message has the same hash as the target # This ensures we only track variations of the same original message if message_hash == self.target_packet_hash: # Try to extract path from RF data first (more reliable) @@ -203,16 +215,18 @@ class MultitestCommand(BaseCommand): if path: self.collected_paths.add(path) - self.logger.debug(f"Collected path during listening: {path} (hash: {message_hash})") + self.logger.info(f"✓ Collected path: {path} (hash: {message_hash[:8]}...)") else: # Log when we have a matching hash but can't extract path routing_info = rf_data.get('routing_info', {}) path_length = routing_info.get('path_length', 0) if path_length == 0: - self.logger.debug(f"Matched hash {message_hash} but path is direct (0 hops)") + self.logger.debug(f"Matched hash {message_hash[:8]}... but path is direct (0 hops)") else: - self.logger.debug(f"Matched hash {message_hash} but couldn't extract path from routing_info: {routing_info}") - # Note: We don't log hash mismatches to avoid spam - only matching hashes are logged + self.logger.debug(f"Matched hash {message_hash[:8]}... but couldn't extract path from routing_info: {routing_info}") + else: + # Log hash mismatches for debugging (but limit to avoid spam) + self.logger.debug(f"✗ Hash mismatch - target: {self.target_packet_hash[:8]}..., received: {message_hash[:8]}... (sender: {message.sender_id})") def _scan_recent_rf_data(self): """Scan recent RF data for packets with matching hash (for messages that haven't been processed yet)""" @@ -221,21 +235,39 @@ class MultitestCommand(BaseCommand): try: current_time = time.time() + matching_count = 0 + mismatching_count = 0 + # Look at RF data from the last few seconds (before listening started, in case packets arrived just before) for rf_data in self.bot.message_handler.recent_rf_data: # Check if this RF data is recent enough rf_timestamp = rf_data.get('timestamp', 0) time_diff = current_time - rf_timestamp - # Include RF data from slightly before listening started (up to 2 seconds) and during listening - if -2.0 <= time_diff <= self.listening_duration: + # Only include RF data from the triggering message timestamp onwards + # This prevents collecting packets from earlier messages that happen to have the same hash + rf_timestamp = rf_data.get('timestamp', 0) + if rf_timestamp >= self.triggering_timestamp and time_diff <= self.listening_duration: packet_hash = rf_data.get('packet_hash') - if packet_hash == self.target_packet_hash: + + # CRITICAL: Only process if hash matches exactly and is not None/empty + if packet_hash and packet_hash == self.target_packet_hash: + matching_count += 1 # Extract path from this RF data path = self.extract_path_from_rf_data(rf_data) if path: self.collected_paths.add(path) - self.logger.debug(f"Collected path from RF data scan: {path} (hash: {packet_hash}, time: {time_diff:.2f}s)") + self.logger.info(f"✓ Collected path from RF scan: {path} (hash: {packet_hash[:8]}..., time: {time_diff:.2f}s)") + else: + self.logger.debug(f"Matched hash {packet_hash[:8]}... in RF scan but couldn't extract path") + elif packet_hash: + mismatching_count += 1 + # Only log first few mismatches to avoid spam + if mismatching_count <= 3: + self.logger.debug(f"✗ RF scan hash mismatch - target: {self.target_packet_hash[:8]}..., found: {packet_hash[:8]}... (time: {time_diff:.2f}s)") + + if matching_count > 0 or mismatching_count > 0: + self.logger.debug(f"RF scan complete: {matching_count} matching, {mismatching_count} mismatching packets") except Exception as e: self.logger.debug(f"Error scanning recent RF data: {e}") @@ -254,7 +286,12 @@ class MultitestCommand(BaseCommand): packet_hash = rf_data.get('packet_hash') if not packet_hash and rf_data.get('raw_hex'): # Fallback: calculate hash if not stored (for older RF data) - packet_hash = calculate_packet_hash(rf_data['raw_hex']) + # IMPORTANT: Must use same payload_type that was used during ingestion + payload_type = None + routing_info = rf_data.get('routing_info', {}) + if routing_info: + payload_type = routing_info.get('payload_type') + packet_hash = calculate_packet_hash(rf_data['raw_hex'], payload_type) if not packet_hash: response = "Error: Could not calculate packet hash for this message. Please try again." @@ -263,7 +300,13 @@ class MultitestCommand(BaseCommand): # Store the packet hash to track self.target_packet_hash = packet_hash - self.logger.info(f"Tracking packet hash: {self.target_packet_hash}") + + # Store the timestamp of the triggering message to avoid collecting older packets + triggering_rf_timestamp = rf_data.get('timestamp', time.time()) + self.triggering_timestamp = triggering_rf_timestamp + + self.logger.info(f"Tracking packet hash: {self.target_packet_hash[:16]}... (full: {self.target_packet_hash})") + self.logger.debug(f"Triggering message timestamp: {triggering_rf_timestamp}") # Also extract path from the triggering message itself initial_path = self.extract_path_from_message(message) @@ -286,6 +329,7 @@ class MultitestCommand(BaseCommand): self.listening_start_time = time.time() # Also scan recent RF data for matching hashes (in case messages haven't been processed yet) + # But only include packets that arrived at or after the triggering message self._scan_recent_rf_data() try: diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index 47fc815..ad8ed9e 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -7,10 +7,10 @@ Decodes hex path data to show which repeaters were involved in message routing import re import time import asyncio -import math from typing import List, Optional, Dict, Any, Tuple from .base_command import BaseCommand from ..models import MeshMessage +from ..utils import calculate_distance class PathCommand(BaseCommand): @@ -414,23 +414,6 @@ class PathCommand(BaseCommand): self.logger.warning(f"Could not get API cache data: {e}") return None - def _calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Calculate haversine distance between two points in kilometers""" - # Convert latitude and longitude from degrees to radians - lat1_rad = math.radians(lat1) - lon1_rad = math.radians(lon1) - lat2_rad = math.radians(lat2) - lon2_rad = math.radians(lon2) - - # Haversine formula - dlat = lat2_rad - lat1_rad - dlon = lon2_rad - lon1_rad - a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2 - c = 2 * math.asin(math.sqrt(a)) - - # Earth's radius in kilometers - earth_radius = 6371.0 - return earth_radius * c def _select_repeater_by_proximity(self, repeaters: List[Dict[str, Any]], node_id: str = None, path_context: List[str] = None) -> Tuple[Optional[Dict[str, Any]], float]: """ @@ -496,7 +479,7 @@ class PathCommand(BaseCommand): # If only one repeater, check if it's within range if len(scored_repeaters) == 1: repeater, recency_score = scored_repeaters[0] - distance = self._calculate_distance( + distance = calculate_distance( self.bot_latitude, self.bot_longitude, repeater['latitude'], repeater['longitude'] ) @@ -511,7 +494,7 @@ class PathCommand(BaseCommand): # Calculate combined proximity + recency scores combined_scores = [] for repeater, recency_score in scored_repeaters: - distance = self._calculate_distance( + distance = calculate_distance( self.bot_latitude, self.bot_longitude, repeater['latitude'], repeater['longitude'] ) @@ -869,13 +852,13 @@ class PathCommand(BaseCommand): for repeater, recency_score in scored_repeaters: # Calculate distance to previous node - prev_distance = self._calculate_distance( + prev_distance = calculate_distance( prev_location[0], prev_location[1], repeater['latitude'], repeater['longitude'] ) # Calculate distance to next node - next_distance = self._calculate_distance( + next_distance = calculate_distance( next_location[0], next_location[1], repeater['latitude'], repeater['longitude'] ) @@ -896,11 +879,11 @@ class PathCommand(BaseCommand): # Apply maximum range threshold if self.max_proximity_range > 0: # Check if any distance is beyond range - prev_dist = self._calculate_distance( + prev_dist = calculate_distance( prev_location[0], prev_location[1], best_repeater['latitude'], best_repeater['longitude'] ) - next_dist = self._calculate_distance( + next_dist = calculate_distance( next_location[0], next_location[1], best_repeater['latitude'], best_repeater['longitude'] ) @@ -929,7 +912,7 @@ class PathCommand(BaseCommand): best_combined_score = 0.0 for repeater, recency_score in scored_repeaters: - distance = self._calculate_distance( + distance = calculate_distance( reference_location[0], reference_location[1], repeater['latitude'], repeater['longitude'] ) diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 04a7bea..fd414d7 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -9,11 +9,10 @@ import aiohttp import time import json import random -import math from typing import Dict, List, Optional, Any, Tuple from .base_command import BaseCommand from ..models import MeshMessage -from ..utils import abbreviate_location, format_location_for_display +from ..utils import abbreviate_location, format_location_for_display, calculate_distance class PrefixCommand(BaseCommand): @@ -349,7 +348,7 @@ class PrefixCommand(BaseCommand): if (row['latitude'] is not None and row['longitude'] is not None and not (row['latitude'] == 0.0 and row['longitude'] == 0.0)): - distance = self._calculate_distance( + distance = calculate_distance( self.bot_latitude, self.bot_longitude, row['latitude'], row['longitude'] ) @@ -430,23 +429,6 @@ class PrefixCommand(BaseCommand): self.logger.error(f"Error querying database for prefix '{prefix}': {e}") return None - def _calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Calculate haversine distance between two points in kilometers""" - # Convert latitude and longitude from degrees to radians - lat1_rad = math.radians(lat1) - lon1_rad = math.radians(lon1) - lat2_rad = math.radians(lat2) - lon2_rad = math.radians(lon2) - - # Haversine formula - dlat = lat2_rad - lat1_rad - dlon = lon2_rad - lon1_rad - a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2 - c = 2 * math.asin(math.sqrt(a)) - - # Earth's radius in kilometers - earth_radius = 6371.0 - return earth_radius * c async def get_free_prefixes(self) -> Tuple[List[str], int, bool]: """Get list of available (unused) prefixes and total count @@ -505,7 +487,7 @@ class PrefixCommand(BaseCommand): if (row.get('latitude') is not None and row.get('longitude') is not None and not (row.get('latitude') == 0.0 and row.get('longitude') == 0.0)): - distance = self._calculate_distance( + distance = calculate_distance( self.bot_latitude, self.bot_longitude, row['latitude'], row['longitude'] ) diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index e33386f..b21116c 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -5,8 +5,12 @@ Handles the 'test' keyword response """ import re +import math +from datetime import datetime +from typing import List, Optional, Tuple, Dict, Any from .base_command import BaseCommand from ..models import MeshMessage +from ..utils import calculate_distance class TestCommand(BaseCommand): @@ -62,6 +66,366 @@ class TestCommand(BaseCommand): return self._strip_quotes_from_config(format_str) if format_str else None return None + def _extract_path_node_ids(self, message: MeshMessage) -> List[str]: + """Extract path node IDs from message path string""" + if not message.path: + return [] + + # Check if it's a direct connection + if "Direct" in message.path or "0 hops" in message.path: + return [] + + # Extract path nodes from the path string + # Path strings are typically in format: "node1,node2,node3 via ROUTE_TYPE_*" + # or just "node1,node2,node3" + path_string = message.path + + # Remove route type suffix if present + if " via ROUTE_TYPE_" in path_string: + path_string = path_string.split(" via ROUTE_TYPE_")[0] + + # Check if it looks like a comma-separated path + if ',' in path_string: + # Clean up any extra info (like hop counts in parentheses) + # Example: "01,7e,55,86 (4 hops)" -> "01,7e,55,86" + if '(' in path_string: + path_string = path_string.split('(')[0].strip() + + # Validate that all parts are 2-character hex values + parts = path_string.split(',') + valid_parts = [] + for part in parts: + part = part.strip() + # Check if it's a 2-character hex value + if len(part) == 2 and all(c in '0123456789abcdefABCDEF' for c in part): + valid_parts.append(part.upper()) + + return valid_parts + + return [] + + + def _lookup_repeater_location(self, node_id: str, path_context: Optional[List[str]] = None) -> Optional[Tuple[float, float]]: + """Look up repeater location for a node ID using geographic proximity selection when path context is available""" + try: + if not hasattr(self.bot, 'db_manager'): + return None + + # Query for all repeaters with matching prefix + query = ''' + SELECT latitude, longitude, public_key, name, + last_advert_timestamp, last_heard, advert_count + FROM complete_contact_tracking + WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') + AND latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude != 0 AND longitude != 0 + ''' + + prefix_pattern = f"{node_id}%" + results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) + + if not results or len(results) == 0: + return None + + # Convert to list of dicts for processing + repeaters = [] + for row in results: + repeaters.append({ + 'latitude': row.get('latitude'), + 'longitude': row.get('longitude'), + 'public_key': row.get('public_key'), + 'name': row.get('name'), + 'last_advert_timestamp': row.get('last_advert_timestamp'), + 'last_heard': row.get('last_heard'), + 'advert_count': row.get('advert_count', 0) + }) + + # If only one repeater, return it + if len(repeaters) == 1: + r = repeaters[0] + return (float(r['latitude']), float(r['longitude'])) + + # Multiple repeaters - use geographic proximity selection if path context available + if path_context and len(path_context) > 1: + selected = self._select_by_path_proximity(repeaters, node_id, path_context) + if selected: + return (float(selected['latitude']), float(selected['longitude'])) + + # Fall back to most recent repeater + scored = self._calculate_recency_weighted_scores(repeaters) + if scored: + best_repeater = scored[0][0] + return (float(best_repeater['latitude']), float(best_repeater['longitude'])) + + return None + except Exception as e: + self.logger.debug(f"Error looking up repeater location for {node_id}: {e}") + return None + + def _calculate_recency_weighted_scores(self, repeaters: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float]]: + """Calculate recency-weighted scores for repeaters (0.0 to 1.0, higher = more recent)""" + scored_repeaters = [] + now = datetime.now() + + for repeater in repeaters: + most_recent_time = None + + # Check last_heard + last_heard = repeater.get('last_heard') + if last_heard: + try: + if isinstance(last_heard, str): + dt = datetime.fromisoformat(last_heard.replace('Z', '+00:00')) + else: + dt = last_heard + if most_recent_time is None or dt > most_recent_time: + most_recent_time = dt + except: + pass + + # Check last_advert_timestamp + last_advert = repeater.get('last_advert_timestamp') + if last_advert: + try: + if isinstance(last_advert, str): + dt = datetime.fromisoformat(last_advert.replace('Z', '+00:00')) + else: + dt = last_advert + if most_recent_time is None or dt > most_recent_time: + most_recent_time = dt + except: + pass + + if most_recent_time is None: + recency_score = 0.1 + else: + hours_ago = (now - most_recent_time).total_seconds() / 3600.0 + recency_score = math.exp(-hours_ago / 12.0) + recency_score = max(0.0, min(1.0, recency_score)) + + scored_repeaters.append((repeater, recency_score)) + + # Sort by recency score (highest first) + scored_repeaters.sort(key=lambda x: x[1], reverse=True) + return scored_repeaters + + def _get_node_location_simple(self, node_id: str) -> Optional[Tuple[float, float]]: + """Simple lookup without proximity selection - used for reference nodes""" + try: + if not hasattr(self.bot, 'db_manager'): + return None + + query = ''' + SELECT latitude, longitude + FROM complete_contact_tracking + WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') + AND latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude != 0 AND longitude != 0 + ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + LIMIT 1 + ''' + + prefix_pattern = f"{node_id}%" + results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) + + if results and len(results) > 0: + row = results[0] + lat = row.get('latitude') + lon = row.get('longitude') + if lat is not None and lon is not None: + return (float(lat), float(lon)) + + return None + except Exception as e: + self.logger.debug(f"Error in simple location lookup for {node_id}: {e}") + return None + + def _select_by_path_proximity(self, repeaters: List[Dict[str, Any]], node_id: str, path_context: List[str]) -> Optional[Dict[str, Any]]: + """Select repeater based on proximity to previous/next nodes in path""" + try: + # Filter by recency first + scored_repeaters = self._calculate_recency_weighted_scores(repeaters) + min_recency_threshold = 0.01 # Approximately 55 hours ago or less + recent_repeaters = [r for r, score in scored_repeaters if score >= min_recency_threshold] + + if not recent_repeaters: + return None + + # Find current node position in path + current_index = path_context.index(node_id) if node_id in path_context else -1 + if current_index == -1: + return None + + # Get previous and next node locations + prev_location = None + next_location = None + + if current_index > 0: + prev_node_id = path_context[current_index - 1] + prev_location = self._get_node_location_simple(prev_node_id) + + if current_index < len(path_context) - 1: + next_node_id = path_context[current_index + 1] + next_location = self._get_node_location_simple(next_node_id) + + # Use proximity selection + if prev_location and next_location: + return self._select_by_dual_proximity(recent_repeaters, prev_location, next_location) + elif prev_location: + return self._select_by_single_proximity(recent_repeaters, prev_location) + elif next_location: + return self._select_by_single_proximity(recent_repeaters, next_location) + else: + return None + + except Exception as e: + self.logger.debug(f"Error in path proximity selection: {e}") + return None + + def _select_by_dual_proximity(self, repeaters: List[Dict[str, Any]], prev_location: Tuple[float, float], next_location: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Select repeater based on proximity to both previous and next nodes""" + scored_repeaters = self._calculate_recency_weighted_scores(repeaters) + min_recency_threshold = 0.01 + scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold] + + if not scored_repeaters: + return None + + best_repeater = None + best_combined_score = 0.0 + + for repeater, recency_score in scored_repeaters: + # Calculate distance to previous node + prev_distance = calculate_distance( + prev_location[0], prev_location[1], + repeater['latitude'], repeater['longitude'] + ) + + # Calculate distance to next node + next_distance = calculate_distance( + next_location[0], next_location[1], + repeater['latitude'], repeater['longitude'] + ) + + # Combined proximity score (lower distance = higher score) + avg_distance = (prev_distance + next_distance) / 2 + normalized_distance = min(avg_distance / 1000.0, 1.0) + proximity_score = 1.0 - normalized_distance + + # Weight: 40% recency, 60% proximity + combined_score = (recency_score * 0.4) + (proximity_score * 0.6) + + if combined_score > best_combined_score: + best_combined_score = combined_score + best_repeater = repeater + + return best_repeater + + def _select_by_single_proximity(self, repeaters: List[Dict[str, Any]], reference_location: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Select repeater based on proximity to single reference node""" + scored_repeaters = self._calculate_recency_weighted_scores(repeaters) + min_recency_threshold = 0.01 + scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold] + + if not scored_repeaters: + return None + + best_repeater = None + best_combined_score = 0.0 + + for repeater, recency_score in scored_repeaters: + distance = calculate_distance( + reference_location[0], reference_location[1], + repeater['latitude'], repeater['longitude'] + ) + + # Proximity score (closer = higher score) + normalized_distance = min(distance / 1000.0, 1.0) + proximity_score = 1.0 - normalized_distance + + # Weight: 40% recency, 60% proximity + combined_score = (recency_score * 0.4) + (proximity_score * 0.6) + + if combined_score > best_combined_score: + best_combined_score = combined_score + best_repeater = repeater + + return best_repeater + + def _calculate_path_distance(self, message: MeshMessage) -> str: + """Calculate total distance along path (sum of distances between consecutive repeaters with locations)""" + node_ids = self._extract_path_node_ids(message) + if len(node_ids) < 2: + # Check if it's a direct connection + if not message.path or "Direct" in message.path or "0 hops" in message.path: + return "N/A" # Direct connection, no path to calculate + return "" # Path exists but insufficient nodes + + total_distance = 0.0 + valid_segments = 0 + skipped_nodes = 0 + + # Get locations for all nodes using path context for proximity selection + locations = [] + for i, node_id in enumerate(node_ids): + location = self._lookup_repeater_location(node_id, path_context=node_ids) + if location: + locations.append((node_id, location)) + else: + skipped_nodes += 1 + + # Calculate distances between consecutive nodes with locations + # This skips nodes without locations but continues the path + for i in range(len(locations) - 1): + prev_node_id, prev_location = locations[i] + next_node_id, next_location = locations[i + 1] + + # Calculate distance between consecutive repeaters with locations + distance = calculate_distance( + prev_location[0], prev_location[1], + next_location[0], next_location[1] + ) + total_distance += distance + valid_segments += 1 + + if valid_segments == 0: + return "" # No valid segments found + + # Format the result compactly + if skipped_nodes > 0: + return f"{total_distance:.1f}km ({valid_segments} segs, {skipped_nodes} no-loc)" + else: + return f"{total_distance:.1f}km ({valid_segments} segs)" + + def _calculate_firstlast_distance(self, message: MeshMessage) -> str: + """Calculate straight-line distance between first and last repeater in path""" + node_ids = self._extract_path_node_ids(message) + if len(node_ids) < 2: + # Check if it's a direct connection + if not message.path or "Direct" in message.path or "0 hops" in message.path: + return "N/A" # Direct connection, no path to calculate + return "" # Path exists but insufficient nodes + + # Get first and last node IDs + first_node_id = node_ids[0] + last_node_id = node_ids[-1] + + # Use path context for better selection when multiple repeaters share prefix + first_location = self._lookup_repeater_location(first_node_id, path_context=node_ids) + last_location = self._lookup_repeater_location(last_node_id, path_context=node_ids) + + # Both locations must be available + if not first_location or not last_location: + return "" # Fail if either location is missing + + # Calculate straight-line distance + distance = calculate_distance( + first_location[0], first_location[1], + last_location[0], last_location[1] + ) + + return f"{distance:.1f}km" + def format_response(self, message: MeshMessage, response_format: str) -> str: """Override to handle phrase extraction""" # Clean content to remove control characters and normalize whitespace @@ -87,6 +451,10 @@ class TestCommand(BaseCommand): connection_info = self.build_enhanced_connection_info(message) timestamp = self.format_timestamp(message) + # Calculate distance placeholders + path_distance = self._calculate_path_distance(message) + firstlast_distance = self._calculate_firstlast_distance(message) + # Format phrase part - add colon and space if phrase exists phrase_part = f": {phrase}" if phrase else "" @@ -97,7 +465,9 @@ class TestCommand(BaseCommand): connection_info=connection_info, path=message.path or self.translate('common.unknown_path'), timestamp=timestamp, - snr=message.snr or self.translate('common.unknown') + snr=message.snr or self.translate('common.unknown'), + path_distance=path_distance or "", + firstlast_distance=firstlast_distance or "" ) except (KeyError, ValueError) as e: self.logger.warning(f"Error formatting test response: {e}") diff --git a/modules/core.py b/modules/core.py index 7bcc3fb..f8b8bbe 100644 --- a/modules/core.py +++ b/modules/core.py @@ -281,14 +281,16 @@ admin_commands = repeater [Keywords] # Keyword-response pairs (keyword = response format) -# Available fields: {sender}, {connection_info}, {snr}, {rssi}, {timestamp}, {path} +# Available fields: {sender}, {connection_info}, {snr}, {rssi}, {timestamp}, {path}, {path_distance}, {firstlast_distance} # {sender}: Name/ID of message sender # {connection_info}: Path info, SNR, and RSSI combined (e.g., "01,5f (2 hops) | SNR: 15 dB | RSSI: -120 dBm") # {snr}: Signal-to-noise ratio in dB # {rssi}: Received signal strength indicator in dBm # {timestamp}: Message timestamp in HH:MM:SS format # {path}: Message routing path (e.g., "01,5f (2 hops)") -test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}" +# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") +# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) +test = "ack [@{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" pong = "Ping!" help = "Bot Help: test, ping, help, hello, cmd, advert, t phrase, @string, wx, aqi, sun, moon, solar, hfcond, satpass | Use 'help ' for details" diff --git a/modules/message_handler.py b/modules/message_handler.py index 7450bd0..6ba7c60 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -479,7 +479,13 @@ class MessageHandler: decoded_packet = self.decode_meshcore_packet(raw_hex, extracted_payload) if decoded_packet: # Calculate packet hash for this packet (useful for tracking same message via different paths) + # Ensure we use the numeric payload_type value (not enum or string) 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'): + payload_type_value = payload_type_value.value + payload_type_value = int(payload_type_value) packet_hash = calculate_packet_hash(raw_hex, payload_type_value) routing_info = { diff --git a/modules/utils.py b/modules/utils.py index 44a3100..dd7f97f 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -261,7 +261,8 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: Args: raw_hex: Raw packet data as hex string - payload_type: Optional payload type (if None, extracted from header) + payload_type: Optional payload type as integer (if None, extracted from header) + Must be numeric value (0-15), not enum or string Returns: 16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error @@ -274,6 +275,11 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: # Get payload type from header (bits 2-5) if payload_type is None: payload_type = (header >> 2) & 0x0F + else: + # Ensure payload_type is an integer (handle enum.value if passed) + if hasattr(payload_type, 'value'): + payload_type = payload_type.value + payload_type = int(payload_type) & 0x0F # Ensure it's 0-15 # Check if transport codes are present route_type = header & 0x03 @@ -284,12 +290,25 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: if has_transport: offset += 4 # Skip 4 bytes of transport codes + # Validate we have enough bytes for path_len + if len(byte_data) <= offset: + return "0000000000000000" + # Read path_len (1 byte on wire, but stored as uint16_t in C++) path_len = byte_data[offset] offset += 1 + # Validate we have enough bytes for the path + if len(byte_data) < offset + path_len: + return "0000000000000000" + # Skip past the path to get to payload payload_start = offset + path_len + + # Validate we have payload data + if len(byte_data) <= payload_start: + return "0000000000000000" + payload_data = byte_data[payload_start:] # Calculate hash exactly like MeshCore Packet::calculatePacketHash(): @@ -312,3 +331,35 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: except Exception as e: # Return default hash on error (caller should handle logging) return "0000000000000000" + + +def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Calculate haversine distance between two points in kilometers. + + Args: + lat1: Latitude of first point in degrees + lon1: Longitude of first point in degrees + lat2: Latitude of second point in degrees + lon2: Longitude of second point in degrees + + Returns: + Distance in kilometers + """ + import math + + # Convert latitude and longitude from degrees to radians + lat1_rad = math.radians(lat1) + lon1_rad = math.radians(lon1) + lat2_rad = math.radians(lat2) + lon2_rad = math.radians(lon2) + + # Haversine formula + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + a = math.sin(dlat/2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon/2)**2 + c = 2 * math.asin(math.sqrt(a)) + + # Earth's radius in kilometers + earth_radius = 6371.0 + return earth_radius * c