diff --git a/CLAUDE.md b/CLAUDE.md
index 722e994..23f3239 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,6 @@ photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
--add Environment Canada as a weather widget provider (app/weather/ -- station/grid-lookup API, more involved than Open-Meteo/NWS)
-widget border option
-battery life widget
-sharing layouts with linked users
diff --git a/docs/widgets.md b/docs/widgets.md
index 35d8af6..d7222f8 100644
--- a/docs/widgets.md
+++ b/docs/widgets.md
@@ -227,21 +227,25 @@ which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
-`"open_meteo"` (worldwide, no API key) or `"nws"` (api.weather.gov, US
+`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
-period rather than a real station observation). Every provider function
-returns already-normalized `{"category": ...}` entries (one of `clear`/
-`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/`thunderstorm`) so
-`app/weather_render.py`'s drawing code never needs to know which
-provider supplied an entry. `geocode_city` (name -> lat/lon) always goes
-through Open-Meteo's free geocoder regardless of which provider is
-chosen to fetch with the result.
+period rather than a real station observation), or `"ec"` (Environment
+Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
+Every provider function returns already-normalized `{"category": ...}`
+entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
+`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
+know which provider supplied an entry. `geocode_city` (name -> lat/lon)
+always goes through Open-Meteo's free geocoder regardless of which
+provider is chosen to fetch with the result.
-**Environment Canada is a deliberate gap, not an oversight** -- its free
-API (the MSC GeoMet OGC service) is built around station/grid lookups,
-not simple lat/lon REST like the two providers above, and would have
-meaningfully expanded the initial pass. Next provider to add if this
-gets revisited.
+EC's `citypageweather-realtime` collection is only queryable by bounding
+box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
+Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
+`_nearest_site` widens the box progressively and picks the closest site
+by straight-line distance, rejecting anything beyond 300 km (calibrated
+against a real bug caught in development: an unconditional "nearest
+site, however far" matched a Miami, FL query to a site in Ontario,
+1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of
diff --git a/server/app/templates/_widget_dialog_weather.html b/server/app/templates/_widget_dialog_weather.html
index 75f7364..3efe73d 100644
--- a/server/app/templates/_widget_dialog_weather.html
+++ b/server/app/templates/_widget_dialog_weather.html
@@ -18,7 +18,7 @@
{% endfor %}
-
National Weather Service only covers US locations.
+ National Weather Service only covers US locations; Environment Canada only covers Canadian locations.
Units
Fahrenheit
diff --git a/server/app/weather/__init__.py b/server/app/weather/__init__.py
index 9143156..f601c42 100644
--- a/server/app/weather/__init__.py
+++ b/server/app/weather/__init__.py
@@ -1,11 +1,10 @@
"""Weather provider registry -- the app/widgets/ "dispatch registry over
pluggable implementations" pattern applied to weather data sources
-instead of widget types. Open-Meteo (app/weather/open_meteo.py, no API
-key, worldwide) and NWS (app/weather/nws.py, no API key, US-only) are the
-two providers wired up now; Environment Canada is a documented next step
-(docs/widgets.md), not included yet -- its free API is built around
-station/grid lookups (the MSC GeoMet OGC service), not simple lat/lon
-REST like these two, and would have meaningfully expanded this pass.
+instead of widget types. Three providers, all free/no API key:
+Open-Meteo (app/weather/open_meteo.py, worldwide), NWS (app/weather/
+nws.py, US-only), and Environment Canada (app/weather/ec.py, Canada-only,
+its own bbox/nearest-site lookup shape rather than simple lat/lon REST --
+see that module's own docstring).
geocode_city stays Open-Meteo-backed regardless of which provider is
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
@@ -33,7 +32,7 @@ class WeatherFetchError(Exception):
endpoints, get_or_refresh_*_for_widget) decide what to do."""
-from . import nws, open_meteo # noqa: E402 -- after WeatherFetchError, which both submodules import
+from . import ec, nws, open_meteo # noqa: E402 -- after WeatherFetchError, which all three submodules import
# Re-exported for existing call sites (routers/common.py, routers/
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
@@ -45,8 +44,12 @@ from .open_meteo import ( # noqa: E402,F401
weather_category,
)
-PROVIDERS = {"open_meteo": open_meteo, "nws": nws}
-PROVIDER_LABELS = {"open_meteo": "Open-Meteo", "nws": "National Weather Service (US)"}
+PROVIDERS = {"open_meteo": open_meteo, "nws": nws, "ec": ec}
+PROVIDER_LABELS = {
+ "open_meteo": "Open-Meteo",
+ "nws": "National Weather Service (US)",
+ "ec": "Environment Canada",
+}
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
diff --git a/server/app/weather/ec.py b/server/app/weather/ec.py
new file mode 100644
index 0000000..5127f94
--- /dev/null
+++ b/server/app/weather/ec.py
@@ -0,0 +1,227 @@
+"""Environment Canada (ECCC MSC GeoMet OGC API, api.weather.gc.ca)
+provider -- Canada-only, free, no API key. Unlike NWS's grid-point
+lookup or Open-Meteo's plain lat/lon REST, EC's `citypageweather-realtime`
+collection (an OGC API - Features collection, the modern replacement for
+the old dd.weatheroffice.gc.ca XML feed -- that host no longer resolves)
+is only queryable by bounding box, not a direct by-coordinate endpoint --
+this widens the box progressively until it finds at least one site, then
+picks the nearest by straight-line distance. This station/bbox-lookup
+shape (not simple lat/lon REST) is exactly why EC was documented as a
+follow-up rather than shipped alongside Open-Meteo/NWS in the first
+pass -- see docs/widgets.md.
+
+EC's numeric icon codes are its own set, distinct from WMO's (Open-
+Meteo) or NWS's icon-URL condition codes. _category_from_code_and_text
+below only maps the codes actually confirmed against live data (see
+this module's own tests, captured from real api.weather.gc.ca
+responses), falling back to the same keyword-match-on-condition-text
+safety net app/weather/nws.py uses for anything unmapped -- correctness
+comes from the text fallback, the numeric table is just a fast path.
+
+Pure functions -- no ORM, no FastAPI Depends -- same testability
+philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py/nws.py.
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import datetime, timedelta
+
+import httpx
+
+from . import WeatherFetchError
+
+HTTP_TIMEOUT_S = 15.0
+BASE_URL = "https://api.weather.gc.ca"
+COLLECTION = "citypageweather-realtime"
+HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
+
+# Progressively wider bounding boxes (degrees) around the target point --
+# EC's ~844 sites are dense near cities but sparse in the north, so a
+# small box can come back empty even for a real Canadian location. 25
+# degrees (~2000-2700 km depending on latitude) is already far beyond
+# _MAX_DISTANCE_KM, so there's no point widening past it.
+_BBOX_PADDINGS_DEG = (1.0, 3.0, 8.0, 25.0)
+
+# See _nearest_site's own docstring for why this exists and how it was
+# calibrated (a real Miami query matched 1824 km away without it).
+_MAX_DISTANCE_KM = 300
+
+_ICON_CODE_CATEGORIES = {
+ 0: "clear", 1: "clear", 30: "clear",
+ 2: "partly_cloudy", 5: "partly_cloudy", 31: "partly_cloudy", 32: "partly_cloudy",
+ 3: "cloudy", 4: "cloudy", 10: "cloudy", 33: "cloudy",
+ 6: "rain", 12: "rain", 28: "rain", 36: "rain",
+ 9: "thunderstorm", 19: "thunderstorm", 39: "thunderstorm",
+ 24: "fog",
+}
+
+_TEXT_CATEGORY_KEYWORDS = [
+ ("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"), ("tornado", "thunderstorm"),
+ ("flurr", "snow"), ("snow", "snow"), ("sleet", "snow"), ("ice pellet", "snow"), ("hail", "snow"),
+ ("freezing", "snow"),
+ ("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
+ ("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
+ ("overcast", "cloudy"), ("cloudy", "cloudy"),
+ ("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
+]
+
+
+def _category_from_code_and_text(code: int | None, text: str) -> str:
+ if code is not None and code in _ICON_CODE_CATEGORIES:
+ return _ICON_CODE_CATEGORIES[code]
+ lowered = text.lower()
+ for keyword, category in _TEXT_CATEGORY_KEYWORDS:
+ if keyword in lowered:
+ return category
+ return "cloudy" # same generic-icon fallback Open-Meteo/NWS both use
+
+
+def _convert_temp(celsius: float, units: str) -> float:
+ """EC's citypage feed reports temperatures in Celsius only (its
+ unitType is always "metric" in this feed) -- convert to the widget's
+ requested units, no-op if celsius was actually asked for."""
+ return celsius * 9 / 5 + 32 if units == "fahrenheit" else celsius
+
+
+def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
+ r = 6371.0
+ p1, p2 = math.radians(lat1), math.radians(lat2)
+ dphi = math.radians(lat2 - lat1)
+ dlambda = math.radians(lon2 - lon1)
+ a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
+ return 2 * r * math.asin(math.sqrt(a))
+
+
+def _items(bbox: str) -> list[dict]:
+ try:
+ resp = httpx.get(
+ f"{BASE_URL}/collections/{COLLECTION}/items",
+ params={"f": "json", "bbox": bbox, "limit": 50},
+ headers=HEADERS, timeout=HTTP_TIMEOUT_S,
+ )
+ resp.raise_for_status()
+ return resp.json()["features"]
+ except (httpx.HTTPError, KeyError) as e:
+ raise WeatherFetchError(str(e)) from e
+
+
+def _nearest_site(latitude: float, longitude: float) -> dict:
+ """This location's nearest Environment Canada citypage site's
+ `properties` dict -- widens the bounding box until it finds one
+ within _MAX_DISTANCE_KM, trying every padding rather than stopping
+ at the first non-empty box: since each padding's box is a superset
+ of the previous one's, a wider box's nearest match can only be the
+ same distance or closer, never farther, so an early non-empty box
+ whose nearest site is still too far away doesn't mean a closer one
+ isn't waiting just outside it.
+
+ Without the distance cutoff, an unconditional "just take whatever's
+ nearest" happily matches a US or overseas location to some real EC
+ site thousands of km away (confirmed live: Miami matched to
+ Leamington, Ontario, 1824 km off) instead of reporting that EC
+ simply doesn't cover this location. 300 km is generous enough for a
+ legitimate rural-Canada query against EC's sparse northern coverage
+ (~844 sites total) while still correctly rejecting a non-Canadian
+ one -- a US border city like Seattle, genuinely ~100 km from the
+ nearest EC site in Victoria, BC, still passes. Deliberately NOT a
+ single whole-country query instead of progressive widening: that
+ collection response is ~29 MB for cheap, in-city lookups fetching a
+ handful of nearby sites."""
+ best_distance_km = None
+ for pad in _BBOX_PADDINGS_DEG:
+ bbox = f"{longitude - pad},{latitude - pad},{longitude + pad},{latitude + pad}"
+ features = _items(bbox)
+ if not features:
+ continue
+ nearest = min(
+ features,
+ key=lambda f: _haversine_km(
+ latitude, longitude, f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0]
+ ),
+ )
+ best_distance_km = _haversine_km(
+ latitude, longitude, nearest["geometry"]["coordinates"][1], nearest["geometry"]["coordinates"][0]
+ )
+ if best_distance_km <= _MAX_DISTANCE_KM:
+ return nearest["properties"]
+ raise WeatherFetchError("No Environment Canada site found near this location (is it in Canada?)")
+
+
+def fetch_current(latitude: float, longitude: float, units: str) -> dict:
+ props = _nearest_site(latitude, longitude)
+ cc = props["currentConditions"]
+ temp_c = cc["temperature"]["value"]["en"]
+ code = (cc.get("iconCode") or {}).get("value")
+ text = (cc.get("condition") or {}).get("en") or ""
+ return {"temp": _convert_temp(temp_c, units), "category": _category_from_code_and_text(code, text)}
+
+
+def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
+ """EC's hourlyForecastGroup is a fixed 24-hour window (unlike Open-
+ Meteo/NWS's own longer hourly ranges) -- `hours` just caps how much
+ of it gets returned, same as the other providers."""
+ props = _nearest_site(latitude, longitude)
+ entries = props["hourlyForecastGroup"]["hourlyForecasts"]
+ result = []
+ for h in entries[:hours]:
+ temp_c = h["temperature"]["value"]["en"]
+ code = (h.get("iconCode") or {}).get("value")
+ text = (h.get("condition") or {}).get("en") or ""
+ result.append({
+ "time": h["timestamp"], "temp": _convert_temp(temp_c, units),
+ "category": _category_from_code_and_text(code, text),
+ })
+ return result
+
+
+def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
+ """Pairs EC's named day/night periods ("Today"/"Tonight"/"Tuesday"/
+ "Tuesday night"/...) into calendar dates by walking them in issued
+ order -- unlike NWS's periods (which carry a real startTime), EC's
+ forecast periods are named relative to "today", not dated, so the
+ date is inferred: a "night" period shares its preceding day period's
+ date, any other period starts the calendar day after the previous
+ one (the forecastGroup's own issued-at timestamp anchors day 0)."""
+ props = _nearest_site(latitude, longitude)
+ group = props["forecastGroup"]
+ issued = datetime.fromisoformat(group["timestamp"]["en"].replace("Z", "+00:00")).date()
+
+ by_date: dict[str, dict] = {}
+ order: list[str] = []
+ current_day = None
+ for period in group["forecasts"]:
+ name = period["period"]["textForecastName"]["en"].strip().lower()
+ if "night" in name:
+ day_date = current_day or issued
+ else:
+ day_date = issued if current_day is None else current_day + timedelta(days=1)
+ current_day = day_date
+ key = day_date.isoformat()
+ entry = by_date.setdefault(key, {"category": None})
+ if key not in order:
+ order.append(key)
+
+ temps = period.get("temperatures", {}).get("temperature") or []
+ if temps:
+ temp = _convert_temp(temps[0]["value"]["en"], units)
+ if temps[0]["class"]["en"] == "high":
+ entry["high"] = temp
+ else:
+ entry["low"] = temp
+ if entry["category"] is None:
+ icon = (period.get("abbreviatedForecast") or {}).get("icon") or {}
+ text = (period.get("abbreviatedForecast") or {}).get("textSummary", {}).get("en") or ""
+ entry["category"] = _category_from_code_and_text(icon.get("value"), text)
+
+ result = {}
+ for key in order[:max(1, days)]:
+ entry = by_date[key]
+ if "high" not in entry and "low" not in entry:
+ continue
+ result[key] = {
+ "high": entry.get("high", entry.get("low")),
+ "low": entry.get("low", entry.get("high")),
+ "category": entry["category"] or "cloudy",
+ }
+ return result
diff --git a/server/tests/test_weather_providers.py b/server/tests/test_weather_providers.py
index 99f244e..8873688 100644
--- a/server/tests/test_weather_providers.py
+++ b/server/tests/test_weather_providers.py
@@ -1,15 +1,17 @@
"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches
httpx.get with canned responses. Covers category normalization (Open-
-Meteo's WMO codes, NWS's icon-URL/text shapes) landing on the same shared
-category set, and each provider's fetch_current/fetch_hourly/fetch_daily
-shape."""
+Meteo's WMO codes, NWS's icon-URL/text shapes, EC's numeric icon codes)
+landing on the same shared category set, and each provider's
+fetch_current/fetch_hourly/fetch_daily shape. The EC fixtures below are
+trimmed-down real response shapes captured live against
+api.weather.gc.ca (Toronto, 2026-07-27), not guessed."""
from __future__ import annotations
import httpx
import pytest
-from app.weather import WeatherFetchError, nws, open_meteo
+from app.weather import WeatherFetchError, ec, nws, open_meteo
class _FakeResponse:
@@ -200,3 +202,165 @@ def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch):
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
nws.fetch_current(45.5, -122.6, "fahrenheit")
+
+
+# --- ec ---------------------------------------------------------------------
+
+def _ec_feature(lon: float, lat: float, properties: dict) -> dict:
+ return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": properties}
+
+
+def _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") -> dict:
+ return {
+ "currentConditions": {
+ "temperature": {"value": {"en": temp_c}},
+ "iconCode": {"value": icon_code},
+ "condition": {"en": condition},
+ },
+ "hourlyForecastGroup": {"hourlyForecasts": []},
+ "forecastGroup": {"timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": []},
+ }
+
+
+def _ec_items_response(features: list[dict]) -> _FakeResponse:
+ return _FakeResponse({"features": features})
+
+
+@pytest.mark.parametrize("code,expected", [
+ (0, "clear"), (1, "clear"), (30, "clear"),
+ (2, "partly_cloudy"), (5, "partly_cloudy"), (31, "partly_cloudy"), (32, "partly_cloudy"),
+ (3, "cloudy"), (4, "cloudy"), (10, "cloudy"), (33, "cloudy"),
+ (6, "rain"), (12, "rain"), (28, "rain"), (36, "rain"),
+ (9, "thunderstorm"), (19, "thunderstorm"), (39, "thunderstorm"),
+ (24, "fog"),
+])
+def test_ec_category_from_known_codes(code, expected):
+ assert ec._category_from_code_and_text(code, "irrelevant text") == expected
+
+
+@pytest.mark.parametrize("text,expected", [
+ ("Periods of snow", "snow"), ("Flurries", "snow"), ("Freezing rain", "snow"),
+ ("Periods of rain", "rain"), ("Chance of showers", "rain"), ("Drizzle", "rain"),
+ ("Risk of thunderstorms", "thunderstorm"), ("Tornado warning", "thunderstorm"),
+ ("Patchy fog", "fog"), ("Mainly sunny", "clear"), ("Something unrelated", "cloudy"),
+])
+def test_ec_category_from_text_fallback_for_unmapped_code(text, expected):
+ assert ec._category_from_code_and_text(9999, text) == expected
+
+
+def test_ec_convert_temp_celsius_to_fahrenheit():
+ assert ec._convert_temp(0, "fahrenheit") == 32
+ assert ec._convert_temp(0, "celsius") == 0
+
+
+def test_ec_fetch_current_converts_and_categorizes(monkeypatch):
+ props = _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy")
+ monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
+ [_ec_feature(-79.38, 43.65, props)]
+ ))
+ result = ec.fetch_current(43.65, -79.38, "fahrenheit")
+ assert result == {"temp": pytest.approx(83.84), "category": "cloudy"}
+
+
+def test_ec_fetch_hourly_respects_hours_limit(monkeypatch):
+ hourly = [
+ {"timestamp": f"2026-07-27T{h:02d}:00:00Z", "temperature": {"value": {"en": 20 + h}},
+ "iconCode": {"value": 2}, "condition": {"en": "A mix of sun and cloud"}}
+ for h in range(24)
+ ]
+ props = _ec_current_properties()
+ props["hourlyForecastGroup"] = {"hourlyForecasts": hourly}
+ monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
+ [_ec_feature(-79.38, 43.65, props)]
+ ))
+ result = ec.fetch_hourly(43.65, -79.38, "celsius", hours=5)
+ assert len(result) == 5
+ assert result[0]["category"] == "partly_cloudy"
+
+
+def _ec_forecast_period(name: str, temp_class: str, temp_c: float, icon_code: int, summary: str) -> dict:
+ return {
+ "period": {"textForecastName": {"en": name}},
+ "temperatures": {"temperature": [{"value": {"en": temp_c}, "class": {"en": temp_class}}]},
+ "abbreviatedForecast": {"icon": {"value": icon_code}, "textSummary": {"en": summary}},
+ }
+
+
+def test_ec_fetch_daily_pairs_day_night_periods_by_walking_order(monkeypatch):
+ props = _ec_current_properties()
+ props["forecastGroup"] = {
+ "timestamp": {"en": "2026-07-27T15:00:00Z"},
+ "forecasts": [
+ _ec_forecast_period("Today", "high", 29, 9, "Chance of showers"),
+ _ec_forecast_period("Tonight", "low", 15, 30, "Clear"),
+ _ec_forecast_period("Tuesday", "high", 25, 2, "Partly cloudy"),
+ _ec_forecast_period("Tuesday night", "low", 17, 32, "Cloudy periods"),
+ ],
+ }
+ monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
+ [_ec_feature(-79.38, 43.65, props)]
+ ))
+ result = ec.fetch_daily(43.65, -79.38, "celsius", days=2)
+ assert result == {
+ "2026-07-27": {"high": 29, "low": 15, "category": "thunderstorm"},
+ "2026-07-28": {"high": 25, "low": 17, "category": "partly_cloudy"},
+ }
+
+
+def test_ec_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
+ props = _ec_current_properties()
+ props["forecastGroup"] = {
+ "timestamp": {"en": "2026-07-27T15:00:00Z"},
+ "forecasts": [_ec_forecast_period("Today", "high", 29, 9, "Chance of showers")],
+ }
+ monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
+ [_ec_feature(-79.38, 43.65, props)]
+ ))
+ result = ec.fetch_daily(43.65, -79.38, "celsius", days=7)
+ assert len(result) == 1
+
+
+def test_ec_nearest_site_widens_bbox_until_within_max_distance(monkeypatch):
+ """A site right at the query point should be picked up by the very
+ first (smallest) bounding box -- no need to widen."""
+ props = _ec_current_properties()
+ calls = []
+
+ def _get(url, params, headers, timeout):
+ calls.append(params["bbox"])
+ return _ec_items_response([_ec_feature(-79.38, 43.65, props)])
+
+ monkeypatch.setattr(httpx, "get", _get)
+ result = ec.fetch_current(43.65, -79.38, "celsius")
+ assert result["temp"] == pytest.approx(28.8)
+ assert len(calls) == 1 # first (smallest) padding already found it
+
+
+def test_ec_nearest_site_rejects_a_match_beyond_max_distance(monkeypatch):
+ """Regression test: without a distance cutoff, progressively
+ widening the bbox until non-empty eventually matches ANY location on
+ Earth to some real EC site once the box is big enough (confirmed
+ live: a Miami, FL query matched Leamington, Ontario, 1824 km away).
+ A site ~785 km from the query point must be rejected as "not
+ covered", not returned as if it were a legitimate nearby match."""
+ far_props = _ec_current_properties()
+ widest_bbox = f"{-ec._BBOX_PADDINGS_DEG[-1]},{-ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]}"
+
+ def _get(url, params, headers, timeout):
+ # Only the widest padding's box actually reaches the far site --
+ # every narrower one comes back empty, same as a genuine gap.
+ if params["bbox"] == widest_bbox:
+ return _ec_items_response([_ec_feature(5.0, 5.0, far_props)])
+ return _ec_items_response([])
+
+ monkeypatch.setattr(httpx, "get", _get)
+ with pytest.raises(WeatherFetchError):
+ ec.fetch_current(0.0, 0.0, "celsius")
+
+
+def test_ec_raises_weather_fetch_error_on_http_failure(monkeypatch):
+ def _raise(*a, **kw):
+ raise httpx.ConnectError("boom")
+ monkeypatch.setattr(httpx, "get", _raise)
+ with pytest.raises(WeatherFetchError):
+ ec.fetch_current(43.65, -79.38, "fahrenheit")