This commit is contained in:
Home Assistant Version Control
2026-08-20 15:12:43 +00:00
parent 33669f8f1e
commit 6644b9e4a8
19 changed files with 285 additions and 110 deletions
@@ -105,9 +105,9 @@ class PirateWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
latitude = user_input[CONF_LATITUDE] latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE] longitude = user_input[CONF_LONGITUDE]
forecastMode = "daily" forecast_mode = "daily"
forecastPlatform = user_input[PW_PLATFORM] forecast_platform = user_input[PW_PLATFORM]
entityNamee = user_input[CONF_NAME] entity_name = user_input[CONF_NAME]
endpoint = user_input[CONF_ENDPOINT] endpoint = user_input[CONF_ENDPOINT]
# Convert scan interval to timedelta # Convert scan interval to timedelta
@@ -125,7 +125,7 @@ class PirateWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
# Unique value includes the location and forcastHours/ forecastDays to seperate WeatherEntity/ Sensor # Unique value includes the location and forcastHours/ forecastDays to seperate WeatherEntity/ Sensor
# await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastDays}-{forecastHours}-{forecastMode}-{entityNamee}") # await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastDays}-{forecastHours}-{forecastMode}-{entityNamee}")
await self.async_set_unique_id( await self.async_set_unique_id(
f"pw-{latitude}-{longitude}-{forecastPlatform}-{forecastMode}-{entityNamee}" f"pw-{latitude}-{longitude}-{forecast_platform}-{forecast_mode}-{entity_name}"
) )
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@@ -328,8 +328,10 @@ class PirateWeatherOptionsFlow(OptionsFlow):
async def _is_pw_api_online(hass, api_key, lat, lon, endpoint): async def _is_pw_api_online(hass, api_key, lat, lon, endpoint):
forecastString = endpoint + "/forecast/" + api_key + "/" + str(lat) + "," + str(lon) forecast_string = (
endpoint + "/forecast/" + api_key + "/" + str(lat) + "," + str(lon)
)
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
async with session.get(forecastString) as resp: async with session.get(forecast_string) as resp:
return resp.status return resp.status
@@ -26,8 +26,8 @@ class Forecast(UnicodeMixin):
self.json = data self.json = data
self._alerts = [] self._alerts = []
for alertJSON in self.json.get("alerts", []): for alert_json in self.json.get("alerts", []):
self._alerts.append(Alert(alertJSON)) self._alerts.append(Alert(alert_json))
def update(self): def update(self):
"""Update the forecast data by making a new request to the same URL.""" """Update the forecast data by making a new request to the same URL."""
@@ -135,24 +135,24 @@ class PirateWeatherFlagsBlock(UnicodeMixin):
class PirateWeatherDataPoint(UnicodeMixin): class PirateWeatherDataPoint(UnicodeMixin):
"""Represent a single data point in a weather forecast, such as an hourly or daily data point.""" """Represent a single data point in a weather forecast, such as an hourly or daily data point."""
def __init__(self, d={}): def __init__(self, d=None):
"""Initialize the data point with timestamp and weather information.""" """Initialize the data point with timestamp and weather information."""
self.d = d self.d = d or {}
try: try:
self.time = datetime.datetime.fromtimestamp(int(d["time"])) self.time = datetime.datetime.fromtimestamp(int(self.d["time"]))
self.utime = d["time"] self.utime = self.d["time"]
except KeyError: except KeyError:
pass pass
try: try:
sr_time = int(d["sunriseTime"]) sr_time = int(self.d["sunriseTime"])
self.sunriseTime = datetime.datetime.fromtimestamp(sr_time) self.sunriseTime = datetime.datetime.fromtimestamp(sr_time)
except KeyError: except KeyError:
self.sunriseTime = None self.sunriseTime = None
try: try:
ss_time = int(d["sunsetTime"]) ss_time = int(self.d["sunsetTime"])
self.sunsetTime = datetime.datetime.fromtimestamp(ss_time) self.sunsetTime = datetime.datetime.fromtimestamp(ss_time)
except KeyError: except KeyError:
self.sunsetTime = None self.sunsetTime = None
@@ -10,5 +10,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/alexander0042/pirate-weather-ha/issues", "issue_tracker": "https://github.com/alexander0042/pirate-weather-ha/issues",
"version": "1.9.0" "version": "1.9.1"
} }
+62 -62
View File
@@ -17,7 +17,6 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONF_API_KEY, CONF_API_KEY,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
@@ -27,6 +26,7 @@ from homeassistant.const import (
DEGREE, DEGREE,
PERCENTAGE, PERCENTAGE,
UV_INDEX, UV_INDEX,
UnitOfDensity,
UnitOfIrradiance, UnitOfIrradiance,
UnitOfLength, UnitOfLength,
UnitOfPressure, UnitOfPressure,
@@ -451,11 +451,11 @@ SENSOR_TYPES: dict[str, PirateWeatherSensorEntityDescription] = {
name="Smoke", name="Smoke",
device_class=SensorDeviceClass.PM25, device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
si_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, si_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
us_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, us_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
ca_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ca_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, uk_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk2_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, uk2_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2, suggested_display_precision=2,
icon="mdi:smoke", icon="mdi:smoke",
forecast_mode=["currently", "hourly"], forecast_mode=["currently", "hourly"],
@@ -465,11 +465,11 @@ SENSOR_TYPES: dict[str, PirateWeatherSensorEntityDescription] = {
name="Smoke Max", name="Smoke Max",
device_class=SensorDeviceClass.PM25, device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
si_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, si_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
us_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, us_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
ca_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ca_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, uk_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk2_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, uk2_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2, suggested_display_precision=2,
icon="mdi:smoke", icon="mdi:smoke",
forecast_mode=["daily"], forecast_mode=["daily"],
@@ -1034,22 +1034,22 @@ async def async_setup_entry(
service_id = config_entry.unique_id or config_entry.entry_id service_id = config_entry.unique_id or config_entry.entry_id
# Round Output # Round Output
outputRound = domain_data[PW_ROUND] output_round = domain_data[PW_ROUND]
sensors: list[PirateWeatherSensor] = [] sensors: list[PirateWeatherSensor] = []
for condition in conditions: for condition in conditions:
# Save units for conversion later # Save units for conversion later
requestUnits = domain_data[CONF_UNITS] request_units = domain_data[CONF_UNITS]
sensorDescription = SENSOR_TYPES[condition] sensor_description = SENSOR_TYPES[condition]
if condition in DEPRECATED_SENSOR_TYPES: if condition in DEPRECATED_SENSOR_TYPES:
_LOGGER.warning("Monitored condition %s is deprecated", condition) _LOGGER.warning("Monitored condition %s is deprecated", condition)
if ( if (
not sensorDescription.forecast_mode not sensor_description.forecast_mode
or "currently" in sensorDescription.forecast_mode or "currently" in sensor_description.forecast_mode
): ):
unique_id = f"{config_entry.unique_id}-sensor-{condition}" unique_id = f"{config_entry.unique_id}-sensor-{condition}"
sensors.append( sensors.append(
@@ -1060,14 +1060,14 @@ async def async_setup_entry(
unique_id, unique_id,
forecast_day=None, forecast_day=None,
forecast_hour=None, forecast_hour=None,
description=sensorDescription, description=sensor_description,
requestUnits=requestUnits, request_units=request_units,
outputRound=outputRound, output_round=output_round,
service_id=service_id, service_id=service_id,
) )
) )
if forecast_days is not None and "daily" in sensorDescription.forecast_mode: if forecast_days is not None and "daily" in sensor_description.forecast_mode:
for forecast_day in forecast_days: for forecast_day in forecast_days:
unique_id = ( unique_id = (
f"{config_entry.unique_id}-sensor-{condition}-daily-{forecast_day}" f"{config_entry.unique_id}-sensor-{condition}-daily-{forecast_day}"
@@ -1080,14 +1080,14 @@ async def async_setup_entry(
unique_id, unique_id,
forecast_day=int(forecast_day), forecast_day=int(forecast_day),
forecast_hour=None, forecast_hour=None,
description=sensorDescription, description=sensor_description,
requestUnits=requestUnits, request_units=request_units,
outputRound=outputRound, output_round=output_round,
service_id=service_id, service_id=service_id,
) )
) )
if forecast_hours is not None and "hourly" in sensorDescription.forecast_mode: if forecast_hours is not None and "hourly" in sensor_description.forecast_mode:
for forecast_h in forecast_hours: for forecast_h in forecast_hours:
unique_id = ( unique_id = (
f"{config_entry.unique_id}-sensor-{condition}-hourly-{forecast_h}" f"{config_entry.unique_id}-sensor-{condition}-hourly-{forecast_h}"
@@ -1100,9 +1100,9 @@ async def async_setup_entry(
unique_id, unique_id,
forecast_day=None, forecast_day=None,
forecast_hour=int(forecast_h), forecast_hour=int(forecast_h),
description=sensorDescription, description=sensor_description,
requestUnits=requestUnits, request_units=request_units,
outputRound=outputRound, output_round=output_round,
service_id=service_id, service_id=service_id,
) )
) )
@@ -1117,7 +1117,7 @@ class PirateWeatherSensor(SensorEntity):
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
entity_description: PirateWeatherSensorEntityDescription entity_description: PirateWeatherSensorEntityDescription
def __init__( def __init__( # noqa: PLR0917
self, self,
weather_coordinator: WeatherUpdateCoordinator, weather_coordinator: WeatherUpdateCoordinator,
condition: str, condition: str,
@@ -1126,8 +1126,8 @@ class PirateWeatherSensor(SensorEntity):
forecast_day: int, forecast_day: int,
forecast_hour: int, forecast_hour: int,
description: PirateWeatherSensorEntityDescription, description: PirateWeatherSensorEntityDescription,
requestUnits: str, request_units: str,
outputRound: str, output_round: str,
service_id: str, service_id: str,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
@@ -1149,8 +1149,8 @@ class PirateWeatherSensor(SensorEntity):
self.forecast_day = forecast_day self.forecast_day = forecast_day
self.forecast_hour = forecast_hour self.forecast_hour = forecast_hour
self.requestUnits = requestUnits self.request_units = request_units
self.outputRound = outputRound self.output_round = output_round
self.type = condition self.type = condition
self._icon = None self._icon = None
self._alerts = None self._alerts = None
@@ -1188,7 +1188,7 @@ class PirateWeatherSensor(SensorEntity):
@property @property
def unit_system(self): def unit_system(self):
"""Return the unit system of this entity.""" """Return the unit system of this entity."""
return self.requestUnits return self.request_units
@property @property
def entity_picture(self) -> str | None: def entity_picture(self) -> str | None:
@@ -1223,11 +1223,11 @@ class PirateWeatherSensor(SensorEntity):
def extra_state_attributes(self): def extra_state_attributes(self):
"""Return the state attributes.""" """Return the state attributes."""
if self.type == "alerts": if self.type == "alerts":
extraATTR = self._alerts extra_attr = self._alerts
extraATTR[ATTR_ATTRIBUTION] = ATTRIBUTION extra_attr[ATTR_ATTRIBUTION] = ATTRIBUTION
else: else:
extraATTR = {ATTR_ATTRIBUTION: ATTRIBUTION} extra_attr = {ATTR_ATTRIBUTION: ATTRIBUTION}
return extraATTR return extra_attr
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
@@ -1249,15 +1249,15 @@ class PirateWeatherSensor(SensorEntity):
dkey = f"{attr}_{i!s}" dkey = f"{attr}_{i!s}"
else: else:
dkey = attr dkey = attr
alertsAttr = getattr(alert, attr) alerts_attr = getattr(alert, attr)
# Convert time to string using dt_util # Convert time to string using dt_util
if isinstance(alertsAttr, int): if isinstance(alerts_attr, int):
alertsAttr = dt_util.as_local( alerts_attr = dt_util.as_local(
dt_util.utc_from_timestamp(alertsAttr) dt_util.utc_from_timestamp(alerts_attr)
).isoformat() ).isoformat()
alerts[dkey] = alertsAttr alerts[dkey] = alerts_attr
self._alerts = alerts self._alerts = alerts
native_val = len(data) native_val = len(data)
@@ -1342,12 +1342,12 @@ class PirateWeatherSensor(SensorEntity):
self._icon = getattr(data, "icon", "") self._icon = getattr(data, "icon", "")
# If output rounding is requested, round to nearest integer # If output rounding is requested, round to nearest integer
if self.outputRound == "Yes": if self.output_round == "Yes":
roundingVal = 0 rounding_val = 0
roundingPrecip = 2 rounding_precip = 2
else: else:
roundingVal = 2 rounding_val = 2
roundingPrecip = 4 rounding_precip = 4
# Some state data needs to be rounded to whole values or converted to # Some state data needs to be rounded to whole values or converted to
# percentages # percentages
@@ -1364,10 +1364,10 @@ class PirateWeatherSensor(SensorEntity):
"sunset_time", "sunset_time",
"time", "time",
]: ]:
outState = datetime.datetime.fromtimestamp(state, datetime.UTC) out_state = datetime.datetime.fromtimestamp(state, datetime.UTC)
elif self.type == "fire_risk_level": elif self.type == "fire_risk_level":
outState = fire_index(state) out_state = fire_index(state)
elif self.type in [ elif self.type in [
"dew_point", "dew_point",
"temperature", "temperature",
@@ -1395,10 +1395,10 @@ class PirateWeatherSensor(SensorEntity):
"solar", "solar",
"solar_max", "solar_max",
]: ]:
if roundingVal == 0: if rounding_val == 0:
outState = int(round(state, roundingVal)) out_state = int(round(state, rounding_val))
else: else:
outState = round(state, roundingVal) out_state = round(state, rounding_val)
elif self.type in [ elif self.type in [
"precip_accumulation", "precip_accumulation",
@@ -1423,12 +1423,12 @@ class PirateWeatherSensor(SensorEntity):
and self.unit_system != "us" and self.unit_system != "us"
): ):
state = state * 10 state = state * 10
outState = round(state, roundingPrecip) out_state = round(state, rounding_precip)
else: else:
outState = state out_state = state
return outState return out_state
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Connect to dispatcher listening for entity data notifications.""" """Connect to dispatcher listening for entity data notifications."""
@@ -1455,16 +1455,16 @@ def fire_index(fire_index):
"""Convert numeric fire index to a textual value.""" """Convert numeric fire index to a textual value."""
if fire_index == -999: if fire_index == -999:
outState = "N/A" out_state = "N/A"
elif fire_index >= 30: elif fire_index >= 30:
outState = "Extreme" out_state = "Extreme"
elif fire_index >= 20: elif fire_index >= 20:
outState = "Very High" out_state = "Very High"
elif fire_index >= 10: elif fire_index >= 10:
outState = "High" out_state = "High"
elif fire_index >= 5: elif fire_index >= 5:
outState = "Moderate" out_state = "Moderate"
else: else:
outState = "Low" out_state = "Low"
return outState return out_state
+15 -13
View File
@@ -267,10 +267,10 @@ async def async_setup_entry(
service_id = config_entry.unique_id or config_entry.entry_id service_id = config_entry.unique_id or config_entry.entry_id
# Round Output # Round Output
outputRound = domain_data[PW_ROUND] output_round = domain_data[PW_ROUND]
pw_weather = PirateWeather( pw_weather = PirateWeather(
name, unique_id, forecast_mode, weather_coordinator, outputRound, service_id name, unique_id, forecast_mode, weather_coordinator, output_round, service_id
) )
async_add_entities([pw_weather], False) async_add_entities([pw_weather], False)
@@ -288,13 +288,13 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
| WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_HOURLY
) )
def __init__( def __init__( # noqa: PLR0917
self, self,
name: str, name: str,
unique_id, unique_id,
forecast_mode: str, forecast_mode: str,
weather_coordinator: WeatherUpdateCoordinator, weather_coordinator: WeatherUpdateCoordinator,
outputRound: str, output_round: str,
service_id: str, service_id: str,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
@@ -315,7 +315,7 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
self._ds_hourly = self._weather_coordinator.data.hourly() self._ds_hourly = self._weather_coordinator.data.hourly()
self._ds_daily = self._weather_coordinator.data.daily() self._ds_daily = self._weather_coordinator.data.daily()
self.outputRound = outputRound self.output_round = output_round
units = WEATHER_UNITS.get( units = WEATHER_UNITS.get(
self._weather_coordinator.requested_units, WEATHER_UNITS["si"] self._weather_coordinator.requested_units, WEATHER_UNITS["si"]
@@ -369,11 +369,13 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
@property @property
def cloud_coverage(self): def cloud_coverage(self):
"""Return the cloud coverage.""" """Return the cloud coverage."""
cloudCover = ( cloud_cover = self._weather_coordinator.data.currently().d.get("cloudCover")
self._weather_coordinator.data.currently().d.get("cloudCover") * 100.0
)
return round(cloudCover, 2) if cloudCover != -999 else None return (
round(cloud_cover * 100, 2)
if cloud_cover is not None and cloud_cover != -999
else None
)
@property @property
def humidity(self): def humidity(self):
@@ -399,16 +401,16 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
@property @property
def native_wind_gust_speed(self): def native_wind_gust_speed(self):
"""Return the wind gust speed.""" """Return the wind gust speed."""
windGust = self._weather_coordinator.data.currently().d.get("windGust") wind_gust = self._weather_coordinator.data.currently().d.get("windGust")
return round(windGust, 2) if windGust != -999 else None return round(wind_gust, 2) if wind_gust != -999 else None
@property @property
def wind_bearing(self): def wind_bearing(self):
"""Return the wind bearing.""" """Return the wind bearing."""
windBearing = self._weather_coordinator.data.currently().d.get("windBearing") wind_bearing = self._weather_coordinator.data.currently().d.get("windBearing")
return windBearing if windBearing != -999 else None return wind_bearing if wind_bearing != -999 else None
@property @property
def ozone(self): def ozone(self):
@@ -21,7 +21,7 @@ ATTRIBUTION = "Powered by Pirate Weather"
class WeatherUpdateCoordinator(DataUpdateCoordinator): class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator.""" """Weather data update coordinator."""
def __init__( def __init__( # noqa: PLR0917
self, self,
api_key, api_key,
latitude, latitude,
@@ -72,25 +72,27 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Poll weather data from PW.""" """Poll weather data from PW."""
if self.latitude == 0.0: if self.latitude == 0.0:
requestLatitude = self.hass.config.latitude request_latitude = self.hass.config.latitude
else: else:
requestLatitude = self.latitude request_latitude = self.latitude
if self.longitude == 0.0: if self.longitude == 0.0:
requestLongitude = self.hass.config.longitude request_longitude = self.hass.config.longitude
else: else:
requestLongitude = self.longitude request_longitude = self.longitude
_LOGGER.debug("Request coordinates: %s, %s", requestLatitude, requestLongitude) _LOGGER.debug(
"Request coordinates: %s, %s", request_latitude, request_longitude
)
forecastString = ( forecast_string = (
self.endpoint self.endpoint
+ "/forecast/" + "/forecast/"
+ self._api_key + self._api_key
+ "/" + "/"
+ str(requestLatitude) + str(request_latitude)
+ "," + ","
+ str(requestLongitude) + str(request_longitude)
+ "?units=" + "?units="
+ self.requested_units + self.requested_units
+ "&extend=hourly" + "&extend=hourly"
@@ -104,12 +106,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
m.strip() for m in self.models.split(",") if m.strip() m.strip() for m in self.models.split(",") if m.strip()
) )
if exclusions: if exclusions:
forecastString += "&exclude=" + exclusions forecast_string += "&exclude=" + exclusions
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
async with session.get(forecastString) as resp: async with session.get(forecast_string) as resp:
resp.raise_for_status() resp.raise_for_status()
jsonText = await resp.json() json_text = await resp.json()
headers = resp.headers headers = resp.headers
_LOGGER.debug("Pirate Weather data update from: %s", self.endpoint) _LOGGER.debug("Pirate Weather data update from: %s", self.endpoint)
return Forecast(jsonText, resp, headers) return Forecast(json_text, resp, headers)
@@ -205,6 +205,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Instantiate all features manager # Instantiate all features manager
self._managers: list[BaseFeatureManager] = [] self._managers: list[BaseFeatureManager] = []
# Names of feature managers provided by external plugins that have
# already been instantiated for this thermostat. Used to avoid
# registering the same external manager twice (post_init + startup retry).
self._external_manager_names: set[str] = set()
# Instances of feature managers provided by external plugins. They are
# also present in ``self._managers`` (for lifecycle) but are tracked
# separately so they can be refreshed on every control cycle (internal
# managers have their own dedicated per-cycle mechanisms).
self._external_managers: list[BaseFeatureManager] = []
self._presence_manager: FeaturePresenceManager = FeaturePresenceManager( self._presence_manager: FeaturePresenceManager = FeaturePresenceManager(
self, hass self, hass
) )
@@ -237,6 +248,42 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Register a manager""" """Register a manager"""
self._managers.append(manager) self._managers.append(manager)
def _load_external_feature_managers(self):
"""Instantiate feature managers provided by external plugins.
Query the VTherm API registry and create one manager instance per
eligible thermostat (the factory decides eligibility through its
``supports`` method). Managers whose plugin registers after this
thermostat has been built are picked up later during ``async_startup``,
mirroring the external proportional algorithm retry behavior.
"""
api = VersatileThermostatAPI.get_vtherm_api(self.hass)
if api is None or not hasattr(api, "get_feature_manager_factories"):
return
for factory in api.get_feature_manager_factories():
name = factory.name
if name in self._external_manager_names:
continue
try:
if not factory.supports(self):
continue
manager = factory.create(self)
manager.post_init(self._entry_infos)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.error(
"%s - Error while creating external feature manager '%s': %s",
self,
name,
exc,
)
continue
self.register_manager(manager)
self._external_manager_names.add(name)
self._external_managers.append(manager)
_LOGGER.info("%s - Registered external feature manager '%s'", self, name)
def clean_central_config_doublon( def clean_central_config_doublon(
self, config_entry: ConfigData, central_config: ConfigEntry | None self, config_entry: ConfigData, central_config: ConfigEntry | None
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -308,6 +355,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
for manager in self._managers: for manager in self._managers:
manager.post_init(entry_infos) manager.post_init(entry_infos)
# Instantiate feature managers provided by external plugins that are
# already registered at this point. Late-registered plugins are handled
# by a retry in async_startup.
self._load_external_feature_managers()
self._use_central_config_temperature = entry_infos.get( self._use_central_config_temperature = entry_infos.get(
CONF_USE_PRESETS_CENTRAL_CONFIG CONF_USE_PRESETS_CENTRAL_CONFIG
) or ( ) or (
@@ -487,6 +539,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.debug("%s - Calling async_startup_internal", self) _LOGGER.debug("%s - Calling async_startup_internal", self)
# need_write_state = False # need_write_state = False
# Retry loading external feature managers: a plugin may have registered
# its factory after this thermostat was built (load order between the
# core and the plugin is not guaranteed). Newly created managers are
# then started in the loop below.
self._load_external_feature_managers()
# start listening for all managers # start listening for all managers
for manager in self._managers: for manager in self._managers:
await manager.start_listening() await manager.start_listening()
@@ -898,6 +956,30 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Return the temperature we try to reach.""" """Return the temperature we try to reach."""
return self._state_manager.current_state.target_temperature return self._state_manager.current_state.target_temperature
@property
def regulated_target_temperature(self) -> float | None:
"""Return the regulated target temperature used to drive the underlying.
The base implementation returns the plain target temperature. Over
climate thermostats override this to expose their regulated value.
"""
return self.target_temperature
@property
def underlying_fan_modes(self) -> list[str] | None:
"""Return the fan modes exposed by the underlying climate(s).
The base implementation returns None because most thermostats do not
expose an underlying climate with fan control.
"""
return None
async def async_set_underlying_fan_mode(self, fan_mode: str) -> None:
"""Send a fan mode to the underlying climate(s).
The base implementation is a no-op for thermostats without fan control.
"""
@property @property
def supported_features(self) -> ClimateEntityFeature: def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features.""" """Return the list of supported features."""
@@ -1609,6 +1691,25 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Call specific control heating # Call specific control heating
await self._control_heating_specific(timestamp, force) await self._control_heating_specific(timestamp, force)
# Refresh external feature managers (provided by plugins) on every cycle.
# This is done after the specific control heating so that a manager can
# react to the regulated target temperature (e.g. drive the underlying
# fan mode), and before the publication block below so that any custom
# attributes updated here are written to HA within the same cycle.
# Only external managers are refreshed here: internal managers have their
# own dedicated per-cycle mechanisms. A per-manager guard prevents a
# faulty plugin from breaking the control loop.
for manager in self._external_managers:
try:
await manager.refresh_state()
except Exception as exc: # pylint: disable=broad-except
_LOGGER.error(
"%s - Error while refreshing external feature manager '%s': %s",
self,
manager.name,
exc,
)
# Check for heating/cooling failures (only for TPI VTherms) # Check for heating/cooling failures (only for TPI VTherms)
await self._heating_failure_detection_manager.refresh_state() await self._heating_failure_detection_manager.refresh_state()
@@ -1884,7 +1985,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self._state_manager.add_custom_attributes(self._attr_extra_state_attributes) self._state_manager.add_custom_attributes(self._attr_extra_state_attributes)
for manager in self._managers: for manager in self._managers:
manager.add_custom_attributes(self._attr_extra_state_attributes) # add_custom_attributes is optional for external feature managers
# (it is not part of the InterfaceFeatureManager contract), so call
# it defensively.
publish_attributes = getattr(manager, "add_custom_attributes", None)
if callable(publish_attributes):
publish_attributes(self._attr_extra_state_attributes)
def send_event(self, event_type: EventType, data: dict): def send_event(self, event_type: EventType, data: dict):
"""Send an event""" """Send an event"""
@@ -125,7 +125,7 @@ async def async_setup_entry(
["None", "Low", "Medium", "High", "Turbo"] ["None", "Low", "Medium", "High", "Turbo"]
), ),
}, },
"service_set_auto_fan_mode", "service_set_auto_fan_mode_deprecated",
) )
platform.async_register_entity_service( platform.async_register_entity_service(
@@ -19,9 +19,9 @@
"requirements": [ "requirements": [
"numpy", "numpy",
"scipy", "scipy",
"vtherm_api>=0.3.0" "vtherm_api>=0.4.0"
], ],
"ssdp": [], "ssdp": [],
"version": "10.1.0", "version": "10.2.0",
"zeroconf": [] "zeroconf": []
} }
@@ -113,7 +113,7 @@ set_auto_regulation_mode:
set_auto_fan_mode: set_auto_fan_mode:
name: Set Auto Fan mode name: Set Auto Fan mode
description: Change the mode of auto-fan (only for VTherm over climate) description: Deprecated when using the vtherm_auto_fan_extended plugin. Without the plugin, legacy core behavior is kept.
target: target:
entity: entity:
integration: versatile_thermostat integration: versatile_thermostat
@@ -8,6 +8,7 @@ from datetime import timedelta, datetime
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval, EventStateChangedData, async_call_later from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval, EventStateChangedData, async_call_later
from homeassistant.components.climate import ( from homeassistant.components.climate import (
HVACAction, HVACAction,
@@ -27,6 +28,9 @@ from .vtherm_hvac_mode import VThermHvacMode
_LOGGER = get_vtherm_logger(__name__) _LOGGER = get_vtherm_logger(__name__)
AUTO_FAN_PLUGIN_DOMAIN = "vtherm_auto_fan_extended"
AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY = "target_vtherm_unique_id"
HVAC_ACTION_ON = [ # pylint: disable=invalid-name HVAC_ACTION_ON = [ # pylint: disable=invalid-name
HVACAction.COOLING, HVACAction.COOLING,
HVACAction.DRYING, HVACAction.DRYING,
@@ -465,8 +469,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
return None return None
def determine_fan_mode_contains_speed(fan_modes: list[str]) -> bool: def determine_fan_mode_contains_speed(fan_modes: list[str]) -> bool:
"""Determine if the fan_modes contains speed modes by searching for the keywords "low"/"1"/"one"/"speed_1".""" """Determine if the fan_modes contains speed modes by searching for the keywords "low"/"1"/"one"/"speed_1"/"on_low"."""
for val in ["low", "1", "one", "speed_1"]: for val in ["low", "1", "one", "speed_1", "on_low"]:
if find_fan_mode(fan_modes, val): if find_fan_mode(fan_modes, val):
return True return True
return False return False
@@ -482,6 +486,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
index = speed_modes.index("one") index = speed_modes.index("one")
elif "speed_1" in speed_modes: elif "speed_1" in speed_modes:
index = speed_modes.index("speed_1") index = speed_modes.index("speed_1")
elif "on_low" in speed_modes:
index = speed_modes.index("on_low")
if index > -1 and index >= len(speed_modes) / 2: if index > -1 and index >= len(speed_modes) / 2:
speed_modes.reverse() speed_modes.reverse()
@@ -1008,6 +1014,20 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Get the regulated target temperature""" """Get the regulated target temperature"""
return self._regulated_target_temp return self._regulated_target_temp
@property
def regulated_target_temperature(self) -> float | None:
"""Return the regulated target temperature used to drive the underlying."""
return self._regulated_target_temp
@property
def underlying_fan_modes(self) -> list[str] | None:
"""Return the fan modes exposed by the underlying climate(s)."""
return self.fan_modes
async def async_set_underlying_fan_mode(self, fan_mode: str) -> None:
"""Send a fan mode to the underlying climate(s)."""
await self.async_set_fan_mode(fan_mode)
@property @property
def is_regulated(self) -> bool: def is_regulated(self) -> bool:
"""Check if the ThermostatOverClimate is regulated""" """Check if the ThermostatOverClimate is regulated"""
@@ -1306,6 +1326,49 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
self.update_custom_attributes() self.update_custom_attributes()
self.async_write_ha_state() self.async_write_ha_state()
async def service_set_auto_fan_mode_deprecated(self, auto_fan_mode: str):
"""Deprecated service handler for auto fan mode.
Auto fan is now managed by the vtherm_auto_fan_extended plugin.
"""
config_entries = getattr(self._hass, "config_entries", None)
plugin_is_configured = False
if config_entries is not None:
try:
plugin_entries = config_entries.async_entries(AUTO_FAN_PLUGIN_DOMAIN)
except Exception: # pylint: disable=broad-except
plugin_entries = []
# If at least one entry targets this VTherm, plugin takes ownership.
plugin_is_configured = any(
entry.data.get(AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY) == self.unique_id
for entry in plugin_entries
)
# Backward compatibility: old entries without target still mean plugin ownership.
if not plugin_is_configured and plugin_entries:
plugin_is_configured = any(
AUTO_FAN_PLUGIN_TARGET_VTHERM_KEY not in entry.data
for entry in plugin_entries
)
# If plugin is not configured for this VTherm, keep legacy core behavior.
if not plugin_is_configured:
await self.service_set_auto_fan_mode(auto_fan_mode)
return
_LOGGER.warning(
"%s - set_auto_fan_mode is deprecated in core and managed by the "
"vtherm_auto_fan_extended plugin (requested: %s)",
self,
auto_fan_mode,
)
raise HomeAssistantError(
"The set_auto_fan_mode service is now managed by the "
"vtherm_auto_fan_extended plugin and is no longer available in "
"Versatile Thermostat core."
)
@overrides @overrides
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn off the climate entity.""" """Turn off the climate entity."""