mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-01 10:32:23 +00:00
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.
Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).
Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.
Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.
The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
784 lines
33 KiB
Python
784 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test command for the MeshCore Bot
|
|
Handles the 'test' keyword response
|
|
"""
|
|
|
|
import math
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
from ..location import latest_contact_position_rows
|
|
from ..models import MeshMessage
|
|
from ..response_template import format_piped_template
|
|
from ..template_reference import MESSAGE_PLACEHOLDERS, template_spec
|
|
from ..utils import (
|
|
calculate_distance,
|
|
decode_escape_sequences,
|
|
extract_path_node_ids_from_message,
|
|
)
|
|
from .base_command import BaseCommand
|
|
|
|
|
|
class TestCommand(BaseCommand):
|
|
"""Handles the test command.
|
|
|
|
Responds to 'test' or 't' with connection info. Supports an optional phrase.
|
|
Can utilize repeater geographic location data to estimate path distance.
|
|
"""
|
|
|
|
# Plugin metadata
|
|
name = "test"
|
|
honors_skip_channel_check = False
|
|
enabled_attr = "test_enabled"
|
|
keywords = ['test', 't']
|
|
description = "Responds to 'test' or 't' with connection info"
|
|
category = "basic"
|
|
|
|
# Documentation
|
|
short_description = "Get test response with connection info"
|
|
usage = "test [phrase]"
|
|
examples = ["test", "t hello world"]
|
|
|
|
# Web-viewer settings schema (see modules/settings_schema.py)
|
|
settings_schema = [
|
|
{"key": "response_format", "label": "Response format", "type": "str", "default": "",
|
|
"help": "Template for the test reply. Empty uses the default format.",
|
|
"template": template_spec(
|
|
"piped",
|
|
MESSAGE_PLACEHOLDERS + ("phrase", "phrase_part", "elapsed", "path_distance", "firstlast_distance"),
|
|
notes=[
|
|
"Blank uses [Keywords] test if set, else the default the preview shows.",
|
|
"{path_distance} and {firstlast_distance} are N/A on a direct message, which "
|
|
"counts as not empty: put hops_min:1 before prefix_if_nonempty to drop them there.",
|
|
"\\n starts a new line.",
|
|
],
|
|
samples={"path_distance": {"direct": "N/A"}, "firstlast_distance": {"direct": "N/A"}},
|
|
blank_default="ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}",
|
|
blank_config=("Keywords", "test"),
|
|
)},
|
|
{"key": "distance_unit", "label": "Distance unit", "type": "enum",
|
|
"options": [
|
|
{"value": "auto", "label": "Auto (follow reply language)"},
|
|
{"value": "km", "label": "Kilometres"},
|
|
{"value": "mi", "label": "Miles"},
|
|
],
|
|
"default": "auto",
|
|
"help": "Unit for {path_distance} and {firstlast_distance}."},
|
|
{"key": "require_path_bytes_greater_or_equal_to", "label": "Require path bytes ≥", "type": "int",
|
|
"min": 0, "max": 3, "default": 0,
|
|
"help": "Only respond when the path's total size is at least 2 bytes (2) or exactly 3 bytes (3), counting every hop. 0/1 = allow all."},
|
|
{"key": "require_path_bytes_failure_response", "label": "Path-byte reject reply", "type": "str",
|
|
"default": "",
|
|
"help": "Reply when rejected by the path-byte requirement. Empty = silent reject."},
|
|
]
|
|
|
|
def __init__(self, bot):
|
|
super().__init__(bot)
|
|
self.test_enabled = self.get_config_value('Test_Command', 'enabled', fallback=True, value_type='bool')
|
|
self.distance_unit = self._read_distance_unit()
|
|
# Get bot location from config for geographic proximity calculations
|
|
self.geographic_guessing_enabled = False
|
|
self.bot_latitude = None
|
|
self.bot_longitude = None
|
|
|
|
# Get recency/proximity weighting from config (same as path command)
|
|
recency_weight = bot.config.getfloat('Path_Command', 'recency_weight', fallback=0.2)
|
|
self.recency_weight = max(0.0, min(1.0, recency_weight)) # Clamp to 0.0-1.0
|
|
self.proximity_weight = 1.0 - self.recency_weight
|
|
|
|
try:
|
|
# Try to get location from Bot section
|
|
if bot.config.has_section('Bot'):
|
|
lat = bot.config.getfloat('Bot', 'bot_latitude', fallback=None)
|
|
lon = bot.config.getfloat('Bot', 'bot_longitude', fallback=None)
|
|
|
|
if lat is not None and lon is not None:
|
|
# Validate coordinates
|
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
|
self.bot_latitude = lat
|
|
self.bot_longitude = lon
|
|
self.geographic_guessing_enabled = True
|
|
self.logger.debug(f"Test command: Geographic proximity enabled with bot location: {lat:.4f}, {lon:.4f}")
|
|
else:
|
|
self.logger.warning(f"Invalid bot coordinates in config: {lat}, {lon}")
|
|
except Exception as e:
|
|
self.logger.warning(f"Error reading bot location from config: {e}")
|
|
|
|
def get_help_text(self, message: MeshMessage | None = None) -> str:
|
|
"""Get help text for the command.
|
|
|
|
Returns:
|
|
str: Help text string.
|
|
"""
|
|
return self.translate('commands.test.help')
|
|
|
|
def clean_content(self, content: str) -> str:
|
|
"""Clean content by removing control characters and normalizing whitespace.
|
|
|
|
Args:
|
|
content: The raw message content.
|
|
|
|
Returns:
|
|
str: Cleaned and normalized content string.
|
|
"""
|
|
# Remove control characters (except newline, tab, carriage return)
|
|
cleaned = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', content)
|
|
# Normalize whitespace
|
|
cleaned = ' '.join(cleaned.split())
|
|
return cleaned
|
|
|
|
def matches_keyword(self, message: MeshMessage) -> bool:
|
|
"""Match 'test'/'t' and any config ``aliases`` over control-cleaned content.
|
|
|
|
Mesh clients occasionally embed stray control bytes in message text, and a
|
|
test is exactly the message someone sends when their link is marginal, so
|
|
the cleaned text is what gets matched. Keyword and alias matching itself is
|
|
inherited from :class:`BaseCommand`.
|
|
|
|
The cleaned text only sticks when this command claims the message. Matching
|
|
runs against the shared message object early in the command scan, and
|
|
collapsing whitespace on someone else's free-form argument (a scheduled
|
|
message body, say) is not this command's business.
|
|
|
|
Args:
|
|
message: The message to check.
|
|
|
|
Returns:
|
|
bool: True if the message matches a test keyword or configured alias.
|
|
"""
|
|
original = message.content
|
|
message.content = self.clean_content(original)
|
|
if super().matches_keyword(message):
|
|
return True
|
|
message.content = original
|
|
return False
|
|
|
|
DEFAULT_FORMAT = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}"
|
|
|
|
def get_response_format(self) -> Optional[str]:
|
|
"""Get the response format from config, falling back to the built-in default.
|
|
|
|
Returns:
|
|
Optional[str]: The configured or default response format string.
|
|
"""
|
|
if self.bot.config.has_section('Test_Command'):
|
|
raw = self.bot.config.get('Test_Command', 'response_format', fallback='')
|
|
if raw:
|
|
cleaned = self._strip_quotes_from_config(raw).strip()
|
|
if cleaned:
|
|
return decode_escape_sequences(cleaned)
|
|
if self.bot.config.has_section('Keywords'):
|
|
format_str = self.bot.config.get('Keywords', 'test', fallback=None)
|
|
if format_str:
|
|
return decode_escape_sequences(
|
|
self._strip_quotes_from_config(format_str)
|
|
)
|
|
return self.DEFAULT_FORMAT
|
|
|
|
def _extract_path_node_ids(self, message: MeshMessage) -> list[str]:
|
|
"""Extract path node IDs from message. Prefers routing_info.path_nodes (multi-byte); else parses message.path.
|
|
|
|
Returns:
|
|
List[str]: List of node IDs (2, 4, or 6 hex chars per node, uppercase).
|
|
"""
|
|
return extract_path_node_ids_from_message(message)
|
|
|
|
|
|
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.
|
|
|
|
Args:
|
|
node_id: The node ID to look up.
|
|
path_context: Optional list of all node IDs in the path for context-aware selection.
|
|
|
|
Returns:
|
|
Optional[Tuple[float, float]]: (latitude, longitude) or None if not found.
|
|
"""
|
|
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, is_starred
|
|
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),
|
|
'is_starred': bool(row.get('is_starred', 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:
|
|
# Get sender location if available (for first repeater selection)
|
|
sender_location = self._get_sender_location()
|
|
selected = self._select_by_path_proximity(repeaters, node_id, path_context, sender_location)
|
|
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 _get_sender_location(self) -> Optional[tuple[float, float]]:
|
|
"""Get sender location from current message if available.
|
|
|
|
Returns:
|
|
Optional[Tuple[float, float]]: (latitude, longitude) or None if unavailable/error.
|
|
"""
|
|
try:
|
|
if not hasattr(self, '_current_message') or not self._current_message:
|
|
return None
|
|
|
|
sender_pubkey = self._current_message.sender_pubkey
|
|
if not sender_pubkey:
|
|
return None
|
|
|
|
# Look up sender location from database (any role, not just repeaters)
|
|
results = latest_contact_position_rows(self.bot, sender_pubkey, zero_rule="either")
|
|
|
|
if results:
|
|
row = results[0]
|
|
return (row['latitude'], row['longitude'])
|
|
return None
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting sender location: {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).
|
|
|
|
Args:
|
|
repeaters: List of repeater dictionaries.
|
|
|
|
Returns:
|
|
List[Tuple[Dict[str, Any], float]]: List of (repeater, score) tuples sorting by score descending.
|
|
"""
|
|
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.
|
|
|
|
Args:
|
|
node_id: The node ID to look up.
|
|
|
|
Returns:
|
|
Optional[Tuple[float, float]]: (latitude, longitude) or None if not found.
|
|
"""
|
|
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 is_starred DESC, 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], sender_location: Optional[tuple[float, float]] = None) -> Optional[dict[str, Any]]:
|
|
"""Select repeater based on proximity to previous/next nodes in path.
|
|
|
|
Args:
|
|
repeaters: List of candidate repeaters.
|
|
node_id: Current node ID being resolved.
|
|
path_context: Full path of node IDs.
|
|
sender_location: Optional sender location for first hop optimization.
|
|
|
|
Returns:
|
|
Optional[Dict[str, Any]]: Selected repeater dict or None.
|
|
"""
|
|
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)
|
|
|
|
# For the first repeater in the path, prioritize sender location as the source
|
|
# The first repeater's primary job is to receive from the sender, so use sender location if available
|
|
is_first_repeater = (current_index == 0)
|
|
if is_first_repeater and sender_location:
|
|
# For first repeater, use sender location only (not averaged with next node)
|
|
self.logger.debug(f"Test command: Using sender location for proximity calculation of first repeater: {sender_location[0]:.4f}, {sender_location[1]:.4f}")
|
|
return self._select_by_single_proximity(recent_repeaters, sender_location, "sender")
|
|
|
|
# For the last repeater in the path, prioritize bot location as the destination
|
|
# The last repeater's primary job is to deliver to the bot, so use bot location only
|
|
is_last_repeater = (current_index == len(path_context) - 1)
|
|
if is_last_repeater and self.geographic_guessing_enabled:
|
|
if self.bot_latitude is not None and self.bot_longitude is not None:
|
|
# For last repeater, use bot location only (not averaged with previous node)
|
|
bot_location = (self.bot_latitude, self.bot_longitude)
|
|
self.logger.debug(f"Test command: Using bot location for proximity calculation of last repeater: {self.bot_latitude:.4f}, {self.bot_longitude:.4f}")
|
|
return self._select_by_single_proximity(recent_repeaters, bot_location, "bot")
|
|
|
|
# For non-first/non-last repeaters, use both previous and next locations if available
|
|
# 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, "previous")
|
|
elif next_location:
|
|
return self._select_by_single_proximity(recent_repeaters, next_location, "next")
|
|
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.
|
|
|
|
Args:
|
|
repeaters: List of candidate repeaters.
|
|
prev_location: Coordinates of previous node.
|
|
next_location: Coordinates of next node.
|
|
|
|
Returns:
|
|
Optional[Dict[str, Any]]: Best matching repeater or None.
|
|
"""
|
|
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
|
|
|
|
# Use configurable weighting (from Path_Command config)
|
|
combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight)
|
|
|
|
# Apply star bias multiplier if repeater is starred (use same config as path command)
|
|
star_bias_multiplier = self.bot.config.getfloat('Path_Command', 'star_bias_multiplier', fallback=2.5)
|
|
if repeater.get('is_starred', False):
|
|
combined_score *= star_bias_multiplier
|
|
|
|
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], direction: str = "unknown") -> Optional[dict[str, Any]]:
|
|
"""Select repeater based on proximity to single reference node.
|
|
|
|
Args:
|
|
repeaters: List of candidate repeaters.
|
|
reference_location: Coordinates of reference node (sender, bot, next, previous).
|
|
direction: Direction indicator ('sender', 'bot', 'next', 'previous').
|
|
|
|
Returns:
|
|
Optional[Dict[str, Any]]: Best matching repeater or None.
|
|
"""
|
|
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
|
|
|
|
# For last repeater (direction="bot") or first repeater (direction="sender"), use 100% proximity (0% recency)
|
|
# The final hop to the bot and first hop from sender should prioritize distance above all else
|
|
# Recency still matters for filtering (min_recency_threshold), but not for scoring
|
|
if direction == "bot" or direction == "sender":
|
|
proximity_weight = 1.0
|
|
recency_weight = 0.0
|
|
else:
|
|
# Use configurable weighting for other cases (from Path_Command config)
|
|
proximity_weight = self.proximity_weight
|
|
recency_weight = self.recency_weight
|
|
|
|
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
|
|
|
|
# Use appropriate weighting based on direction
|
|
combined_score = (recency_score * recency_weight) + (proximity_score * proximity_weight)
|
|
|
|
# Apply star bias multiplier if repeater is starred (use same config as path command)
|
|
star_bias_multiplier = self.bot.config.getfloat('Path_Command', 'star_bias_multiplier', fallback=2.5)
|
|
if repeater.get('is_starred', False):
|
|
combined_score *= star_bias_multiplier
|
|
|
|
if combined_score > best_combined_score:
|
|
best_combined_score = combined_score
|
|
best_repeater = repeater
|
|
|
|
return best_repeater
|
|
|
|
# Whether a reply reads in kilometres or miles is an operator choice, so
|
|
# [Test_Command] distance_unit decides it — the same call !gwx makes with its
|
|
# [Weather] unit config. 'auto' keeps the language as a proxy for the operator
|
|
# not having stated one: only US English gets miles, and en-GB is deliberately
|
|
# excluded because it shares the "en" catalog but not the units.
|
|
KM_TO_MILES = 0.621371
|
|
DISTANCE_UNITS = frozenset({'auto', 'km', 'mi'})
|
|
MILES_LANGUAGES = frozenset({'en', 'en-us'})
|
|
|
|
def _read_distance_unit(self) -> str:
|
|
"""Read and validate ``[Test_Command] distance_unit``.
|
|
|
|
Returns:
|
|
str: One of 'auto', 'km' or 'mi'; 'auto' when unset or invalid.
|
|
"""
|
|
raw = self.get_config_value('Test_Command', 'distance_unit', fallback='auto')
|
|
unit = str(raw or 'auto').strip().lower()
|
|
if unit not in self.DISTANCE_UNITS:
|
|
self.logger.warning(
|
|
f"Invalid distance_unit '{raw}' in [Test_Command]; "
|
|
f"expected one of {sorted(self.DISTANCE_UNITS)}. Falling back to 'auto'."
|
|
)
|
|
return 'auto'
|
|
return unit
|
|
|
|
def _response_language(self) -> str:
|
|
"""Get the normalized language code the current reply is rendered in.
|
|
|
|
Prefers the translator bound for this reply (so an auto-detected sender
|
|
language wins) and falls back to ``[Localization] language``.
|
|
|
|
Returns:
|
|
str: Lowercased language code with ``_`` normalized to ``-`` (e.g. 'en-gb').
|
|
"""
|
|
language = getattr(self.response_translator, 'language', None)
|
|
if not isinstance(language, str) or not language:
|
|
try:
|
|
language = self.bot.config.get('Localization', 'language', fallback='en')
|
|
except Exception:
|
|
language = 'en'
|
|
return str(language).strip().replace('_', '-').lower() or 'en'
|
|
|
|
def _uses_miles(self) -> bool:
|
|
"""Check whether distances should be rendered in miles.
|
|
|
|
Returns:
|
|
bool: True when ``distance_unit`` is 'mi', or when it is 'auto' and
|
|
the reply language is US English.
|
|
"""
|
|
if self.distance_unit != 'auto':
|
|
return self.distance_unit == 'mi'
|
|
return self._response_language() in self.MILES_LANGUAGES
|
|
|
|
def _format_distance(self, distance_km: float) -> str:
|
|
"""Format a distance for display in the reply's units.
|
|
|
|
Only display is converted; proximity scoring stays in kilometres so the
|
|
repeater-selection thresholds keep their meaning.
|
|
|
|
Args:
|
|
distance_km: Distance in kilometres.
|
|
|
|
Returns:
|
|
str: Distance with its unit suffix, e.g. ``'12.4km'`` or ``'7.7mi'``.
|
|
"""
|
|
if self._uses_miles():
|
|
return f"{distance_km * self.KM_TO_MILES:.1f}mi"
|
|
return f"{distance_km:.1f}km"
|
|
|
|
def _calculate_path_distance(self, message: MeshMessage) -> str:
|
|
"""Calculate total distance along path (sum of distances between consecutive repeaters with locations).
|
|
|
|
Args:
|
|
message: The message containing the path.
|
|
|
|
Returns:
|
|
str: Formatted distance string used in response.
|
|
"""
|
|
node_ids = self._extract_path_node_ids(message)
|
|
if len(node_ids) < 2:
|
|
routing_info = getattr(message, 'routing_info', None)
|
|
is_direct = (
|
|
(routing_info is not None and routing_info.get('path_length', 0) == 0)
|
|
or not message.path
|
|
or "Direct" in (message.path or "")
|
|
or "0 hops" in (message.path or "")
|
|
)
|
|
if is_direct:
|
|
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
|
|
distance_str = self._format_distance(total_distance)
|
|
if skipped_nodes > 0:
|
|
return f"{distance_str} ({valid_segments} segs, {skipped_nodes} no-loc)"
|
|
else:
|
|
return f"{distance_str} ({valid_segments} segs)"
|
|
|
|
def _calculate_firstlast_distance(self, message: MeshMessage) -> str:
|
|
"""Calculate straight-line distance between first and last repeater in path.
|
|
|
|
Args:
|
|
message: The message containing the path.
|
|
|
|
Returns:
|
|
str: Formatted distance string used in response.
|
|
"""
|
|
node_ids = self._extract_path_node_ids(message)
|
|
if len(node_ids) < 2:
|
|
routing_info = getattr(message, 'routing_info', None)
|
|
is_direct = (
|
|
(routing_info is not None and routing_info.get('path_length', 0) == 0)
|
|
or not message.path
|
|
or "Direct" in (message.path or "")
|
|
or "0 hops" in (message.path or "")
|
|
)
|
|
if is_direct:
|
|
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 self._format_distance(distance)
|
|
|
|
def format_response(self, message: MeshMessage, response_format: str,
|
|
extra: Optional[dict[str, Any]] = None) -> str:
|
|
"""Override to handle phrase extraction.
|
|
|
|
Args:
|
|
message: The original message.
|
|
response_format: The format string.
|
|
extra: Additional placeholders merged over this command's own fields.
|
|
|
|
Returns:
|
|
str: Formatted response string.
|
|
"""
|
|
content = self.clean_content(message.content)
|
|
trigger, args = self.split_trigger_and_args(content)
|
|
# Phrase is whatever follows the matched stem (test, t, or a config alias),
|
|
# so an alias carries a phrase the same way the built-in stems do.
|
|
phrase = args if trigger is not None else ""
|
|
|
|
try:
|
|
fields = self.get_standard_placeholder_fields(message)
|
|
phrase_part = f": {phrase}" if phrase else ""
|
|
fields.update({
|
|
'sender': message.sender_id or self.translate('common.unknown_sender'),
|
|
'phrase': phrase,
|
|
'phrase_part': phrase_part,
|
|
'elapsed': self.format_elapsed(message),
|
|
'snr': str(message.snr) if message.snr is not None else self.translate('common.unknown'),
|
|
'rssi': str(message.rssi) if message.rssi is not None else self.translate('common.unknown'),
|
|
'path_distance': self._calculate_path_distance(message) or '',
|
|
'firstlast_distance': self._calculate_firstlast_distance(message) or '',
|
|
})
|
|
if extra:
|
|
fields.update(extra)
|
|
return format_piped_template(
|
|
response_format,
|
|
fields,
|
|
message=message,
|
|
logger=self.logger,
|
|
config=self.bot.config,
|
|
prefix_hex_chars=getattr(self.bot, 'prefix_hex_chars', 2),
|
|
)
|
|
except (KeyError, ValueError) as e:
|
|
self.logger.warning(f"Error formatting test response: {e}")
|
|
return response_format
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the test command.
|
|
|
|
Args:
|
|
message: The input message trigger.
|
|
|
|
Returns:
|
|
bool: True if execution was successful.
|
|
"""
|
|
if not await self.enforce_path_byte_requirement(message, 'Test_Command'):
|
|
return True
|
|
|
|
# Store the current message for use in location lookups
|
|
self._current_message = message
|
|
return await self.handle_keyword_match(message)
|