diff --git a/.gitignore b/.gitignore index efd44d0..7a8a95d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ test_*.log docs/local/ tests/ test_scripts/ +dev/ diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 33483c8..8397850 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -560,18 +560,51 @@ class WxCommand(BaseCommand): # If current is Tonight and we haven't found Tomorrow yet, look for next day's periods if is_current_tonight and not tomorrow_period: - # Look for periods after Tonight (next day) - for i, period in enumerate(forecast): - if i > 0: # Skip current period - period_name = period.get('name', '').lower() - # Look for tomorrow, next day, or day names - if any(word in period_name for word in ['tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']): - tomorrow_period = (i, period) - break + # If today_period is a day name (not "Today"), look for the next period after it + if today_period: + period_name_lower = today_period[1].get('name', '').lower() + day_names = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + if any(day in period_name_lower for day in day_names) and 'today' not in period_name_lower: + # today_period is actually tomorrow's daytime period - look for the night period after it + today_period_index = today_period[0] + # Look for the next period after today_period (should be the night period for that day) + for i, period in enumerate(forecast): + if i > today_period_index: # Look for periods after today_period + period_name = period.get('name', '').lower() + # Look for the night period for the same day, or the next day + if any(word in period_name for word in ['night', 'tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']): + tomorrow_period = (i, period) + break + # If we didn't find a night period, use today_period as tomorrow_period + if not tomorrow_period: + tomorrow_period = today_period + else: + # Look for periods after Tonight (next day) + for i, period in enumerate(forecast): + if i > 0: # Skip current period + period_name = period.get('name', '').lower() + # Skip if this period is already set as today_period (avoid duplicates) + if today_period and today_period[0] == i: + continue + # Look for tomorrow, next day, or day names + if any(word in period_name for word in ['tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']): + tomorrow_period = (i, period) + break + else: + # Look for periods after Tonight (next day) + for i, period in enumerate(forecast): + if i > 0: # Skip current period + period_name = period.get('name', '').lower() + # Look for tomorrow, next day, or day names + if any(word in period_name for word in ['tomorrow', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']): + tomorrow_period = (i, period) + break # If current is a night period, prioritize adding Today (the upcoming daytime) + # When today_period is a day name (like "Tuesday"), we still add it as tomorrow's daytime period if is_current_night and today_period: period = today_period[1] + # Always add today_period - it represents tomorrow's daytime when current is Tonight period_name = self.abbreviate_noaa(period.get('name', 'Today')) period_temp = period.get('temperature', '') period_short = period.get('shortForecast', '') @@ -756,10 +789,17 @@ class WxCommand(BaseCommand): period_str = self._add_period_details(period_str, period_detailed, current_weather_len, max_chars) # Only add if we have space (using display width, prioritize current period) - # Be more conservative - only add if current period is reasonable length + # Be more aggressive about adding tomorrow_period when current is Tonight and we have space max_chars = 128 if (is_current_tonight or is_current_night) else 130 - if current_weather_len < 110 and self._count_display_width(weather + period_str) <= max_chars: - weather += period_str + # If current is Tonight and we have plenty of space, be more lenient with the length check + if is_current_tonight or is_current_night: + # Allow adding tomorrow_period if we're under 120 chars (more lenient than 110) + if current_weather_len < 120 and self._count_display_width(weather + period_str) <= max_chars: + weather += period_str + else: + # For non-night periods, use the stricter check + if current_weather_len < 110 and self._count_display_width(weather + period_str) <= max_chars: + weather += period_str return weather, weather_json @@ -2949,6 +2989,8 @@ class WxCommand(BaseCommand): # Weather condition emojis if any(word in condition_lower for word in ['sunny', 'clear']): return "☀️" + elif any(word in condition_lower for word in ['heavy rain', 'heavy showers', 'excessive rain']): + return "🌧️" # Cloud with rain - more rain, less sun elif any(word in condition_lower for word in ['cloudy', 'overcast']): return "☁️" elif any(word in condition_lower for word in ['partly cloudy', 'mostly cloudy']): diff --git a/modules/service_plugins/map_uploader_service.py b/modules/service_plugins/map_uploader_service.py index 4b0fd5e..ad25310 100644 --- a/modules/service_plugins/map_uploader_service.py +++ b/modules/service_plugins/map_uploader_service.py @@ -9,6 +9,7 @@ import asyncio import json import logging import hashlib +import time from typing import Optional, Dict, Any # Import meshcore @@ -123,6 +124,8 @@ class MapUploaderService(BaseServicePlugin): self.connected = False # Track seen adverts: {pubkey: last_timestamp} + # This prevents duplicate uploads and replay attacks + # We'll periodically clean old entries to prevent unbounded growth self.seen_adverts: Dict[str, int] = {} # Device keys and info @@ -139,6 +142,10 @@ class MapUploaderService(BaseServicePlugin): # Exit flag self.should_exit = False + # Cleanup tracking + self._last_cleanup_time = 0 + self._cleanup_interval = 3600 # Clean up every hour + self.logger.info("Map uploader service initialized") def _load_config(self): @@ -239,6 +246,15 @@ class MapUploaderService(BaseServicePlugin): await self.http_session.close() self.http_session = None + # Clear seen_adverts to free memory + self.seen_adverts.clear() + + # Close file handlers + for handler in self.logger.handlers[:]: + if isinstance(handler, logging.FileHandler): + handler.close() + self.logger.removeHandler(handler) + self.logger.info("Map uploader service stopped") async def _fetch_private_key(self): @@ -357,6 +373,53 @@ class MapUploaderService(BaseServicePlugin): # 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""" + current_time = time.time() + + # Only cleanup periodically (not on every packet) + if current_time - self._last_cleanup_time < self._cleanup_interval: + return + + self._last_cleanup_time = current_time + + # Remove entries older than min_reupload_interval * 2 + # This keeps recent entries but removes very old ones + cutoff_timestamp = current_timestamp - (self.min_reupload_interval * 2) + + # Count entries before cleanup + initial_count = len(self.seen_adverts) + + # Remove old entries + keys_to_remove = [ + pubkey for pubkey, last_ts in self.seen_adverts.items() + if last_ts < cutoff_timestamp + ] + + for pubkey in keys_to_remove: + del self.seen_adverts[pubkey] + + removed_count = len(keys_to_remove) + + if removed_count > 0: + self.logger.debug( + f"Cleaned up {removed_count} old seen_adverts entries " + f"({initial_count} -> {len(self.seen_adverts)})" + ) + + # Safety limit: if dictionary still grows too large, use more aggressive cleanup + if len(self.seen_adverts) > 10000: + # Keep only the most recent 5000 entries + sorted_entries = sorted( + self.seen_adverts.items(), + key=lambda x: x[1], + reverse=True + ) + self.seen_adverts = dict(sorted_entries[:5000]) + self.logger.warning( + 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""" try: @@ -464,6 +527,9 @@ class MapUploaderService(BaseServicePlugin): # Update seen adverts self.seen_adverts[pub_key] = timestamp + # Periodically clean up old entries to prevent unbounded memory growth + await self._cleanup_old_seen_adverts(timestamp) + except Exception as e: self.logger.error(f"Error processing packet: {e}", exc_info=True) diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 855f983..d9849ba 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -536,8 +536,11 @@ class PacketCaptureService(BaseServicePlugin): # 4. Try to get from bot's recent_rf_data cache (if message_handler has processed it) # Note: The bot stores full raw_hex (with framing bytes) for packet_prefix, but we use stripped raw_hex # So we need to use the full raw_hex from payload for prefix matching + # Optimized: Use indexed rf_data_by_pubkey for O(1) lookup instead of linear search if packet_hash == '0000000000000000' and hasattr(self.bot, 'message_handler'): try: + message_handler = self.bot.message_handler + # Get full raw_hex from payload for prefix matching (bot uses full raw_hex for packet_prefix) full_raw_hex = payload.get('raw_hex', '') if full_raw_hex: @@ -547,9 +550,23 @@ class PacketCaptureService(BaseServicePlugin): clean_raw_hex_for_lookup = raw_hex.replace('0x', '') packet_prefix = clean_raw_hex_for_lookup[:32] if len(clean_raw_hex_for_lookup) >= 32 else clean_raw_hex_for_lookup - # Check recent_rf_data for matching packet - if hasattr(self.bot.message_handler, 'recent_rf_data'): - for rf_data in self.bot.message_handler.recent_rf_data: + # Use indexed lookup (O(1)) instead of linear search (O(n)) + if hasattr(message_handler, 'rf_data_by_pubkey') and packet_prefix: + rf_data_list = message_handler.rf_data_by_pubkey.get(packet_prefix, []) + # Check most recent entries first (last in list, since they're appended in order) + for rf_data in reversed(rf_data_list): + if 'packet_hash' in rf_data: + packet_hash = rf_data['packet_hash'] + break + elif 'routing_info' in rf_data: + routing_info = rf_data.get('routing_info', {}) + if isinstance(routing_info, dict) and 'packet_hash' in routing_info: + packet_hash = routing_info['packet_hash'] + break + + # Fallback to linear search only if indexed lookup not available (backward compatibility) + if packet_hash == '0000000000000000' and hasattr(message_handler, 'recent_rf_data'): + for rf_data in message_handler.recent_rf_data: # Match by packet_prefix (bot uses full raw_hex for this) if rf_data.get('packet_prefix') == packet_prefix: if 'packet_hash' in rf_data: @@ -1253,11 +1270,13 @@ class PacketCaptureService(BaseServicePlugin): else: return {"model": "unknown", "version": "unknown"} - # Return cached info if available and device is not connected - if self.cached_firmware_info and (not self.meshcore or not self.meshcore.is_connected): - self.logger.debug("Using cached firmware info") + # Always use cached info if available - firmware info doesn't change during runtime + if self.cached_firmware_info: + if self.debug: + self.logger.debug("Using cached firmware info") return self.cached_firmware_info + # Only query if we don't have cached info if not self.meshcore or not self.meshcore.is_connected: return {"model": "unknown", "version": "unknown"}