Enhance weather command functionality by adding support for multi-day and tomorrow forecasts. Updated parsing logic to handle forecast type options and improved response formatting. Integrated new weather data retrieval methods for both local and global commands, ensuring consistent user experience across different locales. Added localization support for new forecast messages in multiple languages.

This commit is contained in:
agessaman
2025-11-18 18:51:10 -08:00
parent 7e2c478662
commit 412883ac67
15 changed files with 1146 additions and 256 deletions
+22 -13
View File
@@ -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():
+303 -49
View File
@@ -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
+8 -1
View File
@@ -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:
+556 -71
View File
@@ -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:
+31 -20
View File
@@ -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
+127 -75
View File
@@ -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}")
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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é!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",
+11 -3
View File
@@ -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!",