Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Netro Sprinklers.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>PluginVersion</key>
<string>2026.2.0</string>
<string>2026.3.0</string>
Comment on lines 5 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use a new, unused plugin version.

CI is already failing because 2026.3.0 exists as a git tag, so this release cannot pass the version check as written. Please bump PluginVersion to a unique value before merge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Info.plist around lines 5 - 6, The
PluginVersion value "2026.3.0" is already used as a git tag and must be bumped
to a unique, unused version; update the <key>PluginVersion</key> entry by
changing the <string> value to a new semantic version (e.g., "2026.3.1" or the
next appropriate patch/minor) that does not already exist in tags, commit the
change, and retry CI so the version check passes.

<key>ServerApiVersion</key>
<string>3.6</string>
<key>IwsApiVersion</key>
Expand Down
94 changes: 93 additions & 1 deletion Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -470,6 +472,87 @@ 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 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 + 1 Netro API call per forecast day per 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 Netro API does not accept t_dew field — strip before sending
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 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.

Expand Down Expand Up @@ -925,6 +1008,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")
Expand Down Expand Up @@ -1100,12 +1184,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")

########################################
Expand Down Expand Up @@ -1418,8 +1504,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
Expand Down Expand Up @@ -1719,16 +1807,20 @@ 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."""
"""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
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -231,3 +232,169 @@ 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 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 (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:
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:
Comment on lines +309 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cap the transformed forecast to six days.

This currently processes every entry in timelines.daily, but the feature contract and token-budget math assume today + 5 days only. If Tomorrow.io returns a longer daily horizon, we'll over-report to Netro and may send dates beyond the supported window. Please slice the daily list before iterating and add a regression test for responses longer than six days.

Proposed fix
-            daily = data["timelines"]["daily"]
+            daily = data["timelines"]["daily"][:6]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py
around lines 309 - 318, Limit the transformed forecast to six days by slicing
the parsed timelines.daily list before iteration: after extracting daily =
data["timelines"]["daily"], replace the unbounded loop over daily with a slice
like daily = daily[:6] (or equivalent) so the for day in daily loop only
processes today +5 days; update the forecasts-building logic that populates
forecasts accordingly and add a regression test that supplies a timelines.daily
array longer than six entries to verify only six are emitted.

values = day.get("values", {})
time_str = day.get("time", "unknown")

# Temperature average is required
temp_avg = values.get("temperatureAvg")
if temp_avg is None:
self.logger.warning(
f"Forecast day {time_str} missing temperatureAvg, skipping"
)
continue

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)

# 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)

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
4 changes: 2 additions & 2 deletions Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading