Skip to content

Commit 80a13cd

Browse files
kmichclaude
andcommitted
feat: v1.2.0 — pluggable forecast providers + nowcast correction + NO2/Ozone/Rain Today sensors
- Pluggable ForecastProvider ABC; ships with Open-Meteo (default), Met.no, NWS/NOAA (US-only, free), OpenWeatherMap (API key), Pirate Weather (API key) - Config/options flow: provider dropdown + conditional API key sub-step - Nowcast correction (0–3 h) now actually applied — blends local readings into first 3 hourly slots with weights 0.70 / 0.40 / 0.10 - sensor.ws_no2 and sensor.ws_ozone exposed as standalone diagnostic sensors - sensor.ws_rain_today for today's accumulated rainfall (resets at local midnight) - Non-breaking: existing installs default to Open-Meteo with no config changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f74b566 commit 80a13cd

18 files changed

Lines changed: 1185 additions & 92 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to Weather Station Core are documented here.
44

5+
## [1.2.0] - 2026-05-18
6+
7+
### New Features
8+
9+
- **Pluggable forecast provider**`sensor.ws_forecast_daily` and all NWP-derived sensors now use a swappable provider. Select in the config/options flow. Ships with two built-in providers: **Open-Meteo** (default, free, no API key) and **Met.no** (Norwegian Meteorological Institute, free, no API key, strong European coverage). Adding new providers requires only a new Python file + one-line registry entry.
10+
- **Nowcast correction (0–3 h) — now actually implemented** — local station readings blend into the first three hourly forecast slots with tapering weights (70 % local at h+0, 40 % at h+1, 10 % at h+2, pure NWP from h+3). Blended fields: temperature, humidity, wind speed, dew point.
11+
- **NO₂ and Ozone sensors**`sensor.ws_no2` and `sensor.ws_ozone` expose the nitrogen dioxide and ozone values already fetched by the AQI module as standalone sensors (µg/m³, disabled by the Air Quality toggle, diagnostic category).
12+
- **Rain Today sensor**`sensor.ws_rain_today` exposes today's accumulated rainfall (resets at local midnight), separate from the 24 h rolling window.
13+
514
## [1.1.1] - 2026-05-18
615

716
### Bug Fixes

custom_components/ws_core/config_flow.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@
4646
CONF_ENABLE_THUNDERSTORM,
4747
CONF_ENABLE_WUNDERGROUND,
4848
CONF_ENABLE_ZAMBRETTI,
49+
CONF_FORECAST_API_KEY,
4950
CONF_FORECAST_ENABLED,
5051
CONF_FORECAST_INTERVAL_MIN,
5152
CONF_FORECAST_LAT,
5253
CONF_FORECAST_LON,
54+
CONF_FORECAST_PROVIDER,
5355
CONF_HEMISPHERE,
5456
CONF_NAME,
5557
CONF_PREFIX,
@@ -93,6 +95,7 @@
9395
DEFAULT_ENABLE_WUNDERGROUND,
9496
DEFAULT_FORECAST_ENABLED,
9597
DEFAULT_FORECAST_INTERVAL_MIN,
98+
DEFAULT_FORECAST_PROVIDER,
9699
DEFAULT_HEMISPHERE,
97100
DEFAULT_NAME,
98101
DEFAULT_PREFIX,
@@ -112,8 +115,14 @@
112115
DEFAULT_UNITS_MODE,
113116
DEFAULT_WU_INTERVAL_MIN,
114117
DOMAIN,
118+
FORECAST_PROVIDER_MET_NO,
119+
FORECAST_PROVIDER_NWS,
120+
FORECAST_PROVIDER_OPEN_METEO,
121+
FORECAST_PROVIDER_OWM,
122+
FORECAST_PROVIDER_PIRATE,
115123
HEMISPHERE_OPTIONS,
116124
OPTIONAL_SOURCES,
125+
PROVIDERS_REQUIRING_API_KEY,
117126
REQUIRED_SOURCES,
118127
SRC_BATTERY,
119128
SRC_DEW_POINT,
@@ -597,6 +606,8 @@ async def async_step_forecast(self, user_input: dict[str, Any] | None = None):
597606
if back:
598607
return back
599608
self._data.update(user_input)
609+
if user_input.get(CONF_FORECAST_PROVIDER) in PROVIDERS_REQUIRING_API_KEY:
610+
return await self.async_step_forecast_api_key()
600611
return await self.async_step_features()
601612

