From e8587d9d0c76745a318398b4fbf66c648e8080cb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:49:26 +0200 Subject: [PATCH 1/6] Implement switch improvements --- plugwise_usb/nodes/switch.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 80b1fc191..d20b97ab6 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -40,7 +40,7 @@ def __init__( super().__init__(mac, address, controller, loaded_callback) self._switch_subscription: Callable[[], None] | None = None self._switch_state: bool | None = None - self._switch: bool | None = None + self._switch: bool = False async def load(self) -> bool: """Load and activate Switch node features.""" @@ -107,7 +107,7 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: async def _switch_state_update( self, switch_state: bool, timestamp: datetime ) -> None: - """Process motion state update.""" + """Process switch state update.""" _LOGGER.debug( "_switch_state_update for %s: %s -> %s", self.name, @@ -115,18 +115,13 @@ async def _switch_state_update( switch_state, ) state_update = False - # Switch on - if switch_state: - self._set_cache(CACHE_SWITCH_STATE, "True") - if self._switch_state is None or not self._switch: - self._switch_state = True - state_update = True - else: - # Switch off - self._set_cache(CACHE_SWITCH_STATE, "False") - if self._switch is None or self._switch: - self._switch_state = False - state_update = True + # Update cache + self._set_cache(CACHE_SWITCH_STATE, str(switch_state)) + # Check for a state change + if self._switch_state != switch_state: + self._switch_state = switch_state + state_update = True + self._set_cache(CACHE_SWITCH_TIMESTAMP, timestamp) if state_update: self._switch = switch_state From 9e58b13d995596ef67e54a05071b6bd9913a428c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:56:18 +0200 Subject: [PATCH 2/6] Implement another two improvement suggestions --- plugwise_usb/nodes/node.py | 2 +- plugwise_usb/nodes/sed.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 95a53f231..b1c8e3a85 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -416,7 +416,7 @@ async def _available_update_state( if ( self._last_seen is not None and timestamp is not None - and (timestamp - self._last_seen).seconds > 5 + and (timestamp - self._last_seen).total_seconds > 5 ): self._last_seen = timestamp diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index b0c15fb51..50ee5facb 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -453,23 +453,23 @@ async def _configure_sed_task(self) -> bool: self.name, ) self._sed_config_task_scheduled = False - change_required = True - if self._new_battery_config.awake_duration is not None: - change_required = True - if self._new_battery_config.clock_interval is not None: - change_required = True - if self._new_battery_config.clock_sync is not None: - change_required = True - if self._new_battery_config.maintenance_interval is not None: - change_required = True - if self._new_battery_config.sleep_duration is not None: + change_required = False + if ( + self._new_battery_config.awake_duration is not None + or self._new_battery_config.clock_interval is not None + or self._new_battery_config.clock_sync is not None + or self._new_battery_config.maintenance_interval is not None + or self._new_battery_config.sleep_duration is not None + ): change_required = True + if not change_required: _LOGGER.debug( "_configure_sed_task | Device %s | no change", self.name, ) return True + _LOGGER.debug( "_configure_sed_task | Device %s | request change", self.name, From 5de735c34c746d52100104ce9d53955bda482162 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 20:01:53 +0200 Subject: [PATCH 3/6] More improvements --- plugwise_usb/messages/requests.py | 6 +++--- plugwise_usb/network/registry.py | 6 ++++-- plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/helpers/pulses.py | 2 +- plugwise_usb/nodes/node.py | 25 ++++++++++++++++--------- plugwise_usb/nodes/scan.py | 20 +++++++++++++------- plugwise_usb/nodes/sense.py | 14 ++++++++++---- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fcae17d5e..818c8c71e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -202,7 +202,7 @@ def start_response_timeout(self) -> None: def stop_response_timeout(self) -> None: """Stop timeout for node response.""" - self._waiting_for_response = True + self._waiting_for_response = False if self._response_timeout is not None: self._response_timeout.cancel() @@ -1231,13 +1231,13 @@ def __init__( async def send(self) -> NodeResponse | None: """Send request.""" result = await self._send_request() - _LOGGER.warning("NodeSleepConfigRequest result: %s", result) + _LOGGER.debug("NodeSleepConfigRequest result: %s", result) if isinstance(result, NodeResponse): return result if result is None: return None raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" ) def __repr__(self) -> str: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 331d83581..144c92ab3 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -128,8 +128,10 @@ async def load_registry_from_cache(self) -> None: "Unable to restore network registry because cache is not initialized" ) return + if self._cache_restored: return + for address, registration in self._network_cache.registrations.items(): mac, node_type = registration if self._registry.get(address) is None: @@ -259,12 +261,12 @@ async def register_node(self, mac: str) -> None: try: await request.send() except StickError as exc: - raise NodeError("{exc}") from exc + raise NodeError(f"{exc}") from exc async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): - raise NodeError(f"MAC '{mac}' invalid") + raise NodeError(f"MAC {mac} invalid") mac_registered = False for registration in self._registry.values(): diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e9314637f..f8aa358f7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -822,14 +822,14 @@ async def _load_from_cache(self) -> bool: # Relay if await self._relay_load_from_cache(): _LOGGER.debug( - "Node %s failed to load relay state from cache", + "Node %s successfully loaded relay state from cache", self._mac_in_str, ) # Relay init config if feature is enabled if NodeFeature.RELAY_INIT in self._features: if await self._relay_init_load_from_cache(): _LOGGER.debug( - "Node %s failed to load relay_init state from cache", + "Node %s successfully loaded relay_init state from cache", self._mac_in_str, ) return True diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 7421a3f0d..9b9fa2c07 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -824,7 +824,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: "The Circle %s does not overwrite old logged data, please reset the Circle's energy-logs via Source", self._mac, ) - return + return None if ( last_address == first_address diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index b1c8e3a85..2844a9c4d 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -618,15 +618,22 @@ def _get_cache_as_datetime(self, setting: str) -> datetime | None: if (timestamp_str := self._get_cache(setting)) is not None: data = timestamp_str.split("-") if len(data) == 6: - return datetime( - year=int(data[0]), - month=int(data[1]), - day=int(data[2]), - hour=int(data[3]), - minute=int(data[4]), - second=int(data[5]), - tzinfo=UTC, - ) + try: + return datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC, + ) + except ValueError: + _LOGGER.warning( + "Invalid datetime format in cache for setting %s: %s", + setting, + timestamp_str, + ) return None def _set_cache(self, setting: str, value: Any) -> None: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index cce83b064..ee54538b2 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -47,6 +47,11 @@ # Light override SCAN_DEFAULT_DAYLIGHT_MODE: Final = False +# Sensitivity values for motion sensor configuration +SENSITIVITY_HIGH_VALUE = 20 # 0x14 +SENSITIVITY_MEDIUM_VALUE = 30 # 0x1E +SENSITIVITY_OFF_VALUE = 255 # 0xFF + # endregion @@ -169,7 +174,7 @@ def _motion_from_cache(self) -> bool: if ( cached_motion_state == "True" and (motion_timestamp := self._motion_timestamp_from_cache()) is not None - and (datetime.now(tz=UTC) - motion_timestamp).seconds < self._reset_timer_from_cache() * 60 + and (datetime.now(tz=UTC) - motion_timestamp).total_seconds < self._reset_timer_from_cache() * 60 ): return True return False @@ -378,7 +383,7 @@ async def _motion_state_update( self._set_cache(CACHE_MOTION_STATE, "False") if self._motion_state.state is None or self._motion_state.state: if self._reset_timer_motion_on is not None: - reset_timer = (timestamp - self._reset_timer_motion_on).seconds + reset_timer = (timestamp - self._reset_timer_motion_on).total_seconds if self._motion_config.reset_timer is None: self._motion_config = replace( self._motion_config, @@ -465,11 +470,12 @@ async def scan_configure( ) -> bool: """Configure Scan device settings. Returns True if successful.""" # Default to medium: - sensitivity_value = 30 # b'1E' - if sensitivity_level == MotionSensitivity.HIGH: - sensitivity_value = 20 # b'14' - if sensitivity_level == MotionSensitivity.OFF: - sensitivity_value = 255 # b'FF' + sensitivity_value = SENSITIVITY_MEDIUM_VALUE + sensitivity_map = { + MotionSensitivity.HIGH: SENSITIVITY_HIGH_VALUE, + MotionSensitivity.OFF: SENSITIVITY_OFF_VALUE, + } + sensitivity_value = sensitivity_map.get(sensitivity_level, SENSITIVITY_MEDIUM_VALUE) request = ScanConfigureRequest( self._send, self._mac_in_bytes, diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 1e4f0ac20..438ce6b0f 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -52,9 +52,10 @@ async def load(self) -> bool: """Load and activate Sense node features.""" if self._loaded: return True + self._node_info.is_battery_powered = True if self._cache_enabled: - _LOGGER.debug("Load Sense node %s from cache", self._node_info.mac) + _LOGGER.debug("Loading Sense node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -64,7 +65,8 @@ async def load(self) -> bool: if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) return True - _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) + + _LOGGER.debug("Loading of Sense node %s failed", self._node_info.mac) return False @raise_not_loaded @@ -94,6 +96,7 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected SenseReportResponse" ) + report_received = False await self._available_update_state(True, response.timestamp) if response.temperature.value != 65535: self._temperature = int( @@ -103,6 +106,8 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: await self.publish_feature_update_to_subscribers( NodeFeature.TEMPERATURE, self._temperature ) + report_received = True + if response.humidity.value != 65535: self._humidity = int( SENSE_HUMIDITY_MULTIPLIER * (response.humidity.value / 65536) @@ -111,8 +116,9 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: await self.publish_feature_update_to_subscribers( NodeFeature.HUMIDITY, self._humidity ) - return True - return False + report_received = True + + return report_received @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: From 372dfad0fc9d9b5ee0de7ce22da009bda80eff53 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 20:32:30 +0200 Subject: [PATCH 4/6] More improvements 2 --- plugwise_usb/network/__init__.py | 6 +++--- plugwise_usb/nodes/helpers/pulses.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 29553f0e0..9a5a42b2f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -263,7 +263,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc if result: return True - + return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: @@ -286,7 +286,7 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: else: _LOGGER.debug("duplicate awake discovery for %s", mac) return True - + return False def _unsubscribe_to_protocol_events(self) -> None: @@ -299,7 +299,7 @@ def _unsubscribe_to_protocol_events(self) -> None: self._unsubscribe_stick_event = None # endregion - + # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: """Discover the Zigbee network coordinator (Circle+/Stealth+).""" diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 9b9fa2c07..ef539c3ae 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -271,7 +271,7 @@ def update_pulse_counter( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: """Update pulse counter. - + Both device consumption and production counters reset after the beginning of a new hour. """ self._cons_pulsecounter_reset = False @@ -287,7 +287,7 @@ def update_pulse_counter( if ( self._pulses_production is not None - and self._pulses_production < pulses_produced + and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True _LOGGER.debug("update_pulse_counter | production pulses reset") @@ -313,9 +313,9 @@ def update_pulse_counter( def _update_rollover(self) -> None: """Update rollover states. - + When the last found timestamp is outside the interval `_last_log_timestamp` - to `_next_log_timestamp` the pulses should not be counted as part of the + to `_next_log_timestamp` the pulses should not be counted as part of the ongoing collection-interval. """ if self._log_addresses_missing is not None and self._log_addresses_missing: @@ -341,7 +341,7 @@ def _detect_rollover( next_log_timestamp: datetime | None, is_consumption=True, ) -> bool: - """Helper function for _update_rollover().""" + """Detect rollover based on timestamp comparisons.""" if ( self._pulses_timestamp is not None From 708253a5caf7dd1a92b19a302d0a7321802c1e31 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 09:36:46 +0200 Subject: [PATCH 5/6] Format total_seconds to int --- plugwise_usb/nodes/node.py | 2 +- plugwise_usb/nodes/scan.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 2844a9c4d..1c0d74efb 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -416,7 +416,7 @@ async def _available_update_state( if ( self._last_seen is not None and timestamp is not None - and (timestamp - self._last_seen).total_seconds > 5 + and int((timestamp - self._last_seen).total_seconds()) > 5 ): self._last_seen = timestamp diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index ee54538b2..669df5434 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -174,7 +174,7 @@ def _motion_from_cache(self) -> bool: if ( cached_motion_state == "True" and (motion_timestamp := self._motion_timestamp_from_cache()) is not None - and (datetime.now(tz=UTC) - motion_timestamp).total_seconds < self._reset_timer_from_cache() * 60 + and int((datetime.now(tz=UTC) - motion_timestamp).total_seconds()) < self._reset_timer_from_cache() * 60 ): return True return False @@ -383,7 +383,7 @@ async def _motion_state_update( self._set_cache(CACHE_MOTION_STATE, "False") if self._motion_state.state is None or self._motion_state.state: if self._reset_timer_motion_on is not None: - reset_timer = (timestamp - self._reset_timer_motion_on).total_seconds + reset_timer = int((timestamp - self._reset_timer_motion_on).total_seconds()) if self._motion_config.reset_timer is None: self._motion_config = replace( self._motion_config, From 451d652c7495b77c99eb051e17f5767448c85c31 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 10:08:53 +0200 Subject: [PATCH 6/6] Scan: sensitivity-fixes --- plugwise_usb/nodes/scan.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 669df5434..2beed8cf9 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -469,12 +469,12 @@ async def scan_configure( daylight_mode: bool, ) -> bool: """Configure Scan device settings. Returns True if successful.""" - # Default to medium: - sensitivity_value = SENSITIVITY_MEDIUM_VALUE sensitivity_map = { MotionSensitivity.HIGH: SENSITIVITY_HIGH_VALUE, + MotionSensitivity.MEDIUM: SENSITIVITY_MEDIUM_VALUE, MotionSensitivity.OFF: SENSITIVITY_OFF_VALUE, } + # Default to medium sensitivity_value = sensitivity_map.get(sensitivity_level, SENSITIVITY_MEDIUM_VALUE) request = ScanConfigureRequest( self._send, @@ -490,17 +490,20 @@ async def scan_configure( self._new_daylight_mode = None _LOGGER.warning("Failed to configure scan settings for %s", self.name) return False + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: await self._scan_configure_update( motion_reset_timer, sensitivity_level, daylight_mode ) return True + _LOGGER.warning( "Unexpected response ack type %s for %s", response.node_ack_type, self.name, ) return False + self._new_reset_timer = None self._new_sensitivity_level = None self._new_daylight_mode = None