diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 4ed496b..7354cb5 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -29,6 +29,9 @@ from ...utils import ( # noqa: F401 format_temperature_high_low and get_nomina from ...weather_common import WeatherCommandMixin, load_open_meteo_model from ..base_command import BaseCommand +# Kept for code that checked them; these imports used to be optional. +WXSIM_PARSER_AVAILABLE = True + # Multiday: plain digits, 7day/7-day, or suffix form 7d/10d (min 2, max below). Open-Meteo allows up to 16 forecast days. GWX_MULTIDAY_MAX_DAYS = 16 @@ -99,6 +102,9 @@ class GlobalWxCommand(WeatherCommandMixin, BaseCommand): self.logger.warning(f"Invalid precipitation_unit '{self.precipitation_unit}', using 'inch'") self.precipitation_unit = 'inch' + # Initialize geocoder (will use rate-limited helpers for actual calls) + self.geolocator = get_nominatim_geocoder() + # Get database manager for geocoding cache self.db_manager = bot.db_manager diff --git a/modules/commands/solarforecast_command.py b/modules/commands/solarforecast_command.py index 646ea10..59164f3 100644 --- a/modules/commands/solarforecast_command.py +++ b/modules/commands/solarforecast_command.py @@ -47,8 +47,6 @@ class SolarforecastCommand(BaseCommand): # Read-only informational output; safe for scheduled {cmd:...} rendering. render_safe = True name = "solarforecast" - honors_skip_channel_check = False - enabled_attr = "solarforecast_enabled" keywords = ['solarforecast', 'sf'] description = "Get solar panel production forecast (usage: sf [panel_size] [azimuth, 0=south] [angle])" category = "solar" @@ -99,9 +97,25 @@ class SolarforecastCommand(BaseCommand): # Get default state from config for city disambiguation self.default_state = self.bot.config.get('Weather', 'default_state', fallback='') + # Initialize geocoder (will use rate-limited helpers for actual calls) + self.geolocator = get_nominatim_geocoder() + # Get database manager for geocoding cache self.db_manager = bot.db_manager + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + """Check if this command can be executed with the given message. + + Args: + message: The message triggering the command. + + Returns: + bool: True if command is enabled and checks pass, False otherwise. + """ + if not self.solarforecast_enabled: + return False + return super().can_execute(message) + def get_help_text(self) -> str: return self.translate('commands.solarforecast.usage') diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index bba8a02..cbaaa22 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -30,7 +30,7 @@ from ..utils import ( format_temperature_high_low, geocode_city_sync, geocode_zipcode_sync, - get_nominatim_geocoder, # noqa: F401 + get_nominatim_geocoder, normalize_us_state, ) from ..weather_common import WeatherCommandMixin @@ -38,6 +38,10 @@ from .alternatives.wx_international import GlobalWxCommand from .base_command import BaseCommand from .rain_command import nws_http_means_no_coverage +# Kept for code that checked them; these imports used to be optional. +WX_INTERNATIONAL_AVAILABLE = True +WXSIM_PARSER_AVAILABLE = True + # Multiday: plain digits (e.g. 7), 7day/7-day, or suffix form 7d/10d (min 2, max below). WX_MULTIDAY_MAX_DAYS = 16 @@ -239,12 +243,20 @@ class WxCommand(WeatherCommandMixin, BaseCommand): # Only initialize NOAA-specific attributes if not delegating if self.delegate_command is None: self.url_timeout = 8 # seconds (reduced from 10 for faster failure detection) + self.forecast_duration = 3 # days + self.num_wx_alerts = 2 # number of alerts to show + self.use_metric = False # Use imperial units by default + self.zulu_time = False # Use local time by default # Get default location/state/country from config for fallback/disambiguation self.default_city = self.bot.config.get('Weather', 'default_city', fallback='').strip() self.default_state = self.bot.config.get('Weather', 'default_state', fallback='') self.default_country = self.bot.config.get('Weather', 'default_country', fallback='US') + # Initialize geocoder (will use rate-limited helpers for actual calls) + # Keep geolocator for backwards compatibility, but prefer rate-limited helpers + self.geolocator = get_nominatim_geocoder() + # Get database manager for geocoding cache self.db_manager = bot.db_manager diff --git a/modules/weather_common.py b/modules/weather_common.py index 32dc21e..aac1e7a 100644 --- a/modules/weather_common.py +++ b/modules/weather_common.py @@ -112,6 +112,11 @@ class WeatherCommandMixin: text, err = mqtt_weather_display_for_topic(topic, cache, fmt) if text is not None: return f"{location_name}: {text}" if location_name else text + return self._mqtt_weather_error_key(err) + + def _mqtt_weather_error_key(self, err: Optional[str]) -> str: + """The translated reply for an MQTT weather lookup error (overridable).""" + ns = self.translation_ns if err == "no_cache": return self.translate(f"{ns}.mqtt_weather_no_subscriber") if err in ("no_data", "empty_payload", "empty_after_sanitize"): diff --git a/tests/unit/test_weather_compat_surface.py b/tests/unit/test_weather_compat_surface.py new file mode 100644 index 0000000..24f37f2 --- /dev/null +++ b/tests/unit/test_weather_compat_surface.py @@ -0,0 +1,53 @@ +"""Names and hooks local plugins may rely on survive the weather refactor.""" + +import configparser +from types import SimpleNamespace +from unittest.mock import Mock + +import modules.commands.alternatives.wx_international as wx_international +import modules.commands.wx_command as wx_command +from modules.commands.alternatives.wx_international import GlobalWxCommand +from modules.commands.solarforecast_command import SolarforecastCommand +from modules.commands.wx_command import WxCommand + + +def _bot(sections=None): + config = configparser.ConfigParser() + config.read_dict({"Weather": {"weather_provider": "noaa"}, "Wx_Command": {}, "Bot": {}, **(sections or {})}) + bot = Mock() + bot.config = config + bot.translator.translate = Mock(side_effect=lambda key, **kwargs: key) + return bot + + +def test_wx_keeps_its_attributes(): + wx = WxCommand(_bot()) + assert (wx.forecast_duration, wx.num_wx_alerts, wx.use_metric, wx.zulu_time) == (3, 2, False, False) + assert wx.geolocator is not None + + +def test_gwx_and_solarforecast_keep_geolocator(): + assert GlobalWxCommand(_bot()).geolocator is not None + assert SolarforecastCommand(_bot()).geolocator is not None + + +def test_availability_flags_still_exist(): + assert wx_command.WX_INTERNATIONAL_AVAILABLE is True + assert wx_command.WXSIM_PARSER_AVAILABLE is True + assert wx_international.WXSIM_PARSER_AVAILABLE is True + + +def test_mqtt_error_text_can_be_overridden(): + class LocalWx(WxCommand): + def _mqtt_weather_error_key(self, err): + return f"local:{err}" + + wx = LocalWx(_bot()) + wx.bot.mqtt_weather_cache = None # no subscriber -> "no_cache" + assert wx._mqtt_weather_line("weather/topic", "default", None) == "local:no_cache" + + +def test_solarforecast_honours_its_enabled_setting(): + cmd = SolarforecastCommand(_bot({"Solarforecast_Command": {"enabled": "false"}})) + message = SimpleNamespace(content="sf Seattle", channel="general", is_dm=True, sender_id="u", sender_pubkey="pk") + assert cmd.can_execute(message) is False