diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index d5ae153..a93bbfe 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.1.2 + 2026.2.0 ServerApiVersion 3.6 IwsApiVersion diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml index 32a01fc..ecc3a3f 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -129,6 +129,46 @@ Status Status + + String + Weather Condition + Weather Condition + + + Number + Weather Temperature + Weather Temperature + + + Integer + Weather Humidity (%) + Weather Humidity (%) + + + Number + Weather Precipitation (mm/hr) + Weather Precipitation (mm/hr) + + + Integer + Weather Rain Probability (%) + Weather Rain Probability (%) + + + Number + Weather Wind Speed (m/s) + Weather Wind Speed (m/s) + + + Number + Weather Pressure (hPa) + Weather Pressure (hPa) + + + String + Weather Last Updated + Weather Last Updated + diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/MenuItems.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/MenuItems.xml index 607755b..9a7a92f 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/MenuItems.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/MenuItems.xml @@ -8,4 +8,8 @@ Update All Status updateAllStatus + + Refresh Weather Now + refreshWeather + \ No newline at end of file diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml index e8afde4..8730bfc 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml @@ -36,6 +36,42 @@ + + + + + + + + + + + Fetch and report weather automatically + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py index 5a45d47..42f3d3d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py @@ -128,8 +128,8 @@ MINIMUM_POLLING_INTERVAL_MINUTES: Final[int] = 3 """Minimum polling interval in minutes to avoid API rate limits.""" -DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES: Final[int] = 10 -"""Default interval for weather updates in minutes.""" +DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES: Final[int] = 30 +"""Default interval for weather updates in minutes (matches PluginConfig.xml).""" THROTTLE_LIMIT_MINUTES: Final[int] = 61 """Duration to wait after rate limit error before retrying (minutes).""" @@ -137,6 +137,9 @@ FORECAST_UPDATE_INTERVAL_MINUTES: Final[int] = 60 """Interval between forecast updates in minutes.""" +MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES: Final[int] = 10 +"""Minimum interval for Tomorrow.io weather updates in minutes.""" + TOKEN_PAUSE_THRESHOLD: Final[int] = 100 """Token count below which polling should pause proactively.""" diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index b71c428..4558015 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -38,7 +38,7 @@ import json import copy import traceback -from datetime import datetime, date +from datetime import datetime, date, timedelta import indigo import requests @@ -47,6 +47,7 @@ from constants import ( MAX_ZONE_DURATION_SECONDS, DEFAULT_API_TIMEOUT_SECONDS, + DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES, MINIMUM_POLLING_INTERVAL_MINUTES, ZONE_START_ENDPOINT, OPERATIONAL_ERROR_EVENTS, @@ -62,7 +63,8 @@ ) from api_client import NetroAPIClient from device_handlers import SprinklerHandler, WhispererHandler, ZoneHandler -from utils import convert_weather_us_to_metric +from utils import convert_weather_us_to_metric, convert_weather_metric_to_us +from tomorrow_client import TomorrowClient ################################################################################ @@ -109,8 +111,12 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): self.triggerDict = {} - # Initialize weather update tracking + # Initialize Tomorrow.io weather integration self._next_weather_update = datetime.now() + self._weather_update_interval = int( + pluginPrefs.get("weatherUpdateInterval", DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES) + ) + self._tomorrow_client = self._create_tomorrow_client(pluginPrefs) # Initialize API client with prefs callbacks for throttle state persistence self.api_client = NetroAPIClient( @@ -335,6 +341,135 @@ def _get_device_auth(dev): return (api_key, "2") return (dev.address, "1") + def _create_tomorrow_client(self, prefs): + """Create a TomorrowClient if Tomorrow.io weather is configured. + + Args: + prefs: Plugin preferences dict + + Returns: + TomorrowClient instance or None if not configured/enabled + """ + if not prefs.get("tomorrowEnabled", False): + return None + + api_key = str(prefs.get("tomorrowApiKey", "")).strip() + location = str(prefs.get("tomorrowLocation", "")).strip() + + if not api_key or not location: + self.logger.warning( + "Tomorrow.io weather is enabled but missing required fields " + "(API key and/or location) — weather integration will not run" + ) + return None + + return TomorrowClient( + api_key=api_key, + location=location, + logger=self.logger, + timeout=self.timeout, + ) + + def _update_weather_from_tomorrow(self): + """Fetch weather from Tomorrow.io and report to all sprinkler devices. + + Called periodically from the polling loop when Tomorrow.io integration + is enabled. Fetches current weather once, then reports it to each + enabled sprinkler device via the Netro report_weather endpoint. + Also updates weather-related device states in Indigo for each + successfully reported device. + + Uses 1 Tomorrow.io API call + 1 Netro API call per sprinkler device. + """ + if self._tomorrow_client is None: + return + + if datetime.now() < self._next_weather_update: + self.logger.debug( + f"Next weather update at {self._next_weather_update:%H:%M}, skipping" + ) + return + + self.logger.info("Fetching weather from Tomorrow.io...") + + # Schedule next update regardless of success/failure + self._next_weather_update = datetime.now() + timedelta( + minutes=self._weather_update_interval + ) + + # Fetch weather from Tomorrow.io + weather_data = self._tomorrow_client.fetch_current_weather() + if weather_data is None: + self.logger.warning("Failed to fetch weather from Tomorrow.io, will retry next interval") + return + + # Map condition codes to human-readable labels + condition_labels = {0: "Clear", 1: "Cloudy", 2: "Rain", 3: "Snow", 4: "Wind"} + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + + # Report to each enabled sprinkler device + reported_count = 0 + for dev in [s for s in indigo.devices.iter(filter="self") if s.enabled]: + if dev.deviceTypeId != "sprinkler": + continue + + try: + key, api_version = self._get_device_auth(dev) + + # Tomorrow.io returns metric; convert to US for v1 API + if api_version == "1": + device_weather = convert_weather_metric_to_us(weather_data) + else: + device_weather = dict(weather_data) + + response = self.api_client.report_weather( + key, device_weather, api_version=api_version + ) + + if response.get("status") == "OK": + reported_count += 1 + unit_label = "C" if api_version == "2" else "F" + self.logger.debug( + f"Weather reported to '{dev.name}': " + f"{device_weather.get('t')}{unit_label}, " + f"condition={device_weather['condition']}" + ) + + # Update device states with weather data (always metric from Tomorrow.io) + state_updates = [ + {"key": "weather_condition", "value": condition_labels.get(weather_data["condition"], "Unknown")}, + {"key": "weather_temperature", "value": weather_data.get("t", 0), "decimalPlaces": 1}, + {"key": "weather_updated", "value": timestamp}, + ] + if "humidity" in weather_data: + state_updates.append({"key": "weather_humidity", "value": weather_data["humidity"]}) + if "rain" in weather_data: + state_updates.append({"key": "weather_rain", "value": weather_data["rain"], "decimalPlaces": 1}) + if "rain_prob" in weather_data: + state_updates.append({"key": "weather_rain_prob", "value": weather_data["rain_prob"]}) + if "wind_speed" in weather_data: + state_updates.append({"key": "weather_wind_speed", "value": weather_data["wind_speed"], "decimalPlaces": 1}) + if "pressure" in weather_data: + state_updates.append({"key": "weather_pressure", "value": weather_data["pressure"], "decimalPlaces": 1}) + + dev.updateStatesOnServer(state_updates) + else: + self.logger.error( + f"Error reporting weather to '{dev.name}': {response}" + ) + + except ThrottleDelayError: + self.logger.debug(f"Skipping weather report for '{dev.name}' - throttled") + except Exception as exc: + self.logger.error(f"Could not report weather to '{dev.name}': {exc}") + self.logger.debug(f"Weather report error: \n{traceback.format_exc(10)}") + + if reported_count > 0: + self.logger.info( + f"Tomorrow.io weather reported to {reported_count} device(s): " + f"{weather_data.get('t')}C, condition={weather_data['condition']}" + ) + def _get_zone_devices(self, parent_dev_id): """Get all zone devices belonging to a parent controller. @@ -736,6 +871,19 @@ def startup(self): auth_type = "API key" if api_version == "2" else "serial number" self.logger.info(f"Device '{dev.name}' using API v{api_version} ({auth_type} auth)") + # Notify Indigo to re-read state lists (picks up any Devices.xml changes) + for dev in indigo.devices.iter(filter="self"): + dev.stateListOrDisplayStateIdChanged() + + # Log Tomorrow.io weather status + if self._tomorrow_client is not None: + self.logger.info( + f"Tomorrow.io weather integration enabled " + f"(updating every {self._weather_update_interval} minutes)" + ) + else: + self.logger.info("Tomorrow.io weather integration not enabled") + # Subscribe to variable changes for zone moisture auto-link indigo.variables.subscribeToChanges() @@ -774,6 +922,9 @@ def runConcurrentThread(self): ) else: self._update_from_netro() + + # Tomorrow.io uses its own API; run regardless of Netro token pause + self._update_weather_from_tomorrow() except self.StopThread: # Clean shutdown requested by Indigo - must re-raise self.logger.debug("Concurrent thread stopping") @@ -921,6 +1072,42 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): except (ValueError, TypeError): self.logger.warning("Invalid max zone runtime value, keeping existing setting") + # Update Tomorrow.io weather integration + weather_settings_changed = False + try: + new_interval = int(valuesDict.get( + "weatherUpdateInterval", DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES + )) + if new_interval != self._weather_update_interval: + self._weather_update_interval = new_interval + weather_settings_changed = True + self.logger.info( + f"Weather update interval updated to {new_interval} minutes" + ) + except (ValueError, TypeError): + self.logger.warning("Invalid weather update interval value, keeping existing setting") + + old_client = self._tomorrow_client + new_client = self._create_tomorrow_client(valuesDict) + was_enabled = old_client is not None + now_enabled = new_client is not None + if was_enabled and now_enabled: + weather_settings_changed = weather_settings_changed or ( + old_client.api_key != new_client.api_key + or old_client.location != new_client.location + ) + self._tomorrow_client = new_client + + if now_enabled and not was_enabled: + self._next_weather_update = datetime.now() + self.logger.info("Tomorrow.io weather integration enabled") + elif not now_enabled and was_enabled: + self.logger.info("Tomorrow.io weather integration disabled") + elif now_enabled: + if weather_settings_changed: + self._next_weather_update = datetime.now() + self.logger.debug("Tomorrow.io weather settings updated") + ######################################## # General device callbacks ######################################## @@ -1232,6 +1419,7 @@ def actionControlUniversal(self, action, dev): if action.deviceAction == indigo.kUniversalAction.RequestStatus: self._next_weather_update = datetime.now() self._update_from_netro() + self._update_weather_from_tomorrow() ######################################## # Custom Plugin Action callbacks defined in Actions.xml @@ -1532,6 +1720,15 @@ def updateAllStatus(self): """ self._next_weather_update = datetime.now() self._update_from_netro() + self._update_weather_from_tomorrow() + + def refreshWeather(self): + """Force immediate weather update from Tomorrow.io via plugin menu.""" + if self._tomorrow_client is None: + self.logger.warning("Tomorrow.io weather integration is not configured") + return + self._next_weather_update = datetime.now() + self._update_weather_from_tomorrow() ######################################## # pylint: disable=unused-argument diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py new file mode 100644 index 0000000..a3f7ce0 --- /dev/null +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py @@ -0,0 +1,233 @@ +"""Tomorrow.io weather API client for automated weather reporting. + +This module provides the TomorrowClient class that fetches current weather +data from the Tomorrow.io API and transforms it into the format expected +by the Netro report_weather endpoint. + +Tomorrow.io returns metric units by default (Celsius, mm/hr, m/s, hPa). +The client returns weather data in metric, and callers are responsible for +converting to US units when needed (for Netro API v1). + +Note: + This module does not import indigo directly. Plugin integration is + achieved through a logger callback passed to the constructor. +""" + +import logging +from datetime import date +import traceback +from typing import Any, Dict, Optional + +import requests + + +# ============================================================================= +# Tomorrow.io Weather Code to Netro Condition Mapping +# ============================================================================= +# +# Netro conditions: 0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind +# +# Tomorrow.io v4 weather codes: +# https://docs.tomorrow.io/reference/data-layers-weather-codes + +_TOMORROW_TO_NETRO_CONDITION: Dict[int, int] = { + # Clear / Sunny + 1000: 0, # Clear, Sunny + 1100: 0, # Mostly Clear + # Cloudy + 1101: 1, # Partly Cloudy + 1102: 1, # Mostly Cloudy + 1001: 1, # Cloudy + # Fog (treat as cloudy) + 2000: 1, # Fog + 2100: 1, # Light Fog + # Rain + 4000: 2, # Drizzle + 4001: 2, # Rain + 4200: 2, # Light Rain + 4201: 2, # Heavy Rain + # Snow + 5000: 3, # Snow + 5001: 3, # Flurries + 5100: 3, # Light Snow + 5101: 3, # Heavy Snow + # Freezing Rain (treat as rain) + 6000: 2, # Freezing Drizzle + 6001: 2, # Freezing Rain + 6200: 2, # Light Freezing Rain + 6201: 2, # Heavy Freezing Rain + # Ice Pellets (treat as snow) + 7000: 3, # Ice Pellets + 7101: 3, # Heavy Ice Pellets + 7102: 3, # Light Ice Pellets + # Thunderstorm (treat as rain) + 8000: 2, # Thunderstorm +} + +# Default condition when code is unknown — Cloudy is the safest default +# for irrigation: it won't suppress watering like Rain/Snow, but won't +# assume clear skies either. +_DEFAULT_CONDITION = 1 # Cloudy + + +# ============================================================================= +# TomorrowClient +# ============================================================================= + +class TomorrowClient: + """Client for fetching weather data from Tomorrow.io API. + + Fetches current weather conditions and transforms them into the dict + format expected by Netro's report_weather endpoint (metric units). + + Args: + api_key: Tomorrow.io API key + location: Location string (lat,lon or place name) + logger: Logger instance for debug/error output + timeout: HTTP request timeout in seconds + """ + + REALTIME_URL = "https://api.tomorrow.io/v4/weather/realtime" + + def __init__( + self, + api_key: str, + location: str, + logger: logging.Logger, + timeout: int = 10, + ): + self.api_key = api_key + self.location = location + self.logger = logger + self.timeout = timeout + + def fetch_current_weather(self) -> Optional[Dict[str, Any]]: + """Fetch current weather from Tomorrow.io and return Netro-format dict. + + Returns weather data in metric units suitable for Netro API v2. + For v1 devices, the caller should convert to US units. + + Returns: + Dict with Netro weather fields (metric): + - condition (int): 0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind + - date (str): YYYY-MM-DD + - t (float): Current temperature in Celsius + - humidity (int): Relative humidity 0-100 + - rain (float): Precipitation intensity in mm/hr + - rain_prob (int): Precipitation probability 0-100 + - wind_speed (float): Wind speed in m/s + - pressure (float): Surface pressure in hPa + None if the request fails. + """ + try: + params = { + "location": self.location, + "apikey": self.api_key, + "units": "metric", + } + + self.logger.debug( + f"Fetching weather from Tomorrow.io for location: {self.location}" + ) + + response = requests.get( + self.REALTIME_URL, + params=params, + timeout=self.timeout, + ) + response.raise_for_status() + + data = response.json() + return self._transform_response(data) + + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else "unknown" + self.logger.error(f"Tomorrow.io API error (HTTP {status})") + self.logger.debug(f"Tomorrow.io error details: {exc}") + return None + except requests.exceptions.ConnectionError: + self.logger.error( + "Could not connect to Tomorrow.io API - check internet connection" + ) + return None + except requests.exceptions.Timeout: + self.logger.error("Tomorrow.io API request timed out") + return None + except Exception as exc: + self.logger.error(f"Unexpected error fetching weather: {exc}") + self.logger.debug(f"Weather fetch traceback:\n{traceback.format_exc()}") + return None + + def _transform_response(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Transform Tomorrow.io API response to Netro weather format. + + Args: + data: Raw JSON response from Tomorrow.io realtime endpoint + + Returns: + Netro-format weather dict (metric) or None on parse error + """ + try: + values = data["data"]["values"] + except (KeyError, TypeError): + self.logger.error( + "Unexpected Tomorrow.io response structure - missing data.values" + ) + return None + + # Map Tomorrow.io weather code to Netro condition + weather_code = values.get("weatherCode") + if weather_code is None: + self.logger.debug("Tomorrow.io response missing weatherCode, defaulting to Cloudy") + weather_code = 1001 + condition = _TOMORROW_TO_NETRO_CONDITION.get(weather_code, _DEFAULT_CONDITION) + + # Check for high wind — override condition to Wind if speed is very high + wind_speed = values.get("windSpeed") + if wind_speed is not None and wind_speed > 15.0 and condition in (0, 1): + # >15 m/s (~34 mph, Beaufort 7 near-gale); only override clear/cloudy + condition = 4 + + weather_data = { + "condition": condition, + "date": date.today().strftime("%Y-%m-%d"), + } + + # Temperature (required by Netro) + temp = values.get("temperature") + if temp is not None: + weather_data["t"] = round(float(temp), 1) + else: + self.logger.error("Tomorrow.io response missing temperature") + return None + + # Optional fields + humidity = values.get("humidity") + if humidity is not None: + weather_data["humidity"] = int(round(float(humidity))) + + precip_intensity = values.get("precipitationIntensity") + if precip_intensity is not None: + # Tomorrow.io returns mm/hr (intensity). Netro expects total mm but + # we only have current intensity, not accumulation. Passed as-is — + # this is an approximation that may overstate rainfall during heavy rain. + weather_data["rain"] = round(float(precip_intensity), 1) + + precip_prob = values.get("precipitationProbability") + if precip_prob is not None: + weather_data["rain_prob"] = int(round(float(precip_prob))) + + if wind_speed is not None: + weather_data["wind_speed"] = round(float(wind_speed), 1) + + pressure = values.get("pressureSurfaceLevel") + if pressure is not None: + weather_data["pressure"] = round(float(pressure), 1) + + self.logger.debug( + f"Tomorrow.io weather: {weather_data['t']}C, " + f"condition={condition} (code {weather_code}), " + f"humidity={weather_data.get('humidity', 'N/A')}%" + ) + + return weather_data diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py index 12d54a9..b6c970c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py @@ -2,6 +2,10 @@ This module provides common utility functions used throughout the plugin: +- Unit conversions: Bidirectional metric/US conversions for temperature, + rainfall, wind speed, and pressure +- Weather data conversion: convert_weather_us_to_metric / convert_weather_metric_to_us + for transforming weather dicts between API v1 (US) and v2 (metric) formats - get_key_from_dict: Safely retrieve values from dictionaries These functions are extracted from plugin.py to enable reuse and testing. @@ -68,6 +72,60 @@ def convert_weather_us_to_metric(weather_data: Dict[str, Any]) -> Dict[str, Any] return converted +def celsius_to_fahrenheit(c: float) -> float: + """Convert Celsius to Fahrenheit.""" + return c * 9 / 5 + 32 + + +def mm_to_inches(mm: float) -> float: + """Convert millimeters to inches.""" + return mm / 25.4 + + +def ms_to_mph(ms: float) -> float: + """Convert meters per second to miles per hour.""" + return ms / 0.44704 + + +def hpa_to_inhg(hpa: float) -> float: + """Convert hectopascals to inches of mercury.""" + return hpa / 33.8639 + + +def convert_weather_metric_to_us(weather_data: Dict[str, Any]) -> Dict[str, Any]: + """Convert weather data from metric to US units for API v1. + + V1 API expects US units (F, inches, mph, inHg). + V2 API expects metric (C, mm, m/s, hPa). + + Args: + weather_data: Weather dict with metric-unit values + + Returns: + New dict with values converted to US units + """ + converted = dict(weather_data) + + # Temperature fields: C -> F + for temp_key in ("t", "t_max", "t_min"): + if temp_key in converted and converted[temp_key] is not None: + converted[temp_key] = round(celsius_to_fahrenheit(float(converted[temp_key])), 1) + + # Rainfall: mm -> inches + if "rain" in converted and converted["rain"] is not None: + converted["rain"] = round(mm_to_inches(float(converted["rain"])), 2) + + # Wind speed: m/s -> mph + if "wind_speed" in converted and converted["wind_speed"] is not None: + converted["wind_speed"] = round(ms_to_mph(float(converted["wind_speed"])), 1) + + # Pressure: hPa -> inHg + if "pressure" in converted and converted["pressure"] is not None: + converted["pressure"] = round(hpa_to_inhg(float(converted["pressure"])), 2) + + return converted + + def get_key_from_dict(key: str, data: dict, default: Any = None) -> Any: """Safely get value from dictionary with graceful error handling. diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py index c160257..d3d6f34 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py @@ -26,7 +26,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from constants import MINIMUM_POLLING_INTERVAL_MINUTES +from constants import MINIMUM_POLLING_INTERVAL_MINUTES, MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES # Type alias for validation function return values @@ -596,6 +596,11 @@ class PrefsFieldSpec: "Max runtime must be at least 60 seconds (1 minute)", "Max runtime cannot exceed 10800 seconds (3 hours)", ), + PrefsFieldSpec( + "weatherUpdateInterval", MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES, 1440, 30, + f"Weather update interval must be at least {MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES} minutes", + "Weather update interval cannot exceed 1440 minutes (24 hours)", + ), ] @@ -604,7 +609,8 @@ def validate_prefs_config( ) -> ValidationResult: """Validate plugin configuration before saving. - Validates polling interval, API timeout, and max zone runtime settings. + Validates polling interval, API timeout, max zone runtime, and + Tomorrow.io weather integration settings. Args: values: Configuration values from plugin preferences UI @@ -632,4 +638,19 @@ def validate_prefs_config( else: errors[spec.field] = error + # Validate Tomorrow.io fields when enabled + tomorrow_enabled = values.get("tomorrowEnabled", False) + if tomorrow_enabled: + api_key = str(values.get("tomorrowApiKey", "")).strip() + if not api_key: + errors["tomorrowApiKey"] = "API key is required when Tomorrow.io weather is enabled" + else: + sanitized["tomorrowApiKey"] = api_key + + location = str(values.get("tomorrowLocation", "")).strip() + if not location: + errors["tomorrowLocation"] = "Location is required when Tomorrow.io weather is enabled" + else: + sanitized["tomorrowLocation"] = location + return (len(errors) == 0, sanitized, errors) diff --git a/pytest.ini b/pytest.ini index ae85e22..27865af 100644 --- a/pytest.ini +++ b/pytest.ini @@ -25,6 +25,7 @@ markers = handlers: Tests for device handler functionality validation: Tests for configuration and action validation actions: Tests for action callback methods + weather: Tests for Tomorrow.io weather integration integration: Integration tests requiring external services slow: Tests that take more than 1 second diff --git a/tests/test_base_modules.py b/tests/test_base_modules.py index 7809011..76d4327 100644 --- a/tests/test_base_modules.py +++ b/tests/test_base_modules.py @@ -136,7 +136,7 @@ def test_minimum_polling_interval(self): def test_default_weather_update_interval(self): """DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES should be reasonable.""" assert DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES >= 1 - assert DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES == 10 + assert DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES == 30 def test_throttle_limit(self): """THROTTLE_LIMIT_MINUTES should be ~1 hour.""" diff --git a/tests/test_tomorrow_client.py b/tests/test_tomorrow_client.py new file mode 100644 index 0000000..9c627e8 --- /dev/null +++ b/tests/test_tomorrow_client.py @@ -0,0 +1,451 @@ +"""Unit tests for tomorrow_client.py module. + +Tests verify the TomorrowClient correctly fetches weather data from +Tomorrow.io API and transforms it to Netro-compatible format. +""" +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock +import pytest + +# Add Server Plugin directory to path for imports +SERVER_PLUGIN_DIR = ( + Path(__file__).parent.parent + / "Netro Sprinklers.indigoPlugin" + / "Contents" + / "Server Plugin" +) +sys.path.insert(0, str(SERVER_PLUGIN_DIR)) + +from tomorrow_client import TomorrowClient, _TOMORROW_TO_NETRO_CONDITION + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def mock_logger(): + """Create a mock logger.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + return logger + + +@pytest.fixture +def client(mock_logger): + """Create a TomorrowClient instance.""" + return TomorrowClient( + api_key="test-api-key-123", + location="42.3478,-71.0466", + logger=mock_logger, + timeout=10, + ) + + +@pytest.fixture +def sample_tomorrow_response(): + """Sample Tomorrow.io realtime API response.""" + return { + "data": { + "time": "2026-04-09T12:00:00Z", + "values": { + "temperature": 22.5, + "temperatureApparent": 21.0, + "humidity": 65, + "precipitationIntensity": 0.0, + "precipitationProbability": 10, + "windSpeed": 5.2, + "pressureSurfaceLevel": 1013.25, + "cloudCover": 30, + "weatherCode": 1100, + } + } + } + + +@pytest.fixture +def rainy_tomorrow_response(): + """Sample Tomorrow.io response with rain.""" + return { + "data": { + "time": "2026-04-09T12:00:00Z", + "values": { + "temperature": 15.0, + "humidity": 90, + "precipitationIntensity": 2.5, + "precipitationProbability": 85, + "windSpeed": 8.0, + "pressureSurfaceLevel": 1005.0, + "weatherCode": 4001, + } + } + } + + +@pytest.fixture +def snowy_tomorrow_response(): + """Sample Tomorrow.io response with snow.""" + return { + "data": { + "time": "2026-04-09T12:00:00Z", + "values": { + "temperature": -2.0, + "humidity": 80, + "precipitationIntensity": 1.0, + "precipitationProbability": 70, + "windSpeed": 3.0, + "pressureSurfaceLevel": 1020.0, + "weatherCode": 5000, + } + } + } + + +@pytest.fixture +def windy_tomorrow_response(): + """Sample Tomorrow.io response with high wind and clear sky.""" + return { + "data": { + "time": "2026-04-09T12:00:00Z", + "values": { + "temperature": 18.0, + "humidity": 40, + "precipitationIntensity": 0.0, + "precipitationProbability": 0, + "windSpeed": 18.5, + "pressureSurfaceLevel": 1010.0, + "weatherCode": 1000, + } + } + } + + +# ============================================================================= +# TestWeatherCodeMapping +# ============================================================================= + +@pytest.mark.weather +class TestWeatherCodeMapping: + """Tests for Tomorrow.io to Netro condition code mapping.""" + + def test_clear_codes_map_to_0(self): + """Clear and Mostly Clear should map to Netro condition 0.""" + assert _TOMORROW_TO_NETRO_CONDITION[1000] == 0 + assert _TOMORROW_TO_NETRO_CONDITION[1100] == 0 + + def test_cloudy_codes_map_to_1(self): + """Cloudy variants should map to Netro condition 1.""" + assert _TOMORROW_TO_NETRO_CONDITION[1101] == 1 + assert _TOMORROW_TO_NETRO_CONDITION[1102] == 1 + assert _TOMORROW_TO_NETRO_CONDITION[1001] == 1 + + def test_fog_codes_map_to_1(self): + """Fog should map to Netro condition 1 (cloudy).""" + assert _TOMORROW_TO_NETRO_CONDITION[2000] == 1 + assert _TOMORROW_TO_NETRO_CONDITION[2100] == 1 + + def test_rain_codes_map_to_2(self): + """Rain variants should map to Netro condition 2.""" + assert _TOMORROW_TO_NETRO_CONDITION[4000] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[4001] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[4200] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[4201] == 2 + + def test_freezing_rain_maps_to_2(self): + """Freezing rain should map to Netro condition 2 (rain).""" + assert _TOMORROW_TO_NETRO_CONDITION[6000] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[6001] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[6200] == 2 + assert _TOMORROW_TO_NETRO_CONDITION[6201] == 2 + + def test_thunderstorm_maps_to_2(self): + """Thunderstorm should map to Netro condition 2 (rain).""" + assert _TOMORROW_TO_NETRO_CONDITION[8000] == 2 + + def test_snow_codes_map_to_3(self): + """Snow variants should map to Netro condition 3.""" + assert _TOMORROW_TO_NETRO_CONDITION[5000] == 3 + assert _TOMORROW_TO_NETRO_CONDITION[5001] == 3 + assert _TOMORROW_TO_NETRO_CONDITION[5100] == 3 + assert _TOMORROW_TO_NETRO_CONDITION[5101] == 3 + + def test_ice_pellets_map_to_3(self): + """Ice pellets should map to Netro condition 3 (snow).""" + assert _TOMORROW_TO_NETRO_CONDITION[7000] == 3 + assert _TOMORROW_TO_NETRO_CONDITION[7101] == 3 + assert _TOMORROW_TO_NETRO_CONDITION[7102] == 3 + + +# ============================================================================= +# TestTransformResponse +# ============================================================================= + +@pytest.mark.weather +class TestTransformResponse: + """Tests for Tomorrow.io response transformation.""" + + def test_clear_weather(self, client, sample_tomorrow_response): + """Clear weather transforms correctly with all fields.""" + result = client._transform_response(sample_tomorrow_response) + + assert result is not None + assert result["condition"] == 0 # Mostly Clear + assert result["t"] == 22.5 + assert result["humidity"] == 65 + assert result["rain"] == 0.0 + assert result["rain_prob"] == 10 + assert result["wind_speed"] == 5.2 + assert result["pressure"] == 1013.2 # Rounded to 1 decimal + assert "date" in result + + def test_rainy_weather(self, client, rainy_tomorrow_response): + """Rain weather transforms with condition=2.""" + result = client._transform_response(rainy_tomorrow_response) + + assert result is not None + assert result["condition"] == 2 # Rain + assert result["t"] == 15.0 + assert result["humidity"] == 90 + assert result["rain"] == 2.5 + assert result["rain_prob"] == 85 + + def test_snowy_weather(self, client, snowy_tomorrow_response): + """Snow weather transforms with condition=3.""" + result = client._transform_response(snowy_tomorrow_response) + + assert result is not None + assert result["condition"] == 3 # Snow + assert result["t"] == -2.0 + + def test_wind_override(self, client, windy_tomorrow_response): + """High wind overrides clear/cloudy condition to Wind (4).""" + result = client._transform_response(windy_tomorrow_response) + + assert result is not None + assert result["condition"] == 4 # Wind + assert result["wind_speed"] == 18.5 + + def test_wind_override_boundary_at_15(self, client): + """Exactly 15.0 m/s should NOT trigger wind override (threshold is >15).""" + data = { + "data": { + "values": { + "temperature": 18.0, + "windSpeed": 15.0, + "weatherCode": 1000, # Clear + } + } + } + result = client._transform_response(data) + assert result is not None + assert result["condition"] == 0 # Still Clear, not Wind + + def test_wind_override_just_above_15(self, client): + """15.1 m/s should trigger wind override for clear/cloudy.""" + data = { + "data": { + "values": { + "temperature": 18.0, + "windSpeed": 15.1, + "weatherCode": 1000, # Clear + } + } + } + result = client._transform_response(data) + assert result is not None + assert result["condition"] == 4 # Wind + + def test_wind_no_override_during_rain(self, client): + """High wind should not override rain condition.""" + data = { + "data": { + "values": { + "temperature": 10.0, + "windSpeed": 20.0, + "weatherCode": 4001, # Rain + } + } + } + result = client._transform_response(data) + assert result is not None + assert result["condition"] == 2 # Still Rain, not Wind + + def test_missing_temperature_returns_none(self, client, mock_logger): + """Missing temperature should return None.""" + data = { + "data": { + "values": { + "humidity": 50, + "weatherCode": 1000, + } + } + } + result = client._transform_response(data) + assert result is None + mock_logger.error.assert_called() + + def test_missing_data_key_returns_none(self, client, mock_logger): + """Response without data key returns None.""" + result = client._transform_response({"error": "bad"}) + assert result is None + mock_logger.error.assert_called() + + def test_missing_values_key_returns_none(self, client, mock_logger): + """Response without values key returns None.""" + result = client._transform_response({"data": {}}) + assert result is None + mock_logger.error.assert_called() + + def test_unknown_weather_code_defaults_to_cloudy(self, client): + """Unknown weather code maps to Cloudy (1).""" + data = { + "data": { + "values": { + "temperature": 20.0, + "weatherCode": 9999, + } + } + } + result = client._transform_response(data) + assert result is not None + assert result["condition"] == 1 # Default: Cloudy + + def test_optional_fields_omitted_when_none(self, client): + """Optional fields not present in response are omitted from result.""" + data = { + "data": { + "values": { + "temperature": 20.0, + "weatherCode": 1000, + } + } + } + result = client._transform_response(data) + assert result is not None + assert "humidity" not in result + assert "rain" not in result + assert "rain_prob" not in result + assert "wind_speed" not in result + assert "pressure" not in result + + def test_temperature_rounded_to_one_decimal(self, client): + """Temperature should be rounded to 1 decimal place.""" + data = { + "data": { + "values": { + "temperature": 22.456, + "weatherCode": 1000, + } + } + } + result = client._transform_response(data) + assert result["t"] == 22.5 + + def test_humidity_rounded_to_integer(self, client): + """Humidity should be rounded to integer.""" + data = { + "data": { + "values": { + "temperature": 20.0, + "humidity": 65.7, + "weatherCode": 1000, + } + } + } + result = client._transform_response(data) + assert result["humidity"] == 66 + assert isinstance(result["humidity"], int) + + +# ============================================================================= +# TestFetchCurrentWeather +# ============================================================================= + +@pytest.mark.weather +class TestFetchCurrentWeather: + """Tests for the full fetch_current_weather method.""" + + @patch("tomorrow_client.requests.get") + def test_successful_fetch(self, mock_get, client, sample_tomorrow_response): + """Successful API call returns weather data.""" + mock_response = MagicMock() + mock_response.json.return_value = sample_tomorrow_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = client.fetch_current_weather() + + assert result is not None + assert result["t"] == 22.5 + assert result["condition"] == 0 + mock_response.raise_for_status.assert_called_once() + mock_get.assert_called_once() + + @patch("tomorrow_client.requests.get") + def test_api_params_correct(self, mock_get, client, sample_tomorrow_response): + """API call uses correct parameters.""" + mock_response = MagicMock() + mock_response.json.return_value = sample_tomorrow_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + client.fetch_current_weather() + + mock_response.raise_for_status.assert_called_once() + call_kwargs = mock_get.call_args + assert call_kwargs[1]["params"]["location"] == "42.3478,-71.0466" + assert call_kwargs[1]["params"]["apikey"] == "test-api-key-123" + assert call_kwargs[1]["params"]["units"] == "metric" + assert call_kwargs[1]["timeout"] == 10 + + @patch("tomorrow_client.requests.get") + def test_http_error_returns_none(self, mock_get, client, mock_logger): + """HTTP error returns None and logs error.""" + import requests + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=mock_response + ) + mock_get.return_value = mock_response + + result = client.fetch_current_weather() + + assert result is None + mock_logger.error.assert_called() + + @patch("tomorrow_client.requests.get") + def test_connection_error_returns_none(self, mock_get, client, mock_logger): + """Connection error returns None and logs error.""" + import requests + mock_get.side_effect = requests.exceptions.ConnectionError() + + result = client.fetch_current_weather() + + assert result is None + mock_logger.error.assert_called() + + @patch("tomorrow_client.requests.get") + def test_timeout_returns_none(self, mock_get, client, mock_logger): + """Timeout returns None and logs error.""" + import requests + mock_get.side_effect = requests.exceptions.Timeout() + + result = client.fetch_current_weather() + + assert result is None + mock_logger.error.assert_called() + + @patch("tomorrow_client.requests.get") + def test_unexpected_error_returns_none(self, mock_get, client, mock_logger): + """Unexpected error returns None and logs error.""" + mock_get.side_effect = ValueError("unexpected") + + result = client.fetch_current_weather() + + assert result is None + mock_logger.error.assert_called() diff --git a/tests/test_weather_integration.py b/tests/test_weather_integration.py new file mode 100644 index 0000000..9efbf6d --- /dev/null +++ b/tests/test_weather_integration.py @@ -0,0 +1,232 @@ +"""Unit tests for Tomorrow.io weather integration. + +Tests cover: +- Metric-to-US unit conversion (utils.py) +- Plugin prefs validation for Tomorrow.io fields (validators.py) +""" +import sys +from pathlib import Path +import pytest + +# Add Server Plugin directory to path for imports +SERVER_PLUGIN_DIR = ( + Path(__file__).parent.parent + / "Netro Sprinklers.indigoPlugin" + / "Contents" + / "Server Plugin" +) +sys.path.insert(0, str(SERVER_PLUGIN_DIR)) + +from utils import ( + convert_weather_metric_to_us, + celsius_to_fahrenheit, + mm_to_inches, + ms_to_mph, + hpa_to_inhg, +) +from validators import validate_prefs_config + + +# ============================================================================= +# TestMetricToUSConversion +# ============================================================================= + +@pytest.mark.weather +class TestMetricToUSConversion: + """Tests for metric-to-US unit conversion functions.""" + + def test_celsius_to_fahrenheit_freezing(self): + """0C = 32F.""" + assert celsius_to_fahrenheit(0) == 32 + + def test_celsius_to_fahrenheit_boiling(self): + """100C = 212F.""" + assert celsius_to_fahrenheit(100) == 212 + + def test_celsius_to_fahrenheit_negative(self): + """-40C = -40F.""" + assert celsius_to_fahrenheit(-40) == -40 + + def test_mm_to_inches(self): + """25.4mm = 1 inch.""" + assert abs(mm_to_inches(25.4) - 1.0) < 0.001 + + def test_ms_to_mph(self): + """1 m/s ~ 2.237 mph.""" + assert abs(ms_to_mph(1.0) - 2.237) < 0.01 + + def test_hpa_to_inhg(self): + """1013.25 hPa ~ 29.92 inHg.""" + assert abs(hpa_to_inhg(1013.25) - 29.92) < 0.01 + + def test_convert_full_weather_dict(self): + """Full weather dict converts all fields correctly.""" + metric = { + "condition": 2, + "date": "2026-04-09", + "t": 22.5, + "t_max": 28.0, + "t_min": 15.0, + "humidity": 65, + "rain": 2.5, + "rain_prob": 80, + "wind_speed": 5.2, + "pressure": 1013.25, + } + us = convert_weather_metric_to_us(metric) + + # Temperature conversions + assert us["t"] == 72.5 + assert us["t_max"] == 82.4 + assert us["t_min"] == 59.0 + + # Rain: 2.5mm -> ~0.10 inches + assert abs(us["rain"] - 0.10) < 0.01 + + # Wind: 5.2 m/s -> ~11.6 mph + assert abs(us["wind_speed"] - 11.6) < 0.1 + + # Pressure: 1013.25 hPa -> ~29.92 inHg + assert abs(us["pressure"] - 29.92) < 0.01 + + # Non-converted fields stay the same + assert us["condition"] == 2 + assert us["date"] == "2026-04-09" + assert us["humidity"] == 65 + assert us["rain_prob"] == 80 + + def test_convert_preserves_original(self): + """Conversion returns a new dict, doesn't modify original.""" + metric = {"t": 20.0, "rain": 5.0} + us = convert_weather_metric_to_us(metric) + assert metric["t"] == 20.0 # Original unchanged + assert us["t"] != 20.0 # Converted is different + + def test_convert_handles_missing_fields(self): + """Missing optional fields don't cause errors.""" + metric = {"condition": 0, "date": "2026-04-09", "t": 20.0} + us = convert_weather_metric_to_us(metric) + assert us["t"] == 68.0 + assert "rain" not in us + assert "wind_speed" not in us + assert "pressure" not in us + + def test_convert_handles_none_values(self): + """None values in optional fields are preserved.""" + metric = {"t": 20.0, "rain": None, "wind_speed": None, "pressure": None} + us = convert_weather_metric_to_us(metric) + assert us["t"] == 68.0 + assert us["rain"] is None + assert us["wind_speed"] is None + assert us["pressure"] is None + + +# ============================================================================= +# TestPrefsValidationTomorrow +# ============================================================================= + +@pytest.mark.weather +class TestPrefsValidationTomorrow: + """Tests for Tomorrow.io plugin prefs validation.""" + + def test_valid_prefs_with_tomorrow_enabled(self): + """Valid prefs with Tomorrow.io enabled passes validation.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "30", + "tomorrowEnabled": True, + "tomorrowApiKey": "my-tomorrow-api-key-12345678", + "tomorrowLocation": "42.3478,-71.0466", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is True + assert len(errors) == 0 + assert sanitized["tomorrowApiKey"] == "my-tomorrow-api-key-12345678" + assert sanitized["tomorrowLocation"] == "42.3478,-71.0466" + + def test_valid_prefs_with_tomorrow_disabled(self): + """Valid prefs with Tomorrow.io disabled - no API key needed.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "30", + "tomorrowEnabled": False, + "tomorrowApiKey": "", + "tomorrowLocation": "", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is True + assert len(errors) == 0 + + def test_tomorrow_enabled_missing_api_key(self): + """Enabled without API key fails validation.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "30", + "tomorrowEnabled": True, + "tomorrowApiKey": "", + "tomorrowLocation": "42.3478,-71.0466", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is False + assert "tomorrowApiKey" in errors + + def test_tomorrow_enabled_missing_location(self): + """Enabled without location fails validation.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "30", + "tomorrowEnabled": True, + "tomorrowApiKey": "my-tomorrow-api-key-12345678", + "tomorrowLocation": "", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is False + assert "tomorrowLocation" in errors + + def test_weather_update_interval_too_low(self): + """Weather interval below minimum fails validation.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "5", + "tomorrowEnabled": False, + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is False + assert "weatherUpdateInterval" in errors + + def test_weather_update_interval_default(self): + """Missing weather interval uses default (30 min).""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is True + assert sanitized["weatherUpdateInterval"] == 30 + + def test_tomorrow_api_key_whitespace_stripped(self): + """API key whitespace is stripped.""" + values = { + "pollingInterval": "5", + "apiTimeout": "5", + "maxZoneRunTime": "3600", + "weatherUpdateInterval": "30", + "tomorrowEnabled": True, + "tomorrowApiKey": " my-api-key-12345678 ", + "tomorrowLocation": " 42.3478,-71.0466 ", + } + is_valid, sanitized, errors = validate_prefs_config(values) + assert is_valid is True + assert sanitized["tomorrowApiKey"] == "my-api-key-12345678" + assert sanitized["tomorrowLocation"] == "42.3478,-71.0466"