602613
default_lat = getattr(self.hass.config, "latitude", 0.0) or 0.0
@@ -618,8 +629,64 @@ async def async_step_forecast(self, user_input: dict[str, Any] | None = None):
618629
vol.Optional(CONF_FORECAST_LON, default=round(default_lon, 4)): selector.NumberSelector(
619630
selector.NumberSelectorConfig(min=-180, max=180, step=0.001, mode="box")
620631
),
632+
vol.Optional(CONF_FORECAST_PROVIDER, default=DEFAULT_FORECAST_PROVIDER): selector.SelectSelector(
633+
selector.SelectSelectorConfig(
634+
options=[
635+
selector.SelectOptionDict(
636+
value=FORECAST_PROVIDER_OPEN_METEO, label="Open-Meteo (free, no key)"
637+
),
638+
selector.SelectOptionDict(
639+
value=FORECAST_PROVIDER_MET_NO, label="Met.no (free, no key)"
640+
),
641+
selector.SelectOptionDict(
642+
value=FORECAST_PROVIDER_NWS, label="NWS/NOAA (free, no key, US only)"
643+
),
644+
selector.SelectOptionDict(
645+
value=FORECAST_PROVIDER_OWM, label="OpenWeatherMap (free tier, API key)"
646+
),
647+
selector.SelectOptionDict(
648+
value=FORECAST_PROVIDER_PIRATE, label="Pirate Weather (free tier, API key)"
649+
),
650+
],
651+
mode=selector.SelectSelectorMode.LIST,
652+
)
653+
),
654+
}
655+
),
656+
last_step=False,
657+
)
658+
659+
# ------------------------------------------------------------------
660+
# Step 6b: API key for providers that require one
661+
# ------------------------------------------------------------------
662+
async def async_step_forecast_api_key(self, user_input: dict[str, Any] | None = None):
663+
"""Step: API key for forecast providers that require one."""
664+
if user_input is not None:
665+
back = await self._handle_back(user_input)
666+
if back:
667+
return back
668+
self._data.update(user_input)
669+
return await self.async_step_features()
670+
671+
provider = self._data.get(CONF_FORECAST_PROVIDER, "")
672+
provider_labels = {
673+
FORECAST_PROVIDER_OWM: "OpenWeatherMap",
674+
FORECAST_PROVIDER_PIRATE: "Pirate Weather",
675+
}
676+
provider_name = provider_labels.get(provider, provider)
677+
678+
return self._show_step(
679+
step_id="forecast_api_key",
680+
data_schema=vol.Schema(
681+
{
682+
vol.Required(
683+
CONF_FORECAST_API_KEY,
684+
default=self._data.get(CONF_FORECAST_API_KEY, ""),
685+
): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)),
686+
vol.Optional("_go_back", default=False): selector.BooleanSelector(),
621687
}
622688
),
689+
description_placeholders={"provider_name": provider_name},
623690
last_step=False,
624691
)
625692

@@ -1056,8 +1123,10 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
10561123
out[CONF_RAIN_PENALTY_HEAVY_MMPH] = _convert_rain_to_mmph(
10571124
float(out.get(CONF_RAIN_PENALTY_HEAVY_MMPH, DEFAULT_RAIN_PENALTY_HEAVY_MMPH)), imperial
10581125
)
1059-
# Merge into options — features step comes next
1126+
# Merge into options — features step comes next (API key step if needed)
10601127
self._opt: dict[str, Any] = out
1128+
if out.get(CONF_FORECAST_PROVIDER) in PROVIDERS_REQUIRING_API_KEY:
1129+
return await self.async_step_forecast_api_key_opt()
10611130
return await self.async_step_features_opt()
10621131

