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:
latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE]
forecastMode = "daily"
forecastPlatform = user_input[PW_PLATFORM]
entityNamee = user_input[CONF_NAME]
forecast_mode = "daily"
forecast_platform = user_input[PW_PLATFORM]
entity_name = user_input[CONF_NAME]
endpoint = user_input[CONF_ENDPOINT]
# 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
# await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastDays}-{forecastHours}-{forecastMode}-{entityNamee}")
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()
@@ -328,8 +328,10 @@ class PirateWeatherOptionsFlow(OptionsFlow):
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)
async with session.get(forecastString) as resp:
async with session.get(forecast_string) as resp:
return resp.status
@@ -26,8 +26,8 @@ class Forecast(UnicodeMixin):
self.json = data
self._alerts = []
for alertJSON in self.json.get("alerts", []):
self._alerts.append(Alert(alertJSON))
for alert_json in self.json.get("alerts", []):
self._alerts.append(Alert(alert_json))
def update(self):
"""Update the forecast data by making a new request to the same URL."""
@@ -135,24 +135,24 @@ class PirateWeatherFlagsBlock(UnicodeMixin):
class PirateWeatherDataPoint(UnicodeMixin):
"""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."""
self.d = d
self.d = d or {}
try:
self.time = datetime.datetime.fromtimestamp(int(d["time"]))
self.utime = d["time"]
self.time = datetime.datetime.fromtimestamp(int(self.d["time"]))
self.utime = self.d["time"]
except KeyError:
pass
try:
sr_time = int(d["sunriseTime"])
sr_time = int(self.d["sunriseTime"])
self.sunriseTime = datetime.datetime.fromtimestamp(sr_time)
except KeyError:
self.sunriseTime = None
try:
ss_time = int(d["sunsetTime"])
ss_time = int(self.d["sunsetTime"])
self.sunsetTime = datetime.datetime.fromtimestamp(ss_time)
except KeyError:
self.sunsetTime = None
@@ -10,5 +10,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"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.const import (
ATTR_ATTRIBUTION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
@@ -27,6 +26,7 @@ from homeassistant.const import (
DEGREE,
PERCENTAGE,
UV_INDEX,
UnitOfDensity,
UnitOfIrradiance,
UnitOfLength,
UnitOfPressure,
@@ -451,11 +451,11 @@ SENSOR_TYPES: dict[str, PirateWeatherSensorEntityDescription] = {
name="Smoke",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
si_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
us_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
ca_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
uk_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
uk2_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
si_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
us_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
ca_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk2_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2,
icon="mdi:smoke",
forecast_mode=["currently", "hourly"],
@@ -465,11 +465,11 @@ SENSOR_TYPES: dict[str, PirateWeatherSensorEntityDescription] = {
name="Smoke Max",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
si_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
us_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
ca_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
uk_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
uk2_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
si_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
us_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
ca_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
uk2_unit=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2,
icon="mdi:smoke",
forecast_mode=["daily"],
@@ -1034,22 +1034,22 @@ async def async_setup_entry(
service_id = config_entry.unique_id or config_entry.entry_id
# Round Output
outputRound = domain_data[PW_ROUND]
output_round = domain_data[PW_ROUND]
sensors: list[PirateWeatherSensor] = []
for condition in conditions:
# 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:
_LOGGER.warning("Monitored condition %s is deprecated", condition)
if (
not sensorDescription.forecast_mode
or "currently" in sensorDescription.forecast_mode
not sensor_description.forecast_mode
or "currently" in sensor_description.forecast_mode
):
unique_id = f"{config_entry.unique_id}-sensor-{condition}"
sensors.append(
@@ -1060,14 +1060,14 @@ async def async_setup_entry(
unique_id,
forecast_day=None,
forecast_hour=None,
description=sensorDescription,
requestUnits=requestUnits,
outputRound=outputRound,
description=sensor_description,
request_units=request_units,
output_round=output_round,
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:
unique_id = (
f"{config_entry.unique_id}-sensor-{condition}-daily-{forecast_day}"
@@ -1080,14 +1080,14 @@ async def async_setup_entry(
unique_id,
forecast_day=int(forecast_day),
forecast_hour=None,
description=sensorDescription,
requestUnits=requestUnits,
outputRound=outputRound,
description=sensor_description,
request_units=request_units,
output_round=output_round,
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:
unique_id = (
f"{config_entry.unique_id}-sensor-{condition}-hourly-{forecast_h}"
@@ -1100,9 +1100,9 @@ async def async_setup_entry(
unique_id,
forecast_day=None,
forecast_hour=int(forecast_h),
description=sensorDescription,
requestUnits=requestUnits,
outputRound=outputRound,
description=sensor_description,
request_units=request_units,
output_round=output_round,
service_id=service_id,
)
)
@@ -1117,7 +1117,7 @@ class PirateWeatherSensor(SensorEntity):
_attr_attribution = ATTRIBUTION
entity_description: PirateWeatherSensorEntityDescription
def __init__(
def __init__( # noqa: PLR0917
self,
weather_coordinator: WeatherUpdateCoordinator,
condition: str,
@@ -1126,8 +1126,8 @@ class PirateWeatherSensor(SensorEntity):
forecast_day: int,
forecast_hour: int,
description: PirateWeatherSensorEntityDescription,
requestUnits: str,
outputRound: str,
request_units: str,
output_round: str,
service_id: str,
) -> None:
"""Initialize the sensor."""
@@ -1149,8 +1149,8 @@ class PirateWeatherSensor(SensorEntity):
self.forecast_day = forecast_day
self.forecast_hour = forecast_hour
self.requestUnits = requestUnits
self.outputRound = outputRound
self.request_units = request_units
self.output_round = output_round
self.type = condition
self._icon = None
self._alerts = None
@@ -1188,7 +1188,7 @@ class PirateWeatherSensor(SensorEntity):
@property
def unit_system(self):
"""Return the unit system of this entity."""
return self.requestUnits
return self.request_units
@property
def entity_picture(self) -> str | None:
@@ -1223,11 +1223,11 @@ class PirateWeatherSensor(SensorEntity):
def extra_state_attributes(self):
"""Return the state attributes."""
if self.type == "alerts":
extraATTR = self._alerts
extraATTR[ATTR_ATTRIBUTION] = ATTRIBUTION
extra_attr = self._alerts
extra_attr[ATTR_ATTRIBUTION] = ATTRIBUTION
else:
extraATTR = {ATTR_ATTRIBUTION: ATTRIBUTION}
return extraATTR
extra_attr = {ATTR_ATTRIBUTION: ATTRIBUTION}
return extra_attr
@property
def native_value(self) -> StateType:
@@ -1249,15 +1249,15 @@ class PirateWeatherSensor(SensorEntity):
dkey = f"{attr}_{i!s}"
else:
dkey = attr
alertsAttr = getattr(alert, attr)
alerts_attr = getattr(alert, attr)
# Convert time to string using dt_util
if isinstance(alertsAttr, int):
alertsAttr = dt_util.as_local(
dt_util.utc_from_timestamp(alertsAttr)
if isinstance(alerts_attr, int):
alerts_attr = dt_util.as_local(
dt_util.utc_from_timestamp(alerts_attr)
).isoformat()
alerts[dkey] = alertsAttr
alerts[dkey] = alerts_attr
self._alerts = alerts
native_val = len(data)
@@ -1342,12 +1342,12 @@ class PirateWeatherSensor(SensorEntity):
self._icon = getattr(data, "icon", "")
# If output rounding is requested, round to nearest integer
if self.outputRound == "Yes":
roundingVal = 0
roundingPrecip = 2
if self.output_round == "Yes":
rounding_val = 0
rounding_precip = 2
else:
roundingVal = 2
roundingPrecip = 4
rounding_val = 2
rounding_precip = 4
# Some state data needs to be rounded to whole values or converted to
# percentages
@@ -1364,10 +1364,10 @@ class PirateWeatherSensor(SensorEntity):
"sunset_time",
"time",
]:
outState = datetime.datetime.fromtimestamp(state, datetime.UTC)
out_state = datetime.datetime.fromtimestamp(state, datetime.UTC)
elif self.type == "fire_risk_level":
outState = fire_index(state)
out_state = fire_index(state)
elif self.type in [
"dew_point",
"temperature",
@@ -1395,10 +1395,10 @@ class PirateWeatherSensor(SensorEntity):
"solar",
"solar_max",
]:
if roundingVal == 0:
outState = int(round(state, roundingVal))
if rounding_val == 0:
out_state = int(round(state, rounding_val))
else:
outState = round(state, roundingVal)
out_state = round(state, rounding_val)
elif self.type in [
"precip_accumulation",
@@ -1423,12 +1423,12 @@ class PirateWeatherSensor(SensorEntity):
and self.unit_system != "us"
):
state = state * 10
outState = round(state, roundingPrecip)
out_state = round(state, rounding_precip)
else:
outState = state
out_state = state
return outState
return out_state
async def async_added_to_hass(self) -> None:
"""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."""
if fire_index == -999:
outState = "N/A"
out_state = "N/A"
elif fire_index >= 30:
outState = "Extreme"
out_state = "Extreme"
elif fire_index >= 20:
outState = "Very High"
out_state = "Very High"
elif fire_index >= 10:
outState = "High"
out_state = "High"
elif fire_index >= 5:
outState = "Moderate"
out_state = "Moderate"
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
# Round Output
outputRound = domain_data[PW_ROUND]
output_round = domain_data[PW_ROUND]
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)
@@ -288,13 +288,13 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
| WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(
def __init__( # noqa: PLR0917
self,
name: str,
unique_id,
forecast_mode: str,
weather_coordinator: WeatherUpdateCoordinator,
outputRound: str,
output_round: str,
service_id: str,
) -> None:
"""Initialize the sensor."""
@@ -315,7 +315,7 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
self._ds_hourly = self._weather_coordinator.data.hourly()
self._ds_daily = self._weather_coordinator.data.daily()
self.outputRound = outputRound
self.output_round = output_round
units = WEATHER_UNITS.get(
self._weather_coordinator.requested_units, WEATHER_UNITS["si"]
@@ -369,11 +369,13 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
@property
def cloud_coverage(self):
"""Return the cloud coverage."""
cloudCover = (
self._weather_coordinator.data.currently().d.get("cloudCover") * 100.0
)
cloud_cover = self._weather_coordinator.data.currently().d.get("cloudCover")
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
def humidity(self):
@@ -399,16 +401,16 @@ class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
@property
def native_wind_gust_speed(self):
"""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
def wind_bearing(self):
"""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
def ozone(self):
@@ -21,7 +21,7 @@ ATTRIBUTION = "Powered by Pirate Weather"
class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator."""
def __init__(
def __init__( # noqa: PLR0917
self,
api_key,
latitude,
@@ -72,25 +72,27 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Poll weather data from PW."""
if self.latitude == 0.0:
requestLatitude = self.hass.config.latitude
request_latitude = self.hass.config.latitude
else:
requestLatitude = self.latitude
request_latitude = self.latitude
if self.longitude == 0.0:
requestLongitude = self.hass.config.longitude
request_longitude = self.hass.config.longitude
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
+ "/forecast/"
+ self._api_key
+ "/"
+ str(requestLatitude)
+ str(request_latitude)
+ ","
+ str(requestLongitude)
+ str(request_longitude)
+ "?units="
+ self.requested_units
+ "&extend=hourly"
@@ -104,12 +106,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
m.strip() for m in self.models.split(",") if m.strip()
)
if exclusions:
forecastString += "&exclude=" + exclusions
forecast_string += "&exclude=" + exclusions
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()
jsonText = await resp.json()
json_text = await resp.json()
headers = resp.headers
_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
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, hass
)
@@ -237,6 +248,42 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Register a 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(
self, config_entry: ConfigData, central_config: ConfigEntry | None
) -> dict[str, Any]:
@@ -308,6 +355,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
for manager in self._managers:
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(
CONF_USE_PRESETS_CENTRAL_CONFIG
) or (
@@ -487,6 +539,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.debug("%s - Calling async_startup_internal", self)
# 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
for manager in self._managers:
await manager.start_listening()
@@ -898,6 +956,30 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"""Return the temperature we try to reach."""
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
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
@@ -1609,6 +1691,25 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
# Call specific control heating
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)
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)
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):
"""Send an event"""
@@ -125,7 +125,7 @@ async def async_setup_entry(
["None", "Low", "Medium", "High", "Turbo"]
),
},
"service_set_auto_fan_mode",
"service_set_auto_fan_mode_deprecated",
)
platform.async_register_entity_service(
@@ -19,9 +19,9 @@
"requirements": [
"numpy",
"scipy",
"vtherm_api>=0.3.0"
"vtherm_api>=0.4.0"
],
"ssdp": [],
"version": "10.1.0",
"version": "10.2.0",
"zeroconf": []
}
@@ -113,7 +113,7 @@ set_auto_regulation_mode:
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:
entity:
integration: versatile_thermostat
@@ -8,6 +8,7 @@ from datetime import timedelta, datetime
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
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.components.climate import (
HVACAction,
@@ -27,6 +28,9 @@ from .vtherm_hvac_mode import VThermHvacMode
_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
HVACAction.COOLING,
HVACAction.DRYING,
@@ -465,8 +469,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
return None
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"."""
for val in ["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", "on_low"]:
if find_fan_mode(fan_modes, val):
return True
return False
@@ -482,6 +486,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
index = speed_modes.index("one")
elif "speed_1" in speed_modes:
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:
speed_modes.reverse()
@@ -1008,6 +1014,20 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Get the regulated target temperature"""
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
def is_regulated(self) -> bool:
"""Check if the ThermostatOverClimate is regulated"""
@@ -1306,6 +1326,49 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
self.update_custom_attributes()
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
async def async_turn_off(self) -> None:
"""Turn off the climate entity."""