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 diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 4558015..25fa412 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,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. @@ -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") @@ -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") ######################################## @@ -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 @@ -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 diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py index a3f7ce0..1f816f9 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 @@ -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: + 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 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..971f51d 100644 --- a/tests/test_tomorrow_client.py +++ b/tests/test_tomorrow_client.py @@ -449,3 +449,283 @@ 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_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 = { + "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() + + @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() 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