diff --git a/custom_components/pirateweather/__pycache__/__init__.cpython-314.pyc b/custom_components/pirateweather/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index cbf42d25..00000000 Binary files a/custom_components/pirateweather/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/config_flow.cpython-314.pyc b/custom_components/pirateweather/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index de71e49a..00000000 Binary files a/custom_components/pirateweather/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/const.cpython-314.pyc b/custom_components/pirateweather/__pycache__/const.cpython-314.pyc deleted file mode 100644 index 5ca32c0b..00000000 Binary files a/custom_components/pirateweather/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/forecast_models.cpython-314.pyc b/custom_components/pirateweather/__pycache__/forecast_models.cpython-314.pyc deleted file mode 100644 index 52441149..00000000 Binary files a/custom_components/pirateweather/__pycache__/forecast_models.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/sensor.cpython-314.pyc b/custom_components/pirateweather/__pycache__/sensor.cpython-314.pyc deleted file mode 100644 index a117a169..00000000 Binary files a/custom_components/pirateweather/__pycache__/sensor.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/weather.cpython-314.opt-beartype0v22v9.pyc b/custom_components/pirateweather/__pycache__/weather.cpython-314.opt-beartype0v22v9.pyc deleted file mode 100644 index e7488745..00000000 Binary files a/custom_components/pirateweather/__pycache__/weather.cpython-314.opt-beartype0v22v9.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/weather.cpython-314.pyc b/custom_components/pirateweather/__pycache__/weather.cpython-314.pyc deleted file mode 100644 index e7488745..00000000 Binary files a/custom_components/pirateweather/__pycache__/weather.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/__pycache__/weather_update_coordinator.cpython-314.pyc b/custom_components/pirateweather/__pycache__/weather_update_coordinator.cpython-314.pyc deleted file mode 100644 index 56c3dbb3..00000000 Binary files a/custom_components/pirateweather/__pycache__/weather_update_coordinator.cpython-314.pyc and /dev/null differ diff --git a/custom_components/pirateweather/config_flow.py b/custom_components/pirateweather/config_flow.py index 52d45bb5..399886f7 100644 --- a/custom_components/pirateweather/config_flow.py +++ b/custom_components/pirateweather/config_flow.py @@ -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 diff --git a/custom_components/pirateweather/forecast_models.py b/custom_components/pirateweather/forecast_models.py index 496f2659..74bd0033 100644 --- a/custom_components/pirateweather/forecast_models.py +++ b/custom_components/pirateweather/forecast_models.py @@ -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 diff --git a/custom_components/pirateweather/manifest.json b/custom_components/pirateweather/manifest.json index 901bb02e..34a06aa1 100644 --- a/custom_components/pirateweather/manifest.json +++ b/custom_components/pirateweather/manifest.json @@ -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" } diff --git a/custom_components/pirateweather/sensor.py b/custom_components/pirateweather/sensor.py index d4c5bc09..445566a6 100644 --- a/custom_components/pirateweather/sensor.py +++ b/custom_components/pirateweather/sensor.py @@ -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 diff --git a/custom_components/pirateweather/weather.py b/custom_components/pirateweather/weather.py index e6ac0f28..6ae5c55d 100644 --- a/custom_components/pirateweather/weather.py +++ b/custom_components/pirateweather/weather.py @@ -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): diff --git a/custom_components/pirateweather/weather_update_coordinator.py b/custom_components/pirateweather/weather_update_coordinator.py index bac5b0e9..e7aaf20a 100644 --- a/custom_components/pirateweather/weather_update_coordinator.py +++ b/custom_components/pirateweather/weather_update_coordinator.py @@ -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) diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index eb0f10d8..ad4d1e1f 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -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""" diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index a650fbaf..d48284c2 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -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( diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index 4128684d..0588b0b1 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -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": [] } diff --git a/custom_components/versatile_thermostat/services.yaml b/custom_components/versatile_thermostat/services.yaml index dbde9346..16086b4c 100644 --- a/custom_components/versatile_thermostat/services.yaml +++ b/custom_components/versatile_thermostat/services.yaml @@ -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 diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 0764ac17..4783856b 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -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."""