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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to Weather Station Core are documented here.

## [Unreleased]

### Added

- **Illuminance-based fallback for ET₀ Penman-Monteith.** When no `solar_radiation` source is mapped, an optional "illuminance fallback" (Solar Forecast step of the config/options flow) derives an approximate solar radiation from a mapped illuminance (lux) sensor using a ~0.0079 W/m² per lux daylight conversion factor. `sensor.ws_et0_penman_monteith` reports the value's `radiation_source` attribute (`sensor` or `illuminance_estimate`) so automations can tell a real pyranometer reading from an estimate (accurate to roughly ±25%, since luminous efficacy varies with sky conditions and sun elevation).
- **Repair issue for a silently-missing PM ET₀ sensor.** `sensor.ws_et0_penman_monteith` never appeared when Solar Forecast was enabled but no `solar_radiation` source (and no illuminance fallback) was configured, with no indication why. A Repairs issue now explains it.
- **Nowcast staleness is now visible.** `binary_sensor.ws_rain_expected_1h` exposes `nowcast_fetched_at` and `nowcast_stale` attributes, and a `source` attribute (`nowcast` or `local_fallback`).
- **Local-only nowcast fallback.** When the Open-Meteo nowcast fetch has failed or its cache is older than 2x the fetch interval, `binary_sensor.ws_rain_expected_1h` now falls back to a coarser rain-expected signal derived from the local pressure-trend/Zambretti forecast instead of silently holding a stale value. The entity goes explicitly `unavailable` only if neither a fresh nowcast nor a Zambretti reading exists.

### Fixed

- **Two orphaned entities from an unreleased feature.** `switch.ws_enable_local_forecaster` and `number.ws_forecaster_learning_rate` are leftovers of the v2.0 AI/local-forecaster work that never shipped (v1.8.0 stripped its user-facing surfaces before release), but a handful of instances that briefly ran a pre-release build still carry them as `restored`/`unavailable`. They are now removed from the entity registry on every setup. Other restored/unavailable entities a user has legitimately disabled are untouched.
- **`binary_sensor.ws_rain_expected_1h` used `device_class: moisture`,** which Home Assistant reserves for a *current* wetness/leak reading, not a forecast flag. The device_class has been removed.

## [2.8.1] - 2026-09-25

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ to a bug report.
Use the one calibrated for your region: **FFDI** (`sensor.ws_ffdi`) in Australia and New Zealand, **FWI** (`sensor.ws_fwi`, Canadian system) elsewhere, and **FFWI** (`sensor.ws_ffwi`) as a US/global cross-check. `sensor.ws_fire_risk_score` is a simplified 1-10 display value. None of these is an official warning - always defer to your local fire authority.
* **Which ET0 should I use for irrigation?**
If you have mapped a solar-radiation sensor, prefer `sensor.ws_et0_penman_monteith` (FAO-56 Penman-Monteith, the reference method). Without solar radiation, use `sensor.ws_et0_daily` (Hargreaves-Samani, ±15-20%).
* **No solar radiation sensor, but I have a lux sensor - can I still get Penman-Monteith ET0?**
Yes. Enable "illuminance fallback" in the Solar Forecast step of the config/options flow (requires an illuminance source mapped in Sources). It derives an approximate solar radiation from lux using a standard daylight conversion factor of ~0.0079 W/m² per lux, but real luminous efficacy varies roughly ±25% with sky conditions (clear vs overcast) and sun elevation/spectrum, so treat the result as an estimate, not a pyranometer-grade reading. When this fallback is active, `sensor.ws_et0_penman_monteith` reports a `radiation_source: illuminance_estimate` attribute (vs `sensor` for a real solar-radiation sensor) so downstream automations can tell which mode produced the value. If neither a solar-radiation sensor is mapped nor the fallback is enabled, a repair issue explains why the sensor never appears.
* **How do I uninstall?**
Go to **Settings → Devices & Services → Weather Station Core → ⋮ → Delete**. This removes the integration, its device, and all derived entities and their history. To remove the code as well, delete the repository from **HACS → Integrations**. Any dashboards or blueprints you imported are independent and can be removed separately.

