diff --git a/CHANGELOG.md b/CHANGELOG.md index df518d7bf..ba4642ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -## Ongoing +## v0.37.6 +- Schedule-related improvements. - Revert removal of set_temperature_offset() function. ## v0.37.5 diff --git a/fixtures/legacy_anna/all_data.json b/fixtures/legacy_anna/all_data.json index f3f35b76b..baa8721cc 100644 --- a/fixtures/legacy_anna/all_data.json +++ b/fixtures/legacy_anna/all_data.json @@ -36,16 +36,16 @@ }, "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", - "available_schedules": ["Thermostat schedule"], + "available_schedules": ["None"], "dev_class": "thermostat", "firmware": "2017-03-13T11:54:58+01:00", "hardware": "6539-1301-500", "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mode": "auto", + "mode": "heat", "model": "ThermoTouch", "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], - "select_schedule": "Thermostat schedule", + "select_schedule": "None", "sensors": { "illuminance": 151, "setpoint": 20.5, diff --git a/fixtures/legacy_anna_2/all_data.json b/fixtures/legacy_anna_2/all_data.json index 413a5febe..9e7443924 100644 --- a/fixtures/legacy_anna_2/all_data.json +++ b/fixtures/legacy_anna_2/all_data.json @@ -2,7 +2,7 @@ "devices": { "9e7377867dc24e51b8098a5ba02bd89d": { "active_preset": null, - "available_schedules": ["Thermostat schedule"], + "available_schedules": ["Thermostat schedule", "off"], "dev_class": "thermostat", "firmware": "2017-03-13T11:54:58+01:00", "hardware": "6539-1301-5002", @@ -11,7 +11,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["vacation", "away", "no_frost", "home", "asleep"], - "select_schedule": "None", + "select_schedule": "off", "sensors": { "illuminance": 19.5, "setpoint": 15.0, diff --git a/plugwise/constants.py b/plugwise/constants.py index 3b959d68c..74a7d9ba3 100644 --- a/plugwise/constants.py +++ b/plugwise/constants.py @@ -207,13 +207,13 @@ "stretch_v2": SMILE(STRETCH, "Stretch"), "stretch_v3": SMILE(STRETCH, "Stretch"), } -REQUIRE_APPLIANCES: Final[list[str]] = [ +REQUIRE_APPLIANCES: Final[tuple[str, ...]] = ( "smile_thermo_v1", "smile_thermo_v3", "smile_thermo_v4", "stretch_v2", "stretch_v3", -] +) # Class, Literal and related tuple-definitions diff --git a/plugwise/data.py b/plugwise/data.py index 1df72b30d..608f778a3 100644 --- a/plugwise/data.py +++ b/plugwise/data.py @@ -204,7 +204,7 @@ def _device_data_climate(self, device: DeviceData, data: DeviceData) -> None: # Operation modes: auto, heat, heat_cool, cool and off data["mode"] = "auto" self._count += 1 - if sel_schedule == NONE: + if sel_schedule in (NONE, OFF): data["mode"] = "heat" if self._cooling_present: data["mode"] = "cool" if self.check_reg_mode("cooling") else "heat_cool" diff --git a/plugwise/helper.py b/plugwise/helper.py index 1eb427446..d54cff2c0 100644 --- a/plugwise/helper.py +++ b/plugwise/helper.py @@ -15,8 +15,6 @@ ADAM, ANNA, ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - BINARY_SENSORS, DATA, DEVICE_MEASUREMENTS, DHW_SETPOINT, @@ -29,9 +27,6 @@ NONE, OFF, P1_MEASUREMENTS, - SENSORS, - SPECIALS, - SWITCHES, TEMP_CELSIUS, THERMOSTAT_CLASSES, TOGGLES, @@ -39,12 +34,9 @@ ActuatorData, ActuatorDataType, ActuatorType, - BinarySensorType, DeviceData, GatewayData, SensorType, - SpecialType, - SwitchType, ThermoLoc, ToggleNameType, ) @@ -56,6 +48,7 @@ ) from plugwise.util import ( check_model, + common_match_cases, escape_illegal_xml_characters, format_measure, skip_obsolete_measurements, @@ -214,7 +207,7 @@ def __init__(self) -> None: self._thermo_locs: dict[str, ThermoLoc] = {} ################################################################### # '_cooling_enabled' can refer to the state of the Elga heatpump - # connected to an Anna. For Elga, 'elga_status_code' in [8, 9] + # connected to an Anna. For Elga, 'elga_status_code' in (8, 9) # means cooling mode is available, next to heating mode. # 'elga_status_code' = 8 means cooling is active, 9 means idle. # @@ -523,7 +516,7 @@ def _get_measurement_data(self, dev_id: str) -> DeviceData: # Techneco Elga has cooling-capability self._cooling_present = True data["model"] = "Generic heater/cooler" - self._cooling_enabled = data["elga_status_code"] in [8, 9] + self._cooling_enabled = data["elga_status_code"] in (8, 9) data["binary_sensors"]["cooling_state"] = self._cooling_active = ( data["elga_status_code"] == 8 ) @@ -585,29 +578,12 @@ def _appliance_measurements( measurement = new_name match measurement: - # measurements with states "on" or "off" that need to be passed directly - case "select_dhw_mode": - data["select_dhw_mode"] = appl_p_loc.text - case _ as measurement if measurement in BINARY_SENSORS: - bs_key = cast(BinarySensorType, measurement) - bs_value = appl_p_loc.text in ["on", "true"] - data["binary_sensors"][bs_key] = bs_value - case _ as measurement if measurement in SENSORS: - s_key = cast(SensorType, measurement) - s_value = format_measure( - appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT) - ) - data["sensors"][s_key] = s_value - case _ as measurement if measurement in SWITCHES: - sw_key = cast(SwitchType, measurement) - sw_value = appl_p_loc.text in ["on", "true"] - data["switches"][sw_key] = sw_value - case _ as measurement if measurement in SPECIALS: - sp_key = cast(SpecialType, measurement) - sp_value = appl_p_loc.text in ["on", "true"] - data[sp_key] = sp_value case "elga_status_code": data["elga_status_code"] = int(appl_p_loc.text) + case "select_dhw_mode": + data["select_dhw_mode"] = appl_p_loc.text + + common_match_cases(measurement, attrs, appl_p_loc, data) i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement' if (appl_i_loc := appliance.find(i_locator)) is not None: @@ -1014,6 +990,8 @@ def _schedules(self, location: str) -> tuple[list[str], str]: if schedules: available.remove(NONE) available.append(OFF) + if selected == NONE: + selected = OFF if self._last_active.get(location) is None: self._last_active[location] = self._last_used_schedule(schedules) diff --git a/plugwise/legacy/data.py b/plugwise/legacy/data.py index 908888c9b..06a9abf65 100644 --- a/plugwise/legacy/data.py +++ b/plugwise/legacy/data.py @@ -6,7 +6,7 @@ # Dict as class # Version detection -from plugwise.constants import NONE, DeviceData +from plugwise.constants import NONE, OFF, DeviceData from plugwise.legacy.helper import SmileLegacyHelper from plugwise.util import remove_empty_platform_dicts @@ -88,5 +88,5 @@ def _device_data_climate(self, device: DeviceData, data: DeviceData) -> None: # Operation modes: auto, heat data["mode"] = "auto" self._count += 1 - if sel_schedule == NONE: + if sel_schedule in (NONE, OFF): data["mode"] = "heat" diff --git a/plugwise/legacy/helper.py b/plugwise/legacy/helper.py index fd6713af3..862a1c3fb 100644 --- a/plugwise/legacy/helper.py +++ b/plugwise/legacy/helper.py @@ -12,8 +12,6 @@ ACTUATOR_CLASSES, APPLIANCES, ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - BINARY_SENSORS, DATA, DEVICE_MEASUREMENTS, ENERGY_WATT_HOUR, @@ -22,10 +20,8 @@ HEATER_CENTRAL_MEASUREMENTS, LIMITS, NONE, + OFF, P1_LEGACY_MEASUREMENTS, - SENSORS, - SPECIALS, - SWITCHES, TEMP_CELSIUS, THERMOSTAT_CLASSES, UOM, @@ -33,15 +29,17 @@ ActuatorDataType, ActuatorType, ApplianceType, - BinarySensorType, DeviceData, GatewayData, SensorType, - SpecialType, - SwitchType, ThermoLoc, ) -from plugwise.util import format_measure, skip_obsolete_measurements, version_to_model +from plugwise.util import ( + common_match_cases, + format_measure, + skip_obsolete_measurements, + version_to_model, +) # This way of importing aiohttp is because of patch/mocking in testing (aiohttp timeouts) from defusedxml import ElementTree as etree @@ -340,25 +338,7 @@ def _appliance_measurements( if new_name := getattr(attrs, ATTR_NAME, None): measurement = new_name - match measurement: - case _ as measurement if measurement in BINARY_SENSORS: - bs_key = cast(BinarySensorType, measurement) - bs_value = appl_p_loc.text in ["on", "true"] - data["binary_sensors"][bs_key] = bs_value - case _ as measurement if measurement in SENSORS: - s_key = cast(SensorType, measurement) - s_value = format_measure( - appl_p_loc.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT) - ) - data["sensors"][s_key] = s_value - case _ as measurement if measurement in SWITCHES: - sw_key = cast(SwitchType, measurement) - sw_value = appl_p_loc.text in ["on", "true"] - data["switches"][sw_key] = sw_value - case _ as measurement if measurement in SPECIALS: - sp_key = cast(SpecialType, measurement) - sp_value = appl_p_loc.text in ["on", "true"] - data[sp_key] = sp_value + common_match_cases(measurement, attrs, appl_p_loc, data) i_locator = f'.//logs/interval_log[type="{measurement}"]/period/measurement' if (appl_i_loc := appliance.find(i_locator)) is not None: @@ -450,16 +430,15 @@ def _presets(self) -> dict[str, list[float]]: return presets def _schedules(self) -> tuple[list[str], str]: - """Collect available schedules/schedules for the legacy thermostat.""" + """Collect the schedule for the legacy thermostat.""" available: list[str] = [NONE] - selected = NONE + rule_id = selected = NONE name: str | None = None search = self._domain_objects - for schedule in search.findall("./rule"): - if rule_name := schedule.find("name").text: - if "preset" not in rule_name: - name = rule_name + if (result := search.find("./rule[name='Thermostat schedule']")) is not None: + name = "Thermostat schedule" + rule_id = result.attrib["id"] log_type = "schedule_state" locator = f"./appliance[type='thermostat']/logs/point_log[type='{log_type}']/period/measurement" @@ -467,10 +446,11 @@ def _schedules(self) -> tuple[list[str], str]: if (result := search.find(locator)) is not None: active = result.text == "on" - if name is not None: - available = [name] - if active: - selected = name + # Show an empty schedule as no schedule found + directives = search.find(f'./rule[@id="{rule_id}"]/directives/when/then') is not None + if directives and name is not None: + available = [name, OFF] + selected = name if active else OFF return available, selected @@ -478,5 +458,4 @@ def _thermostat_uri(self) -> str: """Determine the location-set_temperature uri - from APPLIANCES.""" locator = "./appliance[type='thermostat']" appliance_id = self._appliances.find(locator).attrib["id"] - return f"{APPLIANCES};id={appliance_id}/thermostat" diff --git a/plugwise/legacy/smile.py b/plugwise/legacy/smile.py index 7658c6e16..293f0dfb3 100644 --- a/plugwise/legacy/smile.py +++ b/plugwise/legacy/smile.py @@ -15,6 +15,7 @@ LOCATIONS, LOGGER, MODULES, + OFF, REQUIRE_APPLIANCES, RULES, DeviceData, @@ -181,16 +182,19 @@ async def set_preset(self, _: str, preset: str) -> None: async def set_regulation_mode(self, mode: str) -> None: """Set-function placeholder for legacy devices.""" - async def set_schedule_state(self, _: str, state: str, __: str | None) -> None: + async def set_schedule_state(self, _: str, state: str, name: str | None) -> None: """Activate/deactivate the Schedule. Determined from - DOMAIN_OBJECTS. Used in HA Core to set the hvac_mode: in practice switch between schedule on - off. """ - if state not in ["on", "off"]: + if state not in ("on", "off"): raise PlugwiseError("Plugwise: invalid schedule state.") - name = "Thermostat schedule" + # Handle no schedule-name / Off-schedule provided + if name is None or name == OFF: + name = "Thermostat schedule" + schedule_rule_id: str | None = None for rule in self._domain_objects.findall("rule"): if rule.find("name").text == name: diff --git a/plugwise/smile.py b/plugwise/smile.py index 555c8679d..e1cbdc359 100644 --- a/plugwise/smile.py +++ b/plugwise/smile.py @@ -266,7 +266,7 @@ async def set_schedule_state( Used in HA Core to set the hvac_mode: in practice switch between schedule on - off. """ # Input checking - if new_state not in ["on", "off"]: + if new_state not in ("on", "off"): raise PlugwiseError("Plugwise: invalid schedule state.") # Translate selection of Off-schedule-option to disabling the active schedule diff --git a/plugwise/util.py b/plugwise/util.py index 53e1232d4..0aba3d2d8 100644 --- a/plugwise/util.py +++ b/plugwise/util.py @@ -3,19 +3,30 @@ import datetime as dt import re +from typing import cast from plugwise.constants import ( ATTR_UNIT_OF_MEASUREMENT, + BINARY_SENSORS, + DATA, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, HW_MODELS, OBSOLETE_MEASUREMENTS, PERCENTAGE, POWER_WATT, + SENSORS, SPECIAL_FORMAT, + SPECIALS, + SWITCHES, TEMP_CELSIUS, + UOM, + BinarySensorType, DeviceData, ModelData, + SensorType, + SpecialType, + SwitchType, ) from defusedxml import ElementTree as etree @@ -23,7 +34,7 @@ def check_alternative_location(loc: Munch, legacy: bool) -> Munch: - """Try.""" + """Helper-function for _power_data_peak_value().""" if in_alternative_location(loc, legacy): # Avoid double processing by skipping one peak-list option if loc.peak_select == "nl_offpeak": @@ -102,6 +113,32 @@ def check_model(name: str | None, vendor_name: str | None) -> str | None: return name +def common_match_cases( + measurement: str, + attrs: DATA | UOM, + location: etree, + data: DeviceData, +) -> None: + """Helper-function for common match-case execution.""" + value = location.text in ("on", "true") + match measurement: + case _ as measurement if measurement in BINARY_SENSORS: + bs_key = cast(BinarySensorType, measurement) + data["binary_sensors"][bs_key] = value + case _ as measurement if measurement in SENSORS: + s_key = cast(SensorType, measurement) + s_value = format_measure( + location.text, getattr(attrs, ATTR_UNIT_OF_MEASUREMENT) + ) + data["sensors"][s_key] = s_value + case _ as measurement if measurement in SWITCHES: + sw_key = cast(SwitchType, measurement) + data["switches"][sw_key] = value + case _ as measurement if measurement in SPECIALS: + sp_key = cast(SpecialType, measurement) + data[sp_key] = value + + def escape_illegal_xml_characters(xmldata: str) -> str: """Replace illegal &-characters.""" return re.sub(r"&([^a-zA-Z#])", r"&\1", xmldata) diff --git a/pyproject.toml b/pyproject.toml index bd79dde10..e58a50803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise" -version = "0.37.5" +version = "0.37.6" license = {file = "LICENSE"} description = "Plugwise Smile (Adam/Anna/P1) and Stretch module for Python 3." readme = "README.md" diff --git a/tests/data/anna/legacy_anna.json b/tests/data/anna/legacy_anna.json index 5fa718947..28bc2f8c0 100644 --- a/tests/data/anna/legacy_anna.json +++ b/tests/data/anna/legacy_anna.json @@ -24,9 +24,9 @@ }, "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], "active_preset": "home", - "available_schedules": ["Thermostat schedule"], - "select_schedule": "Thermostat schedule", - "mode": "auto", + "available_schedules": ["None"], + "select_schedule": "None", + "mode": "heat", "sensors": { "temperature": 20.4, "illuminance": 151, diff --git a/tests/data/anna/legacy_anna_2.json b/tests/data/anna/legacy_anna_2.json index ba988c637..61c20873e 100644 --- a/tests/data/anna/legacy_anna_2.json +++ b/tests/data/anna/legacy_anna_2.json @@ -27,8 +27,8 @@ }, "preset_modes": ["vacation", "away", "no_frost", "home", "asleep"], "active_preset": null, - "available_schedules": ["Thermostat schedule"], - "select_schedule": "None", + "available_schedules": ["Thermostat schedule", "off"], + "select_schedule": "off", "mode": "heat", "sensors": { "temperature": 21.4, diff --git a/tests/test_init.py b/tests/test_init.py index c489e7502..fbc6cc727 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -763,7 +763,7 @@ async def tinker_legacy_thermostat_schedule(self, smile, unhappy=False): for state in states: _LOGGER.info("- Adjusting schedule to state %s", state) try: - await smile.set_schedule_state(None, state, None) + await smile.set_schedule_state(None, state) tinker_schedule_passed = True _LOGGER.info(" + working as intended") except pw_exceptions.PlugwiseError: