diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5e02bd4d..5d47c3971 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,9 @@
# Changelog
-## Ongoing
+## v1.7.6
-- Maintenance chores (mostly reworking Github CI Actions) backporting from efforts on Python Plugwise [USB: 264](https://github.com/plugwise/python-plugwise-usb/pull/264) after porting our progress using [USB: 263](https://github.com/plugwise/python-plugwise-usb/pull/263)
+- Maintenance chores (mostly reworking Github CI Actions) backporting from efforts on Python Plugwise [USB: #264](https://github.com/plugwise/python-plugwise-usb/pull/264) after porting our progress using [USB: #263](https://github.com/plugwise/python-plugwise-usb/pull/263)
+- Don't raise an error when a locked switch is being toggled, and other switch-related improvements via [#755](https://github.com/plugwise/python-plugwise/pull/755)
## v1.7.5
diff --git a/fixtures/adam_multiple_devices_per_zone/data.json b/fixtures/adam_multiple_devices_per_zone/data.json
index d3e13a175..8937cd465 100644
--- a/fixtures/adam_multiple_devices_per_zone/data.json
+++ b/fixtures/adam_multiple_devices_per_zone/data.json
@@ -540,6 +540,19 @@
"vendor": "Plugwise",
"zigbee_mac_address": "ABCD012345670A11"
},
+ "e8ef2a01ed3b4139a53bf749204fe6b4": {
+ "dev_class": "switching",
+ "members": [
+ "02cf28bfec924855854c544690a609ef",
+ "4a810418d5394b3f82727340b91ba740"
+ ],
+ "model": "Switchgroup",
+ "name": "Test",
+ "switches": {
+ "relay": true
+ },
+ "vendor": "Plugwise"
+ },
"f1fee6043d3642a9b0a65297455f008e": {
"available": true,
"binary_sensors": {
diff --git a/fixtures/m_adam_multiple_devices_per_zone/data.json b/fixtures/m_adam_multiple_devices_per_zone/data.json
index 7c38b1b21..06459a117 100644
--- a/fixtures/m_adam_multiple_devices_per_zone/data.json
+++ b/fixtures/m_adam_multiple_devices_per_zone/data.json
@@ -531,6 +531,19 @@
"vendor": "Plugwise",
"zigbee_mac_address": "ABCD012345670A11"
},
+ "e8ef2a01ed3b4139a53bf749204fe6b4": {
+ "dev_class": "switching",
+ "members": [
+ "02cf28bfec924855854c544690a609ef",
+ "4a810418d5394b3f82727340b91ba740"
+ ],
+ "model": "Switchgroup",
+ "name": "Test",
+ "switches": {
+ "relay": true
+ },
+ "vendor": "Plugwise"
+ },
"f1fee6043d3642a9b0a65297455f008e": {
"available": true,
"binary_sensors": {
diff --git a/plugwise/__init__.py b/plugwise/__init__.py
index 35250323d..9c56faa6a 100644
--- a/plugwise/__init__.py
+++ b/plugwise/__init__.py
@@ -15,6 +15,8 @@
MODULES,
NONE,
SMILES,
+ STATE_OFF,
+ STATE_ON,
STATUS,
SYSTEM,
GwEntityData,
@@ -398,10 +400,21 @@ async def set_temperature_offset(self, dev_id: str, offset: float) -> None:
async def set_switch_state(
self, appl_id: str, members: list[str] | None, model: str, state: str
- ) -> None:
- """Set the given State of the relevant Switch."""
+ ) -> bool:
+ """Set the given State of the relevant Switch.
+
+ Return the result:
+ - True when switched to state on,
+ - False when switched to state off,
+ - the unchanged state when the switch is for instance locked.
+ """
+ if state not in (STATE_OFF, STATE_ON):
+ raise PlugwiseError("Invalid state supplied to set_switch_state")
+
try:
- await self._smile_api.set_switch_state(appl_id, members, model, state)
+ return await self._smile_api.set_switch_state(
+ appl_id, members, model, state
+ )
except ConnectionFailedError as exc:
raise ConnectionFailedError(
f"Failed to set switch state: {str(exc)}"
diff --git a/plugwise/constants.py b/plugwise/constants.py
index f12c8bbd9..4e1becd56 100644
--- a/plugwise/constants.py
+++ b/plugwise/constants.py
@@ -23,6 +23,8 @@
PRESET_AWAY: Final = "away"
PRESSURE_BAR: Final = "bar"
SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm"
+STATE_OFF: Final = "off"
+STATE_ON: Final = "on"
TEMP_CELSIUS: Final = "°C"
TEMP_KELVIN: Final = "°K"
TIME_MILLISECONDS: Final = "ms"
diff --git a/plugwise/legacy/smile.py b/plugwise/legacy/smile.py
index eedd360f7..fc4f3a2c5 100644
--- a/plugwise/legacy/smile.py
+++ b/plugwise/legacy/smile.py
@@ -18,6 +18,8 @@
OFF,
REQUIRE_APPLIANCES,
RULES,
+ STATE_OFF,
+ STATE_ON,
GwEntityData,
ThermoLoc,
)
@@ -195,7 +197,7 @@ async def set_schedule_state(
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 (STATE_OFF, STATE_ON):
raise PlugwiseError("Plugwise: invalid schedule state.")
# Handle no schedule-name / Off-schedule provided
@@ -214,7 +216,7 @@ async def set_schedule_state(
) # pragma: no cover
new_state = "false"
- if state == "on":
+ if state == STATE_ON:
new_state = "true"
locator = f'.//*[@id="{schedule_rule_id}"]/template'
@@ -234,13 +236,16 @@ async def set_schedule_state(
async def set_switch_state(
self, appl_id: str, members: list[str] | None, model: str, state: str
- ) -> None:
+ ) -> bool:
"""Set the given state of the relevant switch.
For individual switches, sets the state directly.
For group switches, sets the state for each member in the group separately.
For switch-locks, sets the lock state using a different data format.
+ Return the requested state when succesful, the current state otherwise.
"""
+ current_state = self.gw_entities[appl_id]["switches"]["relay"]
+ requested_state = state == STATE_ON
switch = Munch()
switch.actuator = "actuator_functionalities"
switch.func_type = "relay_functionality"
@@ -250,7 +255,7 @@ async def set_switch_state(
# Handle switch-lock
if model == "lock":
- state = "false" if state == "off" else "true"
+ state = "true" if state == STATE_ON else "false"
appliance = self._appliances.find(f'appliance[@id="{appl_id}"]')
appl_name = appliance.find("name").text
appl_type = appliance.find("type").text
@@ -269,37 +274,45 @@ async def set_switch_state(
""
)
await self.call_request(APPLIANCES, method="post", data=data)
- return
+ return requested_state
# Handle group of switches
data = f"<{switch.func_type}>{state}{switch.func_type}>"
if members is not None:
return await self._set_groupswitch_member_state(
- data, members, state, switch
+ appl_id, data, members, state, switch
)
# Handle individual relay switches
uri = f"{APPLIANCES};id={appl_id}/relay"
- if model == "relay":
- locator = (
- f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
- )
+ if model == "relay" and self.gw_entities[appl_id]["switches"]["lock"]:
# Don't bother switching a relay when the corresponding lock-state is true
- if self._appliances.find(locator).text == "true":
- raise PlugwiseError("Plugwise: the locked Relay was not switched.")
+ return current_state
await self.call_request(uri, method="put", data=data)
+ return requested_state
async def _set_groupswitch_member_state(
- self, data: str, members: list[str], state: str, switch: Munch
- ) -> None:
+ self, appl_id: str, data: str, members: list[str], state: str, switch: Munch
+ ) -> bool:
"""Helper-function for set_switch_state().
- Set the given State of the relevant Switch (relay) within a group of members.
+ Set the requested state of the relevant switch within a group of switches.
+ Return the current group-state when none of the switches has changed its state, the requested state otherwise.
"""
+ current_state = self.gw_entities[appl_id]["switches"]["relay"]
+ requested_state = state == STATE_ON
+ switched = 0
for member in members:
- uri = f"{APPLIANCES};id={member}/relay"
- await self.call_request(uri, method="put", data=data)
+ if not self.gw_entities[member]["switches"]["lock"]:
+ uri = f"{APPLIANCES};id={member}/relay"
+ await self.call_request(uri, method="put", data=data)
+ switched += 1
+
+ if switched > 0:
+ return requested_state
+
+ return current_state # pragma: no cover
async def set_temperature(self, _: str, items: dict[str, float]) -> None:
"""Set the given Temperature on the relevant Thermostat."""
@@ -310,7 +323,7 @@ async def set_temperature(self, _: str, items: dict[str, float]) -> None:
if setpoint is None:
raise PlugwiseError(
"Plugwise: failed setting temperature: no valid input provided"
- ) # pragma: no cover"
+ ) # pragma: no cover
temperature = str(setpoint)
data = (
diff --git a/plugwise/smile.py b/plugwise/smile.py
index f3b259ff8..0947401d4 100644
--- a/plugwise/smile.py
+++ b/plugwise/smile.py
@@ -7,7 +7,7 @@
from collections.abc import Awaitable, Callable
import datetime as dt
-from typing import Any
+from typing import Any, cast
from plugwise.constants import (
ADAM,
@@ -22,7 +22,10 @@
NOTIFICATIONS,
OFF,
RULES,
+ STATE_OFF,
+ STATE_ON,
GwEntityData,
+ SwitchType,
ThermoLoc,
)
from plugwise.data import SmileData
@@ -309,12 +312,12 @@ 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 (STATE_OFF, STATE_ON):
raise PlugwiseError("Plugwise: invalid schedule state.")
# Translate selection of Off-schedule-option to disabling the active schedule
if name == OFF:
- new_state = "off"
+ new_state = STATE_OFF
# Handle no schedule-name / Off-schedule provided
if name is None or name == OFF:
@@ -367,18 +370,27 @@ def determine_contexts(
subject = f''
subject = etree.fromstring(subject)
- if state == "off":
+ if state == STATE_OFF:
self._last_active[loc_id] = name
contexts.remove(subject)
- if state == "on":
+ if state == STATE_ON:
contexts.append(subject)
return str(etree.tostring(contexts, encoding="unicode").rstrip())
async def set_switch_state(
self, appl_id: str, members: list[str] | None, model: str, state: str
- ) -> None:
- """Set the given State of the relevant Switch."""
+ ) -> bool:
+ """Set the given state of the relevant Switch.
+
+ For individual switches, sets the state directly.
+ For group switches, sets the state for each member in the group separately.
+ For switch-locks, sets the lock state using a different data format.
+ Return the requested state when succesful, the current state otherwise.
+ """
+ model_type = cast(SwitchType, model)
+ current_state = self.gw_entities[appl_id]["switches"][model_type]
+ requested_state = state == STATE_ON
switch = Munch()
switch.actuator = "actuator_functionalities"
switch.device = "relay"
@@ -396,10 +408,18 @@ async def set_switch_state(
if model == "lock":
switch.func = "lock"
- state = "false" if state == "off" else "true"
+ state = "true" if state == STATE_ON else "false"
+
+ data = (
+ f"<{switch.func_type}>"
+ f"<{switch.func}>{state}{switch.func}>"
+ f"{switch.func_type}>"
+ )
if members is not None:
- return await self._set_groupswitch_member_state(members, state, switch)
+ return await self._set_groupswitch_member_state(
+ appl_id, data, members, state, switch
+ )
locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}'
found = self._domain_objects.findall(locator)
@@ -412,39 +432,42 @@ async def set_switch_state(
else: # actuators with a single item like relay_functionality
switch_id = item.attrib["id"]
- data = (
- f"<{switch.func_type}>"
- f"<{switch.func}>{state}{switch.func}>"
- f"{switch.func_type}>"
- )
uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}"
if model == "relay":
- locator = (
- f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock'
- )
- # Don't bother switching a relay when the corresponding lock-state is true
- if self._domain_objects.find(locator).text == "true":
- raise PlugwiseError("Plugwise: the locked Relay was not switched.")
+ lock_blocked = self.gw_entities[appl_id]["switches"].get("lock")
+ if lock_blocked or lock_blocked is None:
+ # Don't switch a relay when its corresponding lock-state is true or no
+ # lock is present. That means the relay can't be controlled by the user.
+ return current_state
await self.call_request(uri, method="put", data=data)
+ return requested_state
async def _set_groupswitch_member_state(
- self, members: list[str], state: str, switch: Munch
- ) -> None:
+ self, appl_id: str, data: str, members: list[str], state: str, switch: Munch
+ ) -> bool:
"""Helper-function for set_switch_state().
- Set the given State of the relevant Switch within a group of members.
+ Set the requested state of the relevant switch within a group of switches.
+ Return the current group-state when none of the switches has changed its state, the requested state otherwise.
"""
+ current_state = self.gw_entities[appl_id]["switches"]["relay"]
+ requested_state = state == STATE_ON
+ switched = 0
for member in members:
locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}'
switch_id = self._domain_objects.find(locator).attrib["id"]
uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}"
- data = (
- f"<{switch.func_type}>"
- f"<{switch.func}>{state}{switch.func}>"
- f"{switch.func_type}>"
- )
- await self.call_request(uri, method="put", data=data)
+ lock_blocked = self.gw_entities[member]["switches"].get("lock")
+ # Assume Plugs under Plugwise control are not part of a group
+ if lock_blocked is not None and not lock_blocked:
+ await self.call_request(uri, method="put", data=data)
+ switched += 1
+
+ if switched > 0:
+ return requested_state
+
+ return current_state
async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None:
"""Set the given Temperature on the relevant Thermostat."""
diff --git a/pyproject.toml b/pyproject.toml
index a2dd35387..ede4e456a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "plugwise"
-version = "1.7.5"
+version = "1.7.6"
license = "MIT"
description = "Plugwise Smile (Adam/Anna/P1) and Stretch module for Python 3."
readme = "README.md"
diff --git a/tests/data/adam/adam_multiple_devices_per_zone.json b/tests/data/adam/adam_multiple_devices_per_zone.json
index 523f9cfcc..2780afe76 100644
--- a/tests/data/adam/adam_multiple_devices_per_zone.json
+++ b/tests/data/adam/adam_multiple_devices_per_zone.json
@@ -567,6 +567,19 @@
"vendor": "Plugwise",
"zigbee_mac_address": "ABCD012345670A08"
},
+ "e8ef2a01ed3b4139a53bf749204fe6b4": {
+ "dev_class": "switching",
+ "members": [
+ "02cf28bfec924855854c544690a609ef",
+ "4a810418d5394b3f82727340b91ba740"
+ ],
+ "model": "Switchgroup",
+ "name": "Test",
+ "switches": {
+ "relay": true
+ },
+ "vendor": "Plugwise"
+ },
"fe799307f1624099878210aa0b9f1475": {
"binary_sensors": {
"plugwise_notification": true
diff --git a/tests/test_adam.py b/tests/test_adam.py
index 2378d7f41..96b015ce5 100644
--- a/tests/test_adam.py
+++ b/tests/test_adam.py
@@ -106,14 +106,27 @@ async def test_connect_adam_plus_anna_new(self):
smile, "056ee145a816487eaa69243c3280f8bf", model="dhw_cm_switch"
)
assert switch_change
+ # Test relay without lock-attribute
switch_change = await self.tinker_switch(
- smile, "854f8a9b0e7e425db97f1f110e1ce4b3", model="lock"
+ smile,
+ "854f8a9b0e7e425db97f1f110e1ce4b3",
)
- assert switch_change
+ assert not switch_change
switch_change = await self.tinker_switch(
smile, "2568cc4b9c1e401495d4741a5f89bee1"
)
assert not switch_change
+ switch_change = await self.tinker_switch(
+ smile,
+ "2568cc4b9c1e401495d4741a5f89bee1",
+ model="lock",
+ )
+ assert switch_change
+
+ assert await self.tinker_switch_bad_input(
+ smile,
+ "854f8a9b0e7e425db97f1f110e1ce4b3",
+ )
tinkered = await self.tinker_gateway_mode(smile)
assert not tinkered
@@ -288,7 +301,7 @@ async def test_connect_adam_multiple_devices_per_zone(self):
assert smile._last_active["82fa13f017d240daa0d0ea1775420f24"] == CV_JESSIE
assert smile._last_active["08963fec7c53423ca5680aa4cb502c63"] == BADKAMER_SCHEMA
assert smile._last_active["446ac08dd04d4eff8ac57489757b7314"] == BADKAMER_SCHEMA
- assert self.entity_items == 370
+ assert self.entity_items == 375
assert "af82e4ccf9c548528166d38e560662a4" in self.notifications
@@ -304,6 +317,14 @@ async def test_connect_adam_multiple_devices_per_zone(self):
smile, "675416a629f343c495449970e2ca37b5"
)
assert not switch_change
+ # Test a blocked group-change, both relays are locked.
+ group_change = await self.tinker_switch(
+ smile,
+ "e8ef2a01ed3b4139a53bf749204fe6b4",
+ ["02cf28bfec924855854c544690a609ef", "4a810418d5394b3f82727340b91ba740"],
+ )
+ assert not group_change
+
await smile.close_connection()
await self.disconnect(server, client)
diff --git a/tests/test_init.py b/tests/test_init.py
index 0369f6503..c80468ec6 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -684,16 +684,18 @@ async def tinker_switch(
"""Turn a Switch on and off to test functionality."""
_LOGGER.info("Asserting modifying settings for switch devices:")
_LOGGER.info("- Devices (%s):", dev_id)
+ convert = {"on": True, "off": False}
tinker_switch_passed = False
- for new_state in ["false", "true", "false"]:
+ for new_state in ["off", "on", "off"]:
_LOGGER.info("- Switching %s", new_state)
try:
- await smile.set_switch_state(dev_id, members, model, new_state)
- tinker_switch_passed = True
- _LOGGER.info(" + tinker_switch worked as intended")
- except pw_exceptions.PlugwiseError:
- _LOGGER.info(" + locked, not switched as expected")
- return False
+ result = await smile.set_switch_state(dev_id, members, model, new_state)
+ if result == convert[new_state]:
+ tinker_switch_passed = True
+ _LOGGER.info(" + tinker_switch worked as intended")
+ else:
+ _LOGGER.info(" + tinker_switch failed unexpectedly")
+ return False
except (
pw_exceptions.ConnectionFailedError
): # leave for-loop at connect-error
@@ -706,6 +708,20 @@ async def tinker_switch(
return tinker_switch_passed
+ @pytest.mark.asyncio
+ async def tinker_switch_bad_input(
+ self, smile, dev_id=None, members=None, model="relay", unhappy=False
+ ):
+ """Enter a wrong state as input to toggle a Switch."""
+ _LOGGER.info("Test entering bad input set_switch_state:")
+ _LOGGER.info("- Devices (%s):", dev_id)
+ new_state = "false"
+ try:
+ await smile.set_switch_state(dev_id, members, model, new_state)
+ except pw_exceptions.PlugwiseError:
+ _LOGGER.info(" + failed input-check as expected")
+ return True # test is pass!
+
@pytest.mark.asyncio
async def tinker_thermostat_temp(
self, smile, loc_id, block_cooling=False, fail_cooling=False, unhappy=False
diff --git a/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml b/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml
index e38e3a9b1..9a91994ab 100644
--- a/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml
+++ b/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml
@@ -1425,7 +1425,9 @@
2020-03-20T17:44:58.716+01:00
-
+
+
+
2020-03-20T17:30:00+01:00
@@ -3146,7 +3148,9 @@
2020-03-20T17:39:34.219+01:00
-
+
+
+
2020-03-20T17:28:23.547+01:00
@@ -3517,6 +3521,54 @@
+
+ Test
+
+ switching
+ 2021-12-23T08:25:07.571+01:00
+ 2023-12-22T16:29:14.088+01:00
+
+
+
+
+
+
+
+ relay
+
+
+
+
+
+
+ electricity_produced
+ W
+ 2023-12-22T16:29:13.997+01:00
+ 2023-08-16T23:58:55.515+02:00
+
+
+ 0.00
+
+
+
+ electricity_consumed
+ W
+ 2023-12-22T16:29:13.997+01:00
+ 2023-08-16T23:58:55.515+02:00
+
+
+ 14.81
+
+
+
+
+
+
+ false
+ single
+
+
+