Expand Down
23 changes: 23 additions & 0 deletions custom_components/ws_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
DEFAULT_CLIMATE_REGION,
DEFAULT_HEMISPHERE,
DEPRECATED_CONF_KEYS_V030,
DEPRECATED_KEYS_FORECASTER,
DEPRECATED_KEYS_V030,
DOMAIN,
ENTRY_REPAIR_ISSUES,
Expand Down Expand Up @@ -177,6 +178,26 @@ def _loaded_coordinators(hass: HomeAssistant, entry_id: str | None = None) -> li
]


def _async_remove_orphaned_forecaster_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove registry entries left over from the unreleased local-forecaster feature.

switch.<prefix>_enable_local_forecaster and number.<prefix>_forecaster_learning_rate
were never part of a public release (see DEPRECATED_KEYS_FORECASTER), but a handful
of instances that briefly ran a pre-release build still show them as
restored/unavailable. Unlike the v0.3.0 cleanup this isn't tied to a config version
bump, since affected instances are already on the latest schema - so it runs on
every setup and is a no-op once the entities are gone.
"""
registry = er.async_get(hass)
deprecated_uids = {f"{entry.entry_id}_{key}" for key in DEPRECATED_KEYS_FORECASTER}
for ent in list(registry.entities.values()):
if ent.config_entry_id != entry.entry_id:
continue
if ent.unique_id in deprecated_uids:
_LOGGER.info("Removing orphaned local-forecaster entity %s (unique_id=%s)", ent.entity_id, ent.unique_id)
registry.async_remove(ent.entity_id)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
from .coordinator import WSStationCoordinator

Expand All @@ -185,6 +206,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for name in ENTRY_REPAIR_ISSUES:
ir.async_delete_issue(hass, DOMAIN, name)

_async_remove_orphaned_forecaster_entities(hass, entry)

coordinator = WSStationCoordinator(hass, {**entry.data, "entry_id": entry.entry_id}, entry.options)
entry.runtime_data = coordinator

Expand Down
34 changes: 32 additions & 2 deletions custom_components/ws_core/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -18,8 +20,11 @@
DEFAULT_ENABLE_SNOW,
DEFAULT_PREFIX,
DOMAIN,
KEY_NOWCAST_FETCHED_AT,
KEY_NOWCAST_STALE,
KEY_PACKAGE_OK,
KEY_RAIN_EXPECTED_1H,
KEY_RAIN_EXPECTED_SOURCE,
KEY_SNOW_FALLING,
)

Expand Down Expand Up @@ -86,10 +91,16 @@ def is_on(self) -> bool | None:


class WSRainExpected1h(CoordinatorEntity, BinarySensorEntity):
"""True when measurable rain is expected within the next 60 minutes."""
"""True when measurable rain is expected within the next 60 minutes.

No BinarySensorDeviceClass fits a forecast flag: MOISTURE is reserved for
a wetness/leak sensor reporting a *current* physical condition, which this
is not - it's a nowcast prediction. Left without a device_class so HA
doesn't imply a leak-detection semantic that isn't there (see issue about
device_class MOISTURE misuse).
"""

_attr_has_entity_name = True
_attr_device_class = BinarySensorDeviceClass.MOISTURE

def __init__(self, coordinator, entry: ConfigEntry, prefix: str):
super().__init__(coordinator)
Expand All @@ -103,6 +114,16 @@ def __init__(self, coordinator, entry: ConfigEntry, prefix: str):
def device_info(self):
return {"identifiers": {(DOMAIN, self._entry.entry_id)}}

@property
def available(self) -> bool:
# Explicitly unavailable once the Open-Meteo nowcast is stale/failed
# and the local Zambretti fallback couldn't produce a value either,
# rather than silently holding the last (possibly hours-old) reading.
if not super().available:
return False
d = self.coordinator.data or {}
return d.get(KEY_RAIN_EXPECTED_1H) is not None

@property
def is_on(self) -> bool | None:
d = self.coordinator.data or {}
Expand All @@ -111,6 +132,15 @@ def is_on(self) -> bool | None:
return None
return bool(v)

@property
def extra_state_attributes(self) -> dict[str, Any]:
d = self.coordinator.data or {}
return {
"source": d.get(KEY_RAIN_EXPECTED_SOURCE),
"nowcast_fetched_at": d.get(KEY_NOWCAST_FETCHED_AT),
"nowcast_stale": d.get(KEY_NOWCAST_STALE),
}


class WSSnowFalling(CoordinatorEntity, BinarySensorEntity):
"""True when the estimated precipitation phase is snow. (v2.7, opt-in)
Expand Down
27 changes: 25 additions & 2 deletions custom_components/ws_core/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
CONF_ENABLE_WOW,
CONF_ENABLE_WUNDERGROUND,
CONF_ENABLE_ZAMBRETTI,
CONF_ET0_ILLUMINANCE_FALLBACK,
CONF_FORECAST_API_KEY,
CONF_FORECAST_ENABLED,
CONF_FORECAST_ENTITY,
Expand Down Expand Up @@ -147,6 +148,7 @@
DEFAULT_ENABLE_WINDY,
DEFAULT_ENABLE_WOW,
DEFAULT_ENABLE_WUNDERGROUND,
DEFAULT_ET0_ILLUMINANCE_FALLBACK,
DEFAULT_FORECAST_ENABLED,
DEFAULT_FORECAST_INTERVAL_MIN,
DEFAULT_FORECAST_PROVIDER,
Expand Down Expand Up @@ -1386,6 +1388,9 @@ async def async_step_solar_forecast(self, user_input: dict[str, Any] | None = No
self._data[CONF_SOLAR_INTERVAL_MIN] = int(
user_input.get(CONF_SOLAR_INTERVAL_MIN, DEFAULT_SOLAR_INTERVAL_MIN)
)
self._data[CONF_ET0_ILLUMINANCE_FALLBACK] = bool(
user_input.get(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK)
)
if self._data.get(CONF_ENABLE_VIGICRUES):
return await self.async_step_vigicrues_station()
if self._data.get(CONF_ENABLE_INDOOR):
Expand Down Expand Up @@ -1416,10 +1421,16 @@ async def async_step_solar_forecast(self, user_input: dict[str, Any] | None = No
vol.Optional(CONF_SOLAR_INTERVAL_MIN, default=DEFAULT_SOLAR_INTERVAL_MIN): selector.NumberSelector(
selector.NumberSelectorConfig(min=30, max=360, step=30, mode="box", unit_of_measurement="min")
),
vol.Optional(
CONF_ET0_ILLUMINANCE_FALLBACK, default=DEFAULT_ET0_ILLUMINANCE_FALLBACK
): selector.BooleanSelector(),
}
),
description_placeholders={
"info": "Free solar PV generation forecast from forecast.solar. Uses forecast lat/lon. Azimuth: 0=N, 90=E, 180=S, 270=W."
"info": "Free solar PV generation forecast from forecast.solar. Uses forecast lat/lon. Azimuth: 0=N, 90=E, 180=S, 270=W. "
"The illuminance fallback derives an approximate solar radiation from a lux sensor for the ET₀ "
"Penman-Monteith sensor when no solar_radiation source is mapped - lower confidence than a real "
"pyranometer reading, and only used if an illuminance sensor is also mapped in Sources."
},
last_step=False,
)
Expand Down Expand Up @@ -2320,6 +2331,9 @@ async def async_step_solar_forecast_opt(self, user_input: dict[str, Any] | None
self._opt[CONF_SOLAR_INTERVAL_MIN] = int(
user_input.get(CONF_SOLAR_INTERVAL_MIN, DEFAULT_SOLAR_INTERVAL_MIN)
)
self._opt[CONF_ET0_ILLUMINANCE_FALLBACK] = bool(
user_input.get(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK)
)
return await self._finish_or_next("solar_forecast_opt")
return self.async_show_form(
step_id="solar_forecast_opt",
Expand Down Expand Up @@ -2351,9 +2365,18 @@ async def async_step_solar_forecast_opt(self, user_input: dict[str, Any] | None
): selector.NumberSelector(
selector.NumberSelectorConfig(min=30, max=360, step=30, mode="box", unit_of_measurement="min")
),
vol.Optional(
CONF_ET0_ILLUMINANCE_FALLBACK,
default=g(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK),
): selector.BooleanSelector(),
}
),
description_placeholders={"info": "Azimuth: 0=N, 90=E, 180=S, 270=W. Tilt: degrees from horizontal."},
description_placeholders={
"info": "Azimuth: 0=N, 90=E, 180=S, 270=W. Tilt: degrees from horizontal. "
"The illuminance fallback derives an approximate solar radiation from a lux sensor for the ET₀ "
"Penman-Monteith sensor when no solar_radiation source is mapped - lower confidence than a real "
"pyranometer reading, and only used if an illuminance sensor is also mapped in Sources."
},
last_step=False,
)

