diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0e020de..bc66cfccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.43.0(.2) + +- PR [#254](https://github.com/plugwise/python-plugwise-usb/pull/254): + - Feature Request: add a lock-function to disable relay-switch-changes (energy devices only) + - Fix data not loading from cache at (re)start, store `current_log_address` item to cache + ## v0.42.1 - Implement code improvements, extend debug message [#253](https://github.com/plugwise/python-plugwise-usb/pull/247) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index ce9cdbf38..472d06ac0 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -50,6 +50,7 @@ class NodeFeature(str, Enum): POWER = "power" RELAY = "relay" RELAY_INIT = "relay_init" + RELAY_LOCK = "relay_lock" SWITCH = "switch" TEMPERATURE = "temperature" @@ -171,6 +172,13 @@ class RelayConfig: init_state: bool | None = None +@dataclass(frozen=True) +class RelayLock: + """Status of relay lock.""" + + state: bool | None = None + + @dataclass(frozen=True) class RelayState: """Status of relay.""" @@ -369,6 +377,13 @@ def relay(self) -> bool: Raises NodeError when relay feature is not present at device. """ + @property + def relay_lock(self) -> RelayLock: + """Last known relay lock state information. + + Raises NodeError when relay lock feature is not present at device. + """ + @property def relay_state(self) -> RelayState: """Last known relay state information. @@ -420,6 +435,9 @@ async def set_relay(self, state: bool) -> bool: """ + async def set_relay_lock(self, state: bool) -> bool: + """Change the state of the relay-lock.""" + # endregion # region configuration properties diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b760353ce..04c09fa4f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -18,6 +18,7 @@ NodeType, PowerStatistics, RelayConfig, + RelayLock, RelayState, ) from ..connection import StickController @@ -53,6 +54,7 @@ CACHE_ENERGY_COLLECTION = "energy_collection" CACHE_RELAY = "relay" CACHE_RELAY_INIT = "relay_init" +CACHE_RELAY_LOCK = "relay_lock" FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -84,6 +86,7 @@ def __init__( super().__init__(mac, address, controller, loaded_callback) # Relay + self._relay_lock: RelayLock = RelayLock() self._relay_state: RelayState = RelayState() self._relay_config: RelayConfig = RelayConfig() @@ -175,6 +178,11 @@ async def relay_init_on(self) -> None: """Switch relay on.""" await self._relay_init_set(True) + @property + def relay_lock(self) -> RelayLock: + """State of the relay lock.""" + return self._relay_lock + # endregion async def calibration_update(self) -> bool: @@ -598,6 +606,7 @@ async def _energy_log_record_update_state( ) if not self._cache_enabled: return False + log_cache_record = f"{address}:{slot}:{timestamp.year}" log_cache_record += f"-{timestamp.month}-{timestamp.day}" log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" @@ -614,7 +623,9 @@ async def _energy_log_record_update_state( CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record ) return True + return False + _LOGGER.debug( "No existing energy collection log cached for %s", self._mac_in_str ) @@ -628,7 +639,12 @@ async def set_relay(self, state: bool) -> bool: raise FeatureError( f"Changing state of relay is not supported for node {self.mac}" ) - _LOGGER.debug("set_relay() start") + + if self._relay_lock.state: + _LOGGER.debug("Relay switch blocked, relay is locked") + return not state + + _LOGGER.debug("Switching relay to %s", state) request = CircleRelaySwitchRequest(self._send, self._mac_in_bytes, state) response = await request.send() @@ -647,22 +663,44 @@ async def set_relay(self, state: bool) -> bool: + "in response to CircleRelaySwitchRequest for node {self.mac}" ) + @raise_not_loaded + async def set_relay_lock(self, state: bool) -> bool: + """Set the state of the relay-lock.""" + await self._relay_update_lock(state) + return state + async def _relay_load_from_cache(self) -> bool: """Load relay state from cache.""" + result = True if (cached_relay_data := self._get_cache(CACHE_RELAY)) is not None: - _LOGGER.debug("Restore relay state cache for node %s", self._mac_in_str) - relay_state = False - if cached_relay_data == "True": - relay_state = True + _LOGGER.debug( + "Restore relay state from cache for node %s: relay: %s", + self._mac_in_str, + cached_relay_data, + ) + relay_state = cached_relay_data == "True" await self._relay_update_state(relay_state) - return True - _LOGGER.debug( - "Failed to restore relay state from cache for node %s, try to request node info...", - self._mac_in_str, - ) - if await self.node_info_update() is None: - return False - return True + else: + _LOGGER.debug( + "Failed to restore relay state from cache for node %s, try to request node info...", + self._mac_in_str, + ) + if await self.node_info_update() is None: + result = False + + if (cached_relay_lock := self._get_cache(CACHE_RELAY_LOCK)) is not None: + _LOGGER.debug( + "Restore relay_lock state from cache for node %s: relay_lock: %s", + self._mac_in_str, + cached_relay_lock, + ) + relay_lock = cached_relay_lock == "True" + await self._relay_update_lock(relay_lock) + else: + # Set to initial state False when not present in cache + await self._relay_update_lock(False) + + return result async def _relay_update_state( self, state: bool, timestamp: datetime | None = None @@ -673,10 +711,11 @@ async def _relay_update_state( self._set_cache(CACHE_RELAY, "True") if self._relay_state.state is None or not self._relay_state.state: state_update = True - if not state: + else: self._set_cache(CACHE_RELAY, "False") if self._relay_state.state is None or self._relay_state.state: state_update = True + self._relay_state = replace(self._relay_state, state=state, timestamp=timestamp) if state_update: await self.publish_feature_update_to_subscribers( @@ -684,6 +723,25 @@ async def _relay_update_state( ) await self.save_cache() + async def _relay_update_lock(self, state: bool) -> None: + """Process relay lock update.""" + state_update = False + if state: + self._set_cache(CACHE_RELAY_LOCK, "True") + if self._relay_lock.state is None or not self._relay_lock.state: + state_update = True + else: + self._set_cache(CACHE_RELAY_LOCK, "False") + if self._relay_lock.state is None or self._relay_lock.state: + state_update = True + + if state_update: + self._relay_lock = replace(self._relay_lock, state=state) + await self.publish_feature_update_to_subscribers( + NodeFeature.RELAY_LOCK, self._relay_lock + ) + await self.save_cache() + async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" get_clock_request = CircleClockGetRequest(self._send, self._mac_in_bytes) @@ -731,8 +789,9 @@ async def load(self) -> bool: """Load and activate Circle node features.""" if self._loaded: return True + if self._cache_enabled: - _LOGGER.debug("Load Circle node %s from cache", self._mac_in_str) + _LOGGER.debug("Loading Circle node %s from cache", self._mac_in_str) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -740,6 +799,7 @@ async def load(self) -> bool: ( NodeFeature.RELAY, NodeFeature.RELAY_INIT, + NodeFeature.RELAY_LOCK, NodeFeature.ENERGY, NodeFeature.POWER, ), @@ -747,12 +807,13 @@ async def load(self) -> bool: if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) return True + _LOGGER.debug( - "Load Circle node %s from cache failed", + "Loading Circle node %s from cache failed", self._mac_in_str, ) else: - _LOGGER.debug("Load Circle node %s", self._mac_in_str) + _LOGGER.debug("Loading Circle node %s", self._mac_in_str) # Check if node is online if not self._available and not await self.is_online(): @@ -772,52 +833,68 @@ async def load(self) -> bool: self._mac_in_str, ) return False + self._loaded = True self._setup_protocol( CIRCLE_FIRMWARE_SUPPORT, ( NodeFeature.RELAY, NodeFeature.RELAY_INIT, + NodeFeature.RELAY_LOCK, NodeFeature.ENERGY, NodeFeature.POWER, ), ) if not await self.initialize(): return False + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" + result = True if not await super()._load_from_cache(): - return False + _LOGGER.debug("_load_from_cache | super-load failed") + result = False # Calibration settings if not await self._calibration_load_from_cache(): _LOGGER.debug( "Node %s failed to load calibration from cache", self._mac_in_str ) - return False + if result: + result = False + # Energy collection - if await self._energy_log_records_load_from_cache(): + if not await self._energy_log_records_load_from_cache(): _LOGGER.warning( "Node %s failed to load energy_log_records from cache", self._mac_in_str, ) + if result: + result = False + # Relay - if await self._relay_load_from_cache(): + if not await self._relay_load_from_cache(): _LOGGER.debug( - "Node %s successfully loaded relay state from cache", + "Node %s failed to load relay state from cache", self._mac_in_str, ) + if result: + result = False + # Relay init config if feature is enabled if NodeFeature.RELAY_INIT in self._features: - if await self._relay_init_load_from_cache(): + if not await self._relay_init_load_from_cache(): _LOGGER.debug( - "Node %s successfully loaded relay_init state from cache", + "Node %s failed to load relay_init state from cache", self._mac_in_str, ) - return True + if result: + result = False + + return result @raise_not_loaded async def initialize(self) -> bool: @@ -865,14 +942,27 @@ async def node_info_update( if node_info is None: if self.skip_update(self._node_info, 30): return self._node_info + node_request = NodeInfoRequest(self._send, self._mac_in_bytes) node_info = await node_request.send() + if node_info is None: return None + await super().node_info_update(node_info) await self._relay_update_state( node_info.relay_state, timestamp=node_info.timestamp ) + if ( + self._get_cache(CACHE_CURRENT_LOG_ADDRESS) is None + and node_info.current_logaddress_pointer + ): + self._set_cache( + CACHE_CURRENT_LOG_ADDRESS, + node_info.current_logaddress_pointer, + ) + await self.save_cache() + if self._current_log_address is not None and ( self._current_log_address > node_info.current_logaddress_pointer or self._current_log_address == 1 @@ -884,22 +974,29 @@ async def node_info_update( node_info.current_logaddress_pointer, self._mac_in_str, ) + if self._current_log_address != node_info.current_logaddress_pointer: self._current_log_address = node_info.current_logaddress_pointer self._set_cache( CACHE_CURRENT_LOG_ADDRESS, node_info.current_logaddress_pointer ) await self.save_cache() + return self._node_info async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" - result = await super()._node_info_load_from_cache() if ( current_log_address := self._get_cache(CACHE_CURRENT_LOG_ADDRESS) ) is not None: self._current_log_address = int(current_log_address) - return result + _LOGGER.debug( + "circle._node_info_load_from_cache | current_log_address=%s", + self._current_log_address + ) + return True + + _LOGGER.debug("circle._node_info_load_from_cache | current_log_address=None") return False # pylint: disable=too-many-arguments @@ -917,8 +1014,10 @@ async def update_node_details( self._relay_state = replace( self._relay_state, state=relay_state, timestamp=timestamp ) + if logaddress_pointer is not None: self._current_log_address = logaddress_pointer + return await super().update_node_details( firmware, hardware, @@ -937,8 +1036,10 @@ async def unload(self) -> None: ): self._retrieve_energy_logs_task.cancel() await self._retrieve_energy_logs_task + if self._cache_enabled: await self._energy_log_records_save_to_cache() + await super().unload() @raise_not_loaded @@ -1084,32 +1185,38 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any raise NodeError( f"Update of feature '{feature}' is not supported for {self.name}" ) - if feature == NodeFeature.ENERGY: - states[feature] = await self.energy_update() - _LOGGER.debug( - "async_get_state %s - energy: %s", - self._mac_in_str, - states[feature], - ) - elif feature == NodeFeature.RELAY: - states[feature] = self._relay_state - _LOGGER.debug( - "async_get_state %s - relay: %s", - self._mac_in_str, - states[feature], - ) - elif feature == NodeFeature.RELAY_INIT: - states[feature] = self._relay_config - elif feature == NodeFeature.POWER: - states[feature] = await self.power_update() - _LOGGER.debug( - "async_get_state %s - power: %s", - self._mac_in_str, - states[feature], - ) - else: - state_result = await super().get_state((feature,)) - states[feature] = state_result[feature] + + match feature: + case NodeFeature.ENERGY: + states[feature] = await self.energy_update() + _LOGGER.debug( + "async_get_state %s - energy: %s", + self._mac_in_str, + states[feature], + ) + case NodeFeature.RELAY: + states[feature] = self._relay_state + _LOGGER.debug( + "async_get_state %s - relay: %s", + self._mac_in_str, + states[feature], + ) + case NodeFeature.RELAY_LOCK: + states[feature] = self._relay_lock + case NodeFeature.RELAY_INIT: + states[feature] = self._relay_config + case NodeFeature.POWER: + states[feature] = await self.power_update() + _LOGGER.debug( + "async_get_state %s - power: %s", + self._mac_in_str, + states[feature], + ) + case _: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: states[NodeFeature.AVAILABLE] = self.available_state + return states diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index fcda89dee..4de82fd25 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -26,7 +26,7 @@ async def load(self) -> bool: if self._loaded: return True if self._cache_enabled: - _LOGGER.debug("Load Circle node %s from cache", self._node_info.mac) + _LOGGER.debug("Loading Circle node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -34,6 +34,7 @@ async def load(self) -> bool: ( NodeFeature.RELAY, NodeFeature.RELAY_INIT, + NodeFeature.RELAY_LOCK, NodeFeature.ENERGY, NodeFeature.POWER, ), @@ -42,11 +43,11 @@ async def load(self) -> bool: await self._loaded_callback(NodeEvent.LOADED, self.mac) return True _LOGGER.info( - "Load Circle+ node %s from cache failed", + "Loading Circle+ node %s from cache failed", self._node_info.mac, ) else: - _LOGGER.debug("Load Circle+ node %s", self._node_info.mac) + _LOGGER.debug("Loading Circle+ node %s", self._node_info.mac) # Check if node is online if not self._available and not await self.is_online(): @@ -69,6 +70,7 @@ async def load(self) -> bool: ( NodeFeature.RELAY, NodeFeature.RELAY_INIT, + NodeFeature.RELAY_LOCK, NodeFeature.ENERGY, NodeFeature.POWER, ), diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index e0e1600f7..b909e7b42 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -162,6 +162,7 @@ class SupportedVersions(NamedTuple): NodeFeature.POWER: 2.0, NodeFeature.RELAY: 2.0, NodeFeature.RELAY_INIT: 2.6, + NodeFeature.RELAY_LOCK: 2.0, NodeFeature.MOTION: 2.0, NodeFeature.MOTION_CONFIG: 2.0, NodeFeature.SWITCH: 2.0, diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index d5d78d402..fb099c9f2 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -23,6 +23,7 @@ NodeType, PowerStatistics, RelayConfig, + RelayLock, RelayState, ) from ..connection import StickController @@ -73,7 +74,7 @@ def __init__( self._cache_enabled: bool = False self._cache_folder_create: bool = False self._cache_save_task: Task[None] | None = None - self._node_cache = NodeCache(mac, "") + self._node_cache = NodeCache(mac) # Sensors self._available: bool = False self._connected: bool = False @@ -277,10 +278,12 @@ def power(self) -> PowerStatistics: @property @raise_not_loaded - def relay_state(self) -> RelayState: - """State of relay.""" - if NodeFeature.RELAY not in self._features: - raise FeatureError(f"Relay state is not supported for node {self.mac}") + def relay_config(self) -> RelayConfig: + """Relay configuration.""" + if NodeFeature.RELAY_INIT not in self._features: + raise FeatureError( + f"Relay configuration is not supported for node {self.mac}" + ) raise NotImplementedError() @property @@ -293,12 +296,18 @@ def relay(self) -> bool: @property @raise_not_loaded - def relay_config(self) -> RelayConfig: - """Relay configuration.""" - if NodeFeature.RELAY_INIT not in self._features: - raise FeatureError( - f"Relay configuration is not supported for node {self.mac}" - ) + def relay_state(self) -> RelayState: + """State of relay.""" + if NodeFeature.RELAY not in self._features: + raise FeatureError(f"Relay state is not supported for node {self.mac}") + raise NotImplementedError() + + @property + @raise_not_loaded + def relay_lock(self) -> RelayLock: + """State of relay lock.""" + if NodeFeature.RELAY_LOCK not in self._features: + raise FeatureError(f"Relay lock is not supported for node {self.mac}") raise NotImplementedError() @property @@ -394,13 +403,16 @@ async def _load_from_cache(self) -> bool: """Load states from previous cached information. Return True if successful.""" if self._loaded: return True + if not await self._load_cache_file(): _LOGGER.debug("Node %s failed to load cache file", self.mac) return False + # Node Info if not await self._node_info_load_from_cache(): _LOGGER.debug("Node %s failed to load node_info from cache", self.mac) return False + return True async def initialize(self) -> None: @@ -474,6 +486,7 @@ async def _node_info_load_from_cache(self) -> bool: node_type: NodeType | None = None if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: node_type = NodeType(int(node_type_str)) + return await self.update_node_details( firmware=firmware, hardware=hardware, @@ -494,6 +507,18 @@ async def update_node_details( logaddress_pointer: int | None, ) -> bool: """Process new node info and return true if all fields are updated.""" + _LOGGER.debug( + "update_node_details | firmware=%s, hardware=%s, nodetype=%s", + firmware, + hardware, + node_type, + ) + _LOGGER.debug( + "update_node_details | timestamp=%s, relay_state=%s, logaddress_pointer=%s,", + timestamp, + relay_state, + logaddress_pointer, + ) complete = True if node_type is None: complete = False @@ -537,11 +562,14 @@ async def update_node_details( self.mac, hardware, ) + self._node_info.model_type = None if len(model_info) > 1: self._node_info.model_type = " ".join(model_info[1:]) + if self._node_info.model is not None: self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" + self._set_cache(CACHE_HARDWARE, hardware) if timestamp is None: @@ -605,17 +633,20 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any f"Update of feature '{feature.name}' is " + f"not supported for {self.mac}" ) - if feature == NodeFeature.INFO: - states[NodeFeature.INFO] = await self.node_info_update() - elif feature == NodeFeature.AVAILABLE: - states[NodeFeature.AVAILABLE] = self.available_state - elif feature == NodeFeature.PING: - states[NodeFeature.PING] = await self.ping_update() - else: - raise NodeError( - f"Update of feature '{feature.name}' is " - + f"not supported for {self.mac}" - ) + + match feature: + case NodeFeature.INFO: + states[NodeFeature.INFO] = await self.node_info_update() + case NodeFeature.AVAILABLE: + states[NodeFeature.AVAILABLE] = self.available_state + case NodeFeature.PING: + states[NodeFeature.PING] = await self.ping_update() + case _: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + return states async def unload(self) -> None: @@ -659,6 +690,7 @@ def _set_cache(self, setting: str, value: Any) -> None: """Store setting with value in cache memory.""" if not self._cache_enabled: return + if isinstance(value, datetime): self._node_cache.update_state( setting, @@ -676,9 +708,11 @@ async def save_cache( """Save cached data to cache file when cache is enabled.""" if not self._cache_enabled or not self._loaded or not self._initialized: return + _LOGGER.debug("Save cache file for node %s", self.mac) if self._cache_save_task is not None and not self._cache_save_task.done(): await self._cache_save_task + if trigger_only: self._cache_save_task = create_task(self._node_cache.save_cache()) else: @@ -766,7 +800,16 @@ async def set_relay(self, state: bool) -> bool: """Change the state of the relay.""" if NodeFeature.RELAY not in self._features: raise FeatureError( - f"Changing state of relay is not supported for node {self.mac}" + f"Changing relay state is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_relay_lock(self, state: bool) -> bool: + """Change lock of the relay.""" + if NodeFeature.RELAY_LOCK not in self._features: + raise FeatureError( + f"Changing relay lock state is not supported for node {self.mac}" ) raise NotImplementedError() diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 2beed8cf9..ce90c69fb 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -566,13 +566,16 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any f"Update of feature '{feature.name}' is " + f"not supported for {self.mac}" ) - if feature == NodeFeature.MOTION: - states[NodeFeature.MOTION] = self._motion_state - elif feature == NodeFeature.MOTION_CONFIG: - states[NodeFeature.MOTION_CONFIG] = self._motion_config - else: - state_result = await super().get_state((feature,)) - states[feature] = state_result[feature] + + match feature: + case NodeFeature.MOTION: + states[NodeFeature.MOTION] = self._motion_state + case NodeFeature.MOTION_CONFIG: + states[NodeFeature.MOTION_CONFIG] = self._motion_config + case _: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: states[NodeFeature.AVAILABLE] = self.available_state return states diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 66ff9eaca..a583142bb 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -522,6 +522,7 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: NodeFeature.BATTERY, self._battery_config, ), + self.save_cache(), ] self._delayed_task = self._loop.create_task( self._send_tasks(), name=f"Delayed update for {self._mac_in_str}" @@ -560,6 +561,7 @@ def _detect_maintenance_interval(self, timestamp: datetime) -> None: """Detect current maintenance interval.""" if self._last_awake[NodeAwakeResponseType.MAINTENANCE] == timestamp: return + new_interval_in_sec = ( timestamp - self._last_awake[NodeAwakeResponseType.MAINTENANCE] ).seconds @@ -594,6 +596,7 @@ def _detect_maintenance_interval(self, timestamp: datetime) -> None: self._set_cache( CACHE_MAINTENANCE_INTERVAL, SED_DEFAULT_MAINTENANCE_INTERVAL ) + self._maintenance_interval_restored_from_cache = True async def _reset_awake(self, last_alive: datetime) -> None: @@ -756,11 +759,14 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any f"Update of feature '{feature.name}' is " + f"not supported for {self.mac}" ) - if feature == NodeFeature.INFO: - states[NodeFeature.INFO] = await self.node_info_update() - elif feature == NodeFeature.BATTERY: - states[NodeFeature.BATTERY] = self._battery_config - else: - state_result = await super().get_state((feature,)) - states[feature] = state_result[feature] + + match feature: + case NodeFeature.INFO: + states[NodeFeature.INFO] = await self.node_info_update() + case NodeFeature.BATTERY: + states[NodeFeature.BATTERY] = self._battery_config + case _: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + return states diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 438ce6b0f..111c0efb7 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -134,15 +134,19 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any raise NodeError( f"Update of feature '{feature.name}' is not supported for {self.mac}" ) - if feature == NodeFeature.TEMPERATURE: - states[NodeFeature.TEMPERATURE] = self._temperature - elif feature == NodeFeature.HUMIDITY: - states[NodeFeature.HUMIDITY] = self._humidity - elif feature == NodeFeature.PING: - states[NodeFeature.PING] = await self.ping_update() - else: - state_result = await super().get_state((feature,)) - states[feature] = state_result[feature] + + match feature: + case NodeFeature.TEMPERATURE: + states[NodeFeature.TEMPERATURE] = self._temperature + case NodeFeature.HUMIDITY: + states[NodeFeature.HUMIDITY] = self._humidity + case NodeFeature.PING: + states[NodeFeature.PING] = await self.ping_update() + case _: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: states[NodeFeature.AVAILABLE] = self.available_state + return states diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 1a2fed77e..62d82262e 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -147,11 +147,15 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any f"Update of feature '{feature.name}' is " + f"not supported for {self.mac}" ) - if feature == NodeFeature.SWITCH: - states[NodeFeature.SWITCH] = self._switch_state - else: - state_result = await super().get_state((feature,)) - states[feature] = state_result[feature] + + match feature: + case NodeFeature.SWITCH: + states[NodeFeature.SWITCH] = self._switch_state + case _: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: states[NodeFeature.AVAILABLE] = self.available_state + return states diff --git a/pyproject.toml b/pyproject.toml index ad51ec355..bbfe68149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.42.1" +version = "0.43.0.2" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 7e62cab10..884a5221b 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -23,8 +23,7 @@ set +u if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then # Python tests (rerun with debug if failures) - # PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || - PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ + PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ fi if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then diff --git a/tests/test_usb.py b/tests/test_usb.py index 6f1de88f2..4d6eed36c 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -710,11 +710,15 @@ async def node_relay_state( state: pw_api.RelayState, # type: ignore[name-defined] ) -> None: """Handle relay event callback.""" - if feature == pw_api.NodeFeature.RELAY: - if state.state: - self.test_relay_state_on.set_result(state.state) - else: - self.test_relay_state_off.set_result(state.state) + if feature in (pw_api.NodeFeature.RELAY, pw_api.NodeFeature.RELAY_LOCK): + if feature == pw_api.NodeFeature.RELAY: + if state.state: + self.test_relay_state_on.set_result(state.state) + else: + self.test_relay_state_off.set_result(state.state) + if feature == pw_api.NodeFeature.RELAY_LOCK: + # Handle RELAY_LOCK callbacks if needed + pass else: self.test_relay_state_on.set_exception( BaseException( @@ -774,12 +778,19 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No with pytest.raises(pw_exceptions.NodeError): await stick.nodes["0098765432101234"].set_relay(True) + with pytest.raises(pw_exceptions.NodeError): + await stick.nodes["0098765432101234"].set_relay_lock(True) + # Manually load node assert await stick.nodes["0098765432101234"].load() + # Check relay_lock is set to False when not in cache + assert stick.nodes["0098765432101234"].relay_lock + assert not stick.nodes["0098765432101234"].relay_lock.state + unsub_relay = stick.nodes["0098765432101234"].subscribe_to_feature_update( node_feature_callback=self.node_relay_state, - features=(pw_api.NodeFeature.RELAY,), + features=(pw_api.NodeFeature.RELAY, pw_api.NodeFeature.RELAY_LOCK,), ) # Test async switching back from on to off @@ -788,6 +799,15 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No assert not await self.test_relay_state_off assert not stick.nodes["0098765432101234"].relay + # Test blocked async switching due to relay-lock active + await stick.nodes["0098765432101234"].set_relay_lock(True) + assert stick.nodes["0098765432101234"].relay_lock.state + assert not await stick.nodes["0098765432101234"].set_relay(True) + assert not stick.nodes["0098765432101234"].relay + # Make sure to turn lock off for further testing + await stick.nodes["0098765432101234"].set_relay_lock(False) + assert not stick.nodes["0098765432101234"].relay_lock.state + # Test async switching back from off to on self.test_relay_state_on = asyncio.Future() assert await stick.nodes["0098765432101234"].set_relay(True) @@ -2437,6 +2457,7 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.RELAY_LOCK, ) ) @@ -2479,6 +2500,7 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.INFO, pw_api.NodeFeature.PING, pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.RELAY_LOCK, pw_api.NodeFeature.ENERGY, pw_api.NodeFeature.POWER, ) @@ -2499,11 +2521,17 @@ async def test_node_discovery_and_load( assert state[pw_api.NodeFeature.INFO].version == "070073" assert state[pw_api.NodeFeature.RELAY].state + assert not state[pw_api.NodeFeature.RELAY_LOCK].state # Check 1111111111111111 get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["1111111111111111"].get_state( - (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) + ( + pw_api.NodeFeature.PING, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.RELAY_LOCK, + ) ) assert state[pw_api.NodeFeature.INFO].mac == "1111111111111111" @@ -2523,12 +2551,15 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.INFO, pw_api.NodeFeature.PING, pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.RELAY_LOCK, pw_api.NodeFeature.ENERGY, pw_api.NodeFeature.POWER, ) ) assert state[pw_api.NodeFeature.AVAILABLE].state assert state[pw_api.NodeFeature.RELAY].state + assert not state[pw_api.NodeFeature.RELAY_LOCK].state + # region Scan self.test_node_awake = asyncio.Future()