mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 14:22:22 +00:00
Enhance configuration and command files to support new distance metrics in message responses. Updated Keywords section to include {path_distance} and {firstlast_distance} placeholders. Refactored distance calculation methods into utils and improved path command logic for better accuracy in distance reporting. Added detailed logging for packet hash calculations and message handling in multitest command.
This commit is contained in:
1 parent
0ec60e48c2
commit
6c660dc14c
8 files changed
+507
-65
No files matched your search
+7
-3
@@ -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 <command>' 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+4
-2
@@ -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 <command>' for details"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+52
-1
@@ -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
|
||||
Reference in new issue
Block a user