Expand Down
37 changes: 37 additions & 0 deletions custom_components/ws_core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,18 @@
DEFAULT_SOLAR_PANEL_TILT = 30 # degrees from horizontal
DEFAULT_SOLAR_INTERVAL_MIN = 60

# Penman-Monteith ET₀ - optional lux-derived solar radiation fallback.
# Used when no solar_radiation source is mapped but an illuminance sensor is.
# 0.0079 W/m² per lux is a widely-cited rule-of-thumb luminous efficacy for
# daylight (~126 lm/W); actual efficacy varies roughly +/-25% with sky
# conditions (clear vs overcast) and spectrum (sun elevation), so ET₀ derived
# this way is an estimate, not a pyranometer-grade measurement.
CONF_ET0_ILLUMINANCE_FALLBACK = "et0_illuminance_fallback"
DEFAULT_ET0_ILLUMINANCE_FALLBACK = False
LUX_TO_WM2_FACTOR = 0.0079
ET0_RADIATION_SOURCE_SENSOR = "sensor"
ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE = "illuminance_estimate"

# ---------------------------------------------------------------------------
# Data keys - v0.7.0 Air Quality
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -468,6 +480,7 @@
KEY_SOLAR_FORECAST_TOMORROW_KWH = "solar_forecast_tomorrow_kwh"
KEY_SOLAR_FORECAST_STATUS = "solar_forecast_status"
KEY_ET0_PM_DAILY_MM = "et0_pm_daily_mm" # Penman-Monteith (when solar available)
KEY_ET0_PM_RADIATION_SOURCE = "et0_pm_radiation_source" # "sensor" or "illuminance_estimate"

# ---------------------------------------------------------------------------
# v1.2.0 - Self-learning, new met sensors, station intelligence, climatology
Expand Down Expand Up @@ -675,6 +688,11 @@
DEFAULT_ENABLE_NOWCAST = False
DEFAULT_NOWCAST_INTERVAL_MIN = 15

# The Open-Meteo minutely_15 nowcast is considered stale (and the rain-expected
# entity falls back to the local Zambretti forecast, or unavailable) once its
# cache is older than this many fetch intervals.
NOWCAST_STALE_MULTIPLIER = 2

# Suppress HA Repairs notifications (issue #20)
CONF_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
DEFAULT_SUPPRESS_NOTIFICATIONS = False
Expand All @@ -686,6 +704,14 @@
KEY_NOWCAST_INTENSITY = "nowcast_intensity"
KEY_NOWCAST_CONFIDENCE = "nowcast_confidence" # "high", "medium", "low"
KEY_RAIN_EXPECTED_1H = "rain_expected_1h"
KEY_NOWCAST_FETCHED_AT = "_nowcast_fetched_at"
KEY_NOWCAST_STALE = "nowcast_stale"
KEY_RAIN_EXPECTED_SOURCE = "rain_expected_source" # "nowcast" or "local_fallback"
RAIN_EXPECTED_SOURCE_NOWCAST = "nowcast"
RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK = "local_fallback"
# Z-number implied rain probability (%) at/above which the Zambretti-derived
# local fallback reports rain expected. Coarser than the 15-min NWP nowcast.
ZAMBRETTI_FALLBACK_RAIN_THRESHOLD_PCT = 50

# ---------------------------------------------------------------------------
# v2.0 - Degree days + leaf wetness (new agrometeorological group)
Expand Down Expand Up @@ -1075,6 +1101,17 @@ def normalize_indoor_rooms(raw: Any) -> list[dict]:
"time_since_rain",
)

# ---------------------------------------------------------------------------
# Orphaned entities left behind by the v2.0 AI/local-forecaster work that was
# stripped from the public release before it shipped (see v1.8.0 changelog).
# Some instances that briefly ran a pre-release build still carry these in
# their entity registry as restored/unavailable. Removed on every setup.
# ---------------------------------------------------------------------------
DEPRECATED_KEYS_FORECASTER = (
"enable_local_forecaster",
"forecaster_learning_rate",
)

DEPRECATED_CONF_KEYS_V030 = (
"enable_laundry_score",
"enable_stargazing_score",
Expand Down
Loading
Loading