-
Notifications
You must be signed in to change notification settings - Fork 0
Add Tomorrow.io 5-day forecast reporting #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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,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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cap the transformed forecast to six days. This currently processes every entry in Proposed fix- daily = data["timelines"]["daily"]
+ daily = data["timelines"]["daily"][:6]🤖 Prompt for AI Agents |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a new, unused plugin version.
CI is already failing because
2026.3.0exists as a git tag, so this release cannot pass the version check as written. Please bumpPluginVersionto a unique value before merge.🤖 Prompt for AI Agents