10631132
return self.async_show_form(
@@ -1117,6 +1186,28 @@ def _build_core_schema(self, imperial: bool, gust_u: str, rain_u: str, temp_u: s
11171186
vol.Optional(
11181187
CONF_FORECAST_LON, default=g(CONF_FORECAST_LON, round(default_lon, 4))
11191188
): selector.NumberSelector(selector.NumberSelectorConfig(min=-180, max=180, step=0.001, mode="box")),
1189+
vol.Optional(
1190+
CONF_FORECAST_PROVIDER, default=g(CONF_FORECAST_PROVIDER, DEFAULT_FORECAST_PROVIDER)
1191+
): selector.SelectSelector(
1192+
selector.SelectSelectorConfig(
1193+
options=[
1194+
selector.SelectOptionDict(
1195+
value=FORECAST_PROVIDER_OPEN_METEO, label="Open-Meteo (free, no key)"
1196+
),
1197+
selector.SelectOptionDict(value=FORECAST_PROVIDER_MET_NO, label="Met.no (free, no key)"),
1198+
selector.SelectOptionDict(
1199+
value=FORECAST_PROVIDER_NWS, label="NWS/NOAA (free, no key, US only)"
1200+
),
1201+
selector.SelectOptionDict(
1202+
value=FORECAST_PROVIDER_OWM, label="OpenWeatherMap (free tier, API key)"
1203+
),
1204+
selector.SelectOptionDict(
1205+
value=FORECAST_PROVIDER_PIRATE, label="Pirate Weather (free tier, API key)"
1206+
),
1207+
],
1208+
mode=selector.SelectSelectorMode.LIST,
1209+
)
1210+
),
11201211
vol.Optional(
11211212
CONF_THRESH_WIND_GUST_MS, default=round(_convert_gust_to_display(cur_gust_ms, imperial), 1)
11221213
): selector.NumberSelector(
@@ -1241,6 +1332,32 @@ async def async_step_features_opt(self, user_input: dict[str, Any] | None = None
12411332
last_step=False,
12421333
)
12431334

1335+
async def async_step_forecast_api_key_opt(self, user_input: dict[str, Any] | None = None):
1336+
"""Options step: API key for providers that require one."""
1337+
if user_input is not None:
1338+
self._opt.update(user_input)
1339+
return await self.async_step_features_opt()
1340+
1341+
provider = self._opt.get(CONF_FORECAST_PROVIDER, "")
1342+
provider_labels = {
1343+
FORECAST_PROVIDER_OWM: "OpenWeatherMap",
1344+
FORECAST_PROVIDER_PIRATE: "Pirate Weather",
1345+
}
1346+
provider_name = provider_labels.get(provider, provider)
1347+
current_key = self._opt.get(CONF_FORECAST_API_KEY, self._get(CONF_FORECAST_API_KEY, ""))
1348+
1349+
return self.async_show_form(
1350+
step_id="forecast_api_key_opt",
1351+
data_schema=vol.Schema(
1352+
{
1353+
vol.Required(CONF_FORECAST_API_KEY, default=current_key): selector.TextSelector(
1354+
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
1355+
),
1356+
}
1357+
),
1358+
description_placeholders={"provider_name": provider_name},
1359+
)
1360+
12441361
# ------------------------------------------------------------------
12451362
# Sub-steps for each configurable feature
12461363
# ------------------------------------------------------------------

custom_components/ws_core/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
CONF_FORECAST_LAT = "forecast_lat"
2121
CONF_FORECAST_LON = "forecast_lon"
2222
CONF_FORECAST_INTERVAL_MIN = "forecast_interval_min"
23+
CONF_FORECAST_PROVIDER = "forecast_provider"
24+
CONF_FORECAST_API_KEY = "forecast_api_key"
25+
FORECAST_PROVIDER_OPEN_METEO = "open_meteo"
26+
FORECAST_PROVIDER_MET_NO = "met_no"
27+
FORECAST_PROVIDER_NWS = "nws_noaa"
28+
FORECAST_PROVIDER_OWM = "openweathermap"
29+
FORECAST_PROVIDER_PIRATE = "pirate_weather"
30+
PROVIDERS_REQUIRING_API_KEY: set[str] = {"openweathermap", "pirate_weather"}
31+
DEFAULT_FORECAST_PROVIDER = FORECAST_PROVIDER_OPEN_METEO
2332

2433
# Alert & heuristic options (stored in canonical metric units internally)
2534
CONF_THRESH_WIND_GUST_MS = "thresh_wind_gust_ms"
@@ -221,6 +230,7 @@
221230
KEY_RAIN_DISPLAY = "rain_display"
222231
KEY_RAIN_ACCUM_1H = "rain_accum_1h_mm"
223232
KEY_RAIN_ACCUM_24H = "rain_accum_24h_mm"
233+
KEY_RAIN_TODAY_MM = "rain_today_mm"
224234
KEY_TIME_SINCE_RAIN = "time_since_rain"
225235
KEY_PRESSURE_TREND_DISPLAY = "pressure_trend_display"
226236
KEY_HEALTH_DISPLAY = "health_display"

0 commit comments

Comments
 (0)