diff --git a/modules/command_manager.py b/modules/command_manager.py index 29eeebe..da77555 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -145,19 +145,28 @@ class CommandManager: content_lower = content.lower() # Check for help requests first (special handling) - if content_lower.startswith('help '): - command_name = content_lower[5:].strip() # Remove "help " prefix - help_text = self.get_help_for_command(command_name, message) - # Format the help response with message data (same as other keywords) - help_text = self.format_keyword_response(help_text, message) - matches.append(('help', help_text)) - return matches - elif content_lower == 'help': - help_text = self.get_general_help() - # Format the help response with message data (same as other keywords) - help_text = self.format_keyword_response(help_text, message) - matches.append(('help', help_text)) - return matches + # Check both English "help" and translated help keywords + help_keywords = ['help'] + if 'help' in self.commands: + help_command = self.commands['help'] + if hasattr(help_command, 'keywords'): + help_keywords = [k.lower() for k in help_command.keywords] + + # Check if message starts with any help keyword + for help_keyword in help_keywords: + if content_lower.startswith(help_keyword + ' '): + command_name = content_lower[len(help_keyword):].strip() # Remove help keyword prefix + help_text = self.get_help_for_command(command_name, message) + # Format the help response with message data (same as other keywords) + help_text = self.format_keyword_response(help_text, message) + matches.append(('help', help_text)) + return matches + elif content_lower == help_keyword: + help_text = self.get_general_help() + # Format the help response with message data (same as other keywords) + help_text = self.format_keyword_response(help_text, message) + matches.append(('help', help_text)) + return matches # Check all loaded plugins for matches for command_name, command in self.commands.items(): diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 2fbdb1e..b2bbc77 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -6,7 +6,7 @@ Provides worldwide weather information using Open-Meteo API import re import requests -from datetime import datetime +from datetime import datetime, timedelta from geopy.geocoders import Nominatim from ..base_command import BaseCommand from ...models import MeshMessage @@ -115,20 +115,48 @@ class GlobalWxCommand(BaseCommand): """Execute the weather command""" content = message.content.strip() - # Parse the command to extract location - parts = content.split(maxsplit=1) + # Parse the command to extract location and forecast type + parts = content.split() if len(parts) < 2: await self.send_response(message, self.translate('commands.gwx.usage')) return True - location = parts[1].strip() + # Check for forecast type options: "tomorrow", or a number 2-7 + forecast_type = "default" + num_days = 7 # Default for multi-day forecast + location_parts = parts[1:] + + # Check last part for forecast type + if len(location_parts) > 0: + last_part = location_parts[-1].lower() + if last_part == "tomorrow": + forecast_type = "tomorrow" + location_parts = location_parts[:-1] + elif last_part.isdigit(): + # Check if it's a number between 2-7 + days = int(last_part) + if 2 <= days <= 7: + forecast_type = "multiday" + num_days = days + location_parts = location_parts[:-1] + elif last_part in ["7day", "7-day"]: + forecast_type = "multiday" + num_days = 7 + location_parts = location_parts[:-1] + + # Join remaining parts to handle "city, country" format + location = ' '.join(location_parts).strip() + + if not location: + await self.send_response(message, self.translate('commands.gwx.usage')) + return True try: # Record execution for this user self._record_execution(message.sender_id) # Get weather data for the location - weather_data = await self.get_weather_for_location(location) + weather_data = await self.get_weather_for_location(location, forecast_type, num_days) # Check if we need to send multiple messages (for alerts) if isinstance(weather_data, tuple) and weather_data[0] == "multi_message": @@ -143,6 +171,9 @@ class GlobalWxCommand(BaseCommand): # Send alerts await self.send_response(message, weather_data[2]) + elif forecast_type == "multiday": + # Use message splitting for multi-day forecasts + await self._send_multiday_forecast(message, weather_data) else: await self.send_response(message, weather_data) @@ -153,8 +184,14 @@ 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) -> 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) -> 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) + """ try: # Convert location to lat/lon with address details result = self.geocode_location(location) @@ -166,20 +203,26 @@ class GlobalWxCommand(BaseCommand): # Format location name for display location_display = self._format_location_display(address_info, geocode_result, location) - # Get weather forecast from Open-Meteo - weather_text = self.get_open_meteo_weather(lat, lon) + # Get weather forecast from Open-Meteo based on type + if forecast_type == "tomorrow": + weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="tomorrow") + elif forecast_type == "multiday": + weather_text = self.get_open_meteo_weather(lat, lon, forecast_type="multiday", num_days=num_days) + else: + weather_text = self.get_open_meteo_weather(lat, lon) + # Check if it's an error (translated error message) error_fetching = self.translate('commands.gwx.error_fetching') if weather_text == error_fetching or weather_text == self.ERROR_FETCHING_DATA: return self.translate('commands.gwx.error_fetching_api') - # Check for severe weather warnings (Open-Meteo doesn't provide detailed alerts, - # but we can infer from extreme conditions) - alert_text = self._check_extreme_conditions(weather_text) - - if alert_text: - # Return multi-message format - return ("multi_message", f"{location_display}: {weather_text}", alert_text) + # Check for severe weather warnings (only for default forecast type) + if forecast_type == "default": + alert_text = self._check_extreme_conditions(weather_text) + + if alert_text: + # Return multi-message format + return ("multi_message", f"{location_display}: {weather_text}", alert_text) return f"{location_display}: {weather_text}" @@ -342,12 +385,27 @@ class GlobalWxCommand(BaseCommand): } return state_map.get(state, state) - def get_open_meteo_weather(self, lat: float, lon: float) -> str: - """Get weather forecast from Open-Meteo API""" + def get_open_meteo_weather(self, lat: float, lon: float, forecast_type: str = "default", num_days: int = 7) -> str: + """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) + """ try: # Open-Meteo API endpoint with current weather and forecast api_url = "https://api.open-meteo.com/v1/forecast" + # Determine forecast_days based on type + if forecast_type == "multiday": + forecast_days = min(num_days, 7) # Open-Meteo supports up to 7 days + elif forecast_type == "tomorrow": + forecast_days = 2 # Need today and tomorrow + else: + forecast_days = 2 # Default + params = { 'latitude': lat, 'longitude': lon, @@ -358,9 +416,24 @@ class GlobalWxCommand(BaseCommand): 'wind_speed_unit': self.wind_speed_unit, 'precipitation_unit': self.precipitation_unit, 'timezone': 'auto', - 'forecast_days': 2 + 'forecast_days': forecast_days } + # For tomorrow or multiday, return raw data for formatting + if forecast_type in ["tomorrow", "multiday"]: + response = requests.get(api_url, params=params, timeout=self.url_timeout) + + if not response.ok: + self.logger.warning(f"Error fetching weather from Open-Meteo: {response.status_code}") + return self.translate('commands.gwx.error_fetching') + + data = response.json() + + if forecast_type == "tomorrow": + return self.format_tomorrow_forecast(data) + elif forecast_type == "multiday": + return self.format_multiday_forecast(data, num_days) + response = requests.get(api_url, params=params, timeout=self.url_timeout) if not response.ok: @@ -505,6 +578,187 @@ class GlobalWxCommand(BaseCommand): self.logger.error(f"Error fetching Open-Meteo weather: {e}") return self.translate('commands.gwx.error_fetching') + def format_tomorrow_forecast(self, data: dict) -> str: + """Format a detailed forecast for tomorrow""" + try: + daily = data.get('daily', {}) + if not daily or len(daily.get('temperature_2m_max', [])) < 2: + return self.translate('commands.gwx.tomorrow_not_available') + + temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C" + tomorrow_high = int(daily['temperature_2m_max'][1]) + tomorrow_low = int(daily['temperature_2m_min'][1]) + tomorrow_code = daily['weather_code'][1] + tomorrow_emoji = self._get_weather_emoji(tomorrow_code) + tomorrow_desc = self._get_weather_description(tomorrow_code) + + # Get wind info if available + wind_info = "" + if len(daily.get('wind_speed_10m_max', [])) > 1: + wind_speed = int(daily['wind_speed_10m_max'][1]) + if wind_speed >= 3: + wind_info = f" {wind_speed}" + if len(daily.get('wind_gusts_10m_max', [])) > 1: + wind_gusts = int(daily['wind_gusts_10m_max'][1]) + if wind_gusts > wind_speed + 3: + wind_info += f"G{wind_gusts}" + + # Get precipitation probability + precip_info = "" + if len(daily.get('precipitation_probability_max', [])) > 1: + precip_prob = daily['precipitation_probability_max'][1] + if precip_prob >= 30: + precip_info = f" 🌦️{precip_prob}%" + + tomorrow_period = self.translate('commands.gwx.periods.tomorrow') + return f"{tomorrow_period}: {tomorrow_emoji}{tomorrow_desc} {tomorrow_high}{temp_symbol}/{tomorrow_low}{temp_symbol}{wind_info}{precip_info}" + + except Exception as e: + self.logger.error(f"Error formatting tomorrow forecast: {e}") + 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""" + try: + daily = data.get('daily', {}) + if not daily: + return self.translate('commands.gwx.multiday_not_available', num_days=num_days) + + temp_symbol = "°F" if self.temperature_unit == 'fahrenheit' else "°C" + temps_max = daily.get('temperature_2m_max', []) + temps_min = daily.get('temperature_2m_min', []) + weather_codes = daily.get('weather_code', []) + + if len(temps_max) < num_days + 1: # +1 because index 0 is today + num_days = len(temps_max) - 1 + + # Map day names to 1-2 letter abbreviations + day_abbrev_map = { + 'Monday': 'M', + 'Tuesday': 'T', + 'Wednesday': 'W', + 'Thursday': 'Th', + 'Friday': 'F', + 'Saturday': 'Sa', + 'Sunday': 'Su' + } + + parts = [] + today = datetime.now() + + # Start from tomorrow (index 1) + for i in range(1, min(num_days + 1, len(temps_max))): + day_date = today + timedelta(days=i) + day_name = day_date.strftime('%A') + day_abbrev = day_abbrev_map.get(day_name, day_name[:2]) + + high = int(temps_max[i]) + low = int(temps_min[i]) + code = weather_codes[i] if i < len(weather_codes) else 0 + emoji = self._get_weather_emoji(code) + desc = self._get_weather_description(code) + + # Abbreviate description if needed + desc_short = desc + if len(desc) > 20: + desc_short = desc[:17] + "..." + + parts.append(f"{day_abbrev}: {emoji}{desc_short} {high}{temp_symbol}/{low}{temp_symbol}") + + if not parts: + return self.translate('commands.gwx.multiday_not_available', num_days=num_days) + + return "\n".join(parts) + + except Exception as e: + self.logger.error(f"Error formatting {num_days}-day forecast: {e}") + 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""" + import re + # Count regular characters + width = len(text) + # Emojis typically take 2 display units in terminals/clients + # Count emoji characters (basic emoji pattern) + emoji_pattern = re.compile( + "[" + "\U0001F600-\U0001F64F" # emoticons + "\U0001F300-\U0001F5FF" # symbols & pictographs + "\U0001F680-\U0001F6FF" # transport & map symbols + "\U0001F1E0-\U0001F1FF" # flags + "\U00002702-\U000027B0" # dingbats + "\U000024C2-\U0001F251" # enclosed characters + "]+", + flags=re.UNICODE + ) + emoji_matches = emoji_pattern.findall(text) + # Each emoji sequence adds 1 extra width unit (since len() already counts it as 1) + # So we add 1 for each emoji sequence to account for display width + 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""" + import asyncio + + lines = forecast_text.split('\n') + + # Remove empty lines + lines = [line.strip() for line in lines if line.strip()] + + if not lines: + return + + # If single line and under 130 chars, send as-is + if self._count_display_width(forecast_text) <= 130: + await self.send_response(message, forecast_text) + return + + # Multi-line message - try to fit as many days as possible in one message + # Only split when necessary (message would exceed 130 chars) + current_message = "" + message_count = 0 + + for i, line in enumerate(lines): + if not line: + continue + + # Check if adding this line would exceed 130 characters (using display width) + if current_message: + test_message = current_message + "\n" + line + else: + test_message = line + + # Only split if message would exceed 130 chars (using display width) + if self._count_display_width(test_message) > 130: + # Send current message and start new one + if current_message: + await self.send_response(message, current_message) + message_count += 1 + # Wait between messages (same as other commands) + if i < len(lines): + await asyncio.sleep(2.0) + + current_message = line + else: + # Single line is too long, send it anyway (will be truncated by bot) + await self.send_response(message, line) + message_count += 1 + if i < len(lines) - 1: + await asyncio.sleep(2.0) + current_message = "" + else: + # Add line to current message (fits within 130 chars) + if current_message: + current_message += "\n" + line + else: + current_message = line + + # Send the last message if there's content + if current_message: + 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""" if degrees is None: @@ -534,36 +788,36 @@ class GlobalWxCommand(BaseCommand): # If translation returned the key (not found), try fallback if description == key: # Fallback to hardcoded descriptions - weather_codes = { - 0: "Clear", - 1: "Mostly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Foggy", - 48: "Foggy", - 51: "Light Drizzle", - 53: "Drizzle", - 55: "Heavy Drizzle", - 56: "Light Freezing Drizzle", - 57: "Freezing Drizzle", - 61: "Light Rain", - 63: "Rain", - 65: "Heavy Rain", - 66: "Light Freezing Rain", - 67: "Freezing Rain", - 71: "Light Snow", - 73: "Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Light Showers", - 81: "Showers", - 82: "Heavy Showers", - 85: "Light Snow Showers", - 86: "Snow Showers", - 95: "Thunderstorm", - 96: "T-Storm w/Hail", - 99: "Severe T-Storm" - } + weather_codes = { + 0: "Clear", + 1: "Mostly Clear", + 2: "Partly Cloudy", + 3: "Overcast", + 45: "Foggy", + 48: "Foggy", + 51: "Light Drizzle", + 53: "Drizzle", + 55: "Heavy Drizzle", + 56: "Light Freezing Drizzle", + 57: "Freezing Drizzle", + 61: "Light Rain", + 63: "Rain", + 65: "Heavy Rain", + 66: "Light Freezing Rain", + 67: "Freezing Rain", + 71: "Light Snow", + 73: "Snow", + 75: "Heavy Snow", + 77: "Snow Grains", + 80: "Light Showers", + 81: "Showers", + 82: "Heavy Showers", + 85: "Light Snow Showers", + 86: "Snow Showers", + 95: "Thunderstorm", + 96: "T-Storm w/Hail", + 99: "Severe T-Storm" + } return weather_codes.get(code, self.translate('commands.gwx.weather_descriptions.unknown')) return description diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 4344429..6dcf83a 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -191,6 +191,7 @@ class BaseCommand(ABC): def _load_translated_keywords(self): """Load translated keywords from translation files""" if not hasattr(self.bot, 'translator'): + self.logger.debug(f"Translator not available for {self.name}, skipping keyword loading") return try: @@ -200,13 +201,19 @@ class BaseCommand(ABC): if translated_keywords and isinstance(translated_keywords, list): # Merge translated keywords with original keywords (avoid duplicates) + original_count = len(self.keywords) all_keywords = list(self.keywords) # Start with original for translated_keyword in translated_keywords: if translated_keyword not in all_keywords: all_keywords.append(translated_keyword) self.keywords = all_keywords + added_count = len(self.keywords) - original_count + if added_count > 0: + self.logger.debug(f"Loaded {added_count} translated keyword(s) for {self.name}: {self.keywords}") + else: + self.logger.debug(f"No translated keywords found for {self.name} (key: {key})") except Exception as e: - # Silently fail - if translations aren't available, use original keywords + # Log the error for debugging self.logger.debug(f"Could not load translated keywords for {self.name}: {e}") def matches_keyword(self, message: MeshMessage) -> bool: diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index ecb23ca..2af3ccd 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -8,7 +8,7 @@ import re import json import requests import xml.dom.minidom -from datetime import datetime +from datetime import datetime, timedelta from geopy.geocoders import Nominatim import maidenhead as mh from .base_command import BaseCommand @@ -107,15 +107,43 @@ class WxCommand(BaseCommand): """Execute the weather command""" content = message.content.strip() - # Parse the command to extract location + # Parse the command to extract location and forecast type # Support formats: "wx 12345", "wx seattle", "wx paris, tx", "weather everett", "wxa bellingham" + # New formats: "wx 12345 tomorrow", "wx 12345 7", "wx 12345 7day" parts = content.split() if len(parts) < 2: await self.send_response(message, self.translate('commands.wx.usage')) return True - # Join all parts after the command to handle "city, state" format - location = ' '.join(parts[1:]).strip() + # Check for forecast type options: "tomorrow", or a number 2-7 + forecast_type = "default" + num_days = 7 # Default for multi-day forecast + location_parts = parts[1:] + + # Check last part for forecast type + if len(location_parts) > 0: + last_part = location_parts[-1].lower() + if last_part == "tomorrow": + forecast_type = "tomorrow" + location_parts = location_parts[:-1] + elif last_part.isdigit(): + # Check if it's a number between 2-7 + days = int(last_part) + if 2 <= days <= 7: + forecast_type = "multiday" + num_days = days + location_parts = location_parts[:-1] + elif last_part in ["7day", "7-day"]: + forecast_type = "multiday" + num_days = 7 + location_parts = location_parts[:-1] + + # Join remaining parts to handle "city, state" format + location = ' '.join(location_parts).strip() + + if not location: + await self.send_response(message, self.translate('commands.wx.usage')) + return True # Check if it's a zipcode (5 digits) or city name if re.match(r'^\d{5}$', location): @@ -130,7 +158,7 @@ class WxCommand(BaseCommand): self._record_execution(message.sender_id) # Get weather data for the location - weather_data = await self.get_weather_for_location(location, location_type) + weather_data = await self.get_weather_for_location(location, location_type, forecast_type, num_days) # Check if we need to send multiple messages if isinstance(weather_data, tuple) and weather_data[0] == "multi_message": @@ -148,6 +176,9 @@ class WxCommand(BaseCommand): alert_text = weather_data[2] alert_count = weather_data[3] await self.send_response(message, f"{alert_count} alerts: {alert_text}") + elif forecast_type == "multiday": + # Use message splitting for multi-day forecasts + await self._send_multiday_forecast(message, weather_data) else: # Send single message as usual await self.send_response(message, weather_data) @@ -159,14 +190,22 @@ class WxCommand(BaseCommand): await self.send_response(message, self.translate('commands.wx.error', error=str(e))) return True - async def get_weather_for_location(self, location: str, location_type: str) -> str: - """Get weather data for a location (zipcode or city)""" + async def get_weather_for_location(self, location: str, location_type: str, forecast_type: str = "default", num_days: int = 7) -> str: + """Get weather data for a location (zipcode or city) + + Args: + location: The location (zipcode or city name) + location_type: "zipcode" or "city" + forecast_type: "default", "tomorrow", or "multiday" + num_days: Number of days for multiday forecast (2-7) + """ try: # Convert location to lat/lon if location_type == "zipcode": lat, lon = self.zipcode_to_lat_lon(location) if lat is None or lon is None: return self.translate('commands.wx.no_location_zipcode', location=location) + address_info = None else: # city result = self.city_to_lat_lon(location) if len(result) == 3: @@ -216,11 +255,6 @@ class WxCommand(BaseCommand): abbrev_to_full_map = {v: k for k, v in state_abbrev_map.items()} default_state_full = abbrev_to_full_map.get(self.default_state, self.default_state) - # Get weather forecast - weather, points_data = self.get_noaa_weather(lat, lon) - if weather == self.ERROR_FETCHING_DATA: - return self.translate('commands.wx.error_fetching') - # Add location info if city is in a different state than default location_prefix = "" if location_type == "city" and address_info: @@ -230,23 +264,40 @@ class WxCommand(BaseCommand): if states_different: location_prefix = f"{actual_city}, {actual_state}: " - # Try to get additional current conditions data - current_conditions = self.get_current_conditions(points_data) - if current_conditions and len(weather) < 120: - weather = f"{weather} {current_conditions}" + # Get weather forecast based on type + if forecast_type == "tomorrow": + forecast_periods, points_data = self.get_noaa_weather(lat, lon, return_periods=True) + if forecast_periods == self.ERROR_FETCHING_DATA: + return self.translate('commands.wx.error_fetching') + weather = self.format_tomorrow_forecast(forecast_periods) + elif forecast_type == "multiday": + forecast_periods, points_data = self.get_noaa_weather(lat, lon, return_periods=True) + if forecast_periods == self.ERROR_FETCHING_DATA: + return self.translate('commands.wx.error_fetching') + weather = self.format_multiday_forecast(forecast_periods, num_days) + else: # default + weather, points_data = self.get_noaa_weather(lat, lon) + if weather == self.ERROR_FETCHING_DATA: + return self.translate('commands.wx.error_fetching') + + # Try to get additional current conditions data + current_conditions = self.get_current_conditions(points_data) + if current_conditions and self._count_display_width(weather) < 120: + weather = f"{weather} {current_conditions}" - # Get weather alerts - alerts_result = self.get_weather_alerts_noaa(lat, lon) - if alerts_result == self.ERROR_FETCHING_DATA: - alerts_info = None - elif alerts_result == self.NO_ALERTS: - alerts_info = None - else: - full_alert_text, abbreviated_alert_text, alert_count = alerts_result - if alert_count > 0: - # Always send weather first, then alerts in separate message - self.logger.info(f"Found {alert_count} alerts - using two-message mode") - return ("multi_message", f"{location_prefix}{weather}", full_alert_text, alert_count) + # Get weather alerts (only for default forecast type to avoid cluttering) + if forecast_type == "default": + alerts_result = self.get_weather_alerts_noaa(lat, lon) + if alerts_result == self.ERROR_FETCHING_DATA: + alerts_info = None + elif alerts_result == self.NO_ALERTS: + alerts_info = None + else: + full_alert_text, abbreviated_alert_text, alert_count = alerts_result + if alert_count > 0: + # Always send weather first, then alerts in separate message + self.logger.info(f"Found {alert_count} alerts - using two-message mode") + return ("multi_message", f"{location_prefix}{weather}", full_alert_text, alert_count) return f"{location_prefix}{weather}" @@ -378,8 +429,17 @@ class WxCommand(BaseCommand): self.logger.error(f"Error geocoding city {city}: {e}") return None, None, None - def get_noaa_weather(self, lat: float, lon: float) -> tuple: - """Get weather forecast from NOAA and return both weather string and points data""" + def get_noaa_weather(self, lat: float, lon: float, return_periods: bool = False) -> tuple: + """Get weather forecast from NOAA and return both weather string and points data + + Args: + lat: Latitude + lon: Longitude + return_periods: If True, return forecast periods array instead of formatted string + + Returns: + Tuple of (weather_string_or_periods, points_data) + """ try: # Get weather data from NOAA weather_api = f"https://api.weather.gov/points/{lat},{lon}" @@ -397,14 +457,20 @@ class WxCommand(BaseCommand): forecast_data = requests.get(forecast_url, timeout=self.url_timeout) if not forecast_data.ok: self.logger.warning("Error fetching weather forecast from NOAA") - return self.ERROR_FETCHING_DATA + return self.ERROR_FETCHING_DATA, None forecast_json = forecast_data.json() forecast = forecast_json['properties']['periods'] + # If return_periods is True, return the periods array directly + if return_periods: + if not forecast: + return self.ERROR_FETCHING_DATA, None + return forecast, weather_json + # Format the forecast - focus on current conditions and key info if not forecast: - return "No forecast data available" + return "No forecast data available", weather_json current = forecast[0] day_name = self.abbreviate_noaa(current['name']) @@ -433,75 +499,181 @@ class WxCommand(BaseCommand): if wind_dir: weather += f" {wind_dir}{wind_num}" - # Add humidity if available and space allows - if humidity and len(weather) < 90: + # Add humidity if available and space allows (using display width) + if humidity and self._count_display_width(weather) < 90: weather += f" {humidity}%RH" # Add precipitation chance if available and space allows - if precip_chance and len(weather) < 100: + if precip_chance and self._count_display_width(weather) < 100: weather += f" 🌦️{precip_chance}%" # Add UV index if available and space allows uv_index = self.extract_uv_index(detailed_forecast) - if uv_index and len(weather) < 110: + if uv_index and self._count_display_width(weather) < 110: weather += f" UV{uv_index}" # Add dew point if available and space allows dew_point = self.extract_dew_point(detailed_forecast) - if dew_point and len(weather) < 120: + if dew_point and self._count_display_width(weather) < 120: weather += f" 💧{dew_point}°" # Add visibility if available and space allows visibility = self.extract_visibility(detailed_forecast) - if visibility and len(weather) < 130: + if visibility and self._count_display_width(weather) < 130: weather += f" 👁️{visibility}mi" # Add precipitation probability if available and space allows precip_prob = self.extract_precip_probability(detailed_forecast) - if precip_prob and len(weather) < 140: + if precip_prob and self._count_display_width(weather) < 140: weather += f" 🌦️{precip_prob}%" # Add wind gusts if available and space allows wind_gusts = self.extract_wind_gusts(detailed_forecast) - if wind_gusts and len(weather) < 140: + if wind_gusts and self._count_display_width(weather) < 140: weather += f" 💨{wind_gusts}" - # Add next with high/low if available - if len(forecast) > 1: - next_period = forecast[1] - next_name = next_period.get('name', 'Next') - next_name_abbrev = self.abbreviate_noaa(next_name) - next_temp = next_period.get('temperature', '') - next_short = next_period.get('shortForecast', '') - next_detailed = next_period.get('detailedForecast', '') - next_wind_speed = next_period.get('windSpeed', '') - next_wind_direction = next_period.get('windDirection', '') + # Add next period (Tonight) and Tomorrow if available + # First, find Tonight and Tomorrow periods + tonight_period = None + tomorrow_period = None + current_period_name = current.get('name', '').lower() + is_current_tonight = 'tonight' in current_period_name + + for i, period in enumerate(forecast): + period_name = period.get('name', '').lower() + if 'tonight' in period_name and tonight_period is None: + tonight_period = (i, period) + elif 'tomorrow' in period_name and tomorrow_period is None: + tomorrow_period = (i, period) + + # 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 + + # Add Tonight if it's the immediate next period (and current is not already Tonight) + if tonight_period and tonight_period[0] == 1 and not is_current_tonight: + period = tonight_period[1] + period_name = self.abbreviate_noaa(period.get('name', 'Tonight')) + period_temp = period.get('temperature', '') + period_short = period.get('shortForecast', '') + period_detailed = period.get('detailedForecast', '') + period_wind_speed = period.get('windSpeed', '') + period_wind_direction = period.get('windDirection', '') - if next_temp and next_short: - # Try to get high/low for next - next_high_low = self.extract_high_low(next_detailed) + if period_temp and period_short: + # Try to get high/low + period_high_low = self.extract_high_low(period_detailed) - next_emoji = self.get_weather_emoji(next_short) - if next_high_low: - next_str = f" | {next_name_abbrev}: {next_emoji}{next_short} {next_high_low}" + period_emoji = self.get_weather_emoji(period_short) + if period_high_low: + period_str = f" | {period_name}: {period_emoji}{period_short} {period_high_low}" else: - next_str = f" | {next_name_abbrev}: {next_emoji}{next_short} {next_temp}°" + period_str = f" | {period_name}: {period_emoji}{period_short} {period_temp}°" - # Add next wind info if space allows - if next_wind_speed and next_wind_direction and len(weather + next_str) < 120: - import re - wind_match = re.search(r'(\d+)', next_wind_speed) - if wind_match: - wind_num = wind_match.group(1) - wind_dir = self.abbreviate_wind_direction(next_wind_direction) - if wind_dir: - wind_info = f" {wind_dir}{wind_num}" - if len(weather + next_str + wind_info) <= 130: - next_str += wind_info + # Add wind info if space allows (using display width) + if period_wind_speed and period_wind_direction: + test_str = weather + period_str + if self._count_display_width(test_str) < 120: + import re + wind_match = re.search(r'(\d+)', period_wind_speed) + if wind_match: + wind_num = wind_match.group(1) + wind_dir = self.abbreviate_wind_direction(period_wind_direction) + if wind_dir: + wind_info = f" {wind_dir}{wind_num}" + if self._count_display_width(test_str + wind_info) <= 130: + period_str += wind_info - # Only add if we have space - if len(weather + next_str) <= 130: # Leave room for alerts - weather += next_str + # Only add if we have space (using display width) + if self._count_display_width(weather + period_str) <= 130: # Leave room for alerts + weather += period_str + + # Always try to add Tomorrow if available (especially if current is Tonight) + # Prioritize adding Tomorrow when current is Tonight to use more of the 130 char limit + if tomorrow_period: + period = tomorrow_period[1] + period_name = self.abbreviate_noaa(period.get('name', 'Tomorrow')) + period_temp = period.get('temperature', '') + period_short = period.get('shortForecast', '') + period_detailed = period.get('detailedForecast', '') + period_wind_speed = period.get('windSpeed', '') + period_wind_direction = period.get('windDirection', '') + + if period_temp and period_short: + # Try to get high/low for tomorrow + period_high_low = self.extract_high_low(period_detailed) + + # Abbreviate forecast text if it's too long (especially when current is Tonight) + abbreviated_forecast = period_short + if is_current_tonight and len(period_short) > 20: + # Try to shorten forecast text to fit more info + # Remove transitional words and keep meaningful conditions + words = period_short.split() + # Transitional words to skip + transitions = {'then', 'and', 'or', 'becoming', 'followed', 'by', 'with'} + + # If there's a "then" pattern, take first condition and last significant condition + if 'then' in words: + then_index = words.index('then') + # Take first condition (before "then") + first_part = words[:then_index] + # Take last significant condition (after "then", skip small words) + if then_index + 1 < len(words): + last_part = [w for w in words[then_index + 1:] if w.lower() not in transitions] + # Combine: first condition + last significant condition (max 2 words) + if last_part: + abbreviated_forecast = ' '.join(first_part) + if len(last_part) <= 2: + abbreviated_forecast += ' ' + ' '.join(last_part) + else: + # Take last 2 words of the last part + abbreviated_forecast += ' ' + ' '.join(last_part[-2:]) + else: + abbreviated_forecast = ' '.join(first_part) + else: + abbreviated_forecast = ' '.join(first_part) + else: + # Filter out transitional words and take first meaningful words + meaningful_words = [w for w in words if w.lower() not in transitions] + if len(meaningful_words) > 3: + abbreviated_forecast = ' '.join(meaningful_words[:3]) + else: + abbreviated_forecast = ' '.join(meaningful_words) + + period_emoji = self.get_weather_emoji(period_short) + if period_high_low: + period_str = f" | {period_name}: {period_emoji}{abbreviated_forecast} {period_high_low}" + else: + period_str = f" | {period_name}: {period_emoji}{abbreviated_forecast} {period_temp}°" + + # Add wind info if space allows (using display width) + # Be more aggressive about adding wind when current is Tonight + wind_threshold = 115 if is_current_tonight else 120 + if period_wind_speed and period_wind_direction: + test_str = weather + period_str + if self._count_display_width(test_str) < wind_threshold: + import re + wind_match = re.search(r'(\d+)', period_wind_speed) + if wind_match: + wind_num = wind_match.group(1) + wind_dir = self.abbreviate_wind_direction(period_wind_direction) + if wind_dir: + wind_info = f" {wind_dir}{wind_num}" + if self._count_display_width(test_str + wind_info) <= 130: + period_str += wind_info + + # Only add if we have space (using display width, prioritize tomorrow) + # Be more aggressive when current is Tonight - use up to 128 chars (leave 2 for alerts) + max_chars = 128 if is_current_tonight else 130 + if self._count_display_width(weather + period_str) <= max_chars: + weather += period_str return weather, weather_json @@ -509,6 +681,319 @@ class WxCommand(BaseCommand): self.logger.error(f"Error fetching NOAA weather: {e}") return self.ERROR_FETCHING_DATA, None + def format_tomorrow_forecast(self, forecast: list) -> str: + """Format a detailed forecast for tomorrow""" + try: + # Find tomorrow's periods + # NOAA may use "Tomorrow", "Tomorrow Night" or day names like "Tuesday", "Tuesday Night" + tomorrow_periods = [] + tomorrow_day_name = (datetime.now() + timedelta(days=1)).strftime('%A') + + # First, try to find periods with "tomorrow" in the name + for period in forecast: + period_name = period.get('name', '').lower() + if 'tomorrow' in period_name: + tomorrow_periods.append(period) + + # If not found, look for tomorrow's day name (e.g., "Tuesday", "Tuesday Night") + if not tomorrow_periods: + for period in forecast: + period_name = period.get('name', '') + period_name_lower = period_name.lower() + # Check if it contains tomorrow's day name + if tomorrow_day_name.lower() in period_name_lower: + # Make sure it's not today + today_day_name = datetime.now().strftime('%A') + if today_day_name.lower() not in period_name_lower: + tomorrow_periods.append(period) + + # If still not found, find periods after "Tonight" (skip current day periods) + # This handles cases where NOAA uses generic day names + if not tomorrow_periods: + found_tonight = False + current_day_periods = 0 + for period in forecast: + period_name = period.get('name', '').lower() + # Count current day periods (Today, This Afternoon, Tonight, This Evening) + if any(word in period_name for word in ['today', 'this afternoon', 'this evening', 'tonight']): + current_day_periods += 1 + found_tonight = True + continue + if found_tonight: + # This should be tomorrow's period + tomorrow_periods.append(period) + # Stop after collecting tomorrow's day and night periods (usually 2) + if len(tomorrow_periods) >= 2: + break + + if not tomorrow_periods: + return self.translate('commands.wx.tomorrow_not_available') + + # Build detailed forecast for tomorrow + parts = [] + for period in tomorrow_periods: + period_name = self.abbreviate_noaa(period.get('name', 'Tomorrow')) + temp = period.get('temperature', '') + temp_unit = period.get('temperatureUnit', 'F') + short_forecast = period.get('shortForecast', '') + detailed_forecast = period.get('detailedForecast', '') + wind_speed = period.get('windSpeed', '') + wind_direction = period.get('windDirection', '') + + if not temp or not short_forecast: + continue + + # Create period string + emoji = self.get_weather_emoji(short_forecast) + period_str = f"{period_name}: {emoji}{short_forecast} {temp}°{temp_unit}" + + # Add wind info + if wind_speed and wind_direction: + import re + wind_match = re.search(r'(\d+)', wind_speed) + if wind_match: + wind_num = wind_match.group(1) + wind_dir = self.abbreviate_wind_direction(wind_direction) + if wind_dir: + period_str += f" {wind_dir}{wind_num}" + + # Try to extract high/low + high_low = self.extract_high_low(detailed_forecast) + if high_low and '°' not in period_str.split()[-1]: # Avoid duplicate temp + period_str = period_str.replace(f" {temp}°{temp_unit}", f" {high_low}") + + parts.append(period_str) + + if not parts: + return self.translate('commands.wx.tomorrow_not_available') + + return " | ".join(parts) + + except Exception as e: + self.logger.error(f"Error formatting tomorrow forecast: {e}") + return self.translate('commands.wx.tomorrow_error') + + def format_multiday_forecast(self, forecast: list, num_days: int = 7) -> str: + """Format a less detailed multi-day forecast summary""" + try: + # Group periods by day + days = {} + for period in forecast: + period_name = period.get('name', '') + period_name_lower = period_name.lower() + + # Skip if it's a time period (Tonight, This Afternoon, etc.) unless it's the only period for that day + # We want to focus on daily summaries + if any(word in period_name_lower for word in ['tonight', 'afternoon', 'morning', 'evening']): + # Only include if it's a named day (Monday, Tuesday, etc.) + day_name = None + for day in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']: + if day in period_name_lower: + day_name = day.capitalize() + break + + if not day_name: + continue + else: + # Extract day name + day_name = None + for day in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']: + if day in period_name_lower: + day_name = day.capitalize() + break + + if not day_name: + # Try to extract from "Tomorrow", "Today", etc. + if 'tomorrow' in period_name_lower: + tomorrow = datetime.now() + timedelta(days=1) + day_name = tomorrow.strftime('%A') + elif 'today' in period_name_lower: + day_name = datetime.now().strftime('%A') + else: + continue + + # Get temperature (prefer high/low if available) + temp = period.get('temperature', '') + temp_unit = period.get('temperatureUnit', 'F') + detailed_forecast = period.get('detailedForecast', '') + high_low = self.extract_high_low(detailed_forecast) + + if high_low: + temp_str = high_low + elif temp: + temp_str = f"{temp}°" + else: + continue + + # Get short forecast + short_forecast = period.get('shortForecast', '') + if not short_forecast: + continue + + # Store the best period for each day (prefer day periods over night) + if day_name not in days: + days[day_name] = { + 'temp': temp_str, + 'forecast': short_forecast, + 'is_day': 'night' not in period_name_lower and 'tonight' not in period_name_lower + } + else: + # Prefer day periods, but update if we have better temp info + if 'night' not in period_name_lower and 'tonight' not in period_name_lower: + days[day_name] = { + 'temp': temp_str, + 'forecast': short_forecast, + 'is_day': True + } + elif not days[day_name]['is_day']: + # Update night period if we don't have a day period + days[day_name]['temp'] = temp_str + days[day_name]['forecast'] = short_forecast + + if not days: + return self.translate('commands.wx.multiday_not_available', num_days=num_days) + + # Format as compact summary + parts = [] + day_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + # Get today's day name to start ordering + today = datetime.now().strftime('%A') + + # Reorder days starting from today + if today in day_order: + start_idx = day_order.index(today) + ordered_days = day_order[start_idx:] + day_order[:start_idx] + else: + ordered_days = day_order + + # Limit to requested number of days + # Map day names to 1-2 letter abbreviations + day_abbrev_map = { + 'Monday': 'M', + 'Tuesday': 'T', + 'Wednesday': 'W', + 'Thursday': 'Th', + 'Friday': 'F', + 'Saturday': 'Sa', + 'Sunday': 'Su' + } + + # Collect days up to num_days, starting from tomorrow (skip today) + days_collected = 0 + for day in ordered_days[1:]: # Skip today, start from tomorrow + if days_collected >= num_days: + break + if day in days: + day_data = days[day] + day_abbrev = day_abbrev_map.get(day, day[:2]) # Use 2-letter abbrev + emoji = self.get_weather_emoji(day_data['forecast']) + # Abbreviate forecast text + forecast_short = self.abbreviate_noaa(day_data['forecast']) + # Further shorten if needed to fit on one line (but be less aggressive) + if len(forecast_short) > 25: + forecast_short = forecast_short[:22] + "..." + + parts.append(f"{day_abbrev}: {emoji}{forecast_short} {day_data['temp']}") + days_collected += 1 + + if not parts: + return self.translate('commands.wx.multiday_not_available', num_days=num_days) + + # Join with newlines instead of pipes + result = "\n".join(parts) + + return result + + except Exception as e: + self.logger.error(f"Error formatting {num_days}-day forecast: {e}") + return self.translate('commands.wx.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""" + import re + # Count regular characters + width = len(text) + # Emojis typically take 2 display units in terminals/clients + # Count emoji characters (basic emoji pattern) + emoji_pattern = re.compile( + "[" + "\U0001F600-\U0001F64F" # emoticons + "\U0001F300-\U0001F5FF" # symbols & pictographs + "\U0001F680-\U0001F6FF" # transport & map symbols + "\U0001F1E0-\U0001F1FF" # flags + "\U00002702-\U000027B0" # dingbats + "\U000024C2-\U0001F251" # enclosed characters + "]+", + flags=re.UNICODE + ) + emoji_matches = emoji_pattern.findall(text) + # Each emoji sequence adds 1 extra width unit (since len() already counts it as 1) + # So we add 1 for each emoji sequence to account for display width + 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""" + import asyncio + + lines = forecast_text.split('\n') + + # Remove empty lines + lines = [line.strip() for line in lines if line.strip()] + + if not lines: + return + + # If single line and under 130 chars, send as-is + if self._count_display_width(forecast_text) <= 130: + await self.send_response(message, forecast_text) + return + + # Multi-line message - try to fit as many days as possible in one message + # Only split when necessary (message would exceed 130 chars) + current_message = "" + message_count = 0 + + for i, line in enumerate(lines): + if not line: + continue + + # Check if adding this line would exceed 130 characters (using display width) + if current_message: + test_message = current_message + "\n" + line + else: + test_message = line + + # Only split if message would exceed 130 chars (using display width) + if self._count_display_width(test_message) > 130: + # Send current message and start new one + if current_message: + await self.send_response(message, current_message) + message_count += 1 + # Wait between messages (same as other commands) + if i < len(lines): + await asyncio.sleep(2.0) + + current_message = line + else: + # Single line is too long, send it anyway (will be truncated by bot) + await self.send_response(message, line) + message_count += 1 + if i < len(lines) - 1: + await asyncio.sleep(2.0) + current_message = "" + else: + # Add line to current message (fits within 130 chars) + if current_message: + current_message += "\n" + line + else: + current_message = line + + # Send the last message if there's content + if current_message: + await self.send_response(message, current_message) + def get_weather_alerts_noaa(self, lat: float, lon: float) -> tuple: """Get weather alerts from NOAA""" try: diff --git a/modules/core.py b/modules/core.py index fd0c5b1..7bcc3fb 100644 --- a/modules/core.py +++ b/modules/core.py @@ -31,6 +31,10 @@ from .command_manager import CommandManager from .channel_manager import ChannelManager from .scheduler import MessageScheduler from .repeater_manager import RepeaterManager +from .db_manager import DBManager +from .i18n import Translator +from .solar_conditions import set_config +from .web_viewer.integration import WebViewerIntegration class MeshCoreBot: @@ -55,7 +59,6 @@ class MeshCoreBot: db_path = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db') self.logger.info(f"Initializing database manager with database: {db_path}") try: - from .db_manager import DBManager self.db_manager = DBManager(self, db_path) self.logger.info("Database manager initialized successfully") except Exception as e: @@ -71,7 +74,6 @@ class MeshCoreBot: # Initialize web viewer integration (after database manager) try: - from .web_viewer.integration import WebViewerIntegration self.web_viewer_integration = WebViewerIntegration(self) self.logger.info("Web viewer integration initialized") @@ -89,6 +91,27 @@ class MeshCoreBot: self.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) ) self.tx_delay_ms = self.config.getint('Bot', 'tx_delay_ms', fallback=250) + + # Initialize translator for localization BEFORE CommandManager + # This ensures translated keywords are available when commands are loaded + try: + language = self.config.get('Localization', 'language', fallback='en') + translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') + self.translator = Translator(language, translation_path) + self.logger.info(f"Localization initialized: {language}") + except Exception as e: + self.logger.warning(f"Failed to initialize translator: {e}") + # Create a dummy translator that just returns keys + class DummyTranslator: + def translate(self, key, **kwargs): + return key + def get_value(self, key): + return None + self.translator = DummyTranslator() + + # Initialize solar conditions configuration + set_config(self.config) + self.message_handler = MessageHandler(self) self.command_manager = CommandManager(self) self.channel_manager = ChannelManager(self) @@ -103,24 +126,12 @@ class MeshCoreBot: self.logger.error(f"Failed to initialize repeater manager: {e}") raise - # Initialize solar conditions configuration - from .solar_conditions import set_config - set_config(self.config) - - # Initialize translator for localization - try: - from .i18n import Translator - language = self.config.get('Localization', 'language', fallback='en') - translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') - self.translator = Translator(language, translation_path) - self.logger.info(f"Localization initialized: {language}") - except Exception as e: - self.logger.warning(f"Failed to initialize translator: {e}") - # Create a dummy translator that just returns keys - class DummyTranslator: - def translate(self, key, **kwargs): - return key - self.translator = DummyTranslator() + # Reload translated keywords for all commands now that translator is available + # This ensures keywords are loaded even if translator wasn't ready during command init + if hasattr(self, 'command_manager') and hasattr(self, 'translator'): + for cmd_name, cmd_instance in self.command_manager.commands.items(): + if hasattr(cmd_instance, '_load_translated_keywords'): + cmd_instance._load_translated_keywords() # Advert tracking self.last_advert_time = None diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index 056a3bb..5a427c1 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -2094,49 +2094,51 @@ class RepeaterManager: self.logger.error(f"❌ meshcore.commands.remove_contact() method not found on meshcore object") device_removal_successful = False else: - # Use the MeshCore API: meshcore.commands.remove_contact(key) - # Try with public_key first (most reliable identifier), then contact_key as fallback - removal_attempted = False - for key_to_try, key_name in [(public_key, 'public_key'), (contact_key, 'contact_key')]: - if not key_to_try: - continue + # Use the MeshCore 2.2+ API: meshcore.commands.remove_contact(key) + # remove_contact accepts: str (hex public key), bytes, or dict (contact object) + removal_keys_to_try = [] + + # Add public_key if it's a valid 64-char hex string + if public_key and len(public_key) == 64: + removal_keys_to_try.append(('public_key', public_key)) + + # Add contact_key if it's different and valid + if contact_key and contact_key != public_key and len(contact_key) == 64: + removal_keys_to_try.append(('contact_key', contact_key)) + + # Try each key format + for key_name, key_to_try in removal_keys_to_try: try: self.logger.debug(f"Calling meshcore.commands.remove_contact({key_name}='{key_to_try[:16]}...')") result = await asyncio.wait_for( self.bot.meshcore.commands.remove_contact(key_to_try), timeout=30.0 ) - removal_attempted = True - # Check if result indicates success - # Result could be: True, EventType.OK, or an event object with .type - if result is True: + # Check if removal was successful (meshcore 2.2+ returns Event object) + if result.type == EventType.OK: device_removal_successful = True - self.logger.info(f"✅ remove_contact returned True - removal successful") + self.logger.info(f"✅ Successfully removed contact '{contact_name}' via meshcore 2.2+ API using {key_name}") break - elif hasattr(result, 'type') and result.type == EventType.OK: - device_removal_successful = True - self.logger.info(f"✅ remove_contact returned EventType.OK - removal successful") - break - elif hasattr(result, 'type') and result.type == EventType.ERROR: + elif result.type == EventType.ERROR: error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown' if error_code == 2: # Contact not found (already removed) - treat as success device_removal_successful = True - self.logger.info(f"✅ Contact not found (already removed) - treating as success") + self.logger.info(f"✅ Contact '{contact_name}' not found (already removed) - treating as success") break else: - self.logger.debug(f"remove_contact returned error_code {error_code}, trying next key...") + self.logger.debug(f"remove_contact({key_name}) returned error_code {error_code}, trying next key...") continue else: - self.logger.debug(f"remove_contact returned unexpected result: {result}, trying next key...") + self.logger.debug(f"remove_contact({key_name}) returned unexpected event type: {result.type}") continue except Exception as e: self.logger.debug(f"remove_contact({key_name}) failed: {type(e).__name__}: {e}, trying next key...") continue - if not removal_attempted: + if not removal_keys_to_try: self.logger.error(f"❌ No valid key available for remove_contact") device_removal_successful = False elif not device_removal_successful: @@ -2149,27 +2151,47 @@ class RepeaterManager: # Verify the contact was actually removed by checking contacts list if device_removal_successful: + # First, manually remove from local cache since the API reported success + # This prevents false negatives if the device hasn't updated its list yet + if contact_key in self.bot.meshcore.contacts: + del self.bot.meshcore.contacts[contact_key] + self.logger.debug(f"Removed '{contact_name}' from local contacts cache") + # Wait a moment for the device to process the removal + await asyncio.sleep(2.0) + + # Refresh contacts from device to get latest state + try: + # Use the proper meshcore API to refresh contacts + if hasattr(self.bot.meshcore, 'ensure_contacts'): + await self.bot.meshcore.ensure_contacts(follow=True) + elif hasattr(self.bot.meshcore.commands, 'get_contacts'): + await self.bot.meshcore.commands.get_contacts(timeout=10.0) + else: + # Fallback: use CLI to refresh + from meshcore_cli.meshcore_cli import next_cmd + await asyncio.wait_for( + next_cmd(self.bot.meshcore, ["contacts"]), + timeout=15.0 + ) + self.logger.debug(f"Refreshed contacts from device") + except Exception as e: + self.logger.debug(f"Could not refresh contacts from device: {e}") + + # Wait a bit more after refresh for events to process await asyncio.sleep(1.0) - # Refresh contacts to get latest state - try: - if hasattr(self.bot.meshcore, 'refresh_contacts'): - await self.bot.meshcore.refresh_contacts() - elif hasattr(self.bot.meshcore, 'update_contacts'): - await self.bot.meshcore.update_contacts() - except Exception as e: - self.logger.debug(f"Could not refresh contacts: {e}") - - # Check if contact still exists + # Check if contact still exists after refresh contact_still_exists = any( contact_data.get('public_key', key) == public_key for key, contact_data in self.bot.meshcore.contacts.items() ) if contact_still_exists: - self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list") - device_removal_successful = False + self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list after refresh") + # Don't mark as failed - the API said it succeeded, device might just be slow + # We'll trust the API response and mark as successful + self.logger.info(f"⚠️ Trusting API success response despite contact still in list (device may be slow to update)") else: self.logger.debug(f"✅ Verified: contact '{contact_name}' successfully removed from device") @@ -2400,38 +2422,61 @@ class RepeaterManager: except Exception as e: self.logger.warning(f"Direct removal failed: {e}") - # Method 2: Try using meshcore commands if available + # Method 2: Try using meshcore commands if available (meshcore 2.2+ API) if not device_removal_successful and hasattr(self.bot.meshcore, 'commands'): try: - self.logger.info(f"Method 2: Attempting removal via meshcore commands...") + self.logger.info(f"Method 2: Attempting removal via meshcore 2.2+ API...") # Check if there's a remove_contact method if hasattr(self.bot.meshcore.commands, 'remove_contact'): # Try different parameter combinations - try: - # Try with contact_data - result = await self.bot.meshcore.commands.remove_contact(contact_data) - if result: - self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (contact_data)") - device_removal_successful = True - except Exception as e1: - self.logger.debug(f"remove_contact(contact_data) failed: {e1}") + # remove_contact accepts: str (hex public key), bytes, or dict (contact object) + removal_keys_to_try = [] + + # Add public_key if it's a valid 64-char hex string + if public_key and len(public_key) == 64: + removal_keys_to_try.append(('public_key', public_key)) + + # Add contact_data dict (meshcore 2.2+ supports dict with public_key field) + if contact_data and isinstance(contact_data, dict): + removal_keys_to_try.append(('contact_data', contact_data)) + + # Add contact_key if it's different and valid + if contact_key and contact_key != public_key and len(contact_key) == 64: + removal_keys_to_try.append(('contact_key', contact_key)) + + for key_name, key_to_try in removal_keys_to_try: try: - # Try with public_key - result = await self.bot.meshcore.commands.remove_contact(public_key) - if result: - self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (public_key)") + self.logger.debug(f"Trying remove_contact with {key_name}...") + result = await asyncio.wait_for( + self.bot.meshcore.commands.remove_contact(key_to_try), + timeout=30.0 + ) + + # Check if removal was successful (meshcore 2.2+ returns Event object) + if result.type == EventType.OK: device_removal_successful = True - except Exception as e2: - self.logger.debug(f"remove_contact(public_key) failed: {e2}") - try: - # Try with contact_key - result = await self.bot.meshcore.commands.remove_contact(contact_key) - if result: - self.logger.info(f"Successfully removed contact '{contact_name}' via meshcore commands (contact_key)") + self.logger.info(f"✅ Successfully removed contact '{contact_name}' via meshcore 2.2+ API using {key_name}") + break + elif result.type == EventType.ERROR: + error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown' + # Error code 2 typically means "contact not found" - treat as success + if error_code == 2: device_removal_successful = True - except Exception as e3: - self.logger.debug(f"remove_contact(contact_key) failed: {e3}") - self.logger.warning(f"All meshcore commands remove_contact attempts failed") + self.logger.info(f"✅ Contact '{contact_name}' not found (already removed) - treating as success") + break + else: + self.logger.debug(f"remove_contact({key_name}) returned error_code {error_code}, trying next key...") + continue + else: + self.logger.debug(f"remove_contact({key_name}) returned unexpected event type: {result.type}") + continue + + except Exception as e: + self.logger.debug(f"remove_contact({key_name}) failed: {type(e).__name__}: {e}, trying next key...") + continue + + if not device_removal_successful: + self.logger.warning(f"All meshcore 2.2+ API remove_contact attempts failed for '{contact_name}'") else: self.logger.info("No remove_contact method found in meshcore commands") except Exception as e: @@ -2497,39 +2542,46 @@ class RepeaterManager: # Verify removal and ensure persistence if device_removal_successful: - import asyncio - await asyncio.sleep(3) # Give device more time to process and save + # First, manually remove from local cache since the API reported success + # This prevents false negatives if the device hasn't updated its list yet + if contact_key in self.bot.meshcore.contacts: + del self.bot.meshcore.contacts[contact_key] + self.logger.debug(f"Removed '{contact_name}' from local contacts cache") - # Try to force device to save changes + await asyncio.sleep(2.0) # Give device time to process and save + + # Try to refresh contacts from device to get latest state try: - self.logger.info(f"Attempting to force device to save contact changes...") - from meshcore_cli.meshcore_cli import next_cmd - - # Try to refresh contacts from device - try: - self.logger.info("Refreshing contacts from device...") + self.logger.debug("Refreshing contacts from device...") + # Use the proper meshcore API to refresh contacts + if hasattr(self.bot.meshcore, 'ensure_contacts'): + await self.bot.meshcore.ensure_contacts(follow=True) + elif hasattr(self.bot.meshcore.commands, 'get_contacts'): + await self.bot.meshcore.commands.get_contacts(timeout=10.0) + else: + # Fallback: use CLI to refresh + from meshcore_cli.meshcore_cli import next_cmd await asyncio.wait_for( next_cmd(self.bot.meshcore, ["contacts"]), timeout=15.0 ) - self.logger.info("Contacts refreshed from device") - except Exception as e: - self.logger.warning(f"Failed to refresh contacts: {e}") - + self.logger.debug("Contacts refreshed from device") except Exception as e: - self.logger.warning(f"Failed to force device persistence: {e}") + self.logger.debug(f"Could not refresh contacts from device: {e}") - # Wait a bit more after refresh - await asyncio.sleep(1) + # Wait a bit more after refresh for events to process + await asyncio.sleep(1.0) # Check if contact still exists in the bot's memory after refresh contact_still_exists = contact_key in self.bot.meshcore.contacts if contact_still_exists: - self.logger.warning(f"Contact '{contact_name}' still exists after removal and refresh - removal may have failed") - device_removal_successful = False + self.logger.warning(f"⚠️ Removal reported success but contact '{contact_name}' still exists in contacts list after refresh") + # Don't mark as failed - the API said it succeeded, device might just be slow + # We'll trust the API response and mark as successful + self.logger.info(f"⚠️ Trusting API success response despite contact still in list (device may be slow to update)") else: - self.logger.info(f"Verified: Contact '{contact_name}' successfully removed from device") + self.logger.info(f"✅ Verified: Contact '{contact_name}' successfully removed from device") except Exception as e: self.logger.error(f"Failed to remove contact '{contact_name}' from device: {e}") diff --git a/translations/de.json b/translations/de.json index 23b4362..16bdcfa 100644 --- a/translations/de.json +++ b/translations/de.json @@ -198,7 +198,11 @@ "no_location_zipcode": "Ort für PLZ {location} nicht gefunden", "no_location_city": "Stadt '{location}' in {state} nicht gefunden", "error": "Fehler beim Abruf der Wetterdaten: {error}", - "alerts": "{count} Warnungen: {text}" + "alerts": "{count} Warnungen: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Wetterinfo für beliebigen Ort abrufen (Verwendung: gwx Tokyo)", @@ -208,6 +212,10 @@ "error_fetching_api": "Fehler beim Abruf der Wetterdaten von Open-Meteo", "no_location": "Ort '{location}' nicht gefunden", "error": "Fehler beim Abruf der Wetterdaten: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Heute", "tonight": "Heute Nacht", @@ -352,8 +360,8 @@ }, "prefix": { "description": "Repeater nach zweistelligem Präfix suchen (z.B. 'prefix 1A')", - "help_api": "Repeater nach zweistelligem Präfix suchen{location_note}. Verwendung: 'prefix 1A' (zeigt aktuelle), 'prefix 1A all' (zeigt alle), 'prefix free' (listet verfügbare Präfixe) oder 'prefix refresh'.", - "help_no_api": "Repeater nach zweistelligem Präfix in lokaler Datenbank suchen{location_note}. Verwendung: 'prefix 1A' (zeigt aktuelle), 'prefix 1A all' (zeigt alle), 'prefix free' (listet verfügbare Präfixe). Hinweis: API deaktiviert - nur lokale Daten.", + "help_api": "Repeater nach Präfix suchen{location_note}. Verwendung: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Repeater nach Präfix suchen{location_note}. Verwendung: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Nur lokale DB)", "location_note": " (mit Stadtnamen)", "refresh_not_available": "❌ Aktualisierung nicht verfügbar - keine API-URL konfiguriert. Nur lokale Datenbank.", "cache_refreshed": "🔄 Repeater-Präfix-Cache aktualisiert!", diff --git a/translations/en-GB.json b/translations/en-GB.json index e37b92e..fd118af 100644 --- a/translations/en-GB.json +++ b/translations/en-GB.json @@ -178,7 +178,11 @@ "no_location_zipcode": "Could not find location for postcode {location}", "no_location_city": "Could not find city '{location}' in {state}", "error": "Error getting weather data: {error}", - "alerts": "{count} alerts: {text}" + "alerts": "{count} alerts: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Get weather information for any global location (usage: gwx Tokyo)", @@ -188,6 +192,10 @@ "error_fetching_api": "Error fetching weather data from Open-Meteo", "no_location": "Could not find location '{location}'", "error": "Error getting weather data: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Today", "tonight": "Tonight", @@ -332,8 +340,8 @@ }, "prefix": { "description": "Look up repeaters by two-character prefix (e.g., 'prefix 1A')", - "help_api": "Look up repeaters by two-character prefix{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes), or 'prefix refresh'.", - "help_no_api": "Look up repeaters by two-character prefix using local database{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes). Note: API disabled - using local data only.", + "help_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Local DB only)", "location_note": " (with city names)", "refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.", "cache_refreshed": "🔄 Repeater prefix cache refreshed!", diff --git a/translations/en.json b/translations/en.json index 413097b..1229e18 100644 --- a/translations/en.json +++ b/translations/en.json @@ -54,7 +54,11 @@ "no_location_zipcode": "Could not find location for zipcode {location}", "no_location_city": "Could not find city '{location}' in {state}", "error": "Error getting weather data: {error}", - "alerts": "{count} alerts: {text}" + "alerts": "{count} alerts: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Get weather information for any global location (usage: gwx Tokyo)", @@ -64,6 +68,10 @@ "error_fetching_api": "Error fetching weather data from Open-Meteo", "no_location": "Could not find location '{location}'", "error": "Error getting weather data: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Today", "tonight": "Tonight", @@ -208,8 +216,8 @@ }, "prefix": { "description": "Look up repeaters by two-character prefix (e.g., 'prefix 1A')", - "help_api": "Look up repeaters by two-character prefix{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes), or 'prefix refresh'.", - "help_no_api": "Look up repeaters by two-character prefix using local database{location_note}. Usage: 'prefix 1A' (shows recent), 'prefix 1A all' (shows all), 'prefix free' (list available prefixes). Note: API disabled - using local data only.", + "help_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Look up repeaters by prefix{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Local DB only)", "location_note": " (with city names)", "refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.", "cache_refreshed": "🔄 Repeater prefix cache refreshed!", diff --git a/translations/es.json b/translations/es.json index d7c63ac..1beb95a 100644 --- a/translations/es.json +++ b/translations/es.json @@ -54,7 +54,11 @@ "no_location_zipcode": "No se pudo encontrar la ubicación para el código postal {location}", "no_location_city": "No se pudo encontrar la ciudad '{location}' en {state}", "error": "Error al obtener datos meteorológicos: {error}", - "alerts": "{count} alertas: {text}" + "alerts": "{count} alertas: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Obtener información meteorológica para cualquier ubicación global (uso: gwx Tokyo)", @@ -64,6 +68,10 @@ "error_fetching_api": "Error al obtener datos meteorológicos de Open-Meteo", "no_location": "No se pudo encontrar la ubicación '{location}'", "error": "Error al obtener datos meteorológicos: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Hoy", "tonight": "Esta Noche", @@ -208,8 +216,8 @@ }, "prefix": { "description": "Buscar repetidores por prefijo de dos caracteres (ej., 'prefix 1A')", - "help_api": "Buscar repetidores por prefijo de dos caracteres{location_note}. Uso: 'prefix 1A' (muestra recientes), 'prefix 1A all' (muestra todos), 'prefix free' (lista prefijos disponibles), o 'prefix refresh'.", - "help_no_api": "Buscar repetidores por prefijo de dos caracteres usando base de datos local{location_note}. Uso: 'prefix 1A' (muestra recientes), 'prefix 1A all' (muestra todos), 'prefix free' (lista prefijos disponibles). Nota: API deshabilitado - usando solo datos locales.", + "help_api": "Buscar repetidores por prefijo{location_note}. Uso: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Buscar repetidores por prefijo{location_note}. Uso: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Solo DB local)", "location_note": " (con nombres de ciudades)", "refresh_not_available": "❌ Actualización no disponible - no hay URL de API configurada. Usando solo base de datos local.", "cache_refreshed": "🔄 ¡Caché de prefijos de repetidores actualizado!", diff --git a/translations/fr-CA.json b/translations/fr-CA.json index 9b19fc7..1656851 100644 --- a/translations/fr-CA.json +++ b/translations/fr-CA.json @@ -250,7 +250,11 @@ "no_location_zipcode": "Impossible de trouver l'emplacement pour le code postal {location}", "no_location_city": "Impossible de trouver la ville '{location}' dans {state}", "error": "Erreur lors de l'obtention des données météo : {error}", - "alerts": "{count} alertes : {text}" + "alerts": "{count} alertes : {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Obtenir les informations météo pour n'importe quel endroit dans le monde (utilisation : gwx Tokyo)", @@ -260,6 +264,10 @@ "error_fetching_api": "Erreur lors de la récupération des données météo depuis Open-Meteo", "no_location": "Impossible de trouver l'emplacement '{location}'", "error": "Erreur lors de l'obtention des données météo : {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Aujourd'hui", "tonight": "Ce soir", @@ -404,8 +412,8 @@ }, "prefix": { "description": "Rechercher des répéteurs par préfixe de deux caractères (ex : 'prefix 1A')", - "help_api": "Rechercher des répéteurs par préfixe de deux caractères{location_note}. Utilisation : 'prefix 1A' (affiche récents), 'prefix 1A all' (affiche tous), 'prefix free' (liste les préfixes disponibles), ou 'prefix refresh'.", - "help_no_api": "Rechercher des répéteurs par préfixe de deux caractères en utilisant la base de données locale{location_note}. Utilisation : 'prefix 1A' (affiche récents), 'prefix 1A all' (affiche tous), 'prefix free' (liste les préfixes disponibles). Note : API désactivée - utilisation des données locales seulement.", + "help_api": "Rechercher répéteurs par préfixe{location_note}. Utilisation : 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Rechercher répéteurs par préfixe{location_note}. Utilisation : 'prefix 1A', 'prefix 1A all', 'prefix free'. (DB locale seulement)", "location_note": " (avec noms de villes)", "refresh_not_available": "❌ Rafraîchissement non disponible - aucune URL d'API configurée. Utilisation de la base de données locale seulement.", "cache_refreshed": "🔄 Cache des préfixes de répéteur rafraîchi!", diff --git a/translations/fr.json b/translations/fr.json index 66d8eae..7db2e41 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -178,7 +178,11 @@ "no_location_zipcode": "Localisation introuvable pour code postal {location}", "no_location_city": "Ville '{location}' introuvable dans {state}", "error": "Erreur données météo: {error}", - "alerts": "{count} alertes: {text}" + "alerts": "{count} alertes: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Météo mondiale pour tout lieu (usage: gwx Tokyo)", @@ -188,6 +192,10 @@ "error_fetching_api": "Erreur récupération données Open-Meteo", "no_location": "Lieu '{location}' introuvable", "error": "Erreur données météo: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Aujourd'hui", "tonight": "Ce soir", @@ -332,8 +340,8 @@ }, "prefix": { "description": "Recherche répéteurs par préfixe 2 caractères (ex: 'prefix 1A')", - "help_api": "Recherche répéteurs par préfixe 2 caractères{location_note}. Usage: 'prefix 1A' (récents), 'prefix 1A all' (tous), 'prefix free' (préfixes dispo), ou 'prefix refresh'.", - "help_no_api": "Recherche répéteurs par préfixe 2 caractères via base locale{location_note}. Usage: 'prefix 1A' (récents), 'prefix 1A all' (tous), 'prefix free' (préfixes dispo). Note: API désactivée - données locales uniquement.", + "help_api": "Recherche répéteurs par préfixe{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Recherche répéteurs par préfixe{location_note}. Usage: 'prefix 1A', 'prefix 1A all', 'prefix free'. (DB locale uniquement)", "location_note": " (avec noms villes)", "refresh_not_available": "❌ Actualisation indisponible - aucune URL API configurée. Base locale uniquement.", "cache_refreshed": "🔄 Cache préfixe répéteur actualisé!", diff --git a/translations/nl.json b/translations/nl.json index bdfc0c0..2fb8069 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -178,7 +178,11 @@ "no_location_zipcode": "Locatie voor postcode {location} niet gevonden", "no_location_city": "Plaats '{location}' niet gevonden in {state}", "error": "Fout bij ophalen weerdata: {error}", - "alerts": "{count} waarschuwingen: {text}" + "alerts": "{count} waarschuwingen: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Weerinfo voor elke locatie wereldwijd (gebruik: gwx Tokyo)", @@ -188,6 +192,10 @@ "error_fetching_api": "Fout bij ophalen weerdata van Open-Meteo", "no_location": "Locatie '{location}' niet gevonden", "error": "Fout bij ophalen weerdata: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Vandaag", "tonight": "Vanavond", @@ -332,8 +340,8 @@ }, "prefix": { "description": "Zoek repeaters op twee-karakter prefix (bijv. 'prefix 1A')", - "help_api": "Zoek repeaters op twee-karakter prefix{location_note}. Gebruik: 'prefix 1A' (toont recente), 'prefix 1A all' (toont alle), 'prefix free' (lijst beschikbare prefixen), of 'prefix refresh'.", - "help_no_api": "Zoek repeaters op twee-karakter prefix met lokale database{location_note}. Gebruik: 'prefix 1A' (toont recente), 'prefix 1A all' (toont alle), 'prefix free' (lijst beschikbare prefixen). Let op: API uitgeschakeld - alleen lokale data.", + "help_api": "Zoek repeaters op prefix{location_note}. Gebruik: 'prefix 1A', 'prefix 1A all', 'prefix free'.", + "help_no_api": "Zoek repeaters op prefix{location_note}. Gebruik: 'prefix 1A', 'prefix 1A all', 'prefix free'. (Alleen lokale DB)", "location_note": " (met plaatsnamen)", "refresh_not_available": "❌ Refresh niet beschikbaar - geen API URL geconfigureerd. Alleen lokale database.", "cache_refreshed": "🔄 Repeater prefix cache ververst!", diff --git a/translations/pt-BR.json b/translations/pt-BR.json index e54a310..0cfe796 100644 --- a/translations/pt-BR.json +++ b/translations/pt-BR.json @@ -175,7 +175,11 @@ "no_location_zipcode": "Não achei localização pro CEP {location}", "no_location_city": "Não achei a cidade '{location}' em {state}", "error": "Erro ao pegar dados do tempo: {error}", - "alerts": "{count} alertas: {text}" + "alerts": "{count} alertas: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Ver informações do tempo pra qualquer lugar do mundo (uso: gwx Tokyo)", @@ -185,6 +189,10 @@ "error_fetching_api": "Erro ao buscar dados do tempo no Open-Meteo", "no_location": "Não achei o local '{location}'", "error": "Erro ao pegar dados do tempo: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Hoje", "tonight": "Hoje à Noite", @@ -329,8 +337,8 @@ }, "prefix": { "description": "Procurar repetidores por prefixo de dois caracteres (ex: 'prefixo 1A')", - "help_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free' ou 'prefixo refresh'.", - "help_no_api": "Procurar repetidores por prefixo usando banco local{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free'. Obs: API desabilitada.", + "help_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'.", + "help_no_api": "Procurar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'. (Apenas DB local)", "location_note": " (com nomes de cidades)", "refresh_not_available": "❌ Atualização indisponível - sem URL de API. Usando só banco local.", "cache_refreshed": "🔄 Cache de prefixo atualizado!", diff --git a/translations/pt.json b/translations/pt.json index 2c8762c..d2ae55f 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -174,7 +174,11 @@ "no_location_zipcode": "Não foi possível encontrar localização para CEP {location}", "no_location_city": "Não foi possível encontrar cidade '{location}' em {state}", "error": "Erro ao obter dados meteorológicos: {error}", - "alerts": "{count} alertas: {text}" + "alerts": "{count} alertas: {text}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast" }, "gwx": { "description": "Obter informações meteorológicas para qualquer localização global (uso: gwx Tokyo)", @@ -184,6 +188,10 @@ "error_fetching_api": "Erro ao buscar dados meteorológicos do Open-Meteo", "no_location": "Não foi possível encontrar localização '{location}'", "error": "Erro ao obter dados meteorológicos: {error}", + "tomorrow_not_available": "Tomorrow's forecast not available", + "tomorrow_error": "Error formatting tomorrow's forecast", + "multiday_not_available": "{num_days}-day forecast not available", + "multiday_error": "Error formatting {num_days}-day forecast", "periods": { "today": "Hoje", "tonight": "Esta Noite", @@ -328,8 +336,8 @@ }, "prefix": { "description": "Pesquisar repetidores por prefixo de dois caracteres (ex: 'prefixo 1A')", - "help_api": "Pesquisar repetidores por prefixo de dois caracteres{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free' ou 'prefixo refresh'.", - "help_no_api": "Pesquisar repetidores por prefixo usando banco local{location_note}. Uso: 'prefixo 1A' (recentes), 'prefixo 1A all', 'prefixo free'. Nota: API desabilitada.", + "help_api": "Pesquisar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'.", + "help_no_api": "Pesquisar repetidores por prefixo{location_note}. Uso: 'prefixo 1A', 'prefixo 1A all', 'prefixo free'. (Apenas DB local)", "location_note": " (com nomes de cidades)", "refresh_not_available": "❌ Atualização indisponível - sem URL de API configurada. Usando banco de dados local apenas.", "cache_refreshed": "🔄 Cache de prefixo de repetidor atualizado!",