From a32fe0dcfdd3e354638d774cb684a5f037000c4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 1 Jan 2026 20:12:49 -0800 Subject: [PATCH] docs: Add docstrings and type hints across modules for improved clarity and maintainability. --- modules/command_manager.py | 160 ++++++++-- modules/commands/advert_command.py | 36 ++- modules/commands/alert_command.py | 222 +++++++++++--- .../commands/alternatives/wx_international.py | 166 ++++++++-- modules/commands/announcements_command.py | 82 ++++- modules/commands/aqi_command.py | 104 ++++++- modules/commands/base_command.py | 135 +++++--- modules/commands/catfact_command.py | 36 ++- modules/commands/channels_command.py | 120 ++++++-- modules/commands/cmd_command.py | 31 +- modules/commands/dadjoke_command.py | 76 ++++- modules/commands/dice_command.py | 79 ++++- modules/commands/help_command.py | 54 +++- modules/commands/hfcond_command.py | 28 +- modules/commands/magic8_command.py | 30 +- modules/commands/moon_command.py | 40 ++- modules/commands/ping_command.py | 29 +- modules/commands/repeater_command.py | 142 +++++++-- modules/commands/roll_command.py | 69 ++++- modules/commands/satpass_command.py | 30 +- modules/commands/solar_command.py | 31 +- modules/commands/stats_command.py | 112 +++++-- modules/commands/sun_command.py | 30 +- modules/commands/test_command.py | 153 ++++++++- modules/commands/webviewer_command.py | 55 +++- modules/core.py | 120 ++++++-- modules/db_manager.py | 141 +++++++-- modules/message_handler.py | 71 ++++- modules/service_plugins/base_service.py | 47 ++- .../service_plugins/map_uploader_service.py | 166 +++++++--- .../service_plugins/packet_capture_service.py | 288 +++++++++++++---- .../service_plugins/packet_capture_utils.py | 166 ++++++---- modules/service_plugins/weather_service.py | 225 ++++++++++---- modules/utils.py | 290 +++++++++--------- 34 files changed, 2788 insertions(+), 776 deletions(-) diff --git a/modules/command_manager.py b/modules/command_manager.py index faf0972..f5728d7 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -21,7 +21,13 @@ from .utils import check_internet_connectivity_async, format_keyword_response_wi @dataclass class InternetStatusCache: - """Thread-safe cache for internet connectivity status""" + """Thread-safe cache for internet connectivity status. + + Attributes: + has_internet: Boolean indicating if internet is available. + timestamp: Timestamp of the last check. + lock: Asyncio lock for thread-safe operations. + """ has_internet: bool timestamp: float lock: Optional[asyncio.Lock] = None @@ -31,12 +37,24 @@ class InternetStatusCache: self.lock = asyncio.Lock() def is_valid(self, cache_duration: float) -> bool: - """Check if cache entry is still valid""" + """Check if cache entry is still valid. + + Args: + cache_duration: Duration in seconds for which the cache is valid. + + Returns: + bool: True if the cache is still valid, False otherwise. + """ return time.time() - self.timestamp < cache_duration class CommandManager: - """Manages all bot commands and responses using dynamic plugin loading""" + """Manages all bot commands and responses using dynamic plugin loading. + + This class handles loading commands from plugins, matching messages against + commands and keywords, checking permissions and rate limits, and executing + command logic. It also manages channel monitoring and banned users. + """ def __init__(self, bot): self.bot = bot @@ -66,11 +84,15 @@ class CommandManager: await asyncio.sleep(self.bot.tx_delay_ms / 1000.0) async def _check_rate_limits(self) -> Tuple[bool, str]: - """ - Check all rate limits before sending. + """Check all rate limits before sending. + + Checks both the user-specific rate limits and the global bot transmission + limits. Also applies transmission delays if configured. Returns: - Tuple of (can_send: bool, reason: str) + Tuple[bool, str]: A tuple containing: + - can_send: True if the message can be sent, False otherwise. + - reason: Reason string if rate limited, empty string otherwise. """ # Check user rate limiter if not self.bot.rate_limiter.can_send(): @@ -90,17 +112,16 @@ class CommandManager: return True, "" def _handle_send_result(self, result, operation_name: str, target: str, used_retry_method: bool = False) -> bool: - """ - Handle result from message send operations. + """Handle result from message send operations. Args: - result: Result from meshcore send operation - operation_name: "DM" or "Channel message" - target: Recipient name or channel name - used_retry_method: True if send_msg_with_retry was used (affects logging) + result: Result object from meshcore send operation. + operation_name: Name of the operation ("DM" or "Channel message"). + target: Recipient name or channel name for logging. + used_retry_method: True if send_msg_with_retry was used (affects logging). Returns: - bool: True if send succeeded, False otherwise + bool: True if send succeeded (ACK received or sent successfully), False otherwise. """ if not result: if used_retry_method: @@ -148,7 +169,11 @@ class CommandManager: return True def load_keywords(self) -> Dict[str, str]: - """Load keywords from config""" + """Load keywords from config. + + Returns: + Dict[str, str]: Dictionary mapping keywords to response strings. + """ keywords = {} if self.bot.config.has_section('Keywords'): for keyword, response in self.bot.config.items('Keywords'): @@ -180,7 +205,15 @@ class CommandManager: return [channel.strip() for channel in channels.split(',') if channel.strip()] def format_keyword_response(self, response_format: str, message: MeshMessage) -> str: - """Format a keyword response string with message data""" + """Format a keyword response string with message data. + + Args: + response_format: The response string format with placeholders. + message: The message object containing context for placeholders. + + Returns: + str: The formatted response string. + """ # Use shared formatting function from utils return format_keyword_response_with_placeholders( response_format, @@ -190,7 +223,17 @@ class CommandManager: ) def check_keywords(self, message: MeshMessage) -> List[tuple]: - """Check message content for keywords and return matching responses""" + """Check message content for keywords and return matching responses. + + Evaluates the message against configured keywords, custom syntax patterns, + and command triggers. + + Args: + message: The incoming message to check. + + Returns: + List[tuple]: List of (trigger, response) tuples for matched keywords. + """ matches = [] # Strip exclamation mark if present (for command-style messages) content = message.content.strip() @@ -282,7 +325,14 @@ class CommandManager: return matches async def handle_advert_command(self, message: MeshMessage): - """Handle the advert command from DM""" + """Handle the advert command from DM. + + Executes the advert command specifically, ensuring proper stat recording + and response handling. + + Args: + message: The message triggering the advert command. + """ command = self.commands['advert'] success = await command.execute(message) @@ -303,7 +353,17 @@ class CommandManager: stats_command.record_command(message, 'advert', response_sent) async def send_dm(self, recipient_id: str, content: str) -> bool: - """Send a direct message using meshcore-cli command""" + """Send a direct message using meshcore-cli command. + + Handles contact lookup, rate limiting, and uses retry logic if available. + + Args: + recipient_id: The recipient's name or ID. + content: The message content to send. + + Returns: + bool: True if sent successfully, False otherwise. + """ if not self.bot.connected or not self.bot.meshcore: return False @@ -368,7 +428,17 @@ class CommandManager: return False async def send_channel_message(self, channel: str, content: str) -> bool: - """Send a channel message using meshcore-cli command""" + """Send a channel message using meshcore-cli command. + + Resolves channel names to numbers and handles rate limiting. + + Args: + channel: The channel name (e.g., "LongFast"). + content: The message content to send. + + Returns: + bool: True if sent successfully, False otherwise. + """ if not self.bot.connected or not self.bot.meshcore: return False @@ -403,7 +473,15 @@ class CommandManager: return False def get_help_for_command(self, command_name: str, message: MeshMessage = None) -> str: - """Get help text for a specific command (LoRa-friendly compact format)""" + """Get help text for a specific command (LoRa-friendly compact format). + + Args: + command_name: The name of the command to retrieve help for. + message: Optional message object for context-aware help (e.g. translated). + + Returns: + str: The help text for the command. + """ # Special handling for common help requests if command_name.lower() in ['commands', 'list', 'all']: # User is asking for a list of commands, show general help @@ -531,7 +609,18 @@ class CommandManager: return commands_list async def send_response(self, message: MeshMessage, content: str) -> bool: - """Unified method for sending responses to users""" + """Unified method for sending responses to users. + + Automatically determines whether to send a DM or channel message based + on the incoming message type. + + Args: + message: The original message being responded to. + content: The response content. + + Returns: + bool: True if response was sent successfully, False otherwise. + """ try: # Store the response content for web viewer capture if hasattr(self, '_last_response'): @@ -548,7 +637,14 @@ class CommandManager: return False async def execute_commands(self, message): - """Execute command objects that handle their own responses""" + """Execute command objects that handle their own responses. + + Identifies and executes commands that were not handled by simple keyword + matching, managing permissions, internet checks, and error handling. + + Args: + message: The message triggering the command execution. + """ # Strip exclamation mark if present (for command-style messages) content = message.content.strip() if content.startswith('!'): @@ -696,13 +792,13 @@ class CommandManager: return def _check_internet_cached(self) -> bool: - """ - Check internet connectivity with caching to avoid checking on every command. - Uses synchronous check for keyword matching. - Note: This is a synchronous method, but the cache itself is thread-safe. + """Check internet connectivity with caching to avoid checking on every command. + + Uses synchronous check for keyword matching. Note: This is a synchronous + method, but the cache itself is thread-safe. Returns: - True if internet is available, False otherwise + bool: True if internet is available, False otherwise. """ current_time = time.time() @@ -721,13 +817,13 @@ class CommandManager: return has_internet async def _check_internet_cached_async(self) -> bool: - """ - Check internet connectivity with caching to avoid checking on every command. - Uses async check for command execution. - Thread-safe with asyncio.Lock to prevent race conditions. + """Check internet connectivity with caching to avoid checking on every command. + + Uses async check for command execution. Thread-safe with asyncio.Lock + to prevent race conditions. Returns: - True if internet is available, False otherwise + bool: True if internet is available, False otherwise. """ # Use lock to prevent race conditions when checking/updating cache async with self._internet_cache.lock: diff --git a/modules/commands/advert_command.py b/modules/commands/advert_command.py index 68660bc..ce0a4b9 100644 --- a/modules/commands/advert_command.py +++ b/modules/commands/advert_command.py @@ -10,7 +10,12 @@ from ..models import MeshMessage class AdvertCommand(BaseCommand): - """Handles the advert command""" + """Handles the advert command. + + This command allows users to manually trigger a flood advertisement + to help propagate their node information across the mesh network. + It enforces a strict cooldown to prevent network congestion. + """ # Plugin metadata name = "advert" @@ -21,10 +26,25 @@ class AdvertCommand(BaseCommand): category = "special" def get_help_text(self) -> str: + """Get help text for the advert command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.advert.description') def can_execute(self, message: MeshMessage) -> bool: - """Check if advert command can be executed""" + """Check if advert command can be executed. + + Verifies both the standard command cooldowns and checks against the + bot's global last advertisement time. + + Args: + message: The message triggering the command. + + Returns: + bool: True if the command can be executed, False otherwise. + """ # Use the base class cooldown check if not super().can_execute(message): return False @@ -38,7 +58,17 @@ class AdvertCommand(BaseCommand): return True async def execute(self, message: MeshMessage) -> bool: - """Execute the advert command""" + """Execute the advert command. + + Sends a flood advertisement if the cooldown has passed. If on cooldown, + informs the user of the remaining time. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully (including cooldown notice), False otherwise. + """ try: # Check if enough time has passed since last advert (1 hour) current_time = time.time() diff --git a/modules/commands/alert_command.py b/modules/commands/alert_command.py index f9168f7..f06c6cd 100644 --- a/modules/commands/alert_command.py +++ b/modules/commands/alert_command.py @@ -66,7 +66,14 @@ UNIT_STATUS = { def _derive_key(salt: bytes) -> bytes: - """Derive AES key from the obfuscated password.""" + """Derive AES key from the obfuscated password. + + Args: + salt: The salt bytes to use for derivation. + + Returns: + bytes: The derived 32-byte key. + """ e = "CommonIncidents" password = e[13] + e[1] + e[2] + "brady" + "5" + "r" + e.lower()[6] + e[5] + "gs" @@ -85,7 +92,14 @@ def _derive_key(salt: bytes) -> bytes: def _decrypt(data: dict) -> dict: - """Decrypt PulsePoint's encrypted response.""" + """Decrypt PulsePoint's encrypted response. + + Args: + data: The encrypted data dictionary from the API. + + Returns: + dict: The decrypted JSON data. + """ ct = base64.b64decode(data["ct"]) iv = bytes.fromhex(data["iv"]) salt = bytes.fromhex(data["s"]) @@ -100,8 +114,15 @@ def _decrypt(data: dict) -> dict: return json.loads(out) -def _parse_time(iso_str: str) -> datetime: - """Parse ISO timestamp to datetime and convert to local time.""" +def _parse_time(iso_str: str) -> Optional[datetime]: + """Parse ISO timestamp to datetime and convert to local time. + + Args: + iso_str: ISO formatted timestamp string. + + Returns: + Optional[datetime]: Parsed timezone-aware datetime, or None if invalid. + """ if not iso_str: return None try: @@ -115,7 +136,14 @@ def _parse_time(iso_str: str) -> datetime: def _time_ago(dt: datetime) -> str: - """Format datetime as relative time string.""" + """Format datetime as relative time string (e.g., '5m ago'). + + Args: + dt: The datetime to compare against current time. + + Returns: + str: Relative time string. + """ if not dt: return "" @@ -138,7 +166,11 @@ def _time_ago(dt: datetime) -> str: class AlertCommand(BaseCommand): - """Handles alert/incident commands using PulsePoint API""" + """Handles alert/incident commands using PulsePoint API. + + Retrieves and displays active fire and emergency incidents for specified + locations (city, county, zipcode, or coordinates). + """ # Plugin metadata name = "alert" @@ -163,7 +195,14 @@ class AlertCommand(BaseCommand): self.max_incident_age_hours = self.get_config_value('Alert_Command', 'max_incident_age_hours', fallback=24.0, value_type='float') def can_execute(self, message: MeshMessage) -> bool: - """Check if this command can be executed with the given message""" + """Check if this command can be executed with the given message. + + Args: + message: The message triggering the command. + + Returns: + bool: True if command is enabled and checks pass, False otherwise. + """ # Check if alert command is enabled alert_enabled = self.get_config_value('Alert_Command', 'alert_enabled', fallback=True, value_type='bool') if not alert_enabled: @@ -173,7 +212,11 @@ class AlertCommand(BaseCommand): return super().can_execute(message) def _load_agencies(self) -> Tuple[Dict[str, str], Dict[str, str]]: - """Load agency IDs from config, separating cities and counties""" + """Load agency IDs from config, separating cities and counties. + + Returns: + Tuple[Dict[str, str], Dict[str, str]]: Tuple of (cities_map, counties_map). + """ cities = {} counties = {} if self.bot.config.has_section('Alert_Command'): @@ -196,11 +239,26 @@ class AlertCommand(BaseCommand): return cities, counties def _normalize_location_key(self, location: str) -> str: - """Normalize location name to match config key format (spaces -> underscores)""" + """Normalize location name to match config key format (spaces -> underscores). + + Args: + location: The raw location string. + + Returns: + str: Normalized location string. + """ return location.lower().replace(' ', '_') def _get_agency_ids(self, location: str = None, location_type: str = None) -> Optional[str]: - """Get agency IDs for a city or county, or default to all configured agencies""" + """Get agency IDs for a city or county, or default to all configured agencies. + + Args: + location: Name of the city or county. + location_type: Type of location ('city' or 'county'). + + Returns: + Optional[str]: Comma-separated agency IDs, or None if specific location not found. + """ if location: location_lower = location.lower() location_normalized = self._normalize_location_key(location) @@ -263,7 +321,14 @@ class AlertCommand(BaseCommand): return ",".join(all_agencies) def _fetch_incidents(self, agency_ids: str) -> List[Dict]: - """Fetch active incidents from PulsePoint""" + """Fetch active incidents from PulsePoint. + + Args: + agency_ids: Comma-separated string of agency IDs. + + Returns: + List[Dict]: List of incident dictionaries. + """ url = "https://api.pulsepoint.org/v1/webapp" params = {"resource": "incidents", "agencyid": agency_ids} headers = { @@ -357,12 +422,15 @@ class AlertCommand(BaseCommand): return [] def _parse_query(self, query: str) -> Tuple[str, Optional[str], Optional[float], Optional[float]]: - """ - Parse query string to determine search type. + """Parse query string to determine search type. + Args: + query: The raw query string from the user. + Returns: - Tuple of (query_type, location, lat, lon) - query_type: "zipcode", "coordinates", "street_city", "city", "county" + Tuple[str, Optional[str], Optional[float], Optional[float]]: + Tuple of (query_type, location, lat, lon). + query_type can be: "zipcode", "coordinates", "street_city", "city", "county". """ query = query.strip() @@ -444,7 +512,15 @@ class AlertCommand(BaseCommand): return ("city", query, None, None) def _match_street_name(self, incidents: List[Dict], street_query: str) -> Tuple[List[Dict], List[Dict]]: - """Split incidents into matched and unmatched by street name""" + """Split incidents into matched and unmatched by street name. + + Args: + incidents: List of incidents to filter. + street_query: Street name to search for. + + Returns: + Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents). + """ street_lower = street_query.lower().strip() matched = [] unmatched = [] @@ -461,7 +537,15 @@ class AlertCommand(BaseCommand): return matched, unmatched def _matches_city(self, inc: Dict, city_query: str) -> bool: - """Check if incident matches the city name by substring matching on address field""" + """Check if incident matches the city name by substring matching on address field. + + Args: + inc: Incident dictionary. + city_query: City name to check. + + Returns: + bool: True if matched, False otherwise. + """ city_query_lower = city_query.lower().strip() address = inc.get("address", "").lower().strip() city = inc.get("city", "").lower().strip() @@ -470,14 +554,19 @@ class AlertCommand(BaseCommand): return city_query_lower in address def _get_city_match_priority(self, inc: Dict, city_query: str) -> int: - """ - Get priority score for city match (higher = better match). - Returns 0 if no match, higher numbers for better matches. + """Get priority score for city match (higher = better match). We prioritize matches where the city name appears at the end of the address (after a comma), as this is the most reliable indicator. The city field can be inaccurate (e.g., showing "SEATTLE" for addresses in King County but not actually in Seattle). + + Args: + inc: Incident dictionary. + city_query: City name to match. + + Returns: + int: Priority score (2=suffix match, 1=substring match, 0=no match). """ city_query_lower = city_query.lower().strip() address = inc.get("address", "").lower().strip() @@ -500,7 +589,15 @@ class AlertCommand(BaseCommand): return 0 def _match_city_name(self, incidents: List[Dict], city_query: str) -> Tuple[List[Dict], List[Dict]]: - """Split incidents into matched and unmatched by city name using simple substring matching on address""" + """Split incidents into matched and unmatched by city name. + + Args: + incidents: List of incidents to filter. + city_query: City name to filter by. + + Returns: + Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents). + """ matched = [] unmatched = [] @@ -513,7 +610,14 @@ class AlertCommand(BaseCommand): return matched, unmatched def _sort_by_time(self, incidents: List[Dict]) -> List[Dict]: - """Sort incidents by time (most recent first)""" + """Sort incidents by time (most recent first). + + Args: + incidents: List of incidents to sort. + + Returns: + List[Dict]: Sorted list of incidents. + """ def get_time_key(inc): time = inc.get("time") if time is None: @@ -526,9 +630,16 @@ class AlertCommand(BaseCommand): return sorted(incidents, key=get_time_key, reverse=True) def _sort_by_distance_then_time(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]: - """ - Sort incidents by distance first, then by time (most recent first) within same distance. - This maintains distance ordering while sorting by time as secondary criteria. + """Sort incidents by distance first, then by time (most recent first) within same distance. + + Args: + incidents: List of incidents to sort. + lat: Reference latitude. + lon: Reference longitude. + max_distance: Optional max distance in km to filter. + + Returns: + List[Dict]: Sorted list of incidents. """ scored_incidents = [] for inc in incidents: @@ -557,7 +668,14 @@ class AlertCommand(BaseCommand): return sorted(scored_incidents, key=lambda x: (x.get("_distance", float('inf')), -x.get("_time_key", datetime.min).timestamp())) def _has_valid_coordinates(self, inc: Dict) -> bool: - """Check if incident has valid coordinates""" + """Check if incident has valid coordinates. + + Args: + inc: Incident dictionary. + + Returns: + bool: True if coordinates are valid and non-zero, False otherwise. + """ inc_lat = inc.get("latitude", 0) inc_lon = inc.get("longitude", 0) # Valid if both are non-zero and within valid ranges @@ -565,17 +683,16 @@ class AlertCommand(BaseCommand): -90 <= inc_lat <= 90 and -180 <= inc_lon <= 180) def _sort_by_distance(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]: - """ - Sort incidents by distance from given coordinates. + """Sort incidents by distance from given coordinates. Args: - incidents: List of incident dicts - lat: Target latitude - lon: Target longitude - max_distance: Optional maximum distance in km (incidents beyond this are excluded) + incidents: List of incident dicts. + lat: Target latitude. + lon: Target longitude. + max_distance: Optional maximum distance in km (incidents beyond this are excluded). Returns: - Sorted list of incidents (closest first). Only includes incidents with valid coordinates. + List[Dict]: Sorted list of incidents (closest first). Only includes incidents with valid coordinates. """ scored_incidents = [] for inc in incidents: @@ -596,7 +713,14 @@ class AlertCommand(BaseCommand): return sorted(scored_incidents, key=lambda x: x.get("_distance", float('inf'))) def _format_incident_compact(self, inc: Dict) -> str: - """Format a single incident in compact format""" + """Format a single incident in compact format. + + Args: + inc: Incident dictionary. + + Returns: + str: Formatted incident string for display. + """ # Get first unit with status icon unit_str = "" if inc.get("units"): @@ -618,7 +742,15 @@ class AlertCommand(BaseCommand): return f"{inc['type']}: {inc['street']}{city_part}{time_part}{unit_str}" def _format_response(self, incidents: List[Dict], max_length: int = 130) -> str: - """Format incidents into a single message, limiting to max_length""" + """Format incidents into a single message, limiting to max_length. + + Args: + incidents: List of incidents to format. + max_length: Maximum length of the output string (default 130 for LoRa). + + Returns: + str: Formatted response string. + """ if not incidents: return "🚨 No active incidents" @@ -682,8 +814,13 @@ class AlertCommand(BaseCommand): return final_message - async def _send_all_response(self, message: MeshMessage, incidents: List[Dict]): - """Send up to 10 incidents in multiple messages, grouping efficiently""" + async def _send_all_response(self, message: MeshMessage, incidents: List[Dict]) -> None: + """Send up to 10 incidents in multiple messages, grouping efficiently. + + Args: + message: The message to respond to. + incidents: List of incidents to send. + """ import asyncio if not incidents: @@ -742,7 +879,16 @@ class AlertCommand(BaseCommand): await asyncio.sleep(2.0) async def execute(self, message: MeshMessage) -> bool: - """Execute the alert command""" + """Execute the alert command. + + Parses query, fetches incidents, filters/sorts, and sends response. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Parse command diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index da3754f..7a66347 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -11,6 +11,7 @@ from geopy.geocoders import Nominatim from ...utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder, geocode_city_sync from ..base_command import BaseCommand from ...models import MeshMessage +from typing import Any, List, Optional, Tuple, Union class GlobalWxCommand(BaseCommand): @@ -27,7 +28,12 @@ class GlobalWxCommand(BaseCommand): ERROR_FETCHING_DATA = "ERROR_FETCHING_DATA" # Placeholder, will use translate() NO_ALERTS = "No weather alerts available" - def __init__(self, bot): + def __init__(self, bot: Any): + """Initialize the global weather command. + + Args: + bot: The bot instance. + """ super().__init__(bot) self.url_timeout = 10 # seconds @@ -58,10 +64,22 @@ class GlobalWxCommand(BaseCommand): self.db_manager = bot.db_manager def get_help_text(self) -> str: + """Get help text for the command. + + Returns: + str: Help text string. + """ return self.translate('commands.gwx.help') def matches_keyword(self, message: MeshMessage) -> bool: - """Check if message starts with a weather keyword""" + """Check if message starts with a weather keyword. + + Args: + message: The received message. + + Returns: + bool: True if message matches a keyword, False otherwise. + """ content = message.content.strip() if content.startswith('!'): content = content[1:].strip() @@ -73,7 +91,14 @@ class GlobalWxCommand(BaseCommand): async def execute(self, message: MeshMessage) -> bool: - """Execute the weather command""" + """Execute the weather command. + + Args: + message: The received message. + + Returns: + bool: True if execution was successful. + """ content = message.content.strip() # Parse the command to extract location and forecast type @@ -145,14 +170,17 @@ class GlobalWxCommand(BaseCommand): await self.send_response(message, self.translate('commands.gwx.error', error=str(e))) return True - async def get_weather_for_location(self, location: str, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> str: - """Get weather data for any global location + async def get_weather_for_location(self, location: str, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> Union[str, Tuple[str, str, str]]: + """Get weather data for any global location. Args: - location: The location (city name, etc.) - forecast_type: "default", "tomorrow", or "multiday" - num_days: Number of days for multiday forecast (2-7) - message: The MeshMessage for dynamic length calculation + location: The location (city name, etc.). + forecast_type: "default", "tomorrow", or "multiday". + num_days: Number of days for multiday forecast (2-7). + message: The MeshMessage for dynamic length calculation. + + Returns: + Union[str, Tuple[str, str, str]]: Format string or tuple for multi-message response. """ try: # Convert location to lat/lon with address details @@ -193,11 +221,17 @@ class GlobalWxCommand(BaseCommand): return self.translate('commands.gwx.error', error=str(e)) def geocode_location(self, location: str) -> tuple: - """Convert location string to lat/lon with address details + """Convert location string to lat/lon with address details. Handles both coordinate strings (lat,lon) and city names. Uses geocode_city_sync for proper default state/country handling, which prioritizes locations in the configured default state/country. + + Args: + location: Location string (e.g., "Seattle" or "47.6,-122.3"). + + Returns: + tuple: (lat, lon, address_info, geocode_result) or (None, None, None, None) on failure. """ try: # Check if location is coordinates (decimal numbers separated by comma, with optional spaces) @@ -269,8 +303,17 @@ class GlobalWxCommand(BaseCommand): self.logger.error(f"Error geocoding location {location}: {e}") return None, None, None, None - def _format_location_display(self, address_info: dict, geocode_result, fallback: str) -> str: - """Format location name for display from address info - returns 'City, CountryCode' format""" + def _format_location_display(self, address_info: dict, geocode_result: Any, fallback: str) -> str: + """Format location name for display from address info - returns 'City, CountryCode' format. + + Args: + address_info: Dictionary containing address details. + geocode_result: Full geocode result object. + fallback: Fallback location string if detailed info is missing. + + Returns: + str: Formatted location string (e.g., "Seattle, US"). + """ # Get country code first (prefer this over full country name) country_code = '' if address_info: @@ -361,7 +404,14 @@ class GlobalWxCommand(BaseCommand): return fallback.title() def _get_state_abbreviation(self, state: str) -> str: - """Convert full state name to abbreviation""" + """Convert full state name to abbreviation. + + Args: + state: Full state name (e.g., "Washington"). + + Returns: + str: Two-letter state abbreviation (e.g., "WA") or original string if not found. + """ state_map = { 'Washington': 'WA', 'California': 'CA', 'New York': 'NY', 'Texas': 'TX', 'Florida': 'FL', 'Illinois': 'IL', 'Pennsylvania': 'PA', 'Ohio': 'OH', @@ -380,14 +430,17 @@ class GlobalWxCommand(BaseCommand): return state_map.get(state, state) def get_open_meteo_weather(self, lat: float, lon: float, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> str: - """Get weather forecast from Open-Meteo API + """Get weather forecast from Open-Meteo API. Args: - lat: Latitude - lon: Longitude - forecast_type: "default", "tomorrow", or "multiday" - num_days: Number of days for multiday forecast (2-7) - message: The MeshMessage for dynamic length calculation + lat: Latitude. + lon: Longitude. + forecast_type: "default", "tomorrow", or "multiday". + num_days: Number of days for multiday forecast (2-7). + message: The MeshMessage for dynamic length calculation. + + Returns: + str: Formatted weather string or error message. """ # Get max message length dynamically max_length = self.get_max_message_length(message) if message else 130 @@ -592,7 +645,14 @@ class GlobalWxCommand(BaseCommand): return self.translate('commands.gwx.error_fetching') def format_tomorrow_forecast(self, data: dict) -> str: - """Format a detailed forecast for tomorrow""" + """Format a detailed forecast for tomorrow. + + Args: + data: Weather data dictionary from Open-Meteo. + + Returns: + str: Formatted tomorrow forecast string. + """ try: daily = data.get('daily', {}) if not daily or len(daily.get('temperature_2m_max', [])) < 2: @@ -643,7 +703,15 @@ class GlobalWxCommand(BaseCommand): return self.translate('commands.gwx.tomorrow_error') def format_multiday_forecast(self, data: dict, num_days: int = 7) -> str: - """Format a less detailed multi-day forecast summary""" + """Format a less detailed multi-day forecast summary. + + Args: + data: Weather data dictionary from Open-Meteo. + num_days: Number of days to include in forecast. + + Returns: + str: Formatted multi-day forecast string (newlines separate days). + """ try: daily = data.get('daily', {}) if not daily: @@ -700,7 +768,14 @@ class GlobalWxCommand(BaseCommand): return self.translate('commands.gwx.multiday_error', num_days=num_days) def _count_display_width(self, text: str) -> int: - """Count display width of text, accounting for emojis which may take 2 display units""" + """Count display width of text, accounting for emojis which may take 2 display units. + + Args: + text: Text to measure. + + Returns: + int: Estimated display width. + """ import re # Count regular characters width = len(text) @@ -723,8 +798,13 @@ class GlobalWxCommand(BaseCommand): width += len(emoji_matches) return width - async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str): - """Send multi-day forecast response, splitting into multiple messages if needed""" + async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str) -> None: + """Send multi-day forecast response, splitting into multiple messages if needed. + + Args: + message: The original message (for reply context). + forecast_text: The full forecast text (lines separated by \n). + """ import asyncio # Get max message length dynamically @@ -788,7 +868,14 @@ class GlobalWxCommand(BaseCommand): await self.send_response(message, current_message) def _degrees_to_direction(self, degrees: float) -> str: - """Convert wind direction in degrees to compass direction with emoji""" + """Convert wind direction in degrees to compass direction with emoji. + + Args: + degrees: Wind direction in degrees. + + Returns: + str: Compass direction string with emoji (e.g., "⬆️N"). + """ if degrees is None: return "" @@ -808,7 +895,14 @@ class GlobalWxCommand(BaseCommand): return "⬆️N" # Default to North def _get_weather_description(self, code: int) -> str: - """Convert WMO weather code to description""" + """Convert WMO weather code to description. + + Args: + code: WMO weather code. + + Returns: + str: Weather description. + """ # Try to get from translations first key = f"commands.gwx.weather_descriptions.{code}" description = self.translate(key) @@ -851,7 +945,14 @@ class GlobalWxCommand(BaseCommand): return description def _get_weather_emoji(self, code: int) -> str: - """Convert WMO weather code to emoji""" + """Convert WMO weather code to emoji. + + Args: + code: WMO weather code. + + Returns: + str: Weather emoji. + """ emoji_map = { 0: "☀️", # Clear 1: "🌤️", # Mostly Clear @@ -885,8 +986,15 @@ class GlobalWxCommand(BaseCommand): return emoji_map.get(code, "🌤️") - def _check_extreme_conditions(self, weather_text: str) -> str: - """Check for extreme weather conditions that warrant warnings""" + def _check_extreme_conditions(self, weather_text: str) -> Optional[str]: + """Check for extreme weather conditions that warrant warnings. + + Args: + weather_text: The formatted weather text to check. + + Returns: + Optional[str]: Warning text if conditions found, None otherwise. + """ warnings = [] # Extract temperature from weather text diff --git a/modules/commands/announcements_command.py b/modules/commands/announcements_command.py index fbc7d96..17e170f 100644 --- a/modules/commands/announcements_command.py +++ b/modules/commands/announcements_command.py @@ -12,7 +12,11 @@ from ..security_utils import validate_pubkey_format class AnnouncementsCommand(BaseCommand): - """Handles announcements command for sending messages to channels""" + """Handles announcements command for sending messages to channels. + + Allows authorized users to trigger pre-configured announcements to be sent + to specific channels. Requires specific ACL access and operates via DM only. + """ # Plugin metadata name = "announcements" @@ -45,7 +49,11 @@ class AnnouncementsCommand(BaseCommand): self.announcements_acl = self._load_announcements_acl() def _load_triggers(self) -> Dict[str, str]: - """Load announcement triggers from config""" + """Load announcement triggers from config. + + Returns: + Dict[str, str]: Dictionary mapping trigger names to announcement text. + """ triggers = {} if self.bot.config.has_section('Announcements_Command'): for key, value in self.bot.config.items('Announcements_Command'): @@ -55,9 +63,12 @@ class AnnouncementsCommand(BaseCommand): return triggers def _load_announcements_acl(self) -> list: - """ - Load announcements ACL from config. + """Load announcements ACL from config. + Inherits members of admin ACL if announcements_acl is not explicitly set. + + Returns: + list: List of permitted public keys. """ acl_list = [] @@ -94,9 +105,15 @@ class AnnouncementsCommand(BaseCommand): return acl_list def _check_announcements_access(self, message: MeshMessage) -> bool: - """ - Check if the message sender has announcements access. + """Check if the message sender has announcements access. + Uses the same security-hardened approach as admin ACL checking. + + Args: + message: The message to check access for. + + Returns: + bool: True if access is granted, False otherwise. """ if not hasattr(self.bot, 'config'): return False @@ -140,7 +157,14 @@ class AnnouncementsCommand(BaseCommand): return has_access def can_execute(self, message: MeshMessage) -> bool: - """Check if announcements command can be executed""" + """Check if announcements command can be executed. + + Args: + message: The message trigger. + + Returns: + bool: True if allowed to execute. + """ # Check if command is enabled if not self.enabled: return False @@ -156,7 +180,14 @@ class AnnouncementsCommand(BaseCommand): return True def _get_trigger_cooldown_remaining(self, trigger_name: str) -> int: - """Get remaining cooldown time in minutes for a trigger""" + """Get remaining cooldown time in minutes for a trigger. + + Args: + trigger_name: Name of the announcement trigger. + + Returns: + int: Remaining cooldown in minutes (0 if ready). + """ if self.cooldown_seconds <= 0: return 0 @@ -175,14 +206,25 @@ class AnnouncementsCommand(BaseCommand): remaining_minutes = int((remaining_seconds + 59) // 60) return remaining_minutes - def _record_trigger_execution(self, trigger_name: str): - """Record the execution time for a trigger""" + def _record_trigger_execution(self, trigger_name: str) -> None: + """Record the execution time for a trigger. + + Args: + trigger_name: Name of the announcement trigger. + """ current_time = time.time() self.trigger_cooldowns[trigger_name] = current_time self.trigger_lockouts[trigger_name] = current_time def _is_trigger_locked(self, trigger_name: str) -> bool: - """Check if a trigger is currently locked (within 60 seconds of last send)""" + """Check if a trigger is currently locked (within 60 seconds of last send). + + Args: + trigger_name: Name of the announcement trigger. + + Returns: + bool: True if locked, False otherwise. + """ if trigger_name not in self.trigger_lockouts: return False @@ -193,12 +235,15 @@ class AnnouncementsCommand(BaseCommand): return elapsed < self.lockout_seconds def _parse_command(self, content: str) -> tuple: - """ - Parse the announce command. + """Parse the announce command. + Format: announce [channel] [override] + Args: + content: Command content string. + Returns: - (trigger_name, channel_name, is_override) or (None, None, False) if invalid + tuple: (trigger_name, channel_name, is_override) or (None, None, False) if invalid. """ # Remove 'announce' keyword parts = content.strip().split(None, 1) @@ -224,7 +269,14 @@ class AnnouncementsCommand(BaseCommand): return (trigger_name, channel_name, is_override) async def execute(self, message: MeshMessage) -> bool: - """Execute the announcements command""" + """Execute the announcements command. + + Args: + message: The input message trigger. + + Returns: + bool: True if execution was successful. + """ try: # Parse command trigger_name, channel_name, is_override = self._parse_command(message.content) diff --git a/modules/commands/aqi_command.py b/modules/commands/aqi_command.py index 0530812..246eff7 100644 --- a/modules/commands/aqi_command.py +++ b/modules/commands/aqi_command.py @@ -16,7 +16,12 @@ from ..models import MeshMessage class AqiCommand(BaseCommand): - """Handles AQI commands with location support using OpenMeteo API""" + """Handles AQI commands with location support using OpenMeteo API. + + Provides Air Quality Index information for specified locations, including + cities, ZIP codes, and coordinates. Supports international locations and + provides health impact categories. + """ # Plugin metadata name = "aqi" @@ -84,12 +89,23 @@ class AqiCommand(BaseCommand): return f"Usage: aqi - Get AQI for city/neighborhood in {self.default_state}, international cities, coordinates, or pollutant help" def get_pollutant_help(self) -> str: - """Get help text explaining pollutant types within 130 characters""" + """Get help text explaining pollutant types within 130 characters. + + Returns: + str: Compact help string explaining pollutant abbreviations. + """ # Compact explanation of all pollutants - fits within 130 chars return "AQI Help: PM2.5=fine particles, PM10=coarse, O3=ozone, NO2=nitrogen dioxide, CO=carbon monoxide, SO2=sulfur dioxide" async def execute(self, message: MeshMessage) -> bool: - """Execute the AQI command""" + """Execute the AQI command. + + Args: + message: The input message trigger. + + Returns: + bool: True if execution was successful. + """ content = message.content.strip() # Parse the command to extract location @@ -275,7 +291,15 @@ class AqiCommand(BaseCommand): return True async def get_aqi_for_location(self, location: str, location_type: str) -> str: - """Get AQI data for a location (city or coordinates)""" + """Get AQI data for a location (city or coordinates). + + Args: + location: Location string (city name, ZIP, or "lat,lon"). + location_type: Type of location ("city", "zipcode", "coordinates"). + + Returns: + str: Formatted AQI string or error message. + """ try: # Define state abbreviation map for US states (needed for all location types) state_abbrev_map = { @@ -571,7 +595,14 @@ class AqiCommand(BaseCommand): return f"Error getting AQI data: {e}" def city_to_lat_lon(self, city: str) -> tuple: - """Convert city name to latitude and longitude using default state""" + """Convert city name to latitude and longitude using default state. + + Args: + city: City name (can include state/country). + + Returns: + tuple: (latitude, longitude, address_info) or (None, None, None). + """ try: # Check if the input contains a comma (city, state/country format) if ',' in city: @@ -652,7 +683,14 @@ class AqiCommand(BaseCommand): return (None, None, None) def get_neighborhood_queries(self, city: str) -> list: - """Generate neighborhood-specific search queries for major cities""" + """Generate neighborhood-specific search queries for major cities. + + Args: + city: City name. + + Returns: + list: List of neighborhood-specific query strings. + """ city_lower = city.lower() # Seattle neighborhoods @@ -722,7 +760,15 @@ class AqiCommand(BaseCommand): return [] def get_openmeteo_aqi(self, lat: float, lon: float) -> str: - """Get AQI data from OpenMeteo API""" + """Get AQI data from OpenMeteo API. + + Args: + lat: Latitude. + lon: Longitude. + + Returns: + str: Formatted AQI string or error constant. + """ try: # Make sure all required weather variables are listed here # The order of variables in current is important to assign them correctly below @@ -763,7 +809,22 @@ class AqiCommand(BaseCommand): return self.ERROR_FETCHING_DATA def format_aqi_response(self, us_aqi, european_aqi, pm10, pm2_5, co, no2, so2, ozone, dust) -> str: - """Format AQI data for display within 130 characters""" + """Format AQI data for display within 130 characters. + + Args: + us_aqi: US Air Quality Index value. + european_aqi: European Air Quality Index value. + pm10: PM10 concentration. + pm2_5: PM2.5 concentration. + co: Carbon Monoxide concentration. + no2: Nitrogen Dioxide concentration. + so2: Sulfur Dioxide concentration. + ozone: Ozone concentration. + dust: Dust concentration. + + Returns: + str: Formatted AQI string. + """ try: # Start with US AQI as primary if us_aqi is not None and us_aqi > 0: @@ -848,7 +909,14 @@ class AqiCommand(BaseCommand): return "Error formatting AQI data" def get_aqi_emoji(self, aqi: float) -> str: - """Get emoji for US AQI value""" + """Get emoji for US AQI value. + + Args: + aqi: US AQI value. + + Returns: + str: Emoji representing AQI level (🟢, 🟡, 🟠, 🔴, 🟣, 🟤). + """ if aqi <= 50: return "🟢" # Good elif aqi <= 100: @@ -863,7 +931,14 @@ class AqiCommand(BaseCommand): return "🟤" # Hazardous def get_european_aqi_emoji(self, aqi: float) -> str: - """Get emoji for European AQI value""" + """Get emoji for European AQI value. + + Args: + aqi: European AQI value. + + Returns: + str: Emoji representing European AQI level. + """ if aqi <= 25: return "🟢" # Good elif aqi <= 50: @@ -876,7 +951,14 @@ class AqiCommand(BaseCommand): return "🟣" # Very Poor def get_aqi_category(self, aqi: float) -> str: - """Get category name for US AQI value""" + """Get category name for US AQI value. + + Args: + aqi: US AQI value. + + Returns: + str: Category description (e.g., "Good", "Moderate"). + """ if aqi <= 50: return "Good" elif aqi <= 100: diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 0adb3e6..17d41a8 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -14,7 +14,12 @@ from ..security_utils import validate_pubkey_format class BaseCommand(ABC): - """Base class for all bot commands - Plugin Interface""" + """Base class for all bot commands - Plugin Interface. + + This class defines the interface that all commands must implement. It provides + common functionality for configuration loading, localization, permission checking, + rate limiting, and message response handling. + """ # Plugin metadata - to be overridden by subclasses name: str = "" @@ -40,15 +45,14 @@ class BaseCommand(ABC): self._load_translated_keywords() def translate(self, key: str, **kwargs) -> str: - """ - Translate a key using the bot's translator + """Translate a key using the bot's translator. Args: - key: Dot-separated key path (e.g., 'commands.wx.usage') - **kwargs: Formatting parameters for string.format() + key: Dot-separated key path (e.g., 'commands.wx.usage'). + **kwargs: Formatting parameters for string.format(). Returns: - Translated string, or key if translation not found + str: Translated string, or key if translation not found. """ if hasattr(self.bot, 'translator'): return self.bot.translator.translate(key, **kwargs) @@ -56,34 +60,32 @@ class BaseCommand(ABC): return key def translate_get_value(self, key: str) -> Any: - """ - Get a raw value from translations (can be string, list, dict, etc.) + """Get a raw value from translations (can be string, list, dict, etc.). Args: - key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors') + key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors'). Returns: - The value at the key path, or None if not found + Any: The value at the key path, or None if not found. """ if hasattr(self.bot, 'translator'): return self.bot.translator.get_value(key) return None - def get_config_value(self, section: str, key: str, fallback=None, value_type: str = 'str'): - """ - Get config value with backward compatibility for section name changes. + def get_config_value(self, section: str, key: str, fallback: Any = None, value_type: str = 'str') -> Any: + """Get config value with backward compatibility for section name changes. For command configs, checks both old format (e.g., 'Hacker') and new format (e.g., 'Hacker_Command'). This allows smooth migration from old config format to new standardized format. Args: - section: Config section name (new format preferred) - key: Config key name - fallback: Default value if not found - value_type: Type of value ('str', 'bool', 'int', 'float') + section: Config section name (new format preferred). + key: Config key name. + fallback: Default value if not found. + value_type: Type of value ('str', 'bool', 'int', 'float', 'list'). Returns: - Config value of appropriate type, or fallback if not found + Any: Config value of appropriate type, or fallback if not found. """ # Map of old section names to new standardized names section_migration = { @@ -145,21 +147,35 @@ class BaseCommand(ABC): return fallback + @abstractmethod @abstractmethod async def execute(self, message: MeshMessage) -> bool: - """Execute the command with the given message""" + """Execute the command with the given message. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if execution was successful, False otherwise. + """ pass def get_help_text(self) -> str: - """Get help text for this command""" + """Get help text for this command. + + Returns: + str: The help text (description) for this command. + """ return self.description or "No help available for this command." def _derive_config_section_name(self) -> str: - """ - Derive config section name from command name. + """Derive config section name from command name. Handles camelCase names like "dadjoke" -> "DadJoke_Command" Regular names like "sports" -> "Sports_Command" + + Returns: + str: The derived config section name. """ # Special handling for camelCase names camel_case_map = { @@ -175,16 +191,16 @@ class BaseCommand(ABC): return f"{base_name}_Command" def _load_allowed_channels(self) -> Optional[List[str]]: - """ - Load allowed channels from config. + """Load allowed channels from config. Config format: [CommandName_Command] channels = channel1,channel2,channel3 Returns: - - None: Use global monitor_channels (default behavior) - - Empty list []: Command disabled for all channels (only DMs) - - List of channels: Command only works in these channels + Optional[List[str]]: + - None: Use global monitor_channels (default behavior) + - Empty list []: Command disabled for all channels (only DMs) + - List of channels: Command only works in these channels """ # Derive section name from command name # Convert "sports" -> "Sports_Command", "greeter" -> "Greeter_Command", etc. @@ -205,13 +221,16 @@ class BaseCommand(ABC): return channels if channels else None def is_channel_allowed(self, message: MeshMessage) -> bool: - """ - Check if this command is allowed in the message's channel. + """Check if this command is allowed in the message's channel. + Args: + message: The message to check. + Returns: - - True if DM and command allows DMs (unless requires_dm is False, but that's separate) - - True if channel is in allowed_channels (or None for global) - - False otherwise + bool: + - True if DM and command allows DMs (unless requires_dm is False, but that's separate) + - True if channel is in allowed_channels (or None for global) + - False otherwise """ # DMs are always allowed (unless requires_dm is False, but that's checked separately) if message.is_dm: @@ -229,7 +248,16 @@ class BaseCommand(ABC): return message.channel in self.allowed_channels def can_execute(self, message: MeshMessage) -> bool: - """Check if this command can be executed with the given message""" + """Check if this command can be executed with the given message. + + Checks channel permissions, DM requirements, cooldowns, and admin access. + + Args: + message: The message to check execution for. + + Returns: + bool: True if the command can be executed, False otherwise. + """ # Check channel access (standardized channel override) if not self.is_channel_allowed(message): return False @@ -252,7 +280,11 @@ class BaseCommand(ABC): return True def get_metadata(self) -> Dict[str, Any]: - """Get plugin metadata for discovery and registration""" + """Get plugin metadata for discovery and registration. + + Returns: + Dict[str, Any]: A dictionary containing metadata about the command. + """ return { 'name': self.name, 'keywords': self.keywords, @@ -266,7 +298,15 @@ class BaseCommand(ABC): } async def send_response(self, message: MeshMessage, content: str) -> bool: - """Unified method for sending responses to users""" + """Unified method for sending responses to users. + + Args: + message: The message to respond to. + content: The response content. + + Returns: + bool: True if the response was sent successfully, False otherwise. + """ try: # Use the command manager's send_response method to ensure response capture return await self.bot.command_manager.send_response(message, content) @@ -275,8 +315,7 @@ class BaseCommand(ABC): return False def get_max_message_length(self, message: MeshMessage) -> int: - """ - Calculate the maximum message length dynamically based on message type and bot username. + """Calculate the maximum message length dynamically based on message type and bot username. Channel messages are formatted as ": ", so: max_length = 150 - username_length - 2 (for ": ") @@ -285,10 +324,10 @@ class BaseCommand(ABC): max_length = 150 Args: - message: The MeshMessage to calculate max length for + message: The MeshMessage to calculate max length for. Returns: - Maximum message length in characters + int: Maximum message length in characters. """ # For DMs, no username prefix - full 150 characters available if message.is_dm: @@ -323,14 +362,15 @@ class BaseCommand(ABC): return max(130, max_length) def check_cooldown(self, user_id: Optional[str] = None) -> Tuple[bool, float]: - """ - Check if user is on cooldown. + """Check if user is on cooldown. Args: user_id: User ID to check cooldown for. If None, checks global cooldown. Returns: - Tuple of (can_execute: bool, remaining_seconds: float) + Tuple[bool, float]: A tuple containing: + - can_execute: True if command can be executed, False otherwise. + - remaining_seconds: Float representing seconds remaining on cooldown. """ if self.cooldown_seconds <= 0: return True, 0.0 @@ -356,8 +396,7 @@ class BaseCommand(ABC): return True, 0.0 def record_execution(self, user_id: Optional[str] = None) -> None: - """ - Record command execution for cooldown tracking. + """Record command execution for cooldown tracking. Args: user_id: User ID to record execution for. If None, records global execution. @@ -381,8 +420,7 @@ class BaseCommand(ABC): self._last_execution_time = current_time def _record_execution(self, user_id: Optional[str] = None): - """ - Record the execution time for cooldown tracking (backward compatibility). + """Record the execution time for cooldown tracking (backward compatibility). Args: user_id: User ID to record execution for. If None, records global execution. @@ -390,14 +428,13 @@ class BaseCommand(ABC): self.record_execution(user_id) def get_remaining_cooldown(self, user_id: Optional[str] = None) -> int: - """ - Get remaining cooldown time in seconds. + """Get remaining cooldown time in seconds. Args: user_id: User ID to check cooldown for. If None, checks global cooldown. Returns: - Remaining cooldown time in seconds (as integer) + int: Remaining cooldown time in seconds (as integer). """ _, remaining = self.check_cooldown(user_id) return max(0, int(remaining)) diff --git a/modules/commands/catfact_command.py b/modules/commands/catfact_command.py index bcd9c8c..89538e0 100644 --- a/modules/commands/catfact_command.py +++ b/modules/commands/catfact_command.py @@ -5,12 +5,17 @@ Provides random cat facts as a hidden easter egg command """ import random +from typing import List from .base_command import BaseCommand from ..models import MeshMessage class CatfactCommand(BaseCommand): - """Handles cat fact commands - hidden easter egg""" + """Handles cat fact commands - hidden easter egg. + + Responds to various cat-related keywords with random facts about cats. + This is designed as a hidden feature and does not appear in standard help listings. + """ # Plugin metadata name = "catfact" @@ -20,6 +25,11 @@ class CatfactCommand(BaseCommand): cooldown_seconds = 3 # 3 second cooldown per user def __init__(self, bot): + """Initialize the catfact command. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Collection of cat facts - fallback if translations not available @@ -94,19 +104,37 @@ class CatfactCommand(BaseCommand): "A cat's average body temperature is 101.5°F (38.6°C) - higher than humans. 🌡️" ] - def get_cat_facts(self) -> list: - """Get cat facts from translations or fallback to hardcoded list""" + def get_cat_facts(self) -> List[str]: + """Get cat facts from translations or fallback to hardcoded list. + + Returns: + List[str]: A list of cat fact strings. + """ facts = self.translate_get_value('commands.catfact.facts') if facts and isinstance(facts, list) and len(facts) > 0: return facts return self.cat_facts_fallback def get_help_text(self) -> str: + """Get help text for the catfact command. + + Returns: + str: Empty string (to keep the command hidden). + """ # Return empty string so it doesn't appear in help return "" async def execute(self, message: MeshMessage) -> bool: - """Execute the cat fact command""" + """Execute the cat fact command. + + Selects a random cat fact and sends it to the user. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Record execution for this user self.record_execution(message.sender_id) diff --git a/modules/commands/channels_command.py b/modules/commands/channels_command.py index 8397983..fd9a70a 100644 --- a/modules/commands/channels_command.py +++ b/modules/commands/channels_command.py @@ -6,12 +6,17 @@ Lists common hashtag channels for the region with multi-message support from .base_command import BaseCommand from ..models import MeshMessage +from typing import Optional import asyncio import re class ChannelsCommand(BaseCommand): - """Handles the channels command""" + """Handles the channels command. + + Lists common hashtag channels for the region with multi-message support + and sub-category filtering. + """ # Plugin metadata name = "channels" @@ -23,7 +28,14 @@ class ChannelsCommand(BaseCommand): return self.translate('commands.channels.help') def matches_keyword(self, message: MeshMessage) -> bool: - """Check if this command matches the message content based on keywords""" + """Check if this command matches the message content based on keywords. + + Args: + message: The message to check. + + Returns: + bool: True if the message matches a command keyword. + """ if not self.keywords: return False @@ -60,7 +72,14 @@ class ChannelsCommand(BaseCommand): return False async def execute(self, message: MeshMessage) -> bool: - """Execute the channels command""" + """Execute the channels command. + + Args: + message: The input message trigger. + + Returns: + bool: True if execution was successful. + """ try: # Parse the command to check for sub-commands content = message.content.strip() @@ -131,7 +150,14 @@ class ChannelsCommand(BaseCommand): return False def _load_channels_from_config(self, sub_command: str = None) -> dict: - """Load channels from the Channels_List config section with optional sub-command filtering""" + """Load channels from the Channels_List config section with optional sub-command filtering. + + Args: + sub_command: Optional category filter. + + Returns: + dict: Dictionary of channel names to descriptions. + """ channels = {} for channel_name, description in self._parse_config_channels(): @@ -163,8 +189,12 @@ class ChannelsCommand(BaseCommand): return channels - async def _show_all_categories(self, message: MeshMessage): - """Show all available channel categories""" + async def _show_all_categories(self, message: MeshMessage) -> None: + """Show all available channel categories. + + Args: + message: The message to reply to. + """ try: categories = self._get_all_categories() @@ -188,7 +218,11 @@ class ChannelsCommand(BaseCommand): await self.send_response(message, self.translate('commands.channels.error_retrieving_categories', error=str(e))) def _get_all_categories(self) -> dict: - """Get all available channel categories and their channel counts""" + """Get all available channel categories and their channel counts. + + Returns: + dict: Dictionary mapping category names to channel counts. + """ categories = {} for channel_name, description in self._parse_config_channels(): @@ -206,8 +240,15 @@ class ChannelsCommand(BaseCommand): return categories - def _find_channel_by_name(self, search_name: str) -> str: - """Find a channel by partial name match across all categories""" + def _find_channel_by_name(self, search_name: str) -> Optional[str]: + """Find a channel by partial name match across all categories. + + Args: + search_name: The channel name to search for. + + Returns: + Optional[str]: The full channel name if found, None otherwise. + """ search_name_lower = search_name.lower() for config_name, description in self._parse_config_channels(): @@ -224,8 +265,13 @@ class ChannelsCommand(BaseCommand): return None - async def _show_specific_channel(self, message: MeshMessage, channel_name: str): - """Show description for a specific channel""" + async def _show_specific_channel(self, message: MeshMessage, channel_name: str) -> None: + """Show description for a specific channel. + + Args: + message: The message to reply to. + channel_name: The channel name to show info for. + """ try: # Search for the channel in all categories found_channel = None @@ -268,7 +314,15 @@ class ChannelsCommand(BaseCommand): await self.send_response(message, self.translate('commands.channels.error_retrieving_channel_info', error=str(e))) def _split_into_messages(self, channel_list: list, sub_command: str = None) -> list: - """Split channel list into multiple messages if they exceed 130 characters""" + """Split channel list into multiple messages if they exceed 130 characters. + + Args: + channel_list: List of channel string items. + sub_command: The current sub-command/category context. + + Returns: + list: List of message strings ready for sending. + """ messages = [] # Set appropriate header based on sub-command @@ -318,7 +372,14 @@ class ChannelsCommand(BaseCommand): return messages def _get_header_for_subcommand(self, sub_command: str = None) -> str: - """Get the appropriate header for a sub-command""" + """Get the appropriate header for a sub-command. + + Args: + sub_command: The sub-command/category name. + + Returns: + str: Header string. + """ if sub_command == "Available categories": return self.translate('commands.channels.headers.available_categories') elif sub_command and sub_command != "general": @@ -327,7 +388,14 @@ class ChannelsCommand(BaseCommand): return self.translate('commands.channels.headers.common_channels') def _get_continuation_header_for_subcommand(self, sub_command: str = None) -> str: - """Get the appropriate header for continuation messages""" + """Get the appropriate header for continuation messages. + + Args: + sub_command: The sub-command/category name. + + Returns: + str: Continuation header string. + """ if sub_command == "Available categories": return self.translate('commands.channels.headers.available_categories') elif sub_command and sub_command != "general": @@ -335,8 +403,13 @@ class ChannelsCommand(BaseCommand): else: return self.translate('commands.channels.headers.common_channels_cont') - async def _send_multiple_messages(self, message: MeshMessage, messages: list): - """Send multiple messages with delays between them""" + async def _send_multiple_messages(self, message: MeshMessage, messages: list) -> None: + """Send multiple messages with delays between them. + + Args: + message: The original command message. + messages: List of message strings to send. + """ for i, msg_content in enumerate(messages): if i > 0: # Small delay between messages to prevent overwhelming the network @@ -344,7 +417,11 @@ class ChannelsCommand(BaseCommand): await self.send_response(message, msg_content) def _parse_config_channels(self): - """Parse all channels from config, returning a generator of (name, description) tuples""" + """Parse all channels from config, returning a generator of (name, description) tuples. + + Yields: + tuple: (channel_name, description) pairs. + """ if not self.bot.config.has_section('Channels_List'): return @@ -357,7 +434,14 @@ class ChannelsCommand(BaseCommand): yield channel_name, description def _is_valid_category(self, category_name: str) -> bool: - """Check if a category name is valid (has channels with that prefix)""" + """Check if a category name is valid (has channels with that prefix). + + Args: + category_name: The category to check. + + Returns: + bool: True if the category exists. + """ if not category_name: return False diff --git a/modules/commands/cmd_command.py b/modules/commands/cmd_command.py index 76c481f..72affea 100644 --- a/modules/commands/cmd_command.py +++ b/modules/commands/cmd_command.py @@ -4,6 +4,7 @@ Cmd command for the MeshCore Bot Lists available commands in a compact, comma-separated format for LoRa """ +from typing import Optional from .base_command import BaseCommand from ..models import MeshMessage @@ -18,16 +19,21 @@ class CmdCommand(BaseCommand): category = "basic" def get_help_text(self) -> str: - return "Lists commands in compact format." - - def _get_commands_list(self, max_length: int = None) -> str: - """Get a compact list of available commands, prioritizing important ones - - Args: - max_length: Maximum length for the command list (None = no limit) + """Get help text for the cmd command. Returns: - Comma-separated list of commands, truncated if necessary + str: The help text for this command. + """ + return "Lists commands in compact format." + + def _get_commands_list(self, max_length: Optional[int] = None) -> str: + """Get a compact list of available commands, prioritizing important ones. + + Args: + max_length: Maximum length for the command list (None = no limit). + + Returns: + str: Comma-separated list of commands, truncated if necessary. """ # Define priority order - most important/commonly used commands first priority_commands = [ @@ -109,7 +115,14 @@ class CmdCommand(BaseCommand): return prefix + ', '.join(result) async def execute(self, message: MeshMessage) -> bool: - """Execute the cmd command""" + """Execute the cmd command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Check if user has defined a custom cmd keyword response in config # Use the already-loaded keywords dict (quotes are already stripped) diff --git a/modules/commands/dadjoke_command.py b/modules/commands/dadjoke_command.py index d87bae9..b84d416 100644 --- a/modules/commands/dadjoke_command.py +++ b/modules/commands/dadjoke_command.py @@ -29,6 +29,11 @@ class DadJokeCommand(BaseCommand): TIMEOUT = 10 # seconds def __init__(self, bot): + """Initialize the dadjoke command. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Load configuration @@ -36,10 +41,22 @@ class DadJokeCommand(BaseCommand): self.long_jokes = bot.config.getboolean('Jokes', 'long_jokes', fallback=False) def get_help_text(self) -> str: + """Get help text for the dadjoke command. + + Returns: + str: The help text for this command. + """ return "Usage: dadjoke - Get a random dad joke" def matches_keyword(self, message: MeshMessage) -> bool: - """Check if message starts with a dad joke keyword""" + """Check if message starts with a dad joke keyword. + + Args: + message: The received message. + + Returns: + bool: True if message matches a keyword, False otherwise. + """ content = message.content.strip() if content.startswith('!'): content = content[1:].strip() @@ -51,7 +68,14 @@ class DadJokeCommand(BaseCommand): return False def can_execute(self, message: MeshMessage) -> bool: - """Override to add custom check (dadjoke_enabled) while using base class cooldown""" + """Override to add custom check (dadjoke_enabled) while using base class cooldown. + + Args: + message: The message triggering the command. + + Returns: + bool: True if the command can be executed, False otherwise. + """ # Use base class for channel access, DM requirements, and cooldown if not super().can_execute(message): return False @@ -63,7 +87,14 @@ class DadJokeCommand(BaseCommand): return True async def execute(self, message: MeshMessage) -> bool: - """Execute the dad joke command""" + """Execute the dad joke command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Record execution for this user self.record_execution(message.sender_id) @@ -86,7 +117,11 @@ class DadJokeCommand(BaseCommand): return True async def get_dad_joke_from_api(self) -> Optional[Dict[str, Any]]: - """Get a dad joke from icanhazdadjoke.com API""" + """Get a dad joke from icanhazdadjoke.com API. + + Returns: + Optional[Dict[str, Any]]: The JSON response from the API, or None if failed. + """ try: headers = { 'Accept': 'application/json', @@ -128,7 +163,11 @@ class DadJokeCommand(BaseCommand): return None async def get_dad_joke_with_length_handling(self) -> Optional[Dict[str, Any]]: - """Get a dad joke from API with length handling based on configuration""" + """Get a dad joke from API with length handling based on configuration. + + Returns: + Optional[Dict[str, Any]]: The JSON response from the API, or None if failed. + """ max_attempts = 5 # Prevent infinite loops for attempt in range(max_attempts): @@ -155,8 +194,13 @@ class DadJokeCommand(BaseCommand): self.logger.warning(f"Could not get short dad joke after {max_attempts} attempts") return joke_data - async def send_dad_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]): - """Send dad joke with length handling - split if necessary""" + async def send_dad_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]) -> None: + """Send dad joke with length handling - split if necessary. + + Args: + message: The message to reply to. + joke_data: The joke data from the API. + """ joke_text = self.format_dad_joke(joke_data) if len(joke_text) <= 130: @@ -177,7 +221,14 @@ class DadJokeCommand(BaseCommand): await self.send_response(message, joke_text) def split_dad_joke(self, joke_text: str) -> list: - """Split a long dad joke at a logical point""" + """Split a long dad joke at a logical point. + + Args: + joke_text: The long joke text to split. + + Returns: + list: A list of two strings (the split parts). + """ # Remove emoji for splitting clean_joke = joke_text[2:] if joke_text.startswith('🥸 ') else joke_text @@ -210,7 +261,14 @@ class DadJokeCommand(BaseCommand): return [f"🥸 {part1}", f"🥸 {part2}"] def format_dad_joke(self, joke_data: Dict[str, Any]) -> str: - """Format the dad joke data into a readable string""" + """Format the dad joke data into a readable string. + + Args: + joke_data: The joke data from the API. + + Returns: + str: The formatted joke string. + """ try: joke = joke_data.get('joke', '') diff --git a/modules/commands/dice_command.py b/modules/commands/dice_command.py index 944cf20..105c455 100644 --- a/modules/commands/dice_command.py +++ b/modules/commands/dice_command.py @@ -30,10 +30,22 @@ class DiceCommand(BaseCommand): } def get_help_text(self) -> str: + """Get help text for the dice command. + + Returns: + str: Help text string. + """ return self.translate('commands.dice.help') def matches_keyword(self, message: MeshMessage) -> bool: - """Override to handle dice-specific matching""" + """Override to handle dice-specific matching. + + Args: + message: The received message. + + Returns: + bool: True if message is a dice command, False otherwise. + """ content = message.content.strip().lower() # Handle command-style messages @@ -54,10 +66,15 @@ class DiceCommand(BaseCommand): return False def parse_dice_notation(self, dice_input: str) -> tuple: - """ - Parse dice notation and return (sides, count, is_decade) + """Parse dice notation and return (sides, count, is_decade). + Supports: d20, 20, d6, 6, 2d6, 4d10, decade, etc. - Returns (sides, count, is_decade) or (None, None, False) if invalid + + Args: + dice_input: The dice string to parse. + + Returns: + tuple: (sides, count, is_decade) or (None, None, False) if invalid. """ dice_input = dice_input.strip().lower() @@ -107,10 +124,15 @@ class DiceCommand(BaseCommand): return None, None, False def parse_mixed_dice(self, dice_input: str) -> list: - """ - Parse mixed dice notation and return list of (sides, count, is_decade) tuples + """Parse mixed dice notation and return list of (sides, count, is_decade) tuples. + Supports: "d10 d6", "2d6 d20", "d4 d8 d12", "decade", etc. - Returns list of (sides, count, is_decade) tuples, or empty list if invalid + + Args: + dice_input: The space-separated dice string. + + Returns: + list: List of (sides, count, is_decade) tuples, or empty list if invalid. """ dice_input = dice_input.strip() if not dice_input: @@ -129,9 +151,17 @@ class DiceCommand(BaseCommand): return parsed_dice def roll_dice(self, sides: int, count: int = 1, is_decade: bool = False) -> list: - """ - Roll dice and return list of results + """Roll dice and return list of results. + For decade dice, returns values 0, 10, 20, ..., 90 (formatted as 00, 10, 20, etc.) + + Args: + sides: Number of sides on the die. + count: Number of dice to roll. + is_decade: Whether it's a decade die (00-90). + + Returns: + list: List of integer results. """ if is_decade: # Decade die: 00, 10, 20, 30, 40, 50, 60, 70, 80, 90 @@ -140,7 +170,17 @@ class DiceCommand(BaseCommand): return [random.randint(1, sides) for _ in range(count)] def format_dice_result(self, sides: int, count: int, results: list, is_decade: bool = False) -> str: - """Format dice roll results into a readable string""" + """Format dice roll results into a readable string. + + Args: + sides: Number of sides. + count: Number of dice. + results: List of roll results. + is_decade: Whether it's a decade die. + + Returns: + str: Formatted result string. + """ if is_decade: # Format decade dice results (00, 10, 20, etc.) formatted_results = [f"{r:02d}" for r in results] @@ -161,9 +201,13 @@ class DiceCommand(BaseCommand): return self.translate('commands.dice.multiple_dice', count=count, sides=sides, results=results_str, total=total) def format_mixed_dice_result(self, dice_results: list) -> str: - """ - Format mixed dice roll results into a readable string - dice_results: list of tuples (sides, count, results_list, is_decade) + """Format mixed dice roll results into a readable string. + + Args: + dice_results: list of tuples (sides, count, results_list, is_decade). + + Returns: + str: Formatted result string for all dice. """ parts = [] grand_total = 0 @@ -194,7 +238,14 @@ class DiceCommand(BaseCommand): return f"🎲 {result_str}" async def execute(self, message: MeshMessage) -> bool: - """Execute the dice command""" + """Execute the dice command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Handle command-style messages diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 98dcfc2..456e940 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -11,7 +11,12 @@ from ..models import MeshMessage class HelpCommand(BaseCommand): - """Handles the help command""" + """Handles the help command. + + Provides assistance to users by listing available commands or displaying + detailed help for specific commands. It dynamically aggregates command + information from all loaded plugins. + """ # Plugin metadata name = "help" @@ -20,17 +25,43 @@ class HelpCommand(BaseCommand): category = "basic" def get_help_text(self) -> str: + """Get help text for the help command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.help.description') async def execute(self, message: MeshMessage) -> bool: - """Execute the help command""" + """Execute the help command. + + Note: The help command logic is primarily handled by the CommandManager's + keyword matching system. This method serves as a placeholder or fallback. + + Args: + message: The message that triggered the command. + + Returns: + bool: True (always, as actual processing happens elsewhere). + """ # The help command is now handled by keyword matching in the command manager # This is just a placeholder for future functionality self.logger.debug("Help command executed (handled by keyword matching)") return True def get_specific_help(self, command_name: str, message: MeshMessage = None) -> str: - """Get help text for a specific command""" + """Get help text for a specific command. + + Resolves aliases, finds the corresponding command plugin, and retrieves + its help text. + + Args: + command_name: The name or alias of the command. + message: Optional message object for context-aware help. + + Returns: + str: The formatted help text for the specific command. + """ # Map command aliases to their actual command names command_aliases = { 't': 't_phrase', @@ -62,7 +93,13 @@ class HelpCommand(BaseCommand): return self.translate('commands.help.unknown', command=command_name, available=available) def get_general_help(self) -> str: - """Get general help text""" + """Get general help text. + + Compiles a list of available commands and usage examples. + + Returns: + str: The general help message to display to users. + """ commands_list = self.get_available_commands_list() help_text = self.translate('commands.help.general', commands_list=commands_list) help_text += self.translate('commands.help.usage_examples') @@ -70,7 +107,14 @@ class HelpCommand(BaseCommand): return help_text def get_available_commands_list(self) -> str: - """Get a list of most popular commands in descending order, showing only one variant per command""" + """Get a list of most popular commands in descending order. + + Queries usage statistics to order commands by popularity. Ensures each + command is listed only once using its primary name. + + Returns: + str: Comma-separated list of command names. + """ try: # Use the plugin loader's keyword mappings to map keywords/aliases to primary command names plugin_loader = self.bot.command_manager.plugin_loader diff --git a/modules/commands/hfcond_command.py b/modules/commands/hfcond_command.py index 6f5b3b0..905c6dd 100644 --- a/modules/commands/hfcond_command.py +++ b/modules/commands/hfcond_command.py @@ -9,7 +9,11 @@ from ..models import MeshMessage class HfcondCommand(BaseCommand): - """Command to get HF band conditions""" + """Command to get HF band conditions. + + Retrieves and displays propagation conditions for High Frequency (HF) bands, + useful for amateur radio operators. + """ # Plugin metadata name = "hfcond" @@ -18,10 +22,22 @@ class HfcondCommand(BaseCommand): category = "solar" def __init__(self, bot): + """Initialize the hfcond command. + + Args: + bot: The MeshCoreBot instance. + """ super().__init__(bot) async def execute(self, message: MeshMessage) -> bool: - """Execute the hfcond command""" + """Execute the hfcond command. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Get HF band conditions hf_info = hf_band_conditions() @@ -34,6 +50,10 @@ class HfcondCommand(BaseCommand): error_msg = self.translate('commands.hfcond.error', error=str(e)) return await self.send_response(message, error_msg) - def get_help_text(self): - """Get help text for this command""" + def get_help_text(self) -> str: + """Get help text for this command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.hfcond.help') diff --git a/modules/commands/magic8_command.py b/modules/commands/magic8_command.py index 304cafc..0b9fbac 100644 --- a/modules/commands/magic8_command.py +++ b/modules/commands/magic8_command.py @@ -4,6 +4,7 @@ Magic 8-ball command for the MeshCore Bot Handles the 'magic8' keyword response """ import random +from typing import Optional from .base_command import BaseCommand from ..models import MeshMessage @@ -15,7 +16,10 @@ def magic8(): class Magic8Command(BaseCommand): - """Handles the magic8 command""" + """Handles the magic8 command. + + Emulates a Magic 8-Ball, providing a random "fortune" response to a user's question. + """ # Plugin metadata name = "magic8" @@ -24,17 +28,35 @@ class Magic8Command(BaseCommand): category = "games" def get_help_text(self) -> str: + """Get help text for the magic8 command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.magic8.description') - def get_response_format(self) -> str: - """Get the response format from config""" + def get_response_format(self) -> Optional[str]: + """Get the response format from config. + + Returns: + Optional[str]: The format string for the response, or None if not configured. + """ if self.bot.config.has_section('Keywords'): format_str = self.bot.config.get('Keywords', 'magic8', fallback=None) return self._strip_quotes_from_config(format_str) if format_str else None return None async def execute(self, message: MeshMessage) -> bool: - """Execute the magic8 command""" + """Execute the magic8 command. + + Selects a random response and sends it to the user. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ answer = magic8() # Format response with sender mention for channel messages, without for DMs diff --git a/modules/commands/moon_command.py b/modules/commands/moon_command.py index 48be7cb..fcce79f 100644 --- a/modules/commands/moon_command.py +++ b/modules/commands/moon_command.py @@ -18,10 +18,22 @@ class MoonCommand(BaseCommand): category = "solar" def __init__(self, bot): + """Initialize the moon command. + + Args: + bot: The bot instance. + """ super().__init__(bot) async def execute(self, message: MeshMessage) -> bool: - """Execute the moon command""" + """Execute the moon command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Get moon information using default location moon_info = get_moon() @@ -39,7 +51,14 @@ class MoonCommand(BaseCommand): return False def _translate_phase_name(self, phase_name: str) -> str: - """Translate English phase name to localized version""" + """Translate English phase name to localized version. + + Args: + phase_name: The English phase name (e.g., 'New Moon'). + + Returns: + str: The translated phase name, or original if not found. + """ # Map English phase names (with or without emoji) to translation keys phase_mapping = { 'New Moon': 'new_moon', @@ -69,7 +88,14 @@ class MoonCommand(BaseCommand): return phase_name def _format_moon_response(self, moon_info: str) -> str: - """Format moon information to be more compact and readable""" + """Format moon information to be more compact and readable. + + Args: + moon_info: The raw moon info string. + + Returns: + str: The formatted response string. + """ try: # Parse the moon info string to extract key information lines = moon_info.split('\n') @@ -130,6 +156,10 @@ class MoonCommand(BaseCommand): # Fallback to original format if formatting fails return self.translate('commands.moon.fallback', info=moon_info) - def get_help_text(self): - """Get help text for this command""" + def get_help_text(self) -> str: + """Get help text for this command. + + Returns: + str: The help text for this command. + """ return self.description diff --git a/modules/commands/ping_command.py b/modules/commands/ping_command.py index 7d23728..bd6626a 100644 --- a/modules/commands/ping_command.py +++ b/modules/commands/ping_command.py @@ -4,12 +4,17 @@ Ping command for the MeshCore Bot Handles the 'ping' keyword response """ +from typing import Optional from .base_command import BaseCommand from ..models import MeshMessage class PingCommand(BaseCommand): - """Handles the ping command""" + """Handles the ping command. + + A simple diagnostic command that responds with 'Pong!' or a custom configured response + to verify bot connectivity and responsiveness. + """ # Plugin metadata name = "ping" @@ -18,15 +23,31 @@ class PingCommand(BaseCommand): category = "basic" def get_help_text(self) -> str: + """Get help text for the ping command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.ping.description') - def get_response_format(self) -> str: - """Get the response format from config""" + def get_response_format(self) -> Optional[str]: + """Get the response format from config. + + Returns: + Optional[str]: The format string for the response, or None if not configured. + """ if self.bot.config.has_section('Keywords'): format_str = self.bot.config.get('Keywords', 'ping', fallback=None) return self._strip_quotes_from_config(format_str) if format_str else None return None async def execute(self, message: MeshMessage) -> bool: - """Execute the ping command""" + """Execute the ping command. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if the response was sent successfully, False otherwise. + """ return await self.handle_keyword_match(message) diff --git a/modules/commands/repeater_command.py b/modules/commands/repeater_command.py index d47f15c..8bff489 100644 --- a/modules/commands/repeater_command.py +++ b/modules/commands/repeater_command.py @@ -11,7 +11,11 @@ from typing import List, Optional class RepeaterCommand(BaseCommand): - """Command for managing repeater contacts""" + """Command for managing repeater contacts. + + Provides functionality to scan, list, purge, and manage repeater and companion contacts + within the mesh network. Includes automated cleanup tools and statistics. + """ # Plugin metadata name = "repeater" @@ -25,7 +29,14 @@ class RepeaterCommand(BaseCommand): super().__init__(bot) def matches_keyword(self, message: MeshMessage) -> bool: - """Check if message starts with 'repeater' keyword""" + """Check if message starts with 'repeater' keyword. + + Args: + message: The message to check for the keyword. + + Returns: + bool: True if the message starts with any of the command keywords. + """ content = message.content.strip() # Handle exclamation prefix @@ -40,7 +51,16 @@ class RepeaterCommand(BaseCommand): return False async def execute(self, message: MeshMessage) -> bool: - """Execute repeater management command""" + """Execute repeater management command. + + Parses subcommands (scan, list, purge, etc.) and routes to the appropriate handler. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ self.logger.info(f"Repeater command executed with content: {message.content}") # Parse the message content to extract subcommand and args @@ -129,7 +149,13 @@ class RepeaterCommand(BaseCommand): return True async def _handle_scan(self) -> str: - """Scan contacts for repeaters""" + """Scan contacts for repeaters. + + Triggers a scan of the device's contact list to identify and catalog repeaters. + + Returns: + str: Result message describing the scan outcome. + """ self.logger.info("Repeater scan command received") if not hasattr(self.bot, 'repeater_manager'): @@ -157,7 +183,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error scanning for repeaters: {e}" async def _handle_list(self, args: List[str]) -> str: - """List repeater contacts""" + """List repeater contacts. + + Args: + args: Command arguments (e.g., '--all' to show purged repeaters). + + Returns: + str: Formatted list of repeaters. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -205,7 +238,16 @@ class RepeaterCommand(BaseCommand): return f"❌ Error listing repeaters: {e}" async def _handle_purge(self, args: List[str]) -> str: - """Purge repeater or companion contacts""" + """Purge repeater or companion contacts. + + Supports purging by name, age (days), or 'all'. + + Args: + args: Command arguments specifying what to purge. + + Returns: + str: Result message describing the purge outcome. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -363,7 +405,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error purging repeaters: {e}" async def _handle_purge_companions(self, args: List[str]) -> str: - """Purge companion contacts based on inactivity""" + """Purge companion contacts based on inactivity. + + Args: + args: Command arguments (optional days threshold). + + Returns: + str: Result message describing the purge outcome. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -448,7 +497,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error purging companions: {e}" async def _handle_restore(self, args: List[str]) -> str: - """Restore purged repeater contacts""" + """Restore purged repeater contacts. + + Args: + args: Command arguments (name pattern to restore). + + Returns: + str: Result message describing the restore outcome. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -489,7 +545,11 @@ class RepeaterCommand(BaseCommand): return f"❌ Error restoring repeaters: {e}" async def _handle_stats(self) -> str: - """Show repeater management statistics""" + """Show repeater management statistics. + + Returns: + str: Formatted statistics summary. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -517,7 +577,11 @@ class RepeaterCommand(BaseCommand): return f"❌ Error getting statistics: {e}" async def _handle_status(self) -> str: - """Show contact list status and limits""" + """Show contact list status and limits. + + Returns: + str: Formatted status message showing usage vs limits. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -546,7 +610,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error getting contact status: {e}" async def _handle_manage(self, args: List[str]) -> str: - """Manage contact list to prevent hitting limits""" + """Manage contact list to prevent hitting limits. + + Args: + args: Command arguments (e.g., '--dry-run'). + + Returns: + str: Result message describing actions taken or proposed. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -609,7 +680,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error managing contact list: {e}" async def _handle_add(self, args: List[str]) -> str: - """Add a discovered contact to the contact list""" + """Add a discovered contact to the contact list. + + Args: + args: Command arguments (name, public_key, reason). + + Returns: + str: Result message indicating success or failure. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -634,7 +712,13 @@ class RepeaterCommand(BaseCommand): return f"❌ Error adding contact: {e}" async def _handle_discover(self) -> str: - """Discover companion contacts""" + """Discover companion contacts. + + Triggers manual discovery of companion contacts. + + Returns: + str: Result message. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -649,8 +733,12 @@ class RepeaterCommand(BaseCommand): except Exception as e: return f"❌ Error discovering contacts: {e}" - async def _handle_stats(self) -> str: - """Show statistics about the complete repeater tracking database""" + async def _handle_contact_stats(self) -> str: + """Show statistics about the complete repeater tracking database. + + Returns: + str: Formatted statistics summary. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -691,7 +779,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error getting repeater statistics: {e}" async def _handle_auto_purge(self, args: List[str]) -> str: - """Handle auto-purge commands""" + """Handle auto-purge commands. + + Args: + args: Command arguments (trigger, enable, disable, monitor). + + Returns: + str: Result message. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -744,7 +839,11 @@ class RepeaterCommand(BaseCommand): return f"❌ Error with auto-purge command: {e}" async def _handle_purge_status(self) -> str: - """Show detailed purge status and recommendations""" + """Show detailed purge status and recommendations. + + Returns: + str: Formatted status message. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." @@ -771,7 +870,14 @@ class RepeaterCommand(BaseCommand): return f"❌ Error getting purge status: {e}" async def _handle_test_purge(self) -> str: - """Test the improved purge system""" + """Test the improved purge system. + + Runs a test purge operation without permanently removing valid contacts, + useful for verifying system functionality. + + Returns: + str: Test result message. + """ if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." diff --git a/modules/commands/roll_command.py b/modules/commands/roll_command.py index 1397929..08adb7e 100644 --- a/modules/commands/roll_command.py +++ b/modules/commands/roll_command.py @@ -6,12 +6,17 @@ Handles random number generation between 1 and X (default 100) import random import re +from typing import Optional from .base_command import BaseCommand from ..models import MeshMessage class RollCommand(BaseCommand): - """Handles random number rolling commands""" + """Handles random number rolling commands. + + This command generates a random number between 1 and a specified maximum (default 100). + It supports syntax like 'roll' or 'roll 50'. + """ # Plugin metadata name = "roll" @@ -20,10 +25,24 @@ class RollCommand(BaseCommand): category = "games" def get_help_text(self) -> str: + """Get help text for the roll command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.roll.help') def matches_keyword(self, message: MeshMessage) -> bool: - """Override to handle roll-specific matching""" + """Override to handle roll-specific matching. + + Custom matching logic to support variable maximums (e.g., "roll 50"). + + Args: + message: The message to check for a match. + + Returns: + bool: True if the message matches the roll command syntax, False otherwise. + """ content = message.content.strip().lower() # Handle command-style messages @@ -46,11 +65,16 @@ class RollCommand(BaseCommand): return False - def parse_roll_notation(self, roll_input: str) -> int: - """ - Parse roll notation and return the maximum number - Supports: 50, 100, 1000, etc. - Returns the maximum number or None if invalid + def parse_roll_notation(self, roll_input: str) -> Optional[int]: + """Parse roll notation and return the maximum number. + + Supports inputs like: 50, 100, 1000. + + Args: + roll_input: The string part containing the number. + + Returns: + Optional[int]: The maximum number if valid, None otherwise. """ roll_input = roll_input.strip() @@ -65,15 +89,40 @@ class RollCommand(BaseCommand): return None def roll_number(self, max_num: int) -> int: - """Roll a random number between 1 and max_num (inclusive)""" + """Roll a random number between 1 and max_num (inclusive). + + Args: + max_num: The maximum possible value. + + Returns: + int: The generated random number. + """ return random.randint(1, max_num) def format_roll_result(self, max_num: int, result: int) -> str: - """Format roll result into a readable string""" + """Format roll result into a readable string. + + Args: + max_num: The maximum number for the roll. + result: The actual rolled number. + + Returns: + str: The formatted result string. + """ return self.translate('commands.roll.result', max=max_num, result=result) async def execute(self, message: MeshMessage) -> bool: - """Execute the roll command""" + """Execute the roll command. + + Parses the maximum number (if provided), generates a random number, + and sends the result to the user. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Handle command-style messages diff --git a/modules/commands/satpass_command.py b/modules/commands/satpass_command.py index 219e0a5..999a675 100644 --- a/modules/commands/satpass_command.py +++ b/modules/commands/satpass_command.py @@ -28,10 +28,22 @@ class SatpassCommand(BaseCommand): } def __init__(self, bot): + """Initialize the satpass command. + + Args: + bot: The bot instance. + """ super().__init__(bot) async def execute(self, message: MeshMessage) -> bool: - """Execute the satpass command""" + """Execute the satpass command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Check if user provided a satellite number content = message.content.strip() @@ -77,8 +89,12 @@ class SatpassCommand(BaseCommand): await self.send_response(message, error_msg) return False - def _get_help_text(self): - """Get detailed help text with shortcuts""" + def _get_help_text(self) -> str: + """Get detailed help text with shortcuts. + + Returns: + str: Detailed help text including shortcuts. + """ shortcuts_text = self.translate('commands.satpass.help_header') # Group shortcuts by category for better organization @@ -111,6 +127,10 @@ class SatpassCommand(BaseCommand): return shortcuts_text - def get_help_text(self): - """Get help text for this command""" + def get_help_text(self) -> str: + """Get help text for this command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.satpass.description') diff --git a/modules/commands/solar_command.py b/modules/commands/solar_command.py index 8473272..5d2d3f9 100644 --- a/modules/commands/solar_command.py +++ b/modules/commands/solar_command.py @@ -9,7 +9,11 @@ from ..models import MeshMessage class SolarCommand(BaseCommand): - """Command to get solar conditions""" + """Command to get solar conditions. + + Provides information about current solar activity (SFI, sunspots, A-index, K-index) + and improved HF band conditions. + """ # Plugin metadata name = "solar" @@ -18,10 +22,24 @@ class SolarCommand(BaseCommand): category = "solar" def __init__(self, bot): + """Initialize the solar command. + + Args: + bot: The MeshCoreBot instance. + """ super().__init__(bot) async def execute(self, message: MeshMessage) -> bool: - """Execute the solar command""" + """Execute the solar command. + + Retrieves solar conditions and sends a formatted response to the user. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Get solar conditions (more readable format) solar_info = solar_conditions() @@ -32,7 +50,16 @@ class SolarCommand(BaseCommand): # Use the unified send_response method return await self.send_response(message, response) + except Exception as e: error_msg = self.translate('commands.solar.error', error=str(e)) await self.send_response(message, error_msg) return False + + def get_help_text(self) -> str: + """Get help text for this command. + + Returns: + str: The help text for this command. + """ + return self.translate('commands.solar.help') diff --git a/modules/commands/stats_command.py b/modules/commands/stats_command.py index 7389e91..269ebed 100644 --- a/modules/commands/stats_command.py +++ b/modules/commands/stats_command.py @@ -13,7 +13,11 @@ from ..models import MeshMessage class StatsCommand(BaseCommand): - """Handles the stats command with comprehensive data collection""" + """Handles the stats command with comprehensive data collection. + + This command tracks usage statistics including messages, commands, and routing paths. + It provides insights into bot activity and network performance over the last 24 hours. + """ # Plugin metadata name = "stats" @@ -26,8 +30,8 @@ class StatsCommand(BaseCommand): self._load_config() self._init_stats_tables() - def _load_config(self): - """Load configuration settings for stats command""" + def _load_config(self) -> None: + """Load configuration settings for stats command.""" self.stats_enabled = self.get_config_value('Stats_Command', 'stats_enabled', fallback=True, value_type='bool') self.data_retention_days = self.get_config_value('Stats_Command', 'data_retention_days', fallback=7, value_type='int') self.auto_cleanup = self.get_config_value('Stats_Command', 'auto_cleanup', fallback=True, value_type='bool') @@ -35,8 +39,12 @@ class StatsCommand(BaseCommand): self.track_command_details = self.get_config_value('Stats_Command', 'track_command_details', fallback=True, value_type='bool') self.anonymize_users = self.get_config_value('Stats_Command', 'anonymize_users', fallback=False, value_type='bool') - def _init_stats_tables(self): - """Initialize database tables for stats tracking""" + def _init_stats_tables(self) -> None: + """Initialize database tables for stats tracking. + + Creates tables for message stats, command stats, and path stats if they + don't already exist. Also sets up necessary indexes for performance. + """ try: with sqlite3.connect(self.bot.db_manager.db_path) as conn: cursor = conn.cursor() @@ -104,8 +112,12 @@ class StatsCommand(BaseCommand): self.logger.error(f"Failed to initialize stats tables: {e}") raise - def record_message(self, message: MeshMessage): - """Record a message in the stats database""" + def record_message(self, message: MeshMessage) -> None: + """Record a message in the stats database. + + Args: + message: The message to record statistics for. + """ if not self.stats_enabled or not self.track_all_messages: return @@ -138,8 +150,14 @@ class StatsCommand(BaseCommand): except Exception as e: self.logger.error(f"Error recording message stats: {e}") - def record_command(self, message: MeshMessage, command_name: str, response_sent: bool = True): - """Record a command execution in the stats database""" + def record_command(self, message: MeshMessage, command_name: str, response_sent: bool = True) -> None: + """Record a command execution in the stats database. + + Args: + message: The message that triggered the command. + command_name: The name of the command executed. + response_sent: Whether a response was sent back to the user. + """ if not self.stats_enabled or not self.track_command_details: return @@ -169,8 +187,12 @@ class StatsCommand(BaseCommand): except Exception as e: self.logger.error(f"Error recording command stats: {e}") - def record_path_stats(self, message: MeshMessage): - """Record path statistics for longest path tracking""" + def record_path_stats(self, message: MeshMessage) -> None: + """Record path statistics for longest path tracking. + + Args: + message: The message containing path information. + """ if not self.stats_enabled or not self.track_all_messages: return @@ -212,7 +234,14 @@ class StatsCommand(BaseCommand): self.logger.error(f"Error recording path stats: {e}") def _is_valid_path_format(self, path: str) -> bool: - """Check if path contains actual node IDs rather than descriptive text""" + """Check if path contains actual node IDs rather than descriptive text. + + Args: + path: The path string to validate. + + Returns: + bool: True if the path structure appears valid, False otherwise. + """ if not path: return False @@ -234,7 +263,14 @@ class StatsCommand(BaseCommand): return False def _format_path_for_display(self, path: str) -> str: - """Format path string for display (e.g., '75,24,1d,5f,bd')""" + """Format path string for display (e.g., '75,24,1d,5f,bd'). + + Args: + path: The raw path string. + + Returns: + str: The formatted path string. + """ if not path: return "Direct" @@ -262,7 +298,17 @@ class StatsCommand(BaseCommand): return self.translate('commands.stats.help') async def execute(self, message: MeshMessage) -> bool: - """Execute the stats command""" + """Execute the stats command. + + Handles subcommands for messages, channels, and paths, or shows basic stats + if no subcommand is provided. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ if not self.stats_enabled: await self.send_response(message, self.translate('commands.stats.disabled')) return False @@ -302,7 +348,11 @@ class StatsCommand(BaseCommand): return False async def _get_basic_stats(self) -> str: - """Get basic bot statistics""" + """Get basic bot statistics. + + Returns: + str: Formatted string containing basic statistics (commands, top user, etc.). + """ try: # Get time window (24 hours ago) now = int(time.time()) @@ -367,7 +417,11 @@ class StatsCommand(BaseCommand): return self.translate('commands.stats.error', error=str(e)) async def _get_bot_user_leaderboard(self) -> str: - """Get leaderboard for bot users (people who triggered bot responses)""" + """Get leaderboard for bot users (people who triggered bot responses). + + Returns: + str: Formatted leaderboard string. + """ try: # Get time window (24 hours ago) now = int(time.time()) @@ -404,7 +458,11 @@ class StatsCommand(BaseCommand): return self.translate('commands.stats.error_bot_users', error=str(e)) async def _get_channel_leaderboard(self) -> str: - """Get leaderboard for channel message activity""" + """Get leaderboard for channel message activity. + + Returns: + str: Formatted leaderboard string. + """ try: # Get time window (24 hours ago) now = int(time.time()) @@ -446,7 +504,11 @@ class StatsCommand(BaseCommand): return self.translate('commands.stats.error_channels', error=str(e)) async def _get_path_leaderboard(self) -> str: - """Get leaderboard for longest paths seen""" + """Get leaderboard for longest paths seen. + + Returns: + str: Formatted leaderboard string. + """ try: # Get time window (24 hours ago) now = int(time.time()) @@ -497,8 +559,12 @@ class StatsCommand(BaseCommand): self.logger.error(f"Error getting path leaderboard: {e}") return self.translate('commands.stats.error_paths', error=str(e)) - def cleanup_old_stats(self, days_to_keep: int = 7): - """Clean up old stats data to prevent database bloat""" + def cleanup_old_stats(self, days_to_keep: int = 7) -> None: + """Clean up old stats data to prevent database bloat. + + Args: + days_to_keep: Number of days of data to retain. + """ try: cutoff_time = int(time.time()) - (days_to_keep * 24 * 60 * 60) @@ -527,7 +593,11 @@ class StatsCommand(BaseCommand): self.logger.error(f"Error cleaning up old stats: {e}") def get_stats_summary(self) -> Dict[str, Any]: - """Get a summary of all stats data""" + """Get a summary of all stats data. + + Returns: + Dict[str, Any]: Dictionary containing summary statistics. + """ try: with sqlite3.connect(self.bot.db_manager.db_path) as conn: cursor = conn.cursor() diff --git a/modules/commands/sun_command.py b/modules/commands/sun_command.py index 3140b0d..1760049 100644 --- a/modules/commands/sun_command.py +++ b/modules/commands/sun_command.py @@ -9,7 +9,11 @@ from ..models import MeshMessage class SunCommand(BaseCommand): - """Command to get sun information""" + """Command to get sun information. + + Calculates and displays sunrise and sunset times for the bot's configured location + or a default location. + """ # Plugin metadata name = "sun" @@ -18,10 +22,24 @@ class SunCommand(BaseCommand): category = "solar" def __init__(self, bot): + """Initialize the sun command. + + Args: + bot: The MeshCoreBot instance. + """ super().__init__(bot) async def execute(self, message: MeshMessage) -> bool: - """Execute the sun command""" + """Execute the sun command. + + Calculates sun events and sends the information to the user. + + Args: + message: The message that triggered the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ try: # Get sun information using default location sun_info = get_sun() @@ -34,6 +52,10 @@ class SunCommand(BaseCommand): error_msg = self.translate('commands.sun.error', error=str(e)) return await self.send_response(message, error_msg) - def get_help_text(self): - """Get help text for this command""" + def get_help_text(self) -> str: + """Get help text for this command. + + Returns: + str: The help text for this command. + """ return self.translate('commands.sun.help') diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index dc5b761..041b3de 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -14,7 +14,11 @@ from ..utils import calculate_distance class TestCommand(BaseCommand): - """Handles the test command""" + """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" @@ -53,10 +57,22 @@ class TestCommand(BaseCommand): self.logger.warning(f"Error reading bot location from config: {e}") def get_help_text(self) -> 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""" + """Clean content by removing control characters and normalizing whitespace. + + Args: + content: The raw message content. + + Returns: + str: Cleaned and normalized content string. + """ import re # Remove control characters (except newline, tab, carriage return) cleaned = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', content) @@ -65,7 +81,16 @@ class TestCommand(BaseCommand): return cleaned def matches_keyword(self, message: MeshMessage) -> bool: - """Override to implement special test keyword matching with optional phrase""" + """Override to implement special test keyword matching with optional phrase. + + Matches 'test', 't', 'test ', or 't '. + + Args: + message: The message to check. + + Returns: + bool: True if the message matches the keyword patterns. + """ # Clean content to remove control characters and normalize whitespace content = self.clean_content(message.content) @@ -89,15 +114,26 @@ class TestCommand(BaseCommand): return False - def get_response_format(self) -> str: - """Get the response format from config""" + def get_response_format(self) -> Optional[str]: + """Get the response format from config. + + Returns: + Optional[str]: The configured response format string, or None if not set. + """ if self.bot.config.has_section('Keywords'): format_str = self.bot.config.get('Keywords', 'test', fallback=None) 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""" + """Extract path node IDs from message path string. + + Args: + message: The message object containing the path. + + Returns: + List[str]: List of valid 2-character hex node IDs. + """ if not message.path: return [] @@ -136,7 +172,15 @@ class TestCommand(BaseCommand): 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""" + """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 @@ -196,7 +240,11 @@ class TestCommand(BaseCommand): return None def _get_sender_location(self) -> Optional[Tuple[float, float]]: - """Get sender location from current message if available""" + """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 @@ -227,7 +275,14 @@ class TestCommand(BaseCommand): 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)""" + """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() @@ -274,7 +329,14 @@ class TestCommand(BaseCommand): 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""" + """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 @@ -305,7 +367,17 @@ class TestCommand(BaseCommand): 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""" + """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) @@ -366,7 +438,16 @@ class TestCommand(BaseCommand): 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""" + """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] @@ -410,7 +491,16 @@ class TestCommand(BaseCommand): 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""" + """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] @@ -457,7 +547,14 @@ class TestCommand(BaseCommand): return best_repeater def _calculate_path_distance(self, message: MeshMessage) -> str: - """Calculate total distance along path (sum of distances between consecutive repeaters with locations)""" + """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: # Check if it's a direct connection @@ -502,7 +599,14 @@ class TestCommand(BaseCommand): 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""" + """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: # Check if it's a direct connection @@ -531,7 +635,15 @@ class TestCommand(BaseCommand): return f"{distance:.1f}km" def format_response(self, message: MeshMessage, response_format: str) -> str: - """Override to handle phrase extraction""" + """Override to handle phrase extraction. + + Args: + message: The original message. + response_format: The format string. + + Returns: + str: Formatted response string. + """ # Clean content to remove control characters and normalize whitespace content = self.clean_content(message.content) @@ -580,7 +692,14 @@ class TestCommand(BaseCommand): return response_format async def execute(self, message: MeshMessage) -> bool: - """Execute the test command""" + """Execute the test command. + + Args: + message: The input message trigger. + + Returns: + bool: True if execution was successful. + """ # Store the current message for use in location lookups self._current_message = message return await self.handle_keyword_match(message) diff --git a/modules/commands/webviewer_command.py b/modules/commands/webviewer_command.py index 57cad3d..290ca3e 100644 --- a/modules/commands/webviewer_command.py +++ b/modules/commands/webviewer_command.py @@ -20,10 +20,30 @@ class WebViewerCommand(BaseCommand): category = "management" def __init__(self, bot): + """Initialize the webviewer command. + + Args: + bot: The bot instance. + """ super().__init__(bot) + + def get_help_text(self) -> str: + """Get help text for the webviewer command. + + Returns: + str: The help text for this command. + """ + return "Usage: webviewer \nSubcommands: status, reset, restart" def matches_keyword(self, message: MeshMessage) -> bool: - """Check if message starts with 'webviewer' keyword""" + """Check if message starts with 'webviewer' keyword. + + Args: + message: The received message. + + Returns: + bool: True if matches, False otherwise. + """ content = message.content.strip() # Handle exclamation prefix @@ -38,7 +58,14 @@ class WebViewerCommand(BaseCommand): return False async def execute(self, message: MeshMessage) -> bool: - """Execute the webviewer command""" + """Execute the webviewer command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if executed successfully, False otherwise. + """ content = message.content.strip() # Handle exclamation prefix @@ -64,8 +91,12 @@ class WebViewerCommand(BaseCommand): return True - async def _handle_status(self, message: MeshMessage): - """Handle status subcommand""" + async def _handle_status(self, message: MeshMessage) -> None: + """Handle status subcommand. + + Args: + message: The message that triggered the detailed status request. + """ if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration: await self.bot.send_response("Web viewer integration not available") return @@ -91,8 +122,12 @@ class WebViewerCommand(BaseCommand): await self.bot.send_response(status_text) - async def _handle_reset(self, message: MeshMessage): - """Handle reset subcommand""" + async def _handle_reset(self, message: MeshMessage) -> None: + """Handle reset subcommand. + + Args: + message: The message that triggered the reset request. + """ if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration: await self.bot.send_response("Web viewer integration not available") return @@ -103,8 +138,12 @@ class WebViewerCommand(BaseCommand): else: await self.bot.send_response("Bot integration not available") - async def _handle_restart(self, message: MeshMessage): - """Handle restart subcommand""" + async def _handle_restart(self, message: MeshMessage) -> None: + """Handle restart subcommand. + + Args: + message: The message that triggered the restart request. + """ if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration: await self.bot.send_response("Web viewer integration not available") return diff --git a/modules/core.py b/modules/core.py index 34c0863..1725fda 100644 --- a/modules/core.py +++ b/modules/core.py @@ -43,7 +43,11 @@ from .utils import resolve_path class MeshCoreBot: - """MeshCore Bot using official meshcore package""" + """MeshCore Bot using official meshcore package. + + This class handles the core functionality of the bot, including connection management, + message processing initialization, and module coordination. + """ def __init__(self, config_file: str = "config.ini"): self.config_file = config_file @@ -194,15 +198,23 @@ class MeshCoreBot: """Get bot root directory (where config.ini is located)""" return Path(self.config_file).parent.resolve() - def load_config(self): - """Load configuration from file""" + def load_config(self) -> None: + """Load configuration from file. + + Reads the configuration file specified in self.config_file. If the file + does not exist, a default configuration is created first. + """ if not Path(self.config_file).exists(): self.create_default_config() self.config.read(self.config_file) - def create_default_config(self): - """Create default configuration file""" + def create_default_config(self) -> None: + """Create default configuration file. + + Writes a default 'config.ini' file to disk with standard settings + and comments explaining each option. + """ default_config = """[Connection] # Connection type: serial, ble, or tcp # serial: Connect via USB serial port @@ -547,8 +559,13 @@ use_zulu_time = false # Note: Using print here since logger may not be initialized yet print(f"Created default config file: {self.config_file}") - def setup_logging(self): - """Setup logging configuration""" + def setup_logging(self) -> None: + """Setup logging configuration. + + Configures the logging system based on settings in the config file. + Sets up console and file handlers, formatters, and log levels for + both the bot and the underlying meshcore library. + """ log_level = getattr(logging, self.config.get('Logging', 'log_level', fallback='INFO')) # Create formatter @@ -632,8 +649,12 @@ use_zulu_time = false # Setup signal handlers for graceful shutdown self._setup_signal_handlers() - def _setup_routing_capture(self): - """Setup routing information capture for web viewer""" + def _setup_routing_capture(self) -> None: + """Setup routing information capture for web viewer. + + Initializes the mechanism to capture message routing information + if the web viewer integration is enabled. + """ # Web viewer doesn't need complex routing capture # It uses direct database access instead of complex integration if not (hasattr(self, 'web_viewer_integration') and @@ -642,8 +663,12 @@ use_zulu_time = false self.logger.info("Web viewer routing capture setup complete") - def _setup_signal_handlers(self): - """Setup signal handlers for graceful shutdown""" + def _setup_signal_handlers(self) -> None: + """Setup signal handlers for graceful shutdown. + + Registers handlers for SIGTERM and SIGINT to ensure the bot can + clean up resources and disconnect properly when stopped. + """ def signal_handler(signum, frame): self.logger.info(f"Received signal {signum}, initiating graceful shutdown...") # Set shutdown event to break main loop @@ -656,7 +681,14 @@ use_zulu_time = false signal.signal(signal.SIGINT, signal_handler) async def connect(self) -> bool: - """Connect to MeshCore node using official package""" + """Connect to MeshCore node using official package. + + Establishes a connection to the mesh node via Serial, TCP, or BLE + based on the configuration. + + Returns: + bool: True if connection was successful, False otherwise. + """ try: self.logger.info("Connecting to MeshCore node...") @@ -710,7 +742,14 @@ use_zulu_time = false return False async def set_radio_clock(self) -> bool: - """Set radio clock only if device time is earlier than current system time""" + """Set radio clock if device time is earlier than system time. + + Checks the connected device's time and updates it to match the system + time if the device is lagging behind. + + Returns: + bool: True if check/update was successful (or not needed), False on error. + """ try: if not self.meshcore or not self.meshcore.is_connected: self.logger.warning("Cannot set radio clock - not connected to device") @@ -749,8 +788,12 @@ use_zulu_time = false self.logger.warning(f"Error checking/setting radio clock: {e}") return False - async def wait_for_contacts(self): - """Wait for contacts to be loaded""" + async def wait_for_contacts(self) -> None: + """Wait for contacts to be loaded from the device. + + Polls the device for contact list or waits for automatic loading. + Times out after 30 seconds if contacts are not loaded. + """ self.logger.info("Waiting for contacts to load...") # Try to manually load contacts first @@ -781,8 +824,12 @@ use_zulu_time = false self.logger.warning(f"Contacts not loaded after {max_wait} seconds, proceeding anyway") - async def setup_message_handlers(self): - """Setup event handlers for messages""" + async def setup_message_handlers(self) -> None: + """Setup event handlers for messages. + + Registers callbacks for various meshcore events including contact messages, + channel messages, RF data, and raw data packets. + """ # Handle contact messages (DMs) async def on_contact_message(event, metadata=None): await self.message_handler.handle_contact_message(event, metadata) @@ -827,8 +874,12 @@ use_zulu_time = false self.logger.info("Message handlers setup complete") - async def start(self): - """Start the bot""" + async def start(self) -> None: + """Start the bot. + + Initiates the connection to the node, sets up scheduling, services, + and starts the main execution loop. + """ self.logger.info("Starting MeshCore Bot...") # Connect to MeshCore node @@ -903,8 +954,12 @@ use_zulu_time = false finally: await self.stop() - async def stop(self): - """Stop the bot""" + async def stop(self) -> None: + """Stop the bot. + + Performs graceful shutdown by stopping services, scheduling, and + disconnecting from the mesh node. + """ try: self.logger.info("Stopping MeshCore Bot...") except (AttributeError, TypeError): @@ -942,10 +997,13 @@ use_zulu_time = false print("Bot stopped") async def get_system_health(self) -> Dict[str, Any]: - """Aggregate health status from all components + """Aggregate health status from all components. + + Collects status information from the meshcore connection, database, + services, and other components to provide a system health report. Returns: - Dictionary containing overall health status and component details + Dict[str, Any]: Dictionary containing overall health status and component details. """ health = { 'status': 'healthy', @@ -1029,8 +1087,12 @@ use_zulu_time = false return health - def _cleanup_web_viewer(self): - """Cleanup web viewer on exit""" + def _cleanup_web_viewer(self) -> None: + """Cleanup web viewer resources on exit. + + Called by atexit handler to ensure the web viewer process is terminated + properly when the bot shuts down. + """ try: if hasattr(self, 'web_viewer_integration') and self.web_viewer_integration: # Web viewer has simpler cleanup @@ -1045,8 +1107,12 @@ use_zulu_time = false except (AttributeError, TypeError): print(f"Error during web viewer cleanup: {e}") - async def send_startup_advert(self): - """Send a startup advert if enabled in config""" + async def send_startup_advert(self) -> None: + """Send a startup advertisement if configured. + + Sends a 'bot online' status message to the mesh network. Can be configured + as a local zero-hop broadcast or a flood message. + """ try: # Check if startup advert is enabled startup_advert = self.config.get('Bot', 'startup_advert', fallback='false').lower() diff --git a/modules/db_manager.py b/modules/db_manager.py index 09aa833..b629774 100644 --- a/modules/db_manager.py +++ b/modules/db_manager.py @@ -13,7 +13,11 @@ from pathlib import Path class DBManager: - """Generalized database manager for common operations""" + """Generalized database manager for common operations. + + Handles database initialization, schema management, caching, and metadata storage. + Enforces a table whitelist for security. + """ # Whitelist of allowed tables for security ALLOWED_TABLES = { @@ -29,14 +33,18 @@ class DBManager: 'purging_log', # Repeater manager } - def __init__(self, bot, db_path: str = "meshcore_bot.db"): + def __init__(self, bot: Any, db_path: str = "meshcore_bot.db"): self.bot = bot self.logger = bot.logger self.db_path = db_path self._init_database() - def _init_database(self): - """Initialize the SQLite database with required tables""" + def _init_database(self) -> None: + """Initialize the SQLite database with required tables. + + Creates all necessary tables including cache, metadata, feed subscriptions, + activity logs, and proper indexes for performance optimization. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -213,7 +221,15 @@ class DBManager: # Geocoding cache methods def get_cached_geocoding(self, query: str) -> Tuple[Optional[float], Optional[float]]: - """Get cached geocoding result for a query""" + """Get cached geocoding result for a query. + + Args: + query: The geocoding query string. + + Returns: + Tuple[Optional[float], Optional[float]]: A tuple containing (latitude, longitude) + if found and valid, otherwise (None, None). + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -229,8 +245,15 @@ class DBManager: self.logger.error(f"Error getting cached geocoding: {e}") return None, None - def cache_geocoding(self, query: str, latitude: float, longitude: float, cache_hours: int = 720): - """Cache geocoding result for future use (default: 30 days)""" + def cache_geocoding(self, query: str, latitude: float, longitude: float, cache_hours: int = 720) -> None: + """Cache geocoding result for future use. + + Args: + query: The geocoding query string. + latitude: Latitude coordinate. + longitude: Longitude coordinate. + cache_hours: Expiration time in hours (default: 720 hours / 30 days). + """ try: # Validate cache_hours to prevent SQL injection if not isinstance(cache_hours, int) or cache_hours < 1 or cache_hours > 87600: # Max 10 years @@ -250,7 +273,15 @@ class DBManager: # Generic cache methods def get_cached_value(self, cache_key: str, cache_type: str) -> Optional[str]: - """Get cached value for a key and type""" + """Get cached value for a key and type. + + Args: + cache_key: Unique key for the cached item. + cache_type: Category or type identifier for the cache. + + Returns: + Optional[str]: Cached string value if found and valid, None otherwise. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -266,8 +297,15 @@ class DBManager: self.logger.error(f"Error getting cached value: {e}") return None - def cache_value(self, cache_key: str, cache_value: str, cache_type: str, cache_hours: int = 24): - """Cache a value for future use""" + def cache_value(self, cache_key: str, cache_value: str, cache_type: str, cache_hours: int = 24) -> None: + """Cache a value for future use. + + Args: + cache_key: Unique key for the cached item. + cache_value: String value to cache. + cache_type: Category or type identifier. + cache_hours: Expiration time in hours (default: 24 hours). + """ try: # Validate cache_hours to prevent SQL injection if not isinstance(cache_hours, int) or cache_hours < 1 or cache_hours > 87600: # Max 10 years @@ -286,7 +324,15 @@ class DBManager: self.logger.error(f"Error caching value: {e}") def get_cached_json(self, cache_key: str, cache_type: str) -> Optional[Dict]: - """Get cached JSON value for a key and type""" + """Get cached JSON value for a key and type. + + Args: + cache_key: Unique key for the cached item. + cache_type: Category or type identifier. + + Returns: + Optional[Dict]: Parsed JSON dictionary if found and valid, None otherwise. + """ cached_value = self.get_cached_value(cache_key, cache_type) if cached_value: try: @@ -296,8 +342,15 @@ class DBManager: return None return None - def cache_json(self, cache_key: str, cache_value: Dict, cache_type: str, cache_hours: int = 720): - """Cache a JSON value for future use (default: 30 days for geolocation)""" + def cache_json(self, cache_key: str, cache_value: Dict, cache_type: str, cache_hours: int = 720) -> None: + """Cache a JSON value for future use. + + Args: + cache_key: Unique key for the cached item. + cache_value: Dictionary to serialize and cache. + cache_type: Category or type identifier. + cache_hours: Expiration time in hours (default: 720 hours / 30 days). + """ try: json_str = json.dumps(cache_value) self.cache_value(cache_key, json_str, cache_type, cache_hours) @@ -305,8 +358,12 @@ class DBManager: self.logger.error(f"Error caching JSON value: {e}") # Cache cleanup methods - def cleanup_expired_cache(self): - """Remove expired cache entries from all cache tables""" + def cleanup_expired_cache(self) -> None: + """Remove expired cache entries from all cache tables. + + Deletes rows from geocoding_cache and generic_cache where the + expiration timestamp has passed. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -328,7 +385,7 @@ class DBManager: except Exception as e: self.logger.error(f"Error cleaning up expired cache: {e}") - def cleanup_geocoding_cache(self): + def cleanup_geocoding_cache(self) -> None: """Remove expired geocoding cache entries""" try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: @@ -377,8 +434,11 @@ class DBManager: self.logger.error(f"Error getting database stats: {e}") return {} - def vacuum_database(self): - """Optimize database by reclaiming unused space""" + def vacuum_database(self) -> None: + """Optimize database by reclaiming unused space. + + Executes the VACUUM command to rebuild the database file and reduce size. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: conn.execute("VACUUM") @@ -387,8 +447,16 @@ class DBManager: self.logger.error(f"Error vacuuming database: {e}") # Table management methods - def create_table(self, table_name: str, schema: str): - """Create a custom table with the given schema (whitelist-protected)""" + def create_table(self, table_name: str, schema: str) -> None: + """Create a custom table with the given schema. + + Args: + table_name: Name of the table to create (must be whitelist-protected). + schema: SQL schema definition for the table columns. + + Raises: + ValueError: If table_name is not in the allowed whitelist. + """ try: # Validate table name against whitelist if table_name not in self.ALLOWED_TABLES: @@ -408,8 +476,15 @@ class DBManager: self.logger.error(f"Error creating table {table_name}: {e}") raise - def drop_table(self, table_name: str): - """Drop a table (whitelist-protected, use with extreme caution)""" + def drop_table(self, table_name: str) -> None: + """Drop a table. + + Args: + table_name: Name of the table to drop (must be whitelist-protected). + + Raises: + ValueError: If table_name is not in the allowed whitelist. + """ try: # Validate table name against whitelist if table_name not in self.ALLOWED_TABLES: @@ -458,8 +533,13 @@ class DBManager: return 0 # Bot metadata methods - def set_metadata(self, key: str, value: str): - """Set a metadata value for the bot""" + def set_metadata(self, key: str, value: str) -> None: + """Set a metadata value for the bot. + + Args: + key: Metadata key name. + value: Metadata string value. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -472,7 +552,14 @@ class DBManager: self.logger.error(f"Error setting metadata {key}: {e}") def get_metadata(self, key: str) -> Optional[str]: - """Get a metadata value for the bot""" + """Get a metadata value for the bot. + + Args: + key: Metadata key to retrieve. + + Returns: + Optional[str]: Value string if found, None otherwise. + """ try: with sqlite3.connect(str(self.db_path), timeout=30.0) as conn: cursor = conn.cursor() @@ -496,7 +583,7 @@ class DBManager: return None return None - def set_bot_start_time(self, start_time: float): + def set_bot_start_time(self, start_time: float) -> None: """Set bot start time in metadata""" self.set_metadata('start_time', str(start_time)) @@ -510,7 +597,7 @@ class DBManager: conn.row_factory = sqlite3.Row return conn - def set_system_health(self, health_data: Dict[str, Any]): + def set_system_health(self, health_data: Dict[str, Any]) -> None: """Store system health data in metadata""" try: import json diff --git a/modules/message_handler.py b/modules/message_handler.py index 3de64cc..269fc04 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -18,7 +18,13 @@ from .security_utils import sanitize_input class MessageHandler: - """Handles incoming messages and routes them to command processors""" + """Handles incoming messages and routes them to command processors. + + This class is responsible for processing various types of MeshCore events, + including contact messages (DMs), raw data packets, advertisement packets, + and RF log data. It also maintains caches for SNR/RSSI data and correlates + messages with routing information. + """ def __init__(self, bot): self.bot = bot @@ -53,7 +59,15 @@ class MessageHandler: self.logger.info(f"RF Data Correlation: timeout={self.rf_data_timeout}s, enhanced={self.enhanced_correlation}") async def handle_contact_message(self, event, metadata=None): - """Handle incoming contact message (DM)""" + """Handle incoming contact message (DM). + + Processes direct messages, extracts path information, correlates with + RF data for signal metrics (SNR/RSSI), and forwards to the command processor. + + Args: + event: The MeshCore event object containing the message payload. + metadata: Optional metadata dictionary associated with the event. + """ try: payload = event.payload @@ -338,7 +352,15 @@ class MessageHandler: self.logger.error(f"Error handling contact message: {e}") async def handle_raw_data(self, event, metadata=None): - """Handle raw data events (full packet data from debug mode)""" + """Handle raw data events (full packet data from debug mode). + + Processes raw packet data, attempts to decode it, and if successful, + checking if it's an advertisement packet to track. + + Args: + event: The MeshCore event object containing the raw data payload. + metadata: Optional metadata dictionary. + """ try: payload = event.payload self.logger.info(f"📦 RAW_DATA EVENT RECEIVED: {payload}") @@ -381,7 +403,15 @@ class MessageHandler: self.logger.error(traceback.format_exc()) async def _process_advertisement_packet(self, packet_info: Dict, metadata=None): - """Process advertisement packets for complete repeater tracking""" + """Process advertisement packets for complete repeater tracking. + + Extracts node information, location data, and routing path from + advertisement packets and updates the repeater database. + + Args: + packet_info: Dictionary containing decoded packet information. + metadata: Optional metadata dictionary with signal metrics. + """ try: # Check if this is an advertisement packet if (packet_info.get('payload_type') == 'ADVERT' or @@ -519,7 +549,15 @@ class MessageHandler: self.logger.error(f"Error processing advertisement packet: {e}") async def handle_rf_log_data(self, event, metadata=None): - """Handle RF log data events to cache SNR information and store raw packet data""" + """Handle RF log data events to cache SNR information and store raw packet data. + + Captures low-level RF information (SNR, RSSI) and raw packet data to + correlate with higher-level messages for detailed signal reporting. + + Args: + event: The MeshCore event object containing RF data. + metadata: Optional metadata dictionary. + """ try: payload = event.payload @@ -662,8 +700,19 @@ class MessageHandler: except Exception as e: self.logger.error(f"Error handling RF log data: {e}") - def extract_path_from_raw_hex(self, raw_hex, expected_hops): - """Extract path information directly from raw hex data""" + def extract_path_from_raw_hex(self, raw_hex: str, expected_hops: int) -> Optional[str]: + """Extract path information directly from raw hex data. + + Attempts to find a sequence of node IDs in the raw packet data that matches + the expected number of hops. + + Args: + raw_hex: Raw packet data as a hex string. + expected_hops: The expected number of hops in the path. + + Returns: + Optional[str]: Comma-separated path string if found, None otherwise. + """ try: if not raw_hex or len(raw_hex) < 20: return None @@ -723,8 +772,12 @@ class MessageHandler: self.logger.debug(f"Error extracting path from raw hex: {e}") return None - def _cleanup_stale_cache_entries(self, current_time=None): - """Remove stale entries from RF data caches and enforce maximum size limits""" + def _cleanup_stale_cache_entries(self, current_time: Optional[float] = None) -> None: + """Remove stale entries from RF data caches and enforce maximum size limits. + + Args: + current_time: Optional timestamp to use as "now". Defaults to time.time(). + """ if current_time is None: current_time = time.time() diff --git a/modules/service_plugins/base_service.py b/modules/service_plugins/base_service.py index a4c1916..7c26044 100644 --- a/modules/service_plugins/base_service.py +++ b/modules/service_plugins/base_service.py @@ -8,7 +8,12 @@ from typing import Dict, Any, Optional class BaseServicePlugin(ABC): - """Base class for background service plugins""" + """Base class for background service plugins. + + This class defines the interface for service plugins, which are long-running + background tasks that can interact with the bot and mesh network. It manages + service lifecycle (start/stop) and metadata. + """ # Optional: Config section name (if different from class name) # If not set, will be derived from class name (e.g., PacketCaptureService -> PacketCapture) @@ -17,12 +22,11 @@ class BaseServicePlugin(ABC): # Optional: Service description for metadata description: str = "" - def __init__(self, bot): - """ - Initialize the service plugin + def __init__(self, bot: Any): + """Initialize the service plugin. Args: - bot: The MeshCoreBot instance + bot: The MeshCoreBot instance containing the service. """ self.bot = bot self.logger = bot.logger @@ -30,9 +34,8 @@ class BaseServicePlugin(ABC): self._running = False @abstractmethod - async def start(self): - """ - Start the service + async def start(self) -> None: + """Start the service. This method should: - Setup event handlers if needed @@ -42,9 +45,8 @@ class BaseServicePlugin(ABC): pass @abstractmethod - async def stop(self): - """ - Stop the service + async def stop(self) -> None: + """Stop the service. This method should: - Clean up event handlers @@ -54,11 +56,10 @@ class BaseServicePlugin(ABC): pass def get_metadata(self) -> Dict[str, Any]: - """ - Get service metadata + """Get service metadata. Returns: - Dictionary containing service metadata + Dict[str, Any]: Dictionary containing service metadata (name, status, etc.). """ return { 'name': self._derive_service_name(), @@ -70,14 +71,22 @@ class BaseServicePlugin(ABC): } def _derive_service_name(self) -> str: - """Derive service name from class name""" + """Derive service name from class name. + + Returns: + str: Derived service name (e.g., 'PacketCaptureService' -> 'packetcapture'). + """ class_name = self.__class__.__name__ if class_name.endswith('Service'): return class_name[:-7].lower() # Remove 'Service' suffix and lowercase return class_name.lower() def _derive_config_section(self) -> str: - """Derive config section name from class name""" + """Derive config section name from class name. + + Returns: + str: Derived config section name. + """ if self.config_section: return self.config_section @@ -87,6 +96,10 @@ class BaseServicePlugin(ABC): return class_name def is_running(self) -> bool: - """Check if the service is currently running""" + """Check if the service is currently running. + + Returns: + bool: True if the service is running, False otherwise. + """ return self._running diff --git a/modules/service_plugins/map_uploader_service.py b/modules/service_plugins/map_uploader_service.py index 58db16f..5ce5431 100644 --- a/modules/service_plugins/map_uploader_service.py +++ b/modules/service_plugins/map_uploader_service.py @@ -22,8 +22,10 @@ from ..enums import AdvertFlags, PayloadType # Import HTTP client try: import aiohttp + AIOHTTP_AVAILABLE = True except ImportError: aiohttp = None + AIOHTTP_AVAILABLE = False # Import cryptography for signature verification try: @@ -31,7 +33,7 @@ try: CRYPTOGRAPHY_AVAILABLE = True except ImportError: CRYPTOGRAPHY_AVAILABLE = False - ed25519 = None + ed25519 = None # type: ignore # Import private key utilities from .packet_capture_utils import ( @@ -48,13 +50,22 @@ from ..utils import resolve_path class MapUploaderService(BaseServicePlugin): - """Map uploader service - uploads node adverts to map.meshcore.dev""" + """Map uploader service. + + Uploads node adverts relative to the MeshCore network to map.meshcore.dev. + Listens for ADVERT packets and uploads them to the centralized map service. + Handles signing of data using the device's private key to ensure authenticity. + """ config_section = 'MapUploader' # Explicit config section description = "Uploads node adverts to map.meshcore.dev" - def __init__(self, bot): - """Initialize map uploader service""" + def __init__(self, bot: Any): + """Initialize map uploader service. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Setup logging @@ -133,7 +144,8 @@ class MapUploaderService(BaseServicePlugin): self.radio_params: Dict[str, Any] = {} # HTTP session - self.http_session: Optional[aiohttp.ClientSession] = None + # HTTP session + self.http_session: Optional[aiohttp.ClientSession] = None # type: ignore # Event subscriptions self.event_subscriptions = [] @@ -147,8 +159,8 @@ class MapUploaderService(BaseServicePlugin): self.logger.info("Map uploader service initialized") - def _load_config(self): - """Load configuration from bot's config""" + def _load_config(self) -> None: + """Load configuration from bot's config.""" config = self.bot.config # Check if enabled @@ -175,18 +187,26 @@ class MapUploaderService(BaseServicePlugin): self.verbose = config.getboolean('MapUploader', 'verbose', fallback=False) @property - def meshcore(self): - """Get meshcore connection from bot (always current)""" + def meshcore(self) -> Any: + """Get meshcore connection from bot (always current). + + Returns: + Any: The meshcore connection object or None. + """ return self.bot.meshcore if self.bot else None - async def start(self): - """Start the map uploader service""" + async def start(self) -> None: + """Start the map uploader service. + + Initializes connections, fetches device keys, and registers event handlers. + Checks for required dependencies (aiohttp, cryptography) before starting. + """ if not self.enabled: self.logger.info("Map uploader service is disabled") return # Check dependencies - if not aiohttp: + if not AIOHTTP_AVAILABLE: self.logger.error("aiohttp is required for map uploader service. Install with: pip install aiohttp") return @@ -220,7 +240,7 @@ class MapUploaderService(BaseServicePlugin): return # Create HTTP session - self.http_session = aiohttp.ClientSession() + self.http_session = aiohttp.ClientSession() # type: ignore # Setup event handlers await self._setup_event_handlers() @@ -229,8 +249,12 @@ class MapUploaderService(BaseServicePlugin): self._running = True self.logger.info("Map uploader service started") - async def stop(self): - """Stop the map uploader service""" + async def stop(self) -> None: + """Stop the map uploader service. + + Closes connections to the map service and the bot's meshcore. + Cleans up resources and event subscriptions. + """ self.logger.info("Stopping map uploader service...") self.should_exit = True @@ -256,8 +280,12 @@ class MapUploaderService(BaseServicePlugin): self.logger.info("Map uploader service stopped") - async def _fetch_private_key(self): - """Fetch private key from device if not already loaded""" + async def _fetch_private_key(self) -> None: + """Fetch private key from device if not already loaded. + + Attempts to read the private key from the connected MeshCore device + for signing map uploads. This is required for valid uploads. + """ if self.private_key_hex: self.logger.debug("Private key already loaded from file") return @@ -279,8 +307,12 @@ class MapUploaderService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error fetching private key from device: {e}") - async def _fetch_device_info(self): - """Fetch device info (public key and radio parameters)""" + async def _fetch_device_info(self) -> None: + """Fetch device info (public key and radio parameters). + + Retrieve public key and LoRa radio settings (frequency, coding rate, etc.) + from the device self_info. These parameters are sent with map uploads. + """ if not self.meshcore or not self.meshcore.is_connected: self.logger.warning("Cannot fetch device info: not connected") return @@ -349,8 +381,11 @@ class MapUploaderService(BaseServicePlugin): 'bw': 0 } - async def _setup_event_handlers(self): - """Setup event handlers for packet capture""" + async def _setup_event_handlers(self) -> None: + """Setup event handlers for packet capture. + + Subscribes to RX_LOG_DATA events to intercept packets for upload. + """ if not self.meshcore: return @@ -367,13 +402,22 @@ class MapUploaderService(BaseServicePlugin): self.logger.info("Map uploader event handlers registered") - def _cleanup_event_subscriptions(self): - """Clean up event subscriptions""" + def _cleanup_event_subscriptions(self) -> None: + """Clean up event subscriptions. + + Clears the list of tracked subscriptions. The actual unsubscription + is handled by the meshcore library when the client disconnects, + but this clears our local tracking. + """ # Note: meshcore library handles subscription cleanup automatically self.event_subscriptions = [] - async def _cleanup_old_seen_adverts(self, current_timestamp: int): - """Clean up old entries from seen_adverts to prevent unbounded memory growth""" + async def _cleanup_old_seen_adverts(self, current_timestamp: int) -> None: + """Clean up old entries from seen_adverts to prevent unbounded memory growth. + + Args: + current_timestamp: The current timestamp from the latest packet. + """ current_time = time.time() # Only cleanup periodically (not on every packet) @@ -419,8 +463,13 @@ class MapUploaderService(BaseServicePlugin): f"seen_adverts grew too large, trimmed to 5000 most recent entries" ) - async def _handle_rx_log_data(self, event, metadata=None): - """Handle RX log data events""" + async def _handle_rx_log_data(self, event: Any, metadata: Any = None) -> None: + """Handle RX log data events. + + Args: + event: The event object containing packet data. + metadata: Optional metadata for the event. + """ try: payload = event.payload @@ -439,8 +488,15 @@ class MapUploaderService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error handling RX log data: {e}", exc_info=True) - async def _process_packet(self, raw_hex: str): - """Process a packet and upload if it's an ADVERT""" + async def _process_packet(self, raw_hex: str) -> None: + """Process a packet and upload if it's an ADVERT. + + Parses the raw packet hex, validates it is an ADVERT, checks for duplicates, + verifies signature, and triggers upload if valid. + + Args: + raw_hex: Hex string representation of the raw packet. + """ try: # Parse packet to check if it's an ADVERT byte_data = bytes.fromhex(raw_hex) @@ -533,7 +589,14 @@ class MapUploaderService(BaseServicePlugin): self.logger.error(f"Error processing packet: {e}", exc_info=True) def _parse_advert(self, payload: bytes) -> Optional[Dict[str, Any]]: - """Parse advert payload""" + """Parse advert payload. + + Args: + payload: Binary payload of the packet. + + Returns: + Optional[Dict[str, Any]]: Parsed advert data dictionary or None if invalid. + """ try: if len(payload) < 101: return None @@ -602,7 +665,15 @@ class MapUploaderService(BaseServicePlugin): return None async def _verify_advert_signature(self, advert: Dict[str, Any], payload: bytes) -> bool: - """Verify advert signature using ed25519""" + """Verify advert signature using ed25519. + + Args: + advert: The parsed advert dictionary containing the signature and public key. + payload: The full binary payload used to verify the signature. + + Returns: + bool: True if signature is valid, False otherwise. + """ if not CRYPTOGRAPHY_AVAILABLE: self.logger.error("Cryptography library not available, cannot verify signatures") return False # Fail verification if library not available (security) @@ -640,8 +711,15 @@ class MapUploaderService(BaseServicePlugin): self.logger.debug(f"Signature verification failed: {e}") return False - async def _upload_to_map(self, advert: Dict[str, Any], raw_packet_hex: str): - """Upload advert to map.meshcore.dev""" + async def _upload_to_map(self, advert: Dict[str, Any], raw_packet_hex: str) -> None: + """Upload advert to map.meshcore.dev. + + Signs the upload request and sends it via HTTP POST. + + Args: + advert: Parsed advert data. + raw_packet_hex: Raw hex of the packet to report. + """ if not self.http_session: self.logger.error("HTTP session not available") return @@ -723,7 +801,14 @@ class MapUploaderService(BaseServicePlugin): self.logger.error(f"Error uploading to map: {e}", exc_info=True) def _sign_data(self, data: Dict[str, Any]) -> Dict[str, Any]: - """Sign data using private key""" + """Sign data using private key. + + Args: + data: Dictionary of data to sign. + + Returns: + Dict[str, Any]: Object containing original data (JSON string) and signature. + """ # Convert data to JSON json_str = json.dumps(data, separators=(',', ':')) @@ -739,7 +824,18 @@ class MapUploaderService(BaseServicePlugin): } def _sign_hash(self, data_hash: bytes) -> str: - """Sign a hash using ed25519 private key (orlp format)""" + """Sign a hash using ed25519 private key (orlp format). + + Args: + data_hash: The SHA256 hash of the data to sign. + + Returns: + str: Hex string of the signature. + + Raises: + ImportError: If PyNaCl is required but missing. + ValueError: If private key length is invalid. + """ try: # Convert private key to bytes private_key_bytes = hex_to_bytes(self.private_key_hex) diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 3d66120..98ffbb1 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -44,13 +44,21 @@ from .base_service import BaseServicePlugin class PacketCaptureService(BaseServicePlugin): - """Packet capture service using bot's meshcore connection""" + """Packet capture service using bot's meshcore connection. + + Captures packets from MeshCore network and publishes to MQTT. + Supports multiple MQTT brokers, auth tokens, and output to file. + """ config_section = 'PacketCapture' # Explicit config section description = "Captures packets from MeshCore network and publishes to MQTT" def __init__(self, bot): - """Initialize packet capture service""" + """Initialize packet capture service. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Don't store meshcore here - it's None until bot connects @@ -149,8 +157,12 @@ class PacketCaptureService(BaseServicePlugin): self.logger.info("Packet capture service initialized") - def _load_config(self): - """Load configuration from bot's config""" + def _load_config(self) -> None: + """Load configuration from bot's config. + + Loads settings for output file, MQTT brokers, auth tokens, and + other service options. + """ config = self.bot.config # Check if enabled @@ -192,7 +204,14 @@ class PacketCaptureService(BaseServicePlugin): # from the device if private_key_hex is None and meshcore_instance is available def _parse_mqtt_brokers(self, config) -> List[Dict[str, Any]]: - """Parse MQTT broker configuration (mqttN_* format)""" + """Parse MQTT broker configuration (mqttN_* format). + + Args: + config: ConfigParser object containing the configuration. + + Returns: + List[Dict[str, Any]]: List of configured MQTT broker dictionaries. + """ brokers = [] # Parse multiple brokers (mqtt1_*, mqtt2_*, etc.) @@ -236,28 +255,68 @@ class PacketCaptureService(BaseServicePlugin): return brokers def get_config_bool(self, key: str, fallback: bool = False) -> bool: - """Get boolean config value""" + """Get boolean config value. + + Args: + key: Config key to retrieve. + fallback: Default value if key is missing. + + Returns: + bool: Config value or fallback. + """ return self.bot.config.getboolean('PacketCapture', key, fallback=fallback) def get_config_int(self, key: str, fallback: int = 0) -> int: - """Get integer config value""" + """Get integer config value. + + Args: + key: Config key to retrieve. + fallback: Default value if key is missing. + + Returns: + int: Config value or fallback. + """ return self.bot.config.getint('PacketCapture', key, fallback=fallback) def get_config_float(self, key: str, fallback: float = 0.0) -> float: - """Get float config value""" + """Get float config value. + + Args: + key: Config key to retrieve. + fallback: Default value if key is missing. + + Returns: + float: Config value or fallback. + """ return self.bot.config.getfloat('PacketCapture', key, fallback=fallback) def get_config_str(self, key: str, fallback: str = '') -> str: - """Get string config value""" + """Get string config value. + + Args: + key: Config key to retrieve. + fallback: Default value if key is missing. + + Returns: + str: Config value or fallback. + """ return self.bot.config.get('PacketCapture', key, fallback=fallback) @property def meshcore(self): - """Get meshcore connection from bot (always current)""" + """Get meshcore connection from bot (always current). + + Returns: + MeshCore: The meshcore instance from the bot. + """ return self.bot.meshcore if self.bot else None - async def start(self): - """Start the packet capture service""" + async def start(self) -> None: + """Start the packet capture service. + + Initializes output file, MQTT connections, and event handlers. + Waits for bot connection before starting. + """ if not self.enabled: self.logger.info("Packet capture service is disabled") return @@ -304,8 +363,11 @@ class PacketCaptureService(BaseServicePlugin): self._running = True self.logger.info(f"Packet capture service started (MQTT: {'connected' if self.mqtt_connected else 'not connected'})") - async def stop(self): - """Stop the packet capture service""" + async def stop(self) -> None: + """Stop the packet capture service. + + Closes output file, disconnects MQTT, and stops background tasks. + """ self.logger.info("Stopping packet capture service...") self.should_exit = True @@ -343,14 +405,20 @@ class PacketCaptureService(BaseServicePlugin): self.logger.info(f"Packet capture service stopped. Total packets captured: {self.packet_count}") - def cleanup_event_subscriptions(self): - """Clean up event subscriptions""" + def cleanup_event_subscriptions(self) -> None: + """Clean up event subscriptions. + + Clears local subscription tracking list. + """ # Note: meshcore library handles subscription cleanup automatically # This is mainly for tracking/logging self.event_subscriptions = [] - async def setup_event_handlers(self): - """Setup event handlers for packet capture""" + async def setup_event_handlers(self) -> None: + """Setup event handlers for packet capture. + + Subscribes to RX_LOG_DATA and RAW_DATA events. + """ if not self.meshcore: return @@ -373,8 +441,13 @@ class PacketCaptureService(BaseServicePlugin): self.logger.info("Packet capture event handlers registered") - async def handle_rx_log_data(self, event, metadata=None): - """Handle RX log data events (matches original script)""" + async def handle_rx_log_data(self, event: Any, metadata: Optional[Dict[str, Any]] = None) -> None: + """Handle RX log data events (matches original script). + + Args: + event: The RX log data event. + metadata: Optional metadata dictionary. + """ try: payload = event.payload @@ -402,8 +475,13 @@ class PacketCaptureService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error handling RX log data: {e}") - async def handle_raw_data(self, event, metadata=None): - """Handle raw data events""" + async def handle_raw_data(self, event: Any, metadata: Optional[Dict[str, Any]] = None) -> None: + """Handle raw data events. + + Args: + event: The raw data event. + metadata: Optional metadata dictionary. + """ try: payload = event.payload raw_data = payload.get('data', '') @@ -427,8 +505,18 @@ class PacketCaptureService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error handling raw data: {e}") - def _format_packet_data(self, raw_hex: str, packet_info: Dict, payload: Dict, metadata: Optional[Dict] = None) -> Dict[str, Any]: - """Format packet data to match original script's format_packet_data exactly""" + def _format_packet_data(self, raw_hex: str, packet_info: Dict[str, Any], payload: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Format packet data to match original script's format_packet_data exactly. + + Args: + raw_hex: Raw hex string of the packet. + packet_info: Decoded packet information. + payload: Payload dictionary from the event. + metadata: Optional metadata dictionary. + + Returns: + Dict[str, Any]: Formatted packet dictionary. + """ current_time = datetime.now() timestamp = current_time.isoformat() @@ -634,8 +722,16 @@ class PacketCaptureService(BaseServicePlugin): return packet_data - async def process_packet(self, raw_hex: str, payload: Dict, metadata: Optional[Dict] = None): - """Process a captured packet""" + async def process_packet(self, raw_hex: str, payload: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> None: + """Process a captured packet. + + Decodes the packet, formats it, writes to file, and publishes to MQTT. + + Args: + raw_hex: Raw hex string of the packet. + payload: Payload dictionary from the event. + metadata: Optional metadata dictionary. + """ try: self.packet_count += 1 @@ -703,8 +799,16 @@ class PacketCaptureService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error processing packet: {e}") - def decode_packet(self, raw_hex: str, payload: Dict) -> Optional[Dict]: - """Decode a MeshCore packet - matches original packet_capture.py functionality""" + def decode_packet(self, raw_hex: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Decode a MeshCore packet - matches original packet_capture.py functionality. + + Args: + raw_hex: Raw hex string of the packet. + payload: Payload dictionary from the event (unused in this method but kept for compatibility). + + Returns: + Optional[Dict[str, Any]]: Decoded packet info, or None if decoding fails. + """ try: # Remove 0x prefix if present if raw_hex.startswith('0x'): @@ -805,7 +909,11 @@ class PacketCaptureService(BaseServicePlugin): return None def _get_bot_name(self) -> str: - """Get bot name from device or config""" + """Get bot name from device or config. + + Returns: + str: The name of the bot/device. + """ # Try to get name from device first if self.meshcore and hasattr(self.meshcore, 'self_info'): try: @@ -828,7 +936,11 @@ class PacketCaptureService(BaseServicePlugin): return bot_name def _require_mqtt(self) -> bool: - """Check if MQTT is available and required""" + """Check if MQTT is available and required. + + Returns: + bool: True if MQTT requirements are met, False otherwise. + """ if mqtt is None: self.logger.warning( "MQTT support not available. Install paho-mqtt: " @@ -837,8 +949,11 @@ class PacketCaptureService(BaseServicePlugin): return False return True - async def connect_mqtt_brokers(self): - """Connect to MQTT brokers""" + async def connect_mqtt_brokers(self) -> None: + """Connect to MQTT brokers. + + Establish connections to all configured MQTT brokers. + """ if not self._require_mqtt(): return @@ -1101,8 +1216,16 @@ class PacketCaptureService(BaseServicePlugin): else: self.logger.warning("MQTT enabled but no brokers connected") - def _resolve_topic_template(self, template: str, packet_type: str = 'packet') -> str: - """Resolve topic template with placeholders""" + def _resolve_topic_template(self, template: str, packet_type: str = 'packet') -> Optional[str]: + """Resolve topic template with placeholders. + + Args: + template: Topic template string. + packet_type: Type of packet ('packet' or 'status'). + + Returns: + Optional[str]: Resolved topic string, or None if template is empty. + """ if not template: return None @@ -1137,8 +1260,15 @@ class PacketCaptureService(BaseServicePlugin): return topic - async def publish_packet_mqtt(self, packet_info: Dict): - """Publish packet to MQTT - returns metrics dict with 'attempted' and 'succeeded' counts""" + async def publish_packet_mqtt(self, packet_info: Dict[str, Any]) -> Dict[str, int]: + """Publish packet to MQTT - returns metrics dict with 'attempted' and 'succeeded' counts. + + Args: + packet_info: Formatted packet dictionary. + + Returns: + Dict[str, int]: Dictionary with 'attempted' and 'succeeded' counts. + """ # Always log when function is called (helps diagnose if it's not being invoked) self.logger.debug(f"publish_packet_mqtt called (packet {self.packet_count}, {len(self.mqtt_clients)} clients)") @@ -1202,8 +1332,12 @@ class PacketCaptureService(BaseServicePlugin): return metrics - async def start_background_tasks(self): - """Start background tasks""" + async def start_background_tasks(self) -> None: + """Start background tasks. + + Initializes scheduler for stats refresh, JWT renewal, health checks, + and MQTT reconnection monitor. + """ # Stats refresh scheduler (matches original script) if self.stats_status_enabled and self.stats_refresh_interval > 0: self.stats_update_task = asyncio.create_task(self.stats_refresh_scheduler()) @@ -1224,8 +1358,11 @@ class PacketCaptureService(BaseServicePlugin): task = asyncio.create_task(self.mqtt_reconnection_monitor()) self.background_tasks.append(task) - async def stats_refresh_scheduler(self): - """Periodically refresh stats and publish them via MQTT (matches original script)""" + async def stats_refresh_scheduler(self) -> None: + """Periodically refresh stats and publish them via MQTT (matches original script). + + Fetches updated radio stats and triggers status publication. + """ if self.stats_refresh_interval <= 0 or not self.stats_status_enabled: return @@ -1245,14 +1382,25 @@ class PacketCaptureService(BaseServicePlugin): break async def _wait_with_shutdown(self, timeout: float) -> bool: - """Wait for specified time but return immediately if shutdown is requested""" + """Wait for specified time but return immediately if shutdown is requested. + + Args: + timeout: Time to wait in seconds. + + Returns: + bool: True if shutdown requested, False if timeout completed. + """ if self.should_exit: return True await asyncio.sleep(timeout) return False def _load_client_version(self) -> str: - """Load client version (matches original script)""" + """Load client version (matches original script). + + Returns: + str: Version string (e.g., 'meshcore-bot/1.0.0-abcdef'). + """ try: import os import subprocess @@ -1283,8 +1431,12 @@ class PacketCaptureService(BaseServicePlugin): # Final fallback return "meshcore-bot/unknown" - async def get_firmware_info(self): - """Get firmware information from meshcore device (matches original script)""" + async def get_firmware_info(self) -> Dict[str, str]: + """Get firmware information from meshcore device (matches original script). + + Returns: + Dict[str, str]: Dictionary containing 'model' and 'version'. + """ try: # During shutdown, always use cached info - don't query the device if self.should_exit: @@ -1353,7 +1505,11 @@ class PacketCaptureService(BaseServicePlugin): return {"model": "unknown", "version": "unknown"} def stats_commands_available(self) -> bool: - """Detect whether the connected meshcore build exposes stats commands (matches original script)""" + """Detect whether the connected meshcore build exposes stats commands (matches original script). + + Returns: + bool: True if stats commands are available. + """ if not self.meshcore or not hasattr(self.meshcore, "commands"): return False @@ -1370,8 +1526,15 @@ class PacketCaptureService(BaseServicePlugin): self.stats_supported = available return available - async def refresh_stats(self, force: bool = False): - """Fetch stats from the radio and cache them for status publishing (matches original script)""" + async def refresh_stats(self, force: bool = False) -> Optional[Dict[str, Any]]: + """Fetch stats from the radio and cache them for status publishing (matches original script). + + Args: + force: Force refresh even if cache is fresh. + + Returns: + Optional[Dict[str, Any]]: Dictionary of stats or None if unavailable. + """ if not self.stats_status_enabled: if self.debug: self.logger.debug("Stats refresh skipped: stats_status_enabled is False") @@ -1436,8 +1599,13 @@ class PacketCaptureService(BaseServicePlugin): return dict(self.latest_stats) if self.latest_stats else None - async def publish_status(self, status: str, refresh_stats: bool = True): - """Publish status with additional information (matches original script exactly)""" + async def publish_status(self, status: str, refresh_stats: bool = True) -> None: + """Publish status with additional information (matches original script exactly). + + Args: + status: Status string (e.g., 'online', 'offline'). + refresh_stats: Whether to refresh stats before publishing. + """ firmware_info = await self.get_firmware_info() # Get device name and public key @@ -1545,8 +1713,11 @@ class PacketCaptureService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error publishing status to MQTT: {e}") - async def jwt_renewal_scheduler(self): - """Background task to check and renew JWT tokens""" + async def jwt_renewal_scheduler(self) -> None: + """Background task to check and renew JWT tokens. + + Periodically checks if JWT tokens need renewal. + """ if self.jwt_renewal_interval <= 0: return @@ -1561,8 +1732,11 @@ class PacketCaptureService(BaseServicePlugin): self.logger.error(f"Error in JWT renewal scheduler: {e}") await asyncio.sleep(60) - async def health_check_loop(self): - """Background task for health checks""" + async def health_check_loop(self) -> None: + """Background task for health checks. + + Monitors connection status and warns on failures. + """ if self.health_check_interval <= 0: return @@ -1583,8 +1757,12 @@ class PacketCaptureService(BaseServicePlugin): self.logger.error(f"Error in health check loop: {e}") await asyncio.sleep(60) - async def mqtt_reconnection_monitor(self): - """Proactive MQTT reconnection monitor - checks and reconnects disconnected brokers""" + async def mqtt_reconnection_monitor(self) -> None: + """Proactive MQTT reconnection monitor - checks and reconnects disconnected brokers. + + Periodically checks connectivity of all configured MQTT brokers and attempts + reconnection if disconnected. + """ if not self.mqtt_enabled: return diff --git a/modules/service_plugins/packet_capture_utils.py b/modules/service_plugins/packet_capture_utils.py index 36d8244..7bac3a1 100644 --- a/modules/service_plugins/packet_capture_utils.py +++ b/modules/service_plugins/packet_capture_utils.py @@ -38,23 +38,51 @@ except ImportError: def hex_to_bytes(hex_str: str) -> bytes: - """Convert hex string to bytes""" + """Convert hex string to bytes. + + Args: + hex_str: Hexadecimal string to convert. + + Returns: + bytes: Converted bytes object. + """ return bytes.fromhex(hex_str.replace('0x', '').replace(' ', '')) def bytes_to_hex(data: bytes) -> str: - """Convert bytes to hex string (lowercase)""" + """Convert bytes to hex string (lowercase). + + Args: + data: Bytes object to convert. + + Returns: + str: Hexadecimal representation of the bytes (lowercase). + """ return data.hex() def base64url_encode(data: bytes) -> str: - """Base64url encode (URL-safe base64 without padding)""" + """Base64url encode (URL-safe base64 without padding). + + Args: + data: Data to encode. + + Returns: + str: URL-safe Base64 encoded string. + """ b64 = base64.b64encode(data).decode('ascii') return b64.replace('+', '-').replace('/', '_').replace('=', '') def base64url_decode(data: str) -> bytes: - """Base64url decode""" + """Base64url decode. + + Args: + data: URL-safe Base64 encoded string. + + Returns: + bytes: Decoded bytes. + """ b64 = data.replace('-', '+').replace('_', '/') padding = 4 - (len(b64) % 4) if padding != 4: @@ -63,12 +91,27 @@ def base64url_decode(data: str) -> bytes: def int_to_bytes_le(value: int, length: int) -> bytes: - """Convert integer to little-endian bytes""" + """Convert integer to little-endian bytes. + + Args: + value: Integer value to convert. + length: Number of bytes to use. + + Returns: + bytes: Little-endian byte representation. + """ return value.to_bytes(length, byteorder='little') def bytes_to_int_le(data: bytes) -> int: - """Convert little-endian bytes to integer""" + """Convert little-endian bytes to integer. + + Args: + data: Bytes object to convert. + + Returns: + int: Integer value. + """ return int.from_bytes(data, byteorder='little') @@ -77,20 +120,22 @@ L = 2**252 + 27742317777372353535851937790883648493 def ed25519_sign_with_expanded_key(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes: - """ - Sign a message using Ed25519 with pre-expanded key (orlp format) + """Sign a message using Ed25519 with pre-expanded key (orlp format). This implements RFC 8032 Ed25519 signing with an already-expanded key. This matches exactly how orlp/ed25519's ed25519_sign() works. Args: - message: Message to sign - scalar: First 32 bytes of orlp private key (clamped scalar) - prefix: Last 32 bytes of orlp private key (prefix for nonce) - public_key: 32-byte public key + message: Message to sign. + scalar: First 32 bytes of orlp private key (clamped scalar). + prefix: Last 32 bytes of orlp private key (prefix for nonce). + public_key: 32-byte public key. Returns: - 64-byte signature (R || s) + bytes: 64-byte signature (R || s). + + Raises: + ImportError: If PyNaCl is not available. """ if not PYNACL_AVAILABLE: raise ImportError("PyNaCl is required for Ed25519 signing") @@ -117,14 +162,13 @@ def ed25519_sign_with_expanded_key(message: bytes, scalar: bytes, prefix: bytes, def read_private_key_file(key_file_path: str) -> Optional[str]: - """ - Read a private key from a file (64-byte hex format for orlp/ed25519) + """Read a private key from a file (64-byte hex format for orlp/ed25519). Args: - key_file_path: Path to the private key file + key_file_path: Path to the private key file. Returns: - Private key as hex string (128 hex chars = 64 bytes), or None if invalid + Optional[str]: Private key as hex string (128 hex chars = 64 bytes), or None if invalid. """ if not os.path.exists(key_file_path): return None @@ -158,20 +202,23 @@ def read_private_key_file(key_file_path: str) -> Optional[str]: async def _create_auth_token_with_device( payload_dict: Dict[str, Any], public_key_hex: str, - meshcore_instance, + meshcore_instance: Any, chunk_size: int = 120 ) -> str: - """ - Create auth token using on-device signing via meshcore.commands.sign() + """Create auth token using on-device signing via meshcore.commands.sign(). Args: - payload_dict: Token payload as dictionary - public_key_hex: Public key in hex (for verification) - meshcore_instance: Connected MeshCore instance - chunk_size: Maximum chunk size for signing (device may have limits) + payload_dict: Token payload as dictionary. + public_key_hex: Public key in hex (for verification). + meshcore_instance: Connected MeshCore instance. + chunk_size: Maximum chunk size for signing (device may have limits). Returns: - JWT-style token string (header.payload.signature) + str: JWT-style token string (header.payload.signature). + + Raises: + ImportError: If meshcore package is missing. + Exception: If device is not connected or signing fails. """ try: from meshcore import EventType @@ -303,16 +350,19 @@ def _create_auth_token_python( private_key_hex: str, public_key_hex: str ) -> str: - """ - Create auth token using Python signing (PyNaCl) + """Create auth token using Python signing (PyNaCl). Args: - payload_dict: Token payload as dictionary - private_key_hex: 64-byte private key in hex (orlp format: scalar || prefix) - public_key_hex: 32-byte public key in hex + payload_dict: Token payload as dictionary. + private_key_hex: 64-byte private key in hex (orlp format: scalar || prefix). + public_key_hex: 32-byte public key in hex. Returns: - JWT-style token string (header.payload.signature) + str: JWT-style token string (header.payload.signature). + + Raises: + ImportError: If PyNaCl is required but missing. + ValueError: If key lengths are invalid. """ if not PYNACL_AVAILABLE: raise ImportError("PyNaCl is required for Python signing. Install with: pip install pynacl") @@ -378,15 +428,14 @@ def _create_auth_token_python( return token -async def _fetch_private_key_from_device(meshcore_instance) -> Optional[str]: - """ - Attempt to export private key from device +async def _fetch_private_key_from_device(meshcore_instance: Any) -> Optional[str]: + """Attempt to export private key from device. Args: - meshcore_instance: Connected MeshCore instance + meshcore_instance: Connected MeshCore instance. Returns: - Private key as hex string (128 hex chars), or None if not available + Optional[str]: Private key as hex string (128 hex chars), or None if not available. """ if not meshcore_instance or not meshcore_instance.is_connected: return None @@ -430,7 +479,7 @@ async def _fetch_private_key_from_device(meshcore_instance) -> Optional[str]: async def create_auth_token_async( - meshcore_instance=None, + meshcore_instance: Optional[Any] = None, public_key_hex: Optional[str] = None, private_key_hex: Optional[str] = None, iata: str = "LOC", @@ -441,24 +490,28 @@ async def create_auth_token_async( owner_email: Optional[str] = None, use_device: bool = True ) -> str: - """ - Create a JWT-style authentication token for MQTT authentication + """Create a JWT-style authentication token for MQTT authentication. Supports on-device signing (preferred) with fallback to Python signing. Args: - meshcore_instance: Optional connected MeshCore instance for on-device signing - public_key_hex: Public key in hex (required) - private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format) - Required if meshcore_instance not available or device signing fails - iata: IATA code (default: "LOC") - timestamp: Unix timestamp for 'iat' claim (default: current time) - audience: Optional audience for token (e.g., MQTT broker hostname) - exp: Optional expiration time (Unix timestamp) - use_device: If True, try on-device signing first (default: True) + meshcore_instance: Optional connected MeshCore instance for on-device signing. + public_key_hex: Public key in hex (required). + private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format). + Required if meshcore_instance not available or device signing fails. + iata: IATA code (default: "LOC"). + timestamp: Unix timestamp for 'iat' claim (default: current time). + audience: Optional audience for token (e.g., MQTT broker hostname). + exp: Optional expiration time (Unix timestamp). + owner_public_key: Optional owner public key. + owner_email: Optional owner email. + use_device: If True, try on-device signing first (default: True). Returns: - JWT-style token string (header.payload.signature) + str: JWT-style token string (header.payload.signature). + + Raises: + ValueError: If public_key_hex is missing or private key is missing for Python signing. """ if timestamp is None: timestamp = int(time.time()) @@ -564,18 +617,17 @@ def create_auth_token( timestamp: Optional[int] = None, audience: Optional[str] = None ) -> str: - """ - Synchronous version of create_auth_token (Python signing only) + """Synchronous version of create_auth_token (Python signing only). Args: - private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format) - public_key_hex: Public key in hex (32 bytes = 64 hex chars) - iata: IATA code (default: "LOC") - timestamp: Unix timestamp (default: current time) - audience: Optional audience for token + private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format). + public_key_hex: Public key in hex (32 bytes = 64 hex chars). + iata: IATA code (default: "LOC"). + timestamp: Unix timestamp (default: current time). + audience: Optional audience for token. Returns: - JWT-style token string (header.payload.signature) + str: JWT-style token string (header.payload.signature). """ if timestamp is None: timestamp = int(time.time()) diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 9f15d39..7030a5a 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -31,13 +31,21 @@ from .base_service import BaseServicePlugin class WeatherService(BaseServicePlugin): - """Weather service providing scheduled forecasts and alert monitoring""" + """Weather service providing scheduled forecasts and alert monitoring. + + Manages daily weather forecasts, polls for NOAA weather alerts, and + monitors lightning strikes via MQTT (Blitzortung). + """ config_section = 'Weather_Service' description = "Scheduled weather forecasts and alert monitoring" - def __init__(self, bot): - """Initialize weather service""" + def __init__(self, bot: Any): + """Initialize weather service. + + Args: + bot: The bot instance. + """ super().__init__(bot) # Configuration @@ -106,7 +114,11 @@ class WeatherService(BaseServicePlugin): self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}") def _create_retry_session(self) -> requests.Session: - """Create a requests session with retry logic for API calls""" + """Create a requests session with retry logic for API calls. + + Returns: + requests.Session: Configured session with retry adapter. + """ session = requests.Session() retry_strategy = Retry( total=2, @@ -125,13 +137,13 @@ class WeatherService(BaseServicePlugin): return session def _get_sunrise_sunset_time(self, event: str) -> Optional[datetime]: - """Get sunrise or sunset time for configured position + """Get sunrise or sunset time for configured position. Args: - event: 'sunrise' or 'sunset' + event: 'sunrise' or 'sunset'. Returns: - datetime object with the next sunrise/sunset time, or None on error + Optional[datetime]: Datetime object with the next sunrise/sunset time, or None on error. """ try: obs = ephem.Observer() @@ -154,8 +166,11 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error calculating {event}: {e}") return None - async def start(self): - """Start the weather service""" + async def start(self) -> None: + """Start the weather service. + + Initializes scheduled tasks for forecasts, alert polling, and lightning detection. + """ if not self.enabled: self.logger.info("Weather service is disabled, not starting") return @@ -186,8 +201,11 @@ class WeatherService(BaseServicePlugin): self.logger.info("Weather service started") - async def stop(self): - """Stop the weather service""" + async def stop(self) -> None: + """Stop the weather service. + + cancels all background tasks and closes connections. + """ self._running = False self.logger.info("Stopping weather service") @@ -235,8 +253,11 @@ class WeatherService(BaseServicePlugin): self.logger.info("Weather service stopped") - def _setup_daily_forecast(self): - """Setup daily weather forecast schedule for fixed times""" + def _setup_daily_forecast(self) -> None: + """Setup daily weather forecast schedule for fixed times. + + Configures the schedule library to trigger _send_daily_forecast at the configured time. + """ try: # Parse time (format: "HH:MM" or "H:MM") if ':' in self.weather_alarm_time: @@ -252,8 +273,11 @@ class WeatherService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error setting up daily forecast schedule: {e}") - async def _sunrise_sunset_forecast_loop(self): - """Background task for sunrise/sunset-based forecasts""" + async def _sunrise_sunset_forecast_loop(self) -> None: + """Background task for sunrise/sunset-based forecasts. + + Calculates daily sunrise/sunset times and schedules the forecast accordingly. + """ event_type = self.weather_alarm_time.lower() self.logger.info(f"Starting {event_type}-based forecast loop") @@ -301,8 +325,11 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error in {event_type} forecast loop: {e}") await asyncio.sleep(3600) # Wait 1 hour on error - def _send_daily_forecast(self): - """Send daily weather forecast (called by schedule library)""" + def _send_daily_forecast(self) -> None: + """Send daily weather forecast (called by schedule library). + + Wrapper to run the async forecast sender from the synchronous schedule job. + """ if not self._running: return @@ -317,8 +344,12 @@ class WeatherService(BaseServicePlugin): loop.run_until_complete(self._send_daily_forecast_async()) - async def _send_daily_forecast_async(self): - """Send daily weather forecast (async implementation)""" + async def _send_daily_forecast_async(self) -> None: + """Send daily weather forecast (async implementation). + + Fetches the forecast and sends it to the configured channel. + Uses Open-Meteo for weather data and manages its own error logging. + """ try: # Get weather forecast forecast_text = await self._get_weather_forecast() @@ -336,7 +367,11 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error sending daily weather forecast: {e}") async def _get_weather_forecast(self) -> str: - """Get weather forecast for configured position using Open-Meteo API""" + """Get weather forecast for configured position using Open-Meteo API. + + Returns: + str: Formatted forecast string or error message. + """ try: # Open-Meteo API endpoint api_url = "https://api.open-meteo.com/v1/forecast" @@ -451,7 +486,14 @@ class WeatherService(BaseServicePlugin): return "Error fetching weather data" def _degrees_to_direction(self, degrees: float) -> str: - """Convert wind direction in degrees to compass direction""" + """Convert wind direction in degrees to compass direction. + + Args: + degrees: Wind direction in degrees (0-360). + + Returns: + str: Compass direction (e.g., 'N', 'NE', 'SW'). + """ if degrees is None: return "" @@ -461,7 +503,14 @@ class WeatherService(BaseServicePlugin): return directions[index] def _get_weather_description(self, code: int) -> str: - """Get weather description from WMO weather code""" + """Get weather description from WMO weather code. + + Args: + code: WMO weather code integer. + + Returns: + str: Human-readable weather description. + """ # WMO Weather interpretation codes (WW) codes = { 0: "Clear", 1: "Mostly Clear", 2: "Partly Cloudy", 3: "Overcast", @@ -478,7 +527,14 @@ class WeatherService(BaseServicePlugin): return codes.get(code, "Unknown") def _get_weather_emoji(self, code: int) -> str: - """Get weather emoji from WMO weather code""" + """Get weather emoji from WMO weather code. + + Args: + code: WMO weather code integer. + + Returns: + str: Emoji character representing the weather. + """ if code == 0: return "☀️" elif code in [1, 2]: @@ -496,8 +552,11 @@ class WeatherService(BaseServicePlugin): else: return "🌤️" - async def _poll_weather_alerts_loop(self): - """Background task to poll for weather alerts""" + async def _poll_weather_alerts_loop(self) -> None: + """Background task to poll for weather alerts. + + Runs periodically based on configured interval. + """ self.logger.info(f"Starting weather alerts polling (interval: {self.poll_weather_alerts_interval}s)") while self._running: @@ -510,8 +569,8 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error in weather alerts polling loop: {e}") await asyncio.sleep(60) # Wait 1 minute on error before retrying - async def _check_weather_alerts(self): - """Check for new weather alerts (US-only via NOAA API) + async def _check_weather_alerts(self) -> None: + """Check for new weather alerts (US-only via NOAA API). Note: Open-Meteo doesn't provide weather alerts, so we use NOAA which is US-only. For international locations, alerts will not be available. @@ -626,8 +685,11 @@ class WeatherService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error checking weather alerts: {e}") - async def _connect_blitzortung_mqtt(self): - """Connect to Blitzortung MQTT broker and subscribe to lightning data""" + async def _connect_blitzortung_mqtt(self) -> None: + """Connect to Blitzortung MQTT broker and subscribe to lightning data. + + Maintains a connection to the MQTT broker for real-time lightning strikes. + """ if not self.blitz_area or not MQTT_AVAILABLE: return @@ -709,8 +771,14 @@ class WeatherService(BaseServicePlugin): self.logger.info("Reconnecting to Blitzortung MQTT in 30 seconds...") await asyncio.sleep(30) - async def _handle_lightning_strike(self, blitz_data: Dict[str, Any]): - """Handle a single lightning strike from MQTT""" + async def _handle_lightning_strike(self, blitz_data: Dict[str, Any]) -> None: + """Handle a single lightning strike from MQTT. + + Calculates distance and adds to buffer if within range. + + Args: + blitz_data: Dictionary containing lightning strike data. + """ lat = blitz_data.get('lat') lon = blitz_data.get('lon') @@ -737,10 +805,16 @@ class WeatherService(BaseServicePlugin): }) def _calculate_heading_and_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> tuple: - """Calculate heading and distance between two points (same as original implementation) + """Calculate heading and distance between two points (same as original implementation). + Args: + lat1: Latitude of point 1. + lon1: Longitude of point 1. + lat2: Latitude of point 2. + lon2: Longitude of point 2. + Returns: - (heading_degrees, distance_km) + tuple: (heading_degrees, distance_km) """ # Convert to radians lat1_rad = math.radians(lat1) @@ -762,8 +836,11 @@ class WeatherService(BaseServicePlugin): return (int(heading_deg), distance_km) - async def _poll_lightning_loop(self): - """Background task to aggregate and report lightning strikes""" + async def _poll_lightning_loop(self) -> None: + """Background task to aggregate and report lightning strikes. + + Periodically processes the lightning buffer and sends alerts. + """ self.logger.info(f"Starting lightning aggregation (interval: {self.blitz_collection_interval}s)") while self._running: @@ -776,8 +853,11 @@ class WeatherService(BaseServicePlugin): self.logger.error(f"Error in lightning aggregation loop: {e}") await asyncio.sleep(60) # Wait 1 minute on error before retrying - async def _process_lightning_buffer(self): - """Process buffered lightning strikes and send alerts if threshold met""" + async def _process_lightning_buffer(self) -> None: + """Process buffered lightning strikes and send alerts if threshold met. + + Groups strikes by location bucket and sends alerts if count exceeds threshold. + """ if not self.blitz_buffer: return @@ -832,7 +912,14 @@ class WeatherService(BaseServicePlugin): self.seen_blitz_keys = set(list(self.seen_blitz_keys)[-1000:]) def _heading_to_compass(self, heading: int) -> str: - """Convert heading in degrees to compass direction name""" + """Convert heading in degrees to compass direction name. + + Args: + heading: Heading in degrees. + + Returns: + str: Compass direction abbreviation (e.g., 'N', 'NW'). + """ compass_points = [ 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW' @@ -841,7 +928,15 @@ class WeatherService(BaseServicePlugin): return compass_points[index] async def _geocode_location(self, lat: float, lon: float) -> Optional[str]: - """Geocode coordinates to location name (optional, may return None)""" + """Geocode coordinates to location name (optional, may return None). + + Args: + lat: Latitude. + lon: Longitude. + + Returns: + Optional[str]: City/town name or None if lookup fails. + """ try: # Use reverse geocoding if available in utils from ..utils import rate_limited_nominatim_reverse_sync @@ -855,15 +950,15 @@ class WeatherService(BaseServicePlugin): pass return None - def _parse_alert_entry(self, entry, alert_id: str) -> Optional[Dict[str, Any]]: - """Parse alert XML entry and extract full metadata (same logic as wx_command) + def _parse_alert_entry(self, entry: Any, alert_id: str) -> Optional[Dict[str, Any]]: + """Parse alert XML entry and extract full metadata (same logic as wx_command). Args: - entry: XML DOM entry element - alert_id: Alert ID string + entry: XML DOM entry element. + alert_id: Alert ID string. Returns: - Alert dict with event, event_type, severity, expires, office, etc., or None on error + Optional[Dict[str, Any]]: Alert dict with event, event_type, severity, expires, office, etc., or None on error. """ try: # Extract title @@ -1101,14 +1196,14 @@ class WeatherService(BaseServicePlugin): return None async def _format_alert_compact(self, alert: Dict[str, Any], include_details: bool = True) -> str: - """Format a single alert compactly (same as wx_command) + """Format a single alert compactly (same as wx_command). Args: alert: Alert dict with event, event_type, severity, expires, office, etc. - include_details: If True, include expiration time and office + include_details: If True, include expiration time and office. Returns: - Formatted alert string + str: Formatted alert string. """ event = alert.get('event', '') event_type = alert.get('event_type', '') @@ -1240,7 +1335,14 @@ class WeatherService(BaseServicePlugin): return f"{severity_emoji}{event} {event_type_abbrev}" if event else f"{severity_emoji}{event_type_abbrev}" def _compact_time(self, time_str: str) -> str: - """Compact time format (same as wx_command)""" + """Compact time format (same as wx_command). + + Args: + time_str: Time string to format. + + Returns: + str: Compact formatted time string. + """ if not time_str: return time_str @@ -1289,7 +1391,14 @@ class WeatherService(BaseServicePlugin): return time_str def _abbreviate_city_name(self, city: str) -> str: - """Abbreviate city names for compact display (same as wx_command)""" + """Abbreviate city names for compact display (same as wx_command). + + Args: + city: Full city name. + + Returns: + str: Abbreviated city name. + """ if not city: return city @@ -1332,13 +1441,13 @@ class WeatherService(BaseServicePlugin): return city[:4].upper() if len(city) >= 4 else city.upper() def _parse_iso_time(self, time_str: str) -> Optional[float]: - """Parse ISO 8601 timestamp to Unix timestamp + """Parse ISO 8601 timestamp to Unix timestamp. Args: - time_str: ISO 8601 time string (e.g., "2025-12-16T15:12:00-08:00" or "2025-12-16T15:12:00Z") + time_str: ISO 8601 time string (e.g., "2025-12-16T15:12:00-08:00" or "2025-12-16T15:12:00Z"). Returns: - Unix timestamp (seconds since epoch), or None if parsing fails + Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails. """ if not time_str: return None @@ -1351,13 +1460,13 @@ class WeatherService(BaseServicePlugin): return None def _parse_alert_time(self, time_str: str) -> Optional[float]: - """Parse alert effective/issued time string to Unix timestamp + """Parse alert effective/issued time string to Unix timestamp. Args: - time_str: Time string from alert (e.g., "December 16 at 3:12PM PST" or ISO format) + time_str: Time string from alert (e.g., "December 16 at 3:12PM PST" or ISO format). Returns: - Unix timestamp (seconds since epoch), or None if parsing fails + Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails. """ if not time_str: return None @@ -1416,13 +1525,13 @@ class WeatherService(BaseServicePlugin): return None async def _shorten_url(self, url: str) -> str: - """Shorten URL using is.gd service + """Shorten URL using is.gd service. Args: - url: Full URL to shorten + url: Full URL to shorten. Returns: - Shortened URL string, or empty string on error + str: Shortened URL string, or empty string on error. """ if not url: return "" diff --git a/modules/utils.py b/modules/utils.py index 879ef5e..1444b2e 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -15,15 +15,14 @@ from typing import Optional, Tuple, Dict, Union, List, Any def abbreviate_location(location: str, max_length: int = 20) -> str: - """ - Abbreviate a location string to fit within character limits. + """Abbreviate a location string to fit within character limits. Args: - location: The location string to abbreviate - max_length: Maximum length for the abbreviated string + location: The location string to abbreviate. + max_length: Maximum length for the abbreviated string (default: 20). Returns: - Abbreviated location string + str: Abbreviated location string. """ if not location: return location @@ -118,16 +117,15 @@ def abbreviate_location(location: str, max_length: int = 20) -> str: def truncate_string(text: str, max_length: int, ellipsis: str = '...') -> str: - """ - Truncate a string to a maximum length with ellipsis. + """Truncate a string to a maximum length with ellipsis. Args: - text: The string to truncate - max_length: Maximum length including ellipsis - ellipsis: String to append when truncating + text: The string to truncate. + max_length: Maximum length including ellipsis. + ellipsis: String to append when truncating (default: '...'). Returns: - Truncated string + str: Truncated string. """ if not text or len(text) <= max_length: return text @@ -137,17 +135,16 @@ def truncate_string(text: str, max_length: int, ellipsis: str = '...') -> str: def format_location_for_display(city: Optional[str], state: Optional[str] = None, country: Optional[str] = None, max_length: int = 20) -> Optional[str]: - """ - Format location data for display with intelligent abbreviation. + """Format location data for display with intelligent abbreviation. Args: - city: City name (may include neighborhood/district) - state: State/province name - country: Country name - max_length: Maximum length for the formatted location + city: City name (may include neighborhood/district). + state: State/province name (optional). + country: Country name (optional). + max_length: Maximum length for the formatted location (default: 20). Returns: - Formatted location string or None if no location data + Optional[str]: Formatted location string or None if no city provided. """ if not city: return None @@ -164,18 +161,18 @@ def format_location_for_display(city: Optional[str], state: Optional[str] = None return abbreviate_location(full_location, max_length) -def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> list: - """ - Get prioritized geocoding queries for major cities that have multiple locations. +def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> List[str]: + """Get prioritized geocoding queries for major cities that have multiple locations. + This helps ensure that common city names resolve to the most likely major city rather than a small town with the same name. Args: - city: City name (normalized, lowercase) - state_abbr: Optional state abbreviation (e.g., "CA", "NY") + city: City name (normalized, lowercase). + state_abbr: Optional state abbreviation (e.g., "CA", "NY"). Returns: - List of geocoding query strings in priority order + List[str]: List of geocoding query strings in priority order. """ city_lower = city.lower().strip() @@ -264,18 +261,18 @@ def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> list: def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: - """ - Calculate hash for packet identification - based on packet.cpp + """Calculate hash for packet identification - based on packet.cpp. + Packet hashes are unique to the originally sent message, allowing identification of the same message arriving via different paths. Args: - raw_hex: Raw packet data as hex string - payload_type: Optional payload type as integer (if None, extracted from header) - Must be numeric value (0-15), not enum or string + raw_hex: Raw packet data as hex string. + payload_type: Optional payload type as integer (if None, extracted from header). + Must be numeric value (0-15). Returns: - 16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error + str: 16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error. """ try: # Parse the packet to extract payload type and payload data @@ -344,17 +341,16 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """ - Calculate haversine distance between two points in kilometers. + """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 + 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 + float: Distance in kilometers. """ import math @@ -375,32 +371,30 @@ def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl return earth_radius * c -def get_nominatim_geocoder(user_agent: str = "meshcore-bot", timeout: int = 10): - """ - Get a Nominatim geocoder instance with proper User-Agent. +def get_nominatim_geocoder(user_agent: str = "meshcore-bot", timeout: int = 10) -> Any: + """Get a Nominatim geocoder instance with proper User-Agent. Args: - user_agent: User-Agent string for Nominatim (required by their policy) - timeout: Request timeout in seconds + user_agent: User-Agent string for Nominatim (required by their policy). + timeout: Request timeout in seconds. Returns: - Nominatim geocoder instance + Any: Nominatim geocoder instance (from geopy). """ from geopy.geocoders import Nominatim return Nominatim(user_agent=user_agent, timeout=timeout) -async def rate_limited_nominatim_geocode(bot, query: str, timeout: int = 10): - """ - Perform rate-limited Nominatim geocoding (forward geocoding). +async def rate_limited_nominatim_geocode(bot: Any, query: str, timeout: int = 10) -> Optional[Any]: + """Perform rate-limited Nominatim geocoding (forward geocoding). Args: - bot: Bot instance (must have nominatim_rate_limiter attribute) - query: Location query string - timeout: Request timeout in seconds + bot: Bot instance (must have nominatim_rate_limiter attribute). + query: Location query string. + timeout: Request timeout in seconds. Returns: - Geocoding result or None + Optional[Any]: Geocoding result or None if failed/timed out. """ if not hasattr(bot, 'nominatim_rate_limiter'): # Fallback if rate limiter not initialized @@ -420,17 +414,16 @@ async def rate_limited_nominatim_geocode(bot, query: str, timeout: int = 10): return result -async def rate_limited_nominatim_reverse(bot, coordinates: str, timeout: int = 10): - """ - Perform rate-limited Nominatim reverse geocoding. +async def rate_limited_nominatim_reverse(bot: Any, coordinates: str, timeout: int = 10) -> Optional[Any]: + """Perform rate-limited Nominatim reverse geocoding. Args: - bot: Bot instance (must have nominatim_rate_limiter attribute) - coordinates: Coordinates string in format "lat, lon" - timeout: Request timeout in seconds + bot: Bot instance (must have nominatim_rate_limiter attribute). + coordinates: Coordinates string in format "lat, lon". + timeout: Request timeout in seconds. Returns: - Reverse geocoding result or None + Optional[Any]: Reverse geocoding result or None if failed/timed out. """ if not hasattr(bot, 'nominatim_rate_limiter'): # Fallback if rate limiter not initialized @@ -450,17 +443,16 @@ async def rate_limited_nominatim_reverse(bot, coordinates: str, timeout: int = 1 return result -def rate_limited_nominatim_geocode_sync(bot, query: str, timeout: int = 10): - """ - Perform rate-limited Nominatim geocoding (synchronous version). +def rate_limited_nominatim_geocode_sync(bot: Any, query: str, timeout: int = 10) -> Optional[Any]: + """Perform rate-limited Nominatim geocoding (synchronous version). Args: - bot: Bot instance (must have nominatim_rate_limiter attribute) - query: Location query string - timeout: Request timeout in seconds + bot: Bot instance (must have nominatim_rate_limiter attribute). + query: Location query string. + timeout: Request timeout in seconds. Returns: - Geocoding result or None + Optional[Any]: Geocoding result or None if failed/timed out. """ if not hasattr(bot, 'nominatim_rate_limiter'): # Fallback if rate limiter not initialized @@ -480,17 +472,16 @@ def rate_limited_nominatim_geocode_sync(bot, query: str, timeout: int = 10): return result -def rate_limited_nominatim_reverse_sync(bot, coordinates: str, timeout: int = 10): - """ - Perform rate-limited Nominatim reverse geocoding (synchronous version). +def rate_limited_nominatim_reverse_sync(bot: Any, coordinates: str, timeout: int = 10) -> Optional[Any]: + """Perform rate-limited Nominatim reverse geocoding (synchronous version). Args: - bot: Bot instance (must have nominatim_rate_limiter attribute) - coordinates: Coordinates string in format "lat, lon" - timeout: Request timeout in seconds + bot: Bot instance (must have nominatim_rate_limiter attribute). + coordinates: Coordinates string in format "lat, lon". + timeout: Request timeout in seconds. Returns: - Reverse geocoding result or None + Optional[Any]: Reverse geocoding result or None if failed/timed out. """ if not hasattr(bot, 'nominatim_rate_limiter'): # Fallback if rate limiter not initialized @@ -510,19 +501,19 @@ def rate_limited_nominatim_reverse_sync(bot, coordinates: str, timeout: int = 10 return result -async def geocode_zipcode(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]: - """ - Shared function to geocode a ZIP code to lat/lon coordinates. +async def geocode_zipcode(bot: Any, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]: + """Shared function to geocode a ZIP code to lat/lon coordinates. + Checks cache first, then makes rate-limited API call if needed. Args: - bot: Bot instance (must have db_manager and nominatim_rate_limiter) - zipcode: ZIP code string - default_country: Default country code (e.g., "US"). If None, reads from bot.config - timeout: Request timeout in seconds + bot: Bot instance (must have db_manager and nominatim_rate_limiter). + zipcode: ZIP code string. + default_country: Default country code (e.g., "US"). If None, reads from bot.config. + timeout: Request timeout in seconds. Returns: - Tuple of (latitude, longitude) or (None, None) if not found + Tuple[Optional[float], Optional[float]]: Tuple of (latitude, longitude) or (None, None) if not found. """ try: # Get default country from config if not provided @@ -548,18 +539,17 @@ async def geocode_zipcode(bot, zipcode: str, default_country: str = None, timeou return None, None -def geocode_zipcode_sync(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]: - """ - Synchronous version of geocode_zipcode. +def geocode_zipcode_sync(bot: Any, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]: + """Synchronous version of geocode_zipcode. Args: - bot: Bot instance (must have db_manager and nominatim_rate_limiter) - zipcode: ZIP code string - default_country: Default country code (e.g., "US"). If None, reads from bot.config - timeout: Request timeout in seconds + bot: Bot instance (must have db_manager and nominatim_rate_limiter). + zipcode: ZIP code string. + default_country: Default country code (e.g., "US"). If None, reads from bot.config. + timeout: Request timeout in seconds. Returns: - Tuple of (latitude, longitude) or (None, None) if not found + Tuple[Optional[float], Optional[float]]: Tuple of (latitude, longitude) or (None, None) if not found. """ try: # Get default country from config if not provided @@ -585,25 +575,26 @@ def geocode_zipcode_sync(bot, zipcode: str, default_country: str = None, timeout return None, None -async def geocode_city(bot, city: str, default_state: str = None, +async def geocode_city(bot: Any, city: str, default_state: str = None, default_country: str = None, include_address_info: bool = False, timeout: int = 10) -> Tuple[Optional[float], Optional[float], Optional[Dict]]: - """ - Shared function to geocode a city name to lat/lon coordinates. + """Shared function to geocode a city name to lat/lon coordinates. + Uses intelligent fallback logic with major city prioritization. Args: - bot: Bot instance (must have db_manager and nominatim_rate_limiter) - city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France") - default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config - default_country: Default country code (e.g., "US"). If None, reads from bot.config - include_address_info: If True, also return address info via reverse geocoding - timeout: Request timeout in seconds + bot: Bot instance (must have db_manager and nominatim_rate_limiter). + city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France"). + default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config. + default_country: Default country code (e.g., "US"). If None, reads from bot.config. + include_address_info: If True, also return address info via reverse geocoding. + timeout: Request timeout in seconds. Returns: - Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found - address_info_dict is None if include_address_info is False + Tuple[Optional[float], Optional[float], Optional[Dict]]: + Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found. + address_info_dict is None if include_address_info is False. """ try: # Get defaults from config if not provided @@ -778,24 +769,24 @@ async def geocode_city(bot, city: str, default_state: str = None, return None, None, None -def geocode_city_sync(bot, city: str, default_state: str = None, +def geocode_city_sync(bot: Any, city: str, default_state: str = None, default_country: str = None, include_address_info: bool = False, timeout: int = 10) -> Tuple[Optional[float], Optional[float], Optional[Dict]]: - """ - Synchronous version of geocode_city. + """Synchronous version of geocode_city. Args: - bot: Bot instance (must have db_manager and nominatim_rate_limiter) - city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France") - default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config - default_country: Default country code (e.g., "US"). If None, reads from bot.config - include_address_info: If True, also return address info via reverse geocoding - timeout: Request timeout in seconds + bot: Bot instance (must have db_manager and nominatim_rate_limiter). + city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France"). + default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config. + default_country: Default country code (e.g., "US"). If None, reads from bot.config. + include_address_info: If True, also return address info via reverse geocoding. + timeout: Request timeout in seconds. Returns: - Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found - address_info_dict is None if include_address_info is False + Tuple[Optional[float], Optional[float], Optional[Dict]]: + Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found. + address_info_dict is None if include_address_info is False. """ try: # Get defaults from config if not provided @@ -971,18 +962,17 @@ def geocode_city_sync(bot, city: str, default_state: str = None, def resolve_path(file_path: Union[str, Path], base_dir: Union[str, Path] = '.') -> str: - """ - Resolve a file path relative to a base directory. + """Resolve a file path relative to a base directory. If the path is absolute, it is resolved and returned as-is. If the path is relative, it is resolved relative to the base directory. Args: - file_path: Path to resolve (can be string or Path object) - base_dir: Base directory for resolving relative paths (default: current directory) + file_path: Path to resolve (can be string or Path object). + base_dir: Base directory for resolving relative paths (default: current directory). Returns: - Resolved absolute path as a string + str: Resolved absolute path as a string. Examples: >>> resolve_path('data.db', '/opt/bot') @@ -1000,19 +990,18 @@ def resolve_path(file_path: Union[str, Path], base_dir: Union[str, Path] = '.') def check_internet_connectivity(host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool: - """ - Check if internet connectivity is available by attempting to connect to a reliable host. + """Check if internet connectivity is available by attempting to connect to a reliable host. First tries a lightweight DNS port check (faster, doesn't require DNS resolution). If that fails (e.g., DNS port is blocked), falls back to an HTTP request check. Args: - host: Host to connect to (default: 8.8.8.8, Google's public DNS) - port: Port to connect to (default: 53, DNS port) - timeout: Connection timeout in seconds (default: 3.0) + host: Host to connect to (default: 8.8.8.8, Google's public DNS). + port: Port to connect to (default: 53, DNS port). + timeout: Connection timeout in seconds (default: 3.0). Returns: - True if connection successful, False otherwise + bool: True if connection successful, False otherwise. """ # First try: DNS port check (fastest, works if DNS port is open) try: @@ -1045,19 +1034,18 @@ def check_internet_connectivity(host: str = "8.8.8.8", port: int = 53, timeout: async def check_internet_connectivity_async(host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool: - """ - Async version of check_internet_connectivity. + """Async version of check_internet_connectivity. First tries a lightweight DNS port check (faster, doesn't require DNS resolution). If that fails (e.g., DNS port is blocked), falls back to an HTTP request check. Args: - host: Host to connect to (default: 8.8.8.8, Google's public DNS) - port: Port to connect to (default: 53, DNS port) - timeout: Connection timeout in seconds (default: 3.0) + host: Host to connect to (default: 8.8.8.8, Google's public DNS). + port: Port to connect to (default: 53, DNS port). + timeout: Connection timeout in seconds (default: 3.0). Returns: - True if connection successful, False otherwise + bool: True if connection successful, False otherwise. """ # First try: DNS port check (fastest, works if DNS port is open) try: @@ -1109,8 +1097,7 @@ async def check_internet_connectivity_async(host: str = "8.8.8.8", port: int = 5 def parse_path_string(path_str: str) -> List[str]: - """ - Parse a path string to extract node IDs. + """Parse a path string to extract node IDs. Handles various formats: - "11,98,a4,49,cd,5f,01" (comma-separated) @@ -1119,10 +1106,10 @@ def parse_path_string(path_str: str) -> List[str]: - "01,5f (2 hops)" (with hop count suffix) Args: - path_str: Path string in various formats + path_str: Path string in various formats. Returns: - List of 2-character uppercase hex node IDs + List[str]: List of 2-character uppercase hex node IDs. """ if not path_str: return [] @@ -1142,18 +1129,17 @@ def parse_path_string(path_str: str) -> List[str]: return [match.upper() for match in hex_matches] -def calculate_path_distances(bot, path_str: str) -> Tuple[str, str]: - """ - Calculate path distance metrics from a path string. +def calculate_path_distances(bot: Any, path_str: str) -> Tuple[str, str]: + """Calculate path distance metrics from a path string. Args: - bot: Bot instance (must have db_manager) - path_str: Path string (e.g., "11,98,a4,49,cd,5f,01" or "01,5f (2 hops)" or "Direct") + bot: Bot instance (must have db_manager). + path_str: Path string (e.g., "11,98,a4,49,cd,5f,01" or "01,5f (2 hops)" or "Direct"). Returns: - Tuple of (path_distance_str, firstlast_distance_str) - - path_distance_str: Total distance with segment info (e.g., "123.4km (3 segs, 1 no-loc)" or "directly (0 hops)" or "locally (1 hop)") - - firstlast_distance_str: Distance between first and last repeater (e.g., "45.6km" or empty) + Tuple[str, str]: A tuple containing: + - path_distance_str: Total distance with segment info (e.g., "123.4km (3 segs, 1 no-loc)"). + - firstlast_distance_str: Distance between first and last repeater (e.g., "45.6km"). """ if not path_str: return "directly (0 hops)", "N/A (direct)" @@ -1253,16 +1239,15 @@ def calculate_path_distances(bot, path_str: str) -> Tuple[str, str]: return "", "" -def _get_node_location_from_db(bot, node_id: str) -> Optional[Tuple[float, float]]: - """ - Get location for a node ID from the database. +def _get_node_location_from_db(bot: Any, node_id: str) -> Optional[Tuple[float, float]]: + """Get location for a node ID from the database. Args: - bot: Bot instance (must have db_manager) - node_id: 2-character hex node ID (e.g., "01", "5f") + bot: Bot instance (must have db_manager). + node_id: 2-character hex node ID (e.g., "01", "5f"). Returns: - Tuple of (latitude, longitude) or None if not found + Optional[Tuple[float, float]]: Tuple of (latitude, longitude) or None if not found. """ if not hasattr(bot, 'db_manager'): return None @@ -1299,24 +1284,23 @@ def _get_node_location_from_db(bot, node_id: str) -> Optional[Tuple[float, float def format_keyword_response_with_placeholders( response_format: str, - message, - bot, + message: Any, + bot: Any, mesh_info: Optional[Dict[str, Any]] = None ) -> str: - """ - Format a keyword response string with all available placeholders. + """Format a keyword response string with all available placeholders. Supports both message-based placeholders and mesh-info-based placeholders. This is a shared function used by both Keywords and Scheduled_Messages. Args: - response_format: Response format string with placeholders - message: MeshMessage instance (can be None for scheduled messages) - bot: Bot instance (must have config, db_manager) - mesh_info: Optional mesh network info dict (for scheduled message placeholders) + response_format: Response format string with placeholders. + message: MeshMessage instance (can be None for scheduled messages). + bot: Bot instance (must have config, db_manager). + mesh_info: Optional mesh network info dict (for scheduled message placeholders). Returns: - Formatted response string + str: Formatted response string. """ try: replacements = {}