From beee1db77ba77c6e5d82584e799d32fefceec339 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 17:51:03 +0100 Subject: [PATCH 1/3] feat: add Tomorrow.io 5-day forecast reporting to Netro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch daily forecast from Tomorrow.io and report 6 days of weather data (today + 5 days ahead) to each sprinkler device. Gives Netro better scheduling data — e.g., skip watering today if rain forecast tomorrow. - Add fetch_forecast() and _transform_forecast_response() to TomorrowClient - Map daily aggregated fields: temperatureMax→t_max, temperatureMin→t_min, rainAccumulationSum→rain (actual daily total, not intensity), dewPointAvg→t_dew - Forecast runs on separate 60-min interval (vs 30-min realtime) - Add t_dew support to metric↔US conversion functions - Strip t_dew for v1 devices (unsupported by v1 API) - 16 new tests covering forecast transform, fetch, and dew point conversion Closes #44 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Server Plugin/plugin.py | 90 +++++++ .../Contents/Server Plugin/tomorrow_client.py | 166 ++++++++++++- .../Contents/Server Plugin/utils.py | 4 +- tests/test_tomorrow_client.py | 232 ++++++++++++++++++ tests/test_weather_integration.py | 29 +++ 5 files changed, 515 insertions(+), 6 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 4558015..1cfd734 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -48,6 +48,7 @@ MAX_ZONE_DURATION_SECONDS, DEFAULT_API_TIMEOUT_SECONDS, DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES, + FORECAST_UPDATE_INTERVAL_MINUTES, MINIMUM_POLLING_INTERVAL_MINUTES, ZONE_START_ENDPOINT, OPERATIONAL_ERROR_EVENTS, @@ -113,6 +114,7 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): # Initialize Tomorrow.io weather integration self._next_weather_update = datetime.now() + self._next_forecast_update = datetime.now() self._weather_update_interval = int( pluginPrefs.get("weatherUpdateInterval", DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES) ) @@ -470,6 +472,85 @@ def _update_weather_from_tomorrow(self): f"{weather_data.get('t')}C, condition={weather_data['condition']}" ) + def _update_forecast_from_tomorrow(self): + """Fetch daily forecast from Tomorrow.io and report to all sprinkler devices. + + Reports up to 6 days of forecast data (today + 5 days ahead) to each + sprinkler device via the Netro report_weather endpoint. Runs on a + separate, longer interval than realtime weather updates. + + Uses 1 Tomorrow.io API call + up to 6 Netro API calls per sprinkler device. + """ + if self._tomorrow_client is None: + return + + if datetime.now() < self._next_forecast_update: + return + + self.logger.info("Fetching forecast from Tomorrow.io...") + + # Schedule next update regardless of success/failure + self._next_forecast_update = datetime.now() + timedelta( + minutes=FORECAST_UPDATE_INTERVAL_MINUTES + ) + + forecast_data = self._tomorrow_client.fetch_forecast() + if forecast_data is None: + self.logger.warning("Failed to fetch forecast from Tomorrow.io, will retry next interval") + return + + if not forecast_data: + self.logger.warning("Tomorrow.io returned empty forecast") + return + + 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) + days_reported = 0 + + for day_weather in forecast_data: + # Convert units for v1 devices + if api_version == "1": + device_weather = convert_weather_metric_to_us(day_weather) + # v1 API does not support t_dew + device_weather.pop("t_dew", None) + else: + device_weather = dict(day_weather) + + response = self.api_client.report_weather( + key, device_weather, api_version=api_version + ) + + if response.get("status") == "OK": + days_reported += 1 + else: + self.logger.error( + f"Error reporting forecast to '{dev.name}' " + f"for {day_weather.get('date')}: {response}" + ) + + if days_reported > 0: + reported_count += 1 + self.logger.debug( + f"Forecast reported to '{dev.name}': {days_reported} days" + ) + + except ThrottleDelayError: + self.logger.debug(f"Skipping forecast for '{dev.name}' - throttled") + except Exception as exc: + self.logger.error(f"Could not report forecast to '{dev.name}': {exc}") + self.logger.debug(f"Forecast report error:\n{traceback.format_exc(10)}") + + if reported_count > 0: + self.logger.info( + f"Tomorrow.io forecast reported to {reported_count} device(s): " + f"{len(forecast_data)} days" + ) + def _get_zone_devices(self, parent_dev_id): """Get all zone devices belonging to a parent controller. @@ -925,6 +1006,7 @@ def runConcurrentThread(self): # Tomorrow.io uses its own API; run regardless of Netro token pause self._update_weather_from_tomorrow() + self._update_forecast_from_tomorrow() except self.StopThread: # Clean shutdown requested by Indigo - must re-raise self.logger.debug("Concurrent thread stopping") @@ -1100,12 +1182,14 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): if now_enabled and not was_enabled: self._next_weather_update = datetime.now() + self._next_forecast_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._next_forecast_update = datetime.now() self.logger.debug("Tomorrow.io weather settings updated") ######################################## @@ -1418,8 +1502,10 @@ def actionControlUniversal(self, action, dev): # STATUS REQUEST # if action.deviceAction == indigo.kUniversalAction.RequestStatus: self._next_weather_update = datetime.now() + self._next_forecast_update = datetime.now() self._update_from_netro() self._update_weather_from_tomorrow() + self._update_forecast_from_tomorrow() ######################################## # Custom Plugin Action callbacks defined in Actions.xml @@ -1719,8 +1805,10 @@ def updateAllStatus(self): next scheduled update from the concurrent thread. """ self._next_weather_update = datetime.now() + self._next_forecast_update = datetime.now() self._update_from_netro() self._update_weather_from_tomorrow() + self._update_forecast_from_tomorrow() def refreshWeather(self): """Force immediate weather update from Tomorrow.io via plugin menu.""" @@ -1728,7 +1816,9 @@ def refreshWeather(self): self.logger.warning("Tomorrow.io weather integration is not configured") return self._next_weather_update = datetime.now() + self._next_forecast_update = datetime.now() self._update_weather_from_tomorrow() + self._update_forecast_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 index a3f7ce0..c523d7a 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py @@ -1,10 +1,10 @@ """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. +and daily forecasts from the Tomorrow.io API and transforms them into the +format expected by the Netro report_weather endpoint. -Tomorrow.io returns metric units by default (Celsius, mm/hr, m/s, hPa). +Tomorrow.io returns metric units by default (Celsius, mm, 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). @@ -16,7 +16,7 @@ import logging from datetime import date import traceback -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import requests @@ -88,6 +88,7 @@ class TomorrowClient: """ REALTIME_URL = "https://api.tomorrow.io/v4/weather/realtime" + FORECAST_URL = "https://api.tomorrow.io/v4/weather/forecast" def __init__( self, @@ -231,3 +232,160 @@ def _transform_response(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: ) return weather_data + + def fetch_forecast(self) -> Optional[List[Dict[str, Any]]]: + """Fetch daily forecast from Tomorrow.io and return list of Netro-format dicts. + + Returns up to 6 days of daily forecast data (today + 5 days ahead) + in metric units suitable for Netro API v2. + + Returns: + List of Netro weather dicts (one per day), each containing: + - date (str): YYYY-MM-DD + - condition (int): 0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind + - t (float): Average temperature in Celsius + - t_max (float): Maximum temperature in Celsius + - t_min (float): Minimum temperature in Celsius + - t_dew (float): Dew point in Celsius + - humidity (int): Average relative humidity 0-100 + - rain (float): Total rainfall accumulation in mm + - rain_prob (int): Maximum precipitation probability 0-100 + - wind_speed (float): Average wind speed in m/s + - pressure (float): Average surface pressure in hPa + None if the request fails. + """ + try: + params = { + "location": self.location, + "apikey": self.api_key, + "units": "metric", + "timesteps": "1d", + } + + self.logger.debug( + f"Fetching forecast from Tomorrow.io for location: {self.location}" + ) + + response = requests.get( + self.FORECAST_URL, + params=params, + timeout=self.timeout, + ) + response.raise_for_status() + + data = response.json() + return self._transform_forecast_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 forecast API error (HTTP {status})") + self.logger.debug(f"Tomorrow.io forecast 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 forecast API request timed out") + return None + except Exception as exc: + self.logger.error(f"Unexpected error fetching forecast: {exc}") + self.logger.debug(f"Forecast fetch traceback:\n{traceback.format_exc()}") + return None + + def _transform_forecast_response( + self, data: Dict[str, Any] + ) -> Optional[List[Dict[str, Any]]]: + """Transform Tomorrow.io forecast response to list of Netro weather dicts. + + Args: + data: Raw JSON response from Tomorrow.io forecast endpoint + + Returns: + List of Netro-format weather dicts (metric), one per day. + None on parse error. Empty list if no daily data. + """ + try: + daily = data["timelines"]["daily"] + except (KeyError, TypeError): + self.logger.error( + "Unexpected Tomorrow.io forecast structure - missing timelines.daily" + ) + return None + + forecasts = [] + for day in daily: + values = day.get("values", {}) + + # Temperature average is required + temp_avg = values.get("temperatureAvg") + if temp_avg is None: + time_str = day.get("time", "unknown") + self.logger.warning( + f"Forecast day {time_str} missing temperatureAvg, skipping" + ) + continue + + # Extract date from ISO timestamp (e.g. "2026-04-10T05:00:00Z" → "2026-04-10") + forecast_date = day.get("time", "")[:10] + + # Map weather code to Netro condition + weather_code = values.get("weatherCodeMax") + if weather_code is None: + weather_code = 1001 + condition = _TOMORROW_TO_NETRO_CONDITION.get(weather_code, _DEFAULT_CONDITION) + + # Wind override: use max wind speed for threshold check + wind_speed_max = values.get("windSpeedMax") + if wind_speed_max is not None and wind_speed_max > 15.0 and condition in (0, 1): + condition = 4 + + weather_data: Dict[str, Any] = { + "date": forecast_date, + "condition": condition, + "t": round(float(temp_avg), 1), + } + + # Optional fields + temp_max = values.get("temperatureMax") + if temp_max is not None: + weather_data["t_max"] = round(float(temp_max), 1) + + temp_min = values.get("temperatureMin") + if temp_min is not None: + weather_data["t_min"] = round(float(temp_min), 1) + + dew_point = values.get("dewPointAvg") + if dew_point is not None: + weather_data["t_dew"] = round(float(dew_point), 1) + + humidity = values.get("humidityAvg") + if humidity is not None: + weather_data["humidity"] = int(round(float(humidity))) + + rain = values.get("rainAccumulationSum") + if rain is not None: + weather_data["rain"] = round(float(rain), 1) + + rain_prob = values.get("precipitationProbabilityMax") + if rain_prob is not None: + weather_data["rain_prob"] = int(round(float(rain_prob))) + + wind_speed = values.get("windSpeedAvg") + if wind_speed is not None: + weather_data["wind_speed"] = round(float(wind_speed), 1) + + pressure = values.get("pressureSurfaceLevelAvg") + if pressure is not None: + weather_data["pressure"] = round(float(pressure), 1) + + forecasts.append(weather_data) + + self.logger.debug( + f"Tomorrow.io forecast: {len(forecasts)} days, " + f"dates {forecasts[0]['date']} to {forecasts[-1]['date']}" + if forecasts else "Tomorrow.io forecast: no days returned" + ) + + return forecasts diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py index b6c970c..71abc59 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py @@ -53,7 +53,7 @@ def convert_weather_us_to_metric(weather_data: Dict[str, Any]) -> Dict[str, Any] converted = dict(weather_data) # Temperature fields: °F → °C - for temp_key in ("t", "t_max", "t_min"): + for temp_key in ("t", "t_max", "t_min", "t_dew"): if temp_key in converted and converted[temp_key] is not None: converted[temp_key] = round(fahrenheit_to_celsius(float(converted[temp_key])), 1) @@ -107,7 +107,7 @@ def convert_weather_metric_to_us(weather_data: Dict[str, Any]) -> Dict[str, Any] converted = dict(weather_data) # Temperature fields: C -> F - for temp_key in ("t", "t_max", "t_min"): + for temp_key in ("t", "t_max", "t_min", "t_dew"): if temp_key in converted and converted[temp_key] is not None: converted[temp_key] = round(celsius_to_fahrenheit(float(converted[temp_key])), 1) diff --git a/tests/test_tomorrow_client.py b/tests/test_tomorrow_client.py index 9c627e8..f6b0907 100644 --- a/tests/test_tomorrow_client.py +++ b/tests/test_tomorrow_client.py @@ -449,3 +449,235 @@ def test_unexpected_error_returns_none(self, mock_get, client, mock_logger): assert result is None mock_logger.error.assert_called() + + +# ============================================================================= +# Forecast Fixtures +# ============================================================================= + +def _make_daily_values( + temp_avg=15.0, temp_max=20.0, temp_min=10.0, dew_point=8.0, + humidity=60, rain_sum=2.5, rain_prob_max=40, wind_avg=3.0, + wind_max=6.0, pressure_avg=1013.0, weather_code_max=1001, +): + """Helper to build a daily forecast values dict.""" + return { + "temperatureAvg": temp_avg, + "temperatureMax": temp_max, + "temperatureMin": temp_min, + "dewPointAvg": dew_point, + "humidityAvg": humidity, + "rainAccumulationSum": rain_sum, + "precipitationProbabilityMax": rain_prob_max, + "windSpeedAvg": wind_avg, + "windSpeedMax": wind_max, + "pressureSurfaceLevelAvg": pressure_avg, + "weatherCodeMax": weather_code_max, + } + + +@pytest.fixture +def sample_forecast_response(): + """Sample Tomorrow.io forecast response with 6 days.""" + days = [] + for i in range(6): + days.append({ + "time": f"2026-04-{10 + i:02d}T05:00:00Z", + "values": _make_daily_values( + temp_avg=12.0 + i, + temp_max=18.0 + i, + temp_min=6.0 + i, + ), + }) + return {"timelines": {"daily": days}} + + +@pytest.fixture +def rainy_forecast_day(): + """Single rainy forecast day.""" + return { + "timelines": { + "daily": [{ + "time": "2026-04-11T05:00:00Z", + "values": _make_daily_values( + rain_sum=15.0, + rain_prob_max=85, + weather_code_max=4001, + ), + }] + } + } + + +# ============================================================================= +# TestTransformForecastResponse +# ============================================================================= + +@pytest.mark.weather +class TestTransformForecastResponse: + """Tests for Tomorrow.io forecast response transformation.""" + + def test_transforms_all_six_days(self, client, sample_forecast_response): + """Should return 6 dicts, one per day.""" + result = client._transform_forecast_response(sample_forecast_response) + assert result is not None + assert len(result) == 6 + + def test_dates_extracted_correctly(self, client, sample_forecast_response): + """Each dict should have the correct YYYY-MM-DD date.""" + result = client._transform_forecast_response(sample_forecast_response) + assert result[0]["date"] == "2026-04-10" + assert result[5]["date"] == "2026-04-15" + + def test_daily_fields_mapped_correctly(self, client, sample_forecast_response): + """First day should have all fields mapped from aggregated values.""" + result = client._transform_forecast_response(sample_forecast_response) + day = result[0] + assert day["t"] == 12.0 + assert day["t_max"] == 18.0 + assert day["t_min"] == 6.0 + assert day["t_dew"] == 8.0 + assert day["humidity"] == 60 + assert day["rain"] == 2.5 + assert day["rain_prob"] == 40 + assert day["wind_speed"] == 3.0 + assert day["pressure"] == 1013.0 + + def test_weather_code_max_maps_to_condition(self, client, rainy_forecast_day): + """weatherCodeMax 4001 (Rain) should map to condition 2.""" + result = client._transform_forecast_response(rainy_forecast_day) + assert result[0]["condition"] == 2 + + def test_wind_override_uses_wind_speed_max(self, client): + """windSpeedMax > 15 should override clear/cloudy to Wind (4).""" + data = { + "timelines": { + "daily": [{ + "time": "2026-04-10T05:00:00Z", + "values": _make_daily_values( + wind_max=16.0, + weather_code_max=1000, # Clear + ), + }] + } + } + result = client._transform_forecast_response(data) + assert result[0]["condition"] == 4 + + def test_wind_no_override_during_rain(self, client): + """windSpeedMax > 15 should NOT override rain condition.""" + data = { + "timelines": { + "daily": [{ + "time": "2026-04-10T05:00:00Z", + "values": _make_daily_values( + wind_max=20.0, + weather_code_max=4001, # Rain + ), + }] + } + } + result = client._transform_forecast_response(data) + assert result[0]["condition"] == 2 # Still Rain + + def test_missing_temperature_skips_day(self, client, mock_logger): + """Day with no temperatureAvg should be skipped.""" + data = { + "timelines": { + "daily": [ + { + "time": "2026-04-10T05:00:00Z", + "values": {"humidityAvg": 50, "weatherCodeMax": 1000}, + }, + { + "time": "2026-04-11T05:00:00Z", + "values": _make_daily_values(), + }, + ] + } + } + result = client._transform_forecast_response(data) + assert len(result) == 1 + assert result[0]["date"] == "2026-04-11" + mock_logger.warning.assert_called() + + def test_optional_fields_omitted_when_missing(self, client): + """Only date, condition, and t should be present for minimal data.""" + data = { + "timelines": { + "daily": [{ + "time": "2026-04-10T05:00:00Z", + "values": {"temperatureAvg": 15.0, "weatherCodeMax": 1000}, + }] + } + } + result = client._transform_forecast_response(data) + assert result[0]["t"] == 15.0 + assert "t_max" not in result[0] + assert "humidity" not in result[0] + assert "rain" not in result[0] + + def test_missing_timelines_returns_none(self, client, mock_logger): + """Response without timelines key returns None.""" + result = client._transform_forecast_response({"error": "bad"}) + assert result is None + mock_logger.error.assert_called() + + def test_empty_daily_returns_empty_list(self, client): + """Empty daily array returns empty list (not None).""" + result = client._transform_forecast_response({"timelines": {"daily": []}}) + assert result == [] + + +# ============================================================================= +# TestFetchForecast +# ============================================================================= + +@pytest.mark.weather +class TestFetchForecast: + """Tests for the full fetch_forecast method.""" + + @patch("tomorrow_client.requests.get") + def test_successful_fetch(self, mock_get, client, sample_forecast_response): + """Successful API call returns list of weather dicts.""" + mock_response = MagicMock() + mock_response.json.return_value = sample_forecast_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + result = client.fetch_forecast() + + assert result is not None + assert len(result) == 6 + mock_response.raise_for_status.assert_called_once() + mock_get.assert_called_once() + + @patch("tomorrow_client.requests.get") + def test_api_params_include_timesteps_1d(self, mock_get, client, sample_forecast_response): + """Forecast API call should include timesteps=1d.""" + mock_response = MagicMock() + mock_response.json.return_value = sample_forecast_response + mock_response.raise_for_status = Mock() + mock_get.return_value = mock_response + + client.fetch_forecast() + + call_kwargs = mock_get.call_args + assert call_kwargs[1]["params"]["timesteps"] == "1d" + assert call_kwargs[1]["params"]["units"] == "metric" + + @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 = 429 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=mock_response + ) + mock_get.return_value = mock_response + + result = client.fetch_forecast() + + assert result is None + mock_logger.error.assert_called() diff --git a/tests/test_weather_integration.py b/tests/test_weather_integration.py index 9efbf6d..00620ec 100644 --- a/tests/test_weather_integration.py +++ b/tests/test_weather_integration.py @@ -19,6 +19,7 @@ from utils import ( convert_weather_metric_to_us, + convert_weather_us_to_metric, celsius_to_fahrenheit, mm_to_inches, ms_to_mph, @@ -230,3 +231,31 @@ def test_tomorrow_api_key_whitespace_stripped(self): assert is_valid is True assert sanitized["tomorrowApiKey"] == "my-api-key-12345678" assert sanitized["tomorrowLocation"] == "42.3478,-71.0466" + + +# ============================================================================= +# TestDewPointConversion +# ============================================================================= + +@pytest.mark.weather +class TestDewPointConversion: + """Tests for t_dew conversion in weather data.""" + + def test_t_dew_converted_metric_to_us(self): + """t_dew should be converted from Celsius to Fahrenheit.""" + weather = {"condition": 1, "t": 20.0, "t_dew": 10.0} + result = convert_weather_metric_to_us(weather) + assert result["t_dew"] == 50.0 # 10°C = 50°F + + def test_t_dew_converted_us_to_metric(self): + """t_dew should be converted from Fahrenheit to Celsius.""" + weather = {"condition": 1, "t": 68.0, "t_dew": 50.0} + result = convert_weather_us_to_metric(weather) + assert result["t_dew"] == 10.0 # 50°F = 10°C + + def test_missing_t_dew_unaffected(self): + """Dict without t_dew should be unchanged.""" + weather = {"condition": 1, "t": 20.0, "humidity": 60} + result = convert_weather_metric_to_us(weather) + assert "t_dew" not in result + assert result["humidity"] == 60 From 739f4e855dc9d2c6b2e8a6e5d00988a945d64bd8 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 17:51:23 +0100 Subject: [PATCH 2/3] chore: bump version to 2026.3.0 for forecast reporting Co-Authored-By: Claude Opus 4.6 (1M context) --- Netro Sprinklers.indigoPlugin/Contents/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index a93bbfe..b2a17dd 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.2.0 + 2026.3.0 ServerApiVersion 3.6 IwsApiVersion From b1582ae66320066556415e020a09d9c63c8c4a65 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 18:05:52 +0100 Subject: [PATCH 3/3] fix: address all review findings for forecast PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap per-day forecast transform in try/except so one bad value doesn't discard all 6 days - Add missing fetch_forecast error tests (ConnectionError, Timeout, generic Exception) - Add wind override boundary test at exactly 15.0 m/s for forecast - Fix fragile ternary f-string in debug log — use explicit if/else - Update stale class docstring to mention forecasts - Fix docstrings: optional field markers, refreshWeather mentions forecast, accurate API call count, "6 days" not hardcoded - Improve summary log to say "days fetched" not imply all succeeded - Log when zero sprinkler devices found (avoids wasting API calls silently) - Clarify v1 t_dew strip comment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Server Plugin/plugin.py | 16 ++- .../Contents/Server Plugin/tomorrow_client.py | 133 ++++++++++-------- tests/test_tomorrow_client.py | 48 +++++++ 3 files changed, 128 insertions(+), 69 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 1cfd734..25fa412 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -475,11 +475,11 @@ def _update_weather_from_tomorrow(self): def _update_forecast_from_tomorrow(self): """Fetch daily forecast from Tomorrow.io and report to all sprinkler devices. - Reports up to 6 days of forecast data (today + 5 days ahead) to each - sprinkler device via the Netro report_weather endpoint. Runs on a - separate, longer interval than realtime weather updates. + Reports daily forecast data to each sprinkler device via the Netro + report_weather endpoint. Runs on a separate, longer interval than + realtime weather updates. - Uses 1 Tomorrow.io API call + up to 6 Netro API calls per sprinkler device. + Uses 1 Tomorrow.io API call + 1 Netro API call per forecast day per device. """ if self._tomorrow_client is None: return @@ -516,7 +516,7 @@ def _update_forecast_from_tomorrow(self): # Convert units for v1 devices if api_version == "1": device_weather = convert_weather_metric_to_us(day_weather) - # v1 API does not support t_dew + # v1 Netro API does not accept t_dew field — strip before sending device_weather.pop("t_dew", None) else: device_weather = dict(day_weather) @@ -548,8 +548,10 @@ def _update_forecast_from_tomorrow(self): if reported_count > 0: self.logger.info( f"Tomorrow.io forecast reported to {reported_count} device(s): " - f"{len(forecast_data)} days" + f"{len(forecast_data)} days fetched" ) + elif forecast_data: + self.logger.debug("No sprinkler devices available for forecast reporting") def _get_zone_devices(self, parent_dev_id): """Get all zone devices belonging to a parent controller. @@ -1811,7 +1813,7 @@ def updateAllStatus(self): self._update_forecast_from_tomorrow() def refreshWeather(self): - """Force immediate weather update from Tomorrow.io via plugin menu.""" + """Force immediate weather and forecast update from Tomorrow.io via plugin menu.""" if self._tomorrow_client is None: self.logger.warning("Tomorrow.io weather integration is not configured") return diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py index c523d7a..1f816f9 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py @@ -77,8 +77,8 @@ 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). + Fetches current weather and daily forecasts, transforming them into the + dict format expected by Netro's report_weather endpoint (metric units). Args: api_key: Tomorrow.io API key @@ -236,22 +236,22 @@ def _transform_response(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def fetch_forecast(self) -> Optional[List[Dict[str, Any]]]: """Fetch daily forecast from Tomorrow.io and return list of Netro-format dicts. - Returns up to 6 days of daily forecast data (today + 5 days ahead) + Returns daily forecast data (typically 6 days on the free tier) in metric units suitable for Netro API v2. Returns: List of Netro weather dicts (one per day), each containing: - - date (str): YYYY-MM-DD - - condition (int): 0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind - - t (float): Average temperature in Celsius - - t_max (float): Maximum temperature in Celsius - - t_min (float): Minimum temperature in Celsius - - t_dew (float): Dew point in Celsius - - humidity (int): Average relative humidity 0-100 - - rain (float): Total rainfall accumulation in mm - - rain_prob (int): Maximum precipitation probability 0-100 - - wind_speed (float): Average wind speed in m/s - - pressure (float): Average surface pressure in hPa + - date (str): YYYY-MM-DD (always present) + - condition (int): 0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind (always present) + - t (float): Average temperature in Celsius (always present) + - t_max (float): Maximum temperature in Celsius (optional) + - t_min (float): Minimum temperature in Celsius (optional) + - t_dew (float): Dew point in Celsius (optional) + - humidity (int): Average relative humidity 0-100 (optional) + - rain (float): Total rainfall accumulation in mm (optional) + - rain_prob (int): Maximum precipitation probability 0-100 (optional) + - wind_speed (float): Average wind speed in m/s (optional) + - pressure (float): Average surface pressure in hPa (optional) None if the request fails. """ try: @@ -317,75 +317,84 @@ def _transform_forecast_response( forecasts = [] for day in daily: values = day.get("values", {}) + time_str = day.get("time", "unknown") # Temperature average is required temp_avg = values.get("temperatureAvg") if temp_avg is None: - time_str = day.get("time", "unknown") self.logger.warning( f"Forecast day {time_str} missing temperatureAvg, skipping" ) continue - # Extract date from ISO timestamp (e.g. "2026-04-10T05:00:00Z" → "2026-04-10") - forecast_date = day.get("time", "")[:10] + try: + # Extract date from ISO timestamp (e.g. "2026-04-10T05:00:00Z" → "2026-04-10") + forecast_date = day.get("time", "")[:10] - # Map weather code to Netro condition - weather_code = values.get("weatherCodeMax") - if weather_code is None: - weather_code = 1001 - condition = _TOMORROW_TO_NETRO_CONDITION.get(weather_code, _DEFAULT_CONDITION) + # Map weather code to Netro condition + weather_code = values.get("weatherCodeMax") + if weather_code is None: + weather_code = 1001 + condition = _TOMORROW_TO_NETRO_CONDITION.get(weather_code, _DEFAULT_CONDITION) - # Wind override: use max wind speed for threshold check - wind_speed_max = values.get("windSpeedMax") - if wind_speed_max is not None and wind_speed_max > 15.0 and condition in (0, 1): - condition = 4 + # Wind override: use max wind speed for threshold check + wind_speed_max = values.get("windSpeedMax") + if wind_speed_max is not None and wind_speed_max > 15.0 and condition in (0, 1): + condition = 4 - weather_data: Dict[str, Any] = { - "date": forecast_date, - "condition": condition, - "t": round(float(temp_avg), 1), - } + weather_data: Dict[str, Any] = { + "date": forecast_date, + "condition": condition, + "t": round(float(temp_avg), 1), + } - # Optional fields - temp_max = values.get("temperatureMax") - if temp_max is not None: - weather_data["t_max"] = round(float(temp_max), 1) + # Optional fields + temp_max = values.get("temperatureMax") + if temp_max is not None: + weather_data["t_max"] = round(float(temp_max), 1) - temp_min = values.get("temperatureMin") - if temp_min is not None: - weather_data["t_min"] = round(float(temp_min), 1) + temp_min = values.get("temperatureMin") + if temp_min is not None: + weather_data["t_min"] = round(float(temp_min), 1) - dew_point = values.get("dewPointAvg") - if dew_point is not None: - weather_data["t_dew"] = round(float(dew_point), 1) + dew_point = values.get("dewPointAvg") + if dew_point is not None: + weather_data["t_dew"] = round(float(dew_point), 1) - humidity = values.get("humidityAvg") - if humidity is not None: - weather_data["humidity"] = int(round(float(humidity))) + humidity = values.get("humidityAvg") + if humidity is not None: + weather_data["humidity"] = int(round(float(humidity))) - rain = values.get("rainAccumulationSum") - if rain is not None: - weather_data["rain"] = round(float(rain), 1) + rain = values.get("rainAccumulationSum") + if rain is not None: + weather_data["rain"] = round(float(rain), 1) - rain_prob = values.get("precipitationProbabilityMax") - if rain_prob is not None: - weather_data["rain_prob"] = int(round(float(rain_prob))) + rain_prob = values.get("precipitationProbabilityMax") + if rain_prob is not None: + weather_data["rain_prob"] = int(round(float(rain_prob))) - wind_speed = values.get("windSpeedAvg") - if wind_speed is not None: - weather_data["wind_speed"] = round(float(wind_speed), 1) + wind_speed = values.get("windSpeedAvg") + if wind_speed is not None: + weather_data["wind_speed"] = round(float(wind_speed), 1) - pressure = values.get("pressureSurfaceLevelAvg") - if pressure is not None: - weather_data["pressure"] = round(float(pressure), 1) + pressure = values.get("pressureSurfaceLevelAvg") + if pressure is not None: + weather_data["pressure"] = round(float(pressure), 1) - forecasts.append(weather_data) + forecasts.append(weather_data) - self.logger.debug( - f"Tomorrow.io forecast: {len(forecasts)} days, " - f"dates {forecasts[0]['date']} to {forecasts[-1]['date']}" - if forecasts else "Tomorrow.io forecast: no days returned" - ) + except (ValueError, TypeError) as exc: + self.logger.warning( + f"Forecast day {time_str} has invalid data, skipping: {exc}" + ) + continue + + if forecasts: + self.logger.debug( + f"Tomorrow.io forecast: {len(forecasts)} days, " + f"dates {forecasts[0]['date']} to {forecasts[-1]['date']}" + ) + else: + self.logger.debug("Tomorrow.io forecast: no days returned") return forecasts diff --git a/tests/test_tomorrow_client.py b/tests/test_tomorrow_client.py index f6b0907..971f51d 100644 --- a/tests/test_tomorrow_client.py +++ b/tests/test_tomorrow_client.py @@ -564,6 +564,22 @@ def test_wind_override_uses_wind_speed_max(self, client): result = client._transform_forecast_response(data) assert result[0]["condition"] == 4 + def test_wind_override_boundary_at_15(self, client): + """windSpeedMax exactly 15.0 should NOT trigger wind override.""" + data = { + "timelines": { + "daily": [{ + "time": "2026-04-10T05:00:00Z", + "values": _make_daily_values( + wind_max=15.0, + weather_code_max=1000, # Clear + ), + }] + } + } + result = client._transform_forecast_response(data) + assert result[0]["condition"] == 0 # Still Clear + def test_wind_no_override_during_rain(self, client): """windSpeedMax > 15 should NOT override rain condition.""" data = { @@ -681,3 +697,35 @@ def test_http_error_returns_none(self, mock_get, client, mock_logger): 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("no connection") + + result = client.fetch_forecast() + + 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("timed out") + + result = client.fetch_forecast() + + 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_forecast() + + assert result is None + mock_logger.error.assert_called()