diff --git a/CHANGELOG.md b/CHANGELOG.md index 567b8c3dc..713130353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.44.10 - 2025-08-11 + +- PR [302](https://github.com/plugwise/python-plugwise-usb/pull/302) Improve registry discovery and SED/SCAN configuration management +- Fix for [#296](https://github.com/plugwise/plugwise_usb-beta/issues/296) Improve C+ registry collection and node discovery +- Improve SED and SCAN configuration handling, include dirty bool to indicate that the configuration has changed but the node configuration has not yet. + ## v0.44.9 - 2025-07-24 - Fix for [#293](https://github.com/plugwise/plugwise_usb-beta/issues/293) via PR [299](https://github.com/plugwise/python-plugwise-usb/pull/299) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index cb6f6e171..0350fdadb 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from datetime import datetime -from enum import Enum, auto +from enum import Enum, IntEnum, auto import logging from typing import Any, Protocol @@ -19,12 +19,12 @@ class StickEvent(Enum): NETWORK_ONLINE = auto() -class MotionSensitivity(Enum): +class MotionSensitivity(IntEnum): """Motion sensitivity levels for Scan devices.""" - HIGH = auto() - MEDIUM = auto() - OFF = auto() + HIGH = 20 + MEDIUM = 30 + OFF = 255 class NodeEvent(Enum): @@ -118,6 +118,7 @@ class BatteryConfig: clock_sync: bool | None: Indicate if the internal clock must be synced. maintenance_interval: int | None: Interval in minutes a battery powered devices is awake for maintenance purposes. sleep_duration: int | None: Interval in minutes a battery powered devices is sleeping. + dirty: bool: Settings changed, device update pending """ @@ -126,6 +127,7 @@ class BatteryConfig: clock_sync: bool | None = None maintenance_interval: int | None = None sleep_duration: int | None = None + dirty: bool = False @dataclass @@ -145,7 +147,6 @@ class NodeInfo: """Node hardware information.""" mac: str - zigbee_address: int is_battery_powered: bool = False features: tuple[NodeFeature, ...] = (NodeFeature.INFO,) firmware: datetime | None = None @@ -232,13 +233,15 @@ class MotionConfig: Attributes: reset_timer: int | None: Motion reset timer in minutes before the motion detection is switched off. daylight_mode: bool | None: Motion detection when light level is below threshold. - sensitivity_level: MotionSensitivity | None: Motion sensitivity level. + sensitivity_level: int | None: Motion sensitivity level. + dirty: bool: Settings changed, device update pending """ daylight_mode: bool | None = None reset_timer: int | None = None - sensitivity_level: MotionSensitivity | None = None + sensitivity_level: int | None = None + dirty: bool = False @dataclass diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index bf5318ddf..e71ce2d81 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -77,7 +77,7 @@ def __init__( self._unsubscribe_node_rejoin: Callable[[], None] | None = None self._discover_sed_tasks: dict[str, Task[bool]] = {} - self._registry_stragglers: dict[int, str] = {} + self._registry_stragglers: list[str] = [] self._discover_stragglers_task: Task[None] | None = None self._load_stragglers_task: Task[None] | None = None @@ -146,7 +146,7 @@ def nodes( return self._nodes @property - def registry(self) -> dict[int, tuple[str, NodeType | None]]: + def registry(self) -> list[str]: """Return dictionary with all registered (joined) nodes.""" return self._register.registry @@ -232,10 +232,7 @@ async def node_awake_message(self, response: PlugwiseResponse) -> None: self._awake_discovery[mac] = response.timestamp return - if (address := self._register.network_address(mac)) is None: - if self._register.scan_completed: - return - + if not self._register.node_is_registered(mac): _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac, @@ -248,7 +245,7 @@ async def node_awake_message(self, response: PlugwiseResponse) -> None: or self._discover_sed_tasks[mac].done() ): self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) + self._discover_battery_powered_node(mac) ) else: _LOGGER.debug("duplicate maintenance awake discovery for %s", mac) @@ -280,15 +277,14 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeRejoinResponse" ) mac = response.mac_decoded - if (address := self._register.network_address(mac)) is None: - if (address := self._register.update_node_registration(mac)) is None: - raise NodeError(f"Failed to obtain address for node {mac}") - - if self._nodes.get(mac) is None: + if ( + self._register.update_node_registration(mac) + and self._nodes.get(mac) is None + ): task = self._discover_sed_tasks.get(mac) if task is None or task.done(): self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) + self._discover_battery_powered_node(mac) ) else: _LOGGER.debug("duplicate awake discovery for %s", mac) @@ -335,7 +331,7 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: return False if await self._discover_node( - -1, self._controller.mac_coordinator, None, ping_first=False + self._controller.mac_coordinator, None, ping_first=False ): if load: return await self._load_node(self._controller.mac_coordinator) @@ -349,7 +345,6 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: async def _create_node_object( self, mac: str, - address: int, node_type: NodeType, ) -> None: """Create node object and update network registry.""" @@ -361,7 +356,6 @@ async def _create_node_object( return node = get_plugwise_node( mac, - address, self._controller, self._notify_node_event_subscribers, node_type, @@ -371,7 +365,7 @@ async def _create_node_object( return self._nodes[mac] = node _LOGGER.debug("%s node %s added", node.__class__.__name__, mac) - await self._register.update_network_registration(address, mac, node_type) + await self._register.update_network_nodetype(mac, node_type) if self._cache_enabled: _LOGGER.debug( @@ -385,16 +379,13 @@ async def _create_node_object( async def _discover_battery_powered_node( self, - address: int, mac: str, ) -> bool: """Discover a battery powered node and add it to list of nodes. Return True if discovery succeeded. """ - if not await self._discover_node( - address, mac, node_type=None, ping_first=False - ): + if not await self._discover_node(mac, node_type=None, ping_first=False): return False if await self._load_node(mac): await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) @@ -403,7 +394,6 @@ async def _discover_battery_powered_node( async def _discover_node( self, - address: int, mac: str, node_type: NodeType | None, ping_first: bool = True, @@ -420,7 +410,7 @@ async def _discover_node( return True if node_type is not None: - await self._create_node_object(mac, address, node_type) + await self._create_node_object(mac, node_type) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) return True @@ -428,8 +418,10 @@ async def _discover_node( _LOGGER.debug("Starting the discovery of node %s with unknown NodeType", mac) node_info, node_ping = await self._controller.get_node_details(mac, ping_first) if node_info is None: + _LOGGER.debug("Node %s with unknown NodeType not responding", mac) + self._registry_stragglers.append(mac) return False - await self._create_node_object(mac, address, node_info.node_type) + await self._create_node_object(mac, node_info.node_type) # Forward received NodeInfoResponse message to node await self._nodes[mac].message_for_node(node_info) @@ -438,41 +430,16 @@ async def _discover_node( await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) return True - async def _discover_registered_nodes(self) -> None: - """Discover nodes.""" - _LOGGER.debug("Start discovery of registered nodes") - registered_counter = 0 - for address, registration in self._register.registry.items(): - mac, node_type = registration - if mac != "": - if self._nodes.get(mac) is None: - if not await self._discover_node(address, mac, node_type): - self._registry_stragglers[address] = mac - registered_counter += 1 - await sleep(0) - if len(self._registry_stragglers) > 0 and ( - self._discover_stragglers_task is None - or self._discover_stragglers_task.done() - ): - self._discover_stragglers_task = create_task(self._discover_stragglers()) - _LOGGER.debug( - "Total %s online of %s registered node(s)", - str(len(self._nodes)), - str(registered_counter), - ) - async def _discover_stragglers(self) -> None: """Repeat Discovery of Nodes with unknown NodeType.""" while len(self._registry_stragglers) > 0: await sleep(NODE_RETRY_DISCOVER_INTERVAL) - stragglers: dict[int, str] = {} - for address, mac in self._registry_stragglers.items(): - if not await self._discover_node(address, mac, None): - stragglers[address] = mac - self._registry_stragglers = stragglers + for mac in self._registry_stragglers.copy(): + if await self._discover_node(mac, None): + self._registry_stragglers.remove(mac) _LOGGER.debug( "Total %s nodes unreachable having unknown NodeType", - str(len(stragglers)), + str(len(self._registry_stragglers)), ) async def _load_node(self, mac: str) -> bool: @@ -515,6 +482,10 @@ async def _load_discovered_nodes(self) -> bool: ) result_index += 1 _LOGGER.debug("_load_discovered_nodes | END") + if not all(load_result) and ( + self._load_stragglers_task is None or self._load_stragglers_task.done() + ): + self._load_stragglers_task = create_task(self._load_stragglers()) return all(load_result) async def _unload_discovered_nodes(self) -> None: @@ -524,25 +495,26 @@ async def _unload_discovered_nodes(self) -> None: # endregion # region - Network instance - async def start(self) -> None: + async def start(self, load: bool = True) -> None: """Start and activate network.""" - self._register.quick_scan_finished(self._discover_registered_nodes) - self._register.full_scan_finished(self._discover_registered_nodes) + self._register.start_node_discover(self._discover_node) + if load: + self._register.scan_completed_callback(self._load_discovered_nodes) await self._register.start() self._subscribe_to_protocol_events() await self._subscribe_to_node_events() self._is_running = True + if len(self._registry_stragglers) > 0 and ( + self._discover_stragglers_task is None + or self._discover_stragglers_task.done() + ): + self._discover_stragglers_task = create_task(self._discover_stragglers()) async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" await self.discover_network_coordinator(load=load) if not self._is_running: - await self.start() - await self._discover_registered_nodes() - if load and not await self._load_discovered_nodes(): - self._load_stragglers_task = create_task(self._load_stragglers()) - return False - + await self.start(load=load) return True async def stop(self) -> None: diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 5061ceadc..504a89014 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -77,3 +77,14 @@ async def update_nodetypes(self, mac: str, node_type: NodeType | None) -> None: def get_nodetype(self, mac: str) -> NodeType | None: """Return NodeType from cache.""" return self._nodetypes.get(mac) + + async def prune_cache(self, registry: list[str]) -> None: + """Remove items from cache which are not found in registry scan.""" + new_nodetypes: dict[str, NodeType] = {} + for mac in registry: + if mac == "": + continue + if (node_type := self.get_nodetype(mac)) is not None: + new_nodetypes[mac] = node_type + self._nodetypes = new_nodetypes + await self.save_cache() diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 90e71b739..1945be1dc 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -2,10 +2,11 @@ from __future__ import annotations -from asyncio import Task, create_task, sleep +from asyncio import CancelledError, Task, create_task, sleep from collections.abc import Awaitable, Callable from copy import deepcopy import logging +from typing import Final from ..api import NodeType from ..constants import UTF8 @@ -22,6 +23,9 @@ _LOGGER = logging.getLogger(__name__) +CIRCLEPLUS_SCANREQUEST_MAINTENANCE: Final = 10 +CIRCLEPLUS_SCANREQUEST_QUICK: Final = 0.1 + class StickNetworkRegister: """Network register.""" @@ -39,12 +43,14 @@ def __init__( self._cache_enabled = False self._network_cache: NetworkRegistrationCache | None = None self._loaded: bool = False - self._registry: dict[int, tuple[str, NodeType | None]] = {} - self._first_free_address: int = 65 + self._registry: list[str] = [] self._registration_task: Task[None] | None = None - self._quick_scan_finished: Callable[[], Awaitable[None]] | None = None - self._full_scan_finished: Callable[[], Awaitable[None]] | None = None + self._start_node_discover: ( + Callable[[str, NodeType | None, bool], Awaitable[bool]] | None + ) = None + self._registration_scan_delay: float = CIRCLEPLUS_SCANREQUEST_MAINTENANCE self._scan_completed = False + self._scan_completed_callback: Callable[[], Awaitable[None]] | None = None # region Properties @@ -84,8 +90,8 @@ def cache_folder(self, cache_folder: str) -> None: self._network_cache.cache_root_directory = cache_folder @property - def registry(self) -> dict[int, tuple[str, NodeType | None]]: - """Return dictionary with all joined nodes.""" + def registry(self) -> list[str]: + """Return list with mac's of all joined nodes.""" return deepcopy(self._registry) @property @@ -93,13 +99,43 @@ def scan_completed(self) -> bool: """Indicate if scan is completed.""" return self._scan_completed - def quick_scan_finished(self, callback: Callable[[], Awaitable[None]]) -> None: - """Register method to be called when quick scan is finished.""" - self._quick_scan_finished = callback + def start_node_discover( + self, callback: Callable[[str, NodeType | None, bool], Awaitable[bool]] + ) -> None: + """Register async callback invoked when a node is found.""" + self._start_node_discover = callback + + def scan_completed_callback(self, callback: Callable[[], Awaitable[None]]) -> None: + """Register async callback invoked when a node is found. + + Args: + callback: Async callable with signature + (mac: str, node_type: NodeType | None, ping_first: bool) -> bool. + It must return True when discovery succeeded; return False to allow the caller + to fall back (e.g., SED discovery path). - def full_scan_finished(self, callback: Callable[[], Awaitable[None]]) -> None: - """Register method to be called when full scan is finished.""" - self._full_scan_finished = callback + Returns: + None + + """ + self._scan_completed_callback = callback + + async def _exec_node_discover_callback( + self, + mac: str, + node_type: NodeType | None, + ping_first: bool, + ) -> None: + """Protect _start_node_discover() callback execution.""" + if self._start_node_discover is not None: + try: + await self._start_node_discover(mac, node_type, ping_first) + except CancelledError: + raise + except Exception: + _LOGGER.exception("start_node_discover callback failed for %s", mac) + else: + _LOGGER.debug("No start_node_discover callback set; skipping for %s", mac) # endregion @@ -107,7 +143,10 @@ async def start(self) -> None: """Initialize load the network registry.""" if self._cache_enabled: await self.restore_network_cache() - await self.update_missing_registrations_quick() + await self.load_registrations_from_cache() + self._registration_task = create_task( + self.update_missing_registrations_circleplus() + ) async def restore_network_cache(self) -> None: """Restore previously saved cached network and node information.""" @@ -134,99 +173,79 @@ async def retrieve_network_registration( mac_of_node = "" return (address, mac_of_node) - def network_address(self, mac: str) -> int | None: + def node_is_registered(self, mac: str) -> bool: """Return the network registration address for given mac.""" - _LOGGER.debug("Address registrations:") - for address, registration in self._registry.items(): - registered_mac, _ = registration - _LOGGER.debug("address: %s | mac: %s", address, registered_mac) - if mac == registered_mac: - return address - return None - - def network_controller(self) -> tuple[str, NodeType | None]: - """Return the registration for the network controller.""" - if self._registry.get(-1) is None: - raise NodeError("Unable to return network controller details") - return self.registry[-1] - - async def update_network_registration( - self, address: int, mac: str, node_type: NodeType | None - ) -> None: - """Add a network registration.""" - if node_type is None: - if self._registry.get(address) is not None: - _, current_type = self._registry[address] - if current_type is not None: - return + if mac in self._registry: + _LOGGER.debug("mac found in registry: %s", mac) + return True + return False + + async def update_network_nodetype(self, mac: str, node_type: NodeType) -> None: + """Update NodeType inside registry and cache.""" + if self._network_cache is None or mac == "": + return + await self._network_cache.update_nodetypes(mac, node_type) + + def update_network_registration(self, mac: str) -> bool: + """Add a mac to the network registration list return True if it was newly added.""" + if mac == "" or mac in self._registry: + return False + self._registry.append(mac) + return True + + async def remove_network_registration(self, mac: str) -> None: + """Remove a mac from the network registration list.""" + if mac in self._registry: + self._registry.remove(mac) if self._network_cache is not None: - node_type = self._network_cache.get_nodetype(mac) - - self._registry[address] = (mac, node_type) - if node_type is not None and self._network_cache is not None: - await self._network_cache.update_nodetypes(mac, node_type) + await self._network_cache.prune_cache(self._registry) - async def update_missing_registrations_full(self) -> None: - """Full retrieval of all unknown network registrations from network controller.""" + async def update_missing_registrations_circleplus(self) -> None: + """Full retrieval of all (unknown) network registrations from network controller.""" + _maintenance_registry = [] for address in range(0, 64): - if self._registry.get(address) is not None: - mac, _ = self._registry[address] - if mac == "": - self._first_free_address = min(self._first_free_address, address) - continue registration = await self.retrieve_network_registration(address, False) if registration is not None: - nextaddress, mac = registration - if mac == "": - self._first_free_address = min( - self._first_free_address, nextaddress - ) + currentaddress, mac = registration _LOGGER.debug( "Network registration at address %s is %s", - str(nextaddress), + str(currentaddress), "'empty'" if mac == "" else f"set to {mac}", ) - await self.update_network_registration(nextaddress, mac, None) - await sleep(10) - _LOGGER.debug("Full network registration finished") + if mac == "": + continue + _maintenance_registry.append(mac) + if self.update_network_registration(mac): + await self._exec_node_discover_callback(mac, None, False) + await sleep(self._registration_scan_delay) + _LOGGER.debug("CirclePlus registry scan finished") self._scan_completed = True - _LOGGER.info("Full network discovery completed") - if self._full_scan_finished is not None: - await self._full_scan_finished() - self._full_scan_finished = None + if self._network_cache is not None: + await self._network_cache.prune_cache(_maintenance_registry) + if self._scan_completed_callback is not None: + await self._scan_completed_callback() - async def update_missing_registrations_quick(self) -> None: - """Quick retrieval of all unknown network registrations from network controller.""" - for address in range(0, 64): - registration = await self.retrieve_network_registration(address, False) - if registration is not None: - nextaddress, mac = registration - if mac == "": - self._first_free_address = min( - self._first_free_address, nextaddress - ) - break - _LOGGER.debug( - "Network registration at address %s is %s", - str(nextaddress), - "'empty'" if mac == "" else f"set to {mac}", - ) - await self.update_network_registration(nextaddress, mac, None) - await sleep(0.1) - if self._registration_task is None or self._registration_task.done(): - self._registration_task = create_task( - self.update_missing_registrations_full() + async def load_registrations_from_cache(self) -> None: + """Quick retrieval of all unknown network registrations from cache.""" + if self._network_cache is None: + self._registration_scan_delay = CIRCLEPLUS_SCANREQUEST_QUICK + return + if len(self._network_cache.nodetypes) < 4: + self._registration_scan_delay = CIRCLEPLUS_SCANREQUEST_QUICK + _LOGGER.warning( + "Cache contains less than 4 nodes, fast registry scan enabled" ) - if self._quick_scan_finished is not None: - await self._quick_scan_finished() - self._quick_scan_finished = None - _LOGGER.info("Quick network registration discovery finished") + for mac, nodetype in self._network_cache.nodetypes.items(): + self.update_network_registration(mac) + await self._exec_node_discover_callback(mac, nodetype, True) + await sleep(0.1) + _LOGGER.info("Cache network registration discovery finished") + if self._scan_completed_callback is not None: + await self._scan_completed_callback() - async def update_node_registration(self, mac: str) -> int: - """Register (re)joined node to Plugwise network and return network address.""" - await self.update_network_registration(self._first_free_address, mac, None) - self._first_free_address += 1 - return self._first_free_address - 1 + def update_node_registration(self, mac: str) -> bool: + """Register (re)joined node to Plugwise network and return True if newly added.""" + return self.update_network_registration(mac) def _stop_registration_task(self) -> None: """Stop the background registration task.""" @@ -244,19 +263,16 @@ async def register_node(self, mac: str) -> None: await request.send() except StickError as exc: raise NodeError(f"{exc}") from exc + if self.update_network_registration(mac): + await self._exec_node_discover_callback(mac, None, False) 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") - mac_registered = False - for registration in self._registry.values(): - if mac == registration[0]: - mac_registered = True - break - if not mac_registered: - raise NodeError(f"No existing registration '{mac}' found to unregister") + if mac not in self._registry: + raise NodeError(f"No existing Node ({mac}) found to unregister") request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) if (response := await request.send()) is None: @@ -269,8 +285,8 @@ async def unregister_node(self, mac: str) -> None: f"The Zigbee network coordinator '{self._mac_nc!r}'" + f" failed to unregister node '{mac}'" ) - if (address := self.network_address(mac)) is not None: - await self.update_network_registration(address, mac, None) + + await self.remove_network_registration(mac) async def clear_register_cache(self) -> None: """Clear current cache.""" diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index b8a12e5bf..1096c6816 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -16,7 +16,6 @@ def get_plugwise_node( # noqa: PLR0911 mac: str, - address: int, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], node_type: NodeType, @@ -25,7 +24,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.CIRCLE_PLUS: return PlugwiseCirclePlus( mac, - address, node_type, controller, loaded_callback, @@ -33,7 +31,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.CIRCLE: return PlugwiseCircle( mac, - address, node_type, controller, loaded_callback, @@ -41,7 +38,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.SWITCH: return PlugwiseSwitch( mac, - address, node_type, controller, loaded_callback, @@ -49,7 +45,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.SENSE: return PlugwiseSense( mac, - address, node_type, controller, loaded_callback, @@ -57,7 +52,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.SCAN: return PlugwiseScan( mac, - address, node_type, controller, loaded_callback, @@ -65,7 +59,6 @@ def get_plugwise_node( # noqa: PLR0911 if node_type == NodeType.STEALTH: return PlugwiseStealth( mac, - address, node_type, controller, loaded_callback, diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c1dd72cf9..ea0150259 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -94,13 +94,12 @@ class PlugwiseCircle(PlugwiseBaseNode): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize base class for Sleeping End Device.""" - super().__init__(mac, address, node_type, controller, loaded_callback) + super().__init__(mac, node_type, controller, loaded_callback) # Relay self._relay_lock: RelayLock = RelayLock() @@ -911,8 +910,10 @@ async def load(self) -> None: # Check if node is online if ( - not self._available and not await self.is_online() - ) or await self.node_info_update() is None: + not self._available + and not await self.is_online() + or await self.node_info_update() is None + ): _LOGGER.debug( "Failed to retrieve NodeInfo for %s, loading defaults", self._mac_in_str, diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index ce238a2e6..5c223ffa0 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -59,7 +59,6 @@ class PlugwiseBaseNode(FeaturePublisher, ABC): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], @@ -73,7 +72,6 @@ def __init__( self._last_seen = datetime.now(tz=UTC) self._node_info = NodeInfo( mac=mac, - zigbee_address=address, node_type=self.node_type, ) self._ping = NetworkStatistics() @@ -223,11 +221,6 @@ def name(self) -> str: return self._node_info.name return self._mac_in_str - @property - def network_address(self) -> int: - """Zigbee network registration address.""" - return self._node_info.zigbee_address - @property def node_info(self) -> NodeInfo: """Node information.""" @@ -662,6 +655,14 @@ def _get_cache(self, setting: str) -> str | None: return None return self._node_cache.get_state(setting) + def _get_cache_as_bool(self, setting: str) -> bool | None: + """Retrieve bool of specified setting from cache memory.""" + if not self._cache_enabled: + return None + if (bool_value := self._node_cache.get_state(setting)) is None: + return None + return bool_value == "True" + def _get_cache_as_datetime(self, setting: str) -> datetime | None: """Retrieve value of specified setting from cache memory and return it as datetime object.""" if (timestamp_str := self._get_cache(setting)) is not None: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index f36fa061a..f97ddc26a 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -28,40 +28,36 @@ PlugwiseResponse, ) from ..nodes.sed import NodeSED -from .helpers import raise_not_loaded from .helpers.firmware import SCAN_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) -CACHE_MOTION_STATE = "motion_state" -CACHE_MOTION_TIMESTAMP = "motion_timestamp" -CACHE_MOTION_RESET_TIMER = "motion_reset_timer" +CACHE_SCAN_MOTION_STATE = "motion_state" +CACHE_SCAN_MOTION_TIMESTAMP = "motion_timestamp" -CACHE_SCAN_SENSITIVITY = "scan_sensitivity_level" -CACHE_SCAN_DAYLIGHT_MODE = "scan_daylight_mode" +CACHE_SCAN_CONFIG_DAYLIGHT_MODE = "scan_daylight_mode" +CACHE_SCAN_CONFIG_DIRTY = "scan_config_dirty" +CACHE_SCAN_CONFIG_RESET_TIMER = "motion_reset_timer" +CACHE_SCAN_CONFIG_SENSITIVITY = "scan_sensitivity_level" # region Defaults for Scan Devices -SCAN_DEFAULT_MOTION_STATE: Final = False +DEFAULT_MOTION_STATE: Final = False # Time in minutes the motion sensor should not sense motion to # report "no motion" state [Source: 1min - 4uur] -SCAN_DEFAULT_MOTION_RESET_TIMER: Final = 10 +DEFAULT_RESET_TIMER: Final = 10 # Default sensitivity of the motion sensors -SCAN_DEFAULT_SENSITIVITY: Final = MotionSensitivity.MEDIUM +DEFAULT_SENSITIVITY = MotionSensitivity.MEDIUM # Light override -SCAN_DEFAULT_DAYLIGHT_MODE: Final = False +DEFAULT_DAYLIGHT_MODE: Final = False # Default firmware if not known DEFAULT_FIRMWARE: Final = datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC) -# Sensitivity values for motion sensor configuration -SENSITIVITY_HIGH_VALUE = 20 # 0x14 -SENSITIVITY_MEDIUM_VALUE = 30 # 0x1E -SENSITIVITY_OFF_VALUE = 255 # 0xFF # Scan Features SCAN_FEATURES: Final = ( @@ -78,24 +74,19 @@ class PlugwiseScan(NodeSED): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, node_type, controller, loaded_callback) + super().__init__(mac, node_type, controller, loaded_callback) self._unsubscribe_switch_group: Callable[[], None] | None = None self._reset_timer_motion_on: datetime | None = None self._scan_subscription: Callable[[], None] | None = None self._motion_state = MotionState() self._motion_config = MotionConfig() - self._new_daylight_mode: bool | None = None - self._new_reset_timer: int | None = None - self._new_sensitivity_level: MotionSensitivity | None = None - self._scan_config_task_scheduled = False self._configure_daylight_mode_task: Task[Coroutine[Any, Any, None]] | None = ( None ) @@ -114,7 +105,6 @@ async def load(self) -> None: await self.initialize() await self._loaded_callback(NodeEvent.LOADED, self.mac) - @raise_not_loaded async def initialize(self) -> None: """Initialize Scan node.""" if self._initialized: @@ -137,53 +127,60 @@ async def unload(self) -> None: async def _load_defaults(self) -> None: """Load default configuration settings.""" await super()._load_defaults() - self._motion_state = MotionState( - state=SCAN_DEFAULT_MOTION_STATE, - timestamp=None, - ) - self._motion_config = MotionConfig( - reset_timer=SCAN_DEFAULT_MOTION_RESET_TIMER, - daylight_mode=SCAN_DEFAULT_DAYLIGHT_MODE, - sensitivity_level=SCAN_DEFAULT_SENSITIVITY, - ) if self._node_info.model is None: self._node_info.model = "Scan" + self._sed_node_info_update_task_scheduled = True if self._node_info.name is None: self._node_info.name = f"Scan {self._node_info.mac[-5:]}" + self._sed_node_info_update_task_scheduled = True if self._node_info.firmware is None: self._node_info.firmware = DEFAULT_FIRMWARE - self._new_reset_timer = SCAN_DEFAULT_MOTION_RESET_TIMER - self._new_daylight_mode = SCAN_DEFAULT_DAYLIGHT_MODE - self._new_sensitivity_level = SCAN_DEFAULT_SENSITIVITY - await self.schedule_task_when_awake(self._configure_scan_task()) - self._scan_config_task_scheduled = True + self._sed_node_info_update_task_scheduled = True + if self._sed_node_info_update_task_scheduled: + _LOGGER.debug( + "NodeInfo cache-miss for node %s, assuming defaults", self._mac_in_str + ) async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" + super_load_success = True if not await super()._load_from_cache(): - return False + super_load_success = False self._motion_state = MotionState( state=self._motion_from_cache(), timestamp=self._motion_timestamp_from_cache(), ) + dirty = False + if (daylight_mode := self._daylight_mode_from_cache()) is None: + dirty = True + daylight_mode = DEFAULT_DAYLIGHT_MODE + if (reset_timer := self._reset_timer_from_cache()) is None: + dirty = True + reset_timer = DEFAULT_RESET_TIMER + if (sensitivity_level := self._sensitivity_level_from_cache()) is None: + dirty = True + sensitivity_level = DEFAULT_SENSITIVITY + dirty |= self._motion_config_dirty_from_cache() + self._motion_config = MotionConfig( - daylight_mode=self._daylight_mode_from_cache(), - reset_timer=self._reset_timer_from_cache(), - sensitivity_level=self._sensitivity_level_from_cache(), + daylight_mode=daylight_mode, + reset_timer=reset_timer, + sensitivity_level=sensitivity_level, + dirty=dirty, ) - return True + if dirty: + await self._scan_configure_update() + return super_load_success - def _daylight_mode_from_cache(self) -> bool: + def _daylight_mode_from_cache(self) -> bool | None: """Load awake duration from cache.""" - if (daylight_mode := self._get_cache(CACHE_SCAN_DAYLIGHT_MODE)) is not None: - if daylight_mode == "True": - return True - return False - return SCAN_DEFAULT_DAYLIGHT_MODE + return self._get_cache_as_bool(CACHE_SCAN_CONFIG_DAYLIGHT_MODE) def _motion_from_cache(self) -> bool: """Load motion state from cache.""" - if (cached_motion_state := self._get_cache(CACHE_MOTION_STATE)) is not None: + if ( + cached_motion_state := self._get_cache(CACHE_SCAN_MOTION_STATE) + ) is not None: if ( cached_motion_state == "True" and (motion_timestamp := self._motion_timestamp_from_cache()) @@ -193,24 +190,34 @@ def _motion_from_cache(self) -> bool: ): return True return False - return SCAN_DEFAULT_MOTION_STATE + return DEFAULT_MOTION_STATE - def _reset_timer_from_cache(self) -> int: + def _reset_timer_from_cache(self) -> int | None: """Load reset timer from cache.""" - if (reset_timer := self._get_cache(CACHE_MOTION_RESET_TIMER)) is not None: + if (reset_timer := self._get_cache(CACHE_SCAN_CONFIG_RESET_TIMER)) is not None: return int(reset_timer) - return SCAN_DEFAULT_MOTION_RESET_TIMER + return None - def _sensitivity_level_from_cache(self) -> MotionSensitivity: + def _sensitivity_level_from_cache(self) -> int | None: """Load sensitivity level from cache.""" - if (sensitivity_level := self._get_cache(CACHE_SCAN_SENSITIVITY)) is not None: + if ( + sensitivity_level := self._get_cache( + CACHE_SCAN_CONFIG_SENSITIVITY + ) # returns level in string CAPITALS + ) is not None: return MotionSensitivity[sensitivity_level] - return SCAN_DEFAULT_SENSITIVITY + return None + + def _motion_config_dirty_from_cache(self) -> bool: + """Load dirty from cache.""" + if (dirty := self._get_cache_as_bool(CACHE_SCAN_CONFIG_DIRTY)) is not None: + return dirty + return True def _motion_timestamp_from_cache(self) -> datetime | None: """Load motion timestamp from cache.""" if ( - motion_timestamp := self._get_cache_as_datetime(CACHE_MOTION_TIMESTAMP) + motion_timestamp := self._get_cache_as_datetime(CACHE_SCAN_MOTION_TIMESTAMP) ) is not None: return motion_timestamp return None @@ -218,19 +225,19 @@ def _motion_timestamp_from_cache(self) -> datetime | None: # endregion # region Properties + @property + def dirty(self) -> bool: + """Motion configuration dirty flag.""" + return self._motion_config.dirty @property - @raise_not_loaded def daylight_mode(self) -> bool: """Daylight mode of motion sensor.""" - if self._new_daylight_mode is not None: - return self._new_daylight_mode if self._motion_config.daylight_mode is not None: return self._motion_config.daylight_mode - return SCAN_DEFAULT_DAYLIGHT_MODE + return DEFAULT_DAYLIGHT_MODE @property - @raise_not_loaded def motion(self) -> bool: """Motion detection value.""" if self._motion_state.state is not None: @@ -238,13 +245,11 @@ def motion(self) -> bool: raise NodeError(f"Motion state is not available for {self.name}") @property - @raise_not_loaded def motion_state(self) -> MotionState: """Motion detection state.""" return self._motion_state @property - @raise_not_loaded def motion_timestamp(self) -> datetime: """Timestamp of last motion state change.""" if self._motion_state.timestamp is not None: @@ -252,43 +257,32 @@ def motion_timestamp(self) -> datetime: raise NodeError(f"Motion timestamp is currently not available for {self.name}") @property - @raise_not_loaded def motion_config(self) -> MotionConfig: """Motion configuration.""" return MotionConfig( reset_timer=self.reset_timer, daylight_mode=self.daylight_mode, sensitivity_level=self.sensitivity_level, + dirty=self.dirty, ) @property - @raise_not_loaded def reset_timer(self) -> int: """Total minutes without motion before no motion is reported.""" - if self._new_reset_timer is not None: - return self._new_reset_timer if self._motion_config.reset_timer is not None: return self._motion_config.reset_timer - return SCAN_DEFAULT_MOTION_RESET_TIMER + return DEFAULT_RESET_TIMER @property - def scan_config_task_scheduled(self) -> bool: - """Check if a configuration task is scheduled.""" - return self._scan_config_task_scheduled - - @property - def sensitivity_level(self) -> MotionSensitivity: + def sensitivity_level(self) -> int: """Sensitivity level of motion sensor.""" - if self._new_sensitivity_level is not None: - return self._new_sensitivity_level if self._motion_config.sensitivity_level is not None: return self._motion_config.sensitivity_level - return SCAN_DEFAULT_SENSITIVITY + return DEFAULT_SENSITIVITY # endregion # region Configuration actions - @raise_not_loaded async def set_motion_daylight_mode(self, state: bool) -> bool: """Configure if motion must be detected when light level is below threshold. @@ -300,19 +294,16 @@ async def set_motion_daylight_mode(self, state: bool) -> bool: self._motion_config.daylight_mode, state, ) - self._new_daylight_mode = state if self._motion_config.daylight_mode == state: return False - if not self._scan_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_scan_task()) - self._scan_config_task_scheduled = True - _LOGGER.debug( - "set_motion_daylight_mode | Device %s | config scheduled", - self.name, - ) + self._motion_config = replace( + self._motion_config, + daylight_mode=state, + dirty=True, + ) + await self._scan_configure_update() return True - @raise_not_loaded async def set_motion_reset_timer(self, minutes: int) -> bool: """Configure the motion reset timer in minutes.""" _LOGGER.debug( @@ -325,20 +316,17 @@ async def set_motion_reset_timer(self, minutes: int) -> bool: raise ValueError( f"Invalid motion reset timer ({minutes}). It must be between 1 and 255 minutes." ) - self._new_reset_timer = minutes if self._motion_config.reset_timer == minutes: return False - if not self._scan_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_scan_task()) - self._scan_config_task_scheduled = True - _LOGGER.debug( - "set_motion_reset_timer | Device %s | config scheduled", - self.name, - ) + self._motion_config = replace( + self._motion_config, + reset_timer=minutes, + dirty=True, + ) + await self._scan_configure_update() return True - @raise_not_loaded - async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool: + async def set_motion_sensitivity_level(self, level: int) -> bool: """Configure the motion sensitivity level.""" _LOGGER.debug( "set_motion_sensitivity_level | Device %s | %s -> %s", @@ -346,16 +334,14 @@ async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool: self._motion_config.sensitivity_level, level, ) - self._new_sensitivity_level = level if self._motion_config.sensitivity_level == level: return False - if not self._scan_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_scan_task()) - self._scan_config_task_scheduled = True - _LOGGER.debug( - "set_motion_sensitivity_level | Device %s | config scheduled", - self.name, - ) + self._motion_config = replace( + self._motion_config, + sensitivity_level=level, + dirty=True, + ) + await self._scan_configure_update() return True # endregion @@ -390,12 +376,12 @@ async def _motion_state_update( ) state_update = False if motion_state: - self._set_cache(CACHE_MOTION_STATE, "True") + self._set_cache(CACHE_SCAN_MOTION_STATE, "True") if self._motion_state.state is None or not self._motion_state.state: self._reset_timer_motion_on = timestamp state_update = True else: - self._set_cache(CACHE_MOTION_STATE, "False") + self._set_cache(CACHE_SCAN_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 = int( @@ -403,9 +389,9 @@ async def _motion_state_update( ) if self._motion_config.reset_timer is None: self._motion_config = replace( - self._motion_config, - reset_timer=reset_timer, + self._motion_config, reset_timer=reset_timer, dirty=True ) + await self._scan_configure_update() elif reset_timer < self._motion_config.reset_timer: _LOGGER.warning( "Adjust reset timer for %s from %s -> %s", @@ -414,11 +400,11 @@ async def _motion_state_update( reset_timer, ) self._motion_config = replace( - self._motion_config, - reset_timer=reset_timer, + self._motion_config, reset_timer=reset_timer, dirty=True ) + await self._scan_configure_update() state_update = True - self._set_cache(CACHE_MOTION_TIMESTAMP, timestamp) + self._set_cache(CACHE_SCAN_MOTION_TIMESTAMP, timestamp) if state_update: self._motion_state = replace( self._motion_state, @@ -435,118 +421,65 @@ async def _motion_state_update( ] ) + async def _run_awake_tasks(self) -> None: + """Execute all awake tasks.""" + await super()._run_awake_tasks() + if self._motion_config.dirty: + await self._configure_scan_task() + async def _configure_scan_task(self) -> bool: """Configure Scan device settings. Returns True if successful.""" - self._scan_config_task_scheduled = False - change_required = False - if self._new_reset_timer is not None: - change_required = True - if self._new_sensitivity_level is not None: - change_required = True - if self._new_daylight_mode is not None: - change_required = True - if not change_required: + if not self._motion_config.dirty: return True - if not await self.scan_configure( - motion_reset_timer=self.reset_timer, - sensitivity_level=self.sensitivity_level, - daylight_mode=self.daylight_mode, - ): + if not await self.scan_configure(): + _LOGGER.debug("Motion Configuration for %s failed", self._mac_in_str) return False - if self._new_reset_timer is not None: - _LOGGER.info( - "Change of motion reset timer from %s to %s minutes has been accepted by %s", - self._motion_config.reset_timer, - self._new_reset_timer, - self.name, - ) - self._new_reset_timer = None - if self._new_sensitivity_level is not None: - _LOGGER.info( - "Change of sensitivity level from %s to %s has been accepted by %s", - self._motion_config.sensitivity_level, - self._new_sensitivity_level, - self.name, - ) - self._new_sensitivity_level = None - if self._new_daylight_mode is not None: - _LOGGER.info( - "Change of daylight mode from %s to %s has been accepted by %s", - "On" if self._motion_config.daylight_mode else "Off", - "On" if self._new_daylight_mode else "Off", - self.name, - ) - self._new_daylight_mode = None return True - async def scan_configure( - self, - motion_reset_timer: int, - sensitivity_level: MotionSensitivity, - daylight_mode: bool, - ) -> bool: + async def scan_configure(self) -> bool: """Configure Scan device settings. Returns True if successful.""" - 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, self._mac_in_bytes, - motion_reset_timer, - sensitivity_value, - daylight_mode, + self._motion_config.reset_timer, + self._motion_config.sensitivity_level, + self._motion_config.daylight_mode, ) - if (response := await request.send()) is not None: - if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: - self._new_reset_timer = None - self._new_sensitivity_level = None - 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 - + if (response := await request.send()) is None: _LOGGER.warning( - "Unexpected response ack type %s for %s", - response.node_ack_type, - self.name, + "No response from %s to configure motion settings request", self.name ) return False + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: + _LOGGER.warning("Failed to configure scan settings for %s", self.name) + return False + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: + _LOGGER.debug("Successful configure scan settings for %s", self.name) + self._motion_config = replace(self._motion_config, dirty=False) + await self._scan_configure_update() + return True - self._new_reset_timer = None - self._new_sensitivity_level = None - self._new_daylight_mode = None + _LOGGER.warning( + "Unexpected response ack type %s for %s", + response.node_ack_type, + self.name, + ) return False - async def _scan_configure_update( - self, - motion_reset_timer: int, - sensitivity_level: MotionSensitivity, - daylight_mode: bool, - ) -> None: - """Process result of scan configuration update.""" - self._motion_config = replace( - self._motion_config, - reset_timer=motion_reset_timer, - sensitivity_level=sensitivity_level, - daylight_mode=daylight_mode, + async def _scan_configure_update(self) -> None: + """Push scan configuration update to cache.""" + self._set_cache( + CACHE_SCAN_CONFIG_RESET_TIMER, str(self._motion_config.reset_timer) ) - self._set_cache(CACHE_MOTION_RESET_TIMER, str(motion_reset_timer)) - self._set_cache(CACHE_SCAN_SENSITIVITY, sensitivity_level.name) - if daylight_mode: - self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "True") - else: - self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "False") + self._set_cache( + CACHE_SCAN_CONFIG_SENSITIVITY, + str(MotionSensitivity(self._motion_config.sensitivity_level).name), + ) + self._set_cache( + CACHE_SCAN_CONFIG_DAYLIGHT_MODE, str(self._motion_config.daylight_mode) + ) + self._set_cache(CACHE_SCAN_CONFIG_DIRTY, str(self._motion_config.dirty)) await gather( self.publish_feature_update_to_subscribers( NodeFeature.MOTION_CONFIG, @@ -570,7 +503,6 @@ async def scan_calibrate_light(self) -> bool: + "to light calibration request." ) - @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index d454a76c6..aab5615ae 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -2,22 +2,14 @@ from __future__ import annotations -from asyncio import ( - CancelledError, - Future, - Lock, - Task, - gather, - get_running_loop, - wait_for, -) +from asyncio import CancelledError, Future, Task, gather, get_running_loop, wait_for from collections.abc import Awaitable, Callable, Coroutine from dataclasses import replace from datetime import datetime, timedelta import logging from typing import Any, Final -from ..api import BatteryConfig, NodeEvent, NodeFeature, NodeInfo, NodeType +from ..api import BatteryConfig, NodeEvent, NodeFeature, NodeType from ..connection import StickController from ..constants import MAX_UINT_2, MAX_UINT_4 from ..exceptions import MessageError, NodeError @@ -26,20 +18,19 @@ NODE_AWAKE_RESPONSE_ID, NodeAwakeResponse, NodeAwakeResponseType, - NodeInfoResponse, NodeResponseType, PlugwiseResponse, ) -from .helpers import raise_not_loaded from .node import PlugwiseBaseNode -CACHE_AWAKE_DURATION = "awake_duration" -CACHE_CLOCK_INTERVAL = "clock_interval" -CACHE_SLEEP_DURATION = "sleep_duration" -CACHE_CLOCK_SYNC = "clock_sync" -CACHE_MAINTENANCE_INTERVAL = "maintenance_interval" -CACHE_AWAKE_TIMESTAMP = "awake_timestamp" -CACHE_AWAKE_REASON = "awake_reason" +CACHE_SED_AWAKE_DURATION = "awake_duration" +CACHE_SED_CLOCK_INTERVAL = "clock_interval" +CACHE_SED_SLEEP_DURATION = "sleep_duration" +CACHE_SED_DIRTY = "sed_dirty" +CACHE_SED_CLOCK_SYNC = "clock_sync" +CACHE_SED_MAINTENANCE_INTERVAL = "maintenance_interval" +CACHE_SED_AWAKE_TIMESTAMP = "awake_timestamp" +CACHE_SED_AWAKE_REASON = "awake_reason" # Number of seconds to ignore duplicate awake messages AWAKE_RETRY: Final = 5 @@ -88,22 +79,18 @@ class NodeSED(PlugwiseBaseNode): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize base class for Sleeping End Device.""" - super().__init__(mac, address, node_type, controller, loaded_callback) + super().__init__(mac, node_type, controller, loaded_callback) self._loop = get_running_loop() self._node_info.is_battery_powered = True # Configure SED self._battery_config = BatteryConfig() - self._new_battery_config = BatteryConfig() - self._sed_config_task_scheduled = False - self._send_task_queue: list[Coroutine[Any, Any, bool]] = [] - self._send_task_lock = Lock() + self._sed_node_info_update_task_scheduled = False self._delayed_task: Task[None] | None = None self._last_awake: dict[NodeAwakeResponseType, datetime] = {} @@ -139,15 +126,8 @@ async def unload(self) -> None: self._awake_subscription() if self._delayed_task is not None and not self._delayed_task.done(): await self._delayed_task - if len(self._send_task_queue) > 0: - _LOGGER.warning( - "Unable to execute %s open tasks for %s", - len(self._send_task_queue), - self.name, - ) await super().unload() - @raise_not_loaded async def initialize(self) -> None: """Initialize SED node.""" if self._initialized: @@ -162,78 +142,89 @@ async def initialize(self) -> None: async def _load_defaults(self) -> None: """Load default configuration settings.""" - self._battery_config = BatteryConfig( - awake_duration=SED_DEFAULT_AWAKE_DURATION, - clock_interval=SED_DEFAULT_CLOCK_INTERVAL, - clock_sync=SED_DEFAULT_CLOCK_SYNC, - maintenance_interval=SED_DEFAULT_MAINTENANCE_INTERVAL, - sleep_duration=SED_DEFAULT_SLEEP_DURATION, - ) - await self.schedule_task_when_awake(self.node_info_update(None)) - self._sed_config_task_scheduled = True - self._new_battery_config = self._battery_config - await self.schedule_task_when_awake(self._configure_sed_task()) async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" + super_load_success = True if not await super()._load_from_cache(): - return False + super_load_success = False + dirty = False + if (awake_duration := self._awake_duration_from_cache()) is None: + dirty = True + awake_duration = SED_DEFAULT_AWAKE_DURATION + if (clock_interval := self._clock_interval_from_cache()) is None: + dirty = True + clock_interval = SED_DEFAULT_CLOCK_INTERVAL + if (clock_sync := self._clock_sync_from_cache()) is None: + dirty = True + clock_sync = SED_DEFAULT_CLOCK_SYNC + if (maintenance_interval := self._maintenance_interval_from_cache()) is None: + dirty = True + maintenance_interval = SED_DEFAULT_MAINTENANCE_INTERVAL + if (sleep_duration := self._sleep_duration_from_cache()) is None: + dirty = True + sleep_duration = SED_DEFAULT_SLEEP_DURATION + dirty |= self._sed_config_dirty_from_cache() self._battery_config = BatteryConfig( - awake_duration=self._awake_duration_from_cache(), - clock_interval=self._clock_interval_from_cache(), - clock_sync=self._clock_sync_from_cache(), - maintenance_interval=self._maintenance_interval_from_cache(), - sleep_duration=self._sleep_duration_from_cache(), + awake_duration=awake_duration, + clock_interval=clock_interval, + clock_sync=clock_sync, + maintenance_interval=maintenance_interval, + sleep_duration=sleep_duration, + dirty=dirty, ) + if dirty: + await self._sed_configure_update() self._awake_timestamp_from_cache() self._awake_reason_from_cache() - return True + return super_load_success - def _awake_duration_from_cache(self) -> int: + def _awake_duration_from_cache(self) -> int | None: """Load awake duration from cache.""" - if (awake_duration := self._get_cache(CACHE_AWAKE_DURATION)) is not None: + if (awake_duration := self._get_cache(CACHE_SED_AWAKE_DURATION)) is not None: return int(awake_duration) - return SED_DEFAULT_AWAKE_DURATION + return None - def _clock_interval_from_cache(self) -> int: + def _clock_interval_from_cache(self) -> int | None: """Load clock interval from cache.""" - if (clock_interval := self._get_cache(CACHE_CLOCK_INTERVAL)) is not None: + if (clock_interval := self._get_cache(CACHE_SED_CLOCK_INTERVAL)) is not None: return int(clock_interval) - return SED_DEFAULT_CLOCK_INTERVAL + return None - def _clock_sync_from_cache(self) -> bool: + def _clock_sync_from_cache(self) -> bool | None: """Load clock sync state from cache.""" - if (clock_sync := self._get_cache(CACHE_CLOCK_SYNC)) is not None: - if clock_sync == "True": - return True - return False - return SED_DEFAULT_CLOCK_SYNC + return self._get_cache_as_bool(CACHE_SED_CLOCK_SYNC) - def _maintenance_interval_from_cache(self) -> int: + def _maintenance_interval_from_cache(self) -> int | None: """Load maintenance interval from cache.""" if ( - maintenance_interval := self._get_cache(CACHE_MAINTENANCE_INTERVAL) + maintenance_interval := self._get_cache(CACHE_SED_MAINTENANCE_INTERVAL) ) is not None: self._maintenance_interval_restored_from_cache = True return int(maintenance_interval) - return SED_DEFAULT_MAINTENANCE_INTERVAL + return None - def _sleep_duration_from_cache(self) -> int: + def _sleep_duration_from_cache(self) -> int | None: """Load sleep duration from cache.""" - if (sleep_duration := self._get_cache(CACHE_SLEEP_DURATION)) is not None: + if (sleep_duration := self._get_cache(CACHE_SED_SLEEP_DURATION)) is not None: return int(sleep_duration) - return SED_DEFAULT_SLEEP_DURATION + return None def _awake_timestamp_from_cache(self) -> datetime | None: """Load last awake timestamp from cache.""" - return self._get_cache_as_datetime(CACHE_AWAKE_TIMESTAMP) + return self._get_cache_as_datetime(CACHE_SED_AWAKE_TIMESTAMP) def _awake_reason_from_cache(self) -> str | None: """Load last awake state from cache.""" - return self._get_cache(CACHE_AWAKE_REASON) + return self._get_cache(CACHE_SED_AWAKE_REASON) + + def _sed_config_dirty_from_cache(self) -> bool: + """Load battery config dirty from cache.""" + if (dirty := self._get_cache_as_bool(CACHE_SED_DIRTY)) is not None: + return dirty + return True # region Configuration actions - @raise_not_loaded async def set_awake_duration(self, seconds: int) -> bool: """Change the awake duration.""" _LOGGER.debug( @@ -247,20 +238,13 @@ async def set_awake_duration(self, seconds: int) -> bool: f"Invalid awake duration ({seconds}). It must be between 1 and 255 seconds." ) - self._new_battery_config = replace( - self._new_battery_config, awake_duration=seconds + self._battery_config = replace( + self._battery_config, + awake_duration=seconds, + dirty=True, ) - if not self._sed_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_sed_task()) - self._sed_config_task_scheduled = True - _LOGGER.debug( - "set_awake_duration | Device %s | config scheduled", - self.name, - ) - return True - @raise_not_loaded async def set_clock_interval(self, minutes: int) -> bool: """Change the clock interval.""" _LOGGER.debug( @@ -277,20 +261,11 @@ async def set_clock_interval(self, minutes: int) -> bool: if self.battery_config.clock_interval == minutes: return False - self._new_battery_config = replace( - self._new_battery_config, clock_interval=minutes + self._battery_config = replace( + self._battery_config, clock_interval=minutes, dirty=True ) - if not self._sed_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_sed_task()) - self._sed_config_task_scheduled = True - _LOGGER.debug( - "set_clock_interval | Device %s | config scheduled", - self.name, - ) - return True - @raise_not_loaded async def set_clock_sync(self, sync: bool) -> bool: """Change the clock synchronization setting.""" _LOGGER.debug( @@ -302,18 +277,11 @@ async def set_clock_sync(self, sync: bool) -> bool: if self._battery_config.clock_sync == sync: return False - self._new_battery_config = replace(self._new_battery_config, clock_sync=sync) - if not self._sed_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_sed_task()) - self._sed_config_task_scheduled = True - _LOGGER.debug( - "set_clock_sync | Device %s | config scheduled", - self.name, - ) - + self._battery_config = replace( + self._battery_config, clock_sync=sync, dirty=True + ) return True - @raise_not_loaded async def set_maintenance_interval(self, minutes: int) -> bool: """Change the maintenance interval.""" _LOGGER.debug( @@ -330,20 +298,11 @@ async def set_maintenance_interval(self, minutes: int) -> bool: if self.battery_config.maintenance_interval == minutes: return False - self._new_battery_config = replace( - self._new_battery_config, maintenance_interval=minutes + self._battery_config = replace( + self._battery_config, maintenance_interval=minutes, dirty=True ) - if not self._sed_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_sed_task()) - self._sed_config_task_scheduled = True - _LOGGER.debug( - "set_maintenance_interval | Device %s | config scheduled", - self.name, - ) - return True - @raise_not_loaded async def set_sleep_duration(self, minutes: int) -> bool: """Reconfigure the sleep duration in minutes for a Sleeping Endpoint Device. @@ -363,33 +322,26 @@ async def set_sleep_duration(self, minutes: int) -> bool: if self._battery_config.sleep_duration == minutes: return False - self._new_battery_config = replace( - self._new_battery_config, sleep_duration=minutes + self._battery_config = replace( + self._battery_config, sleep_duration=minutes, dirty=True ) - if not self._sed_config_task_scheduled: - await self.schedule_task_when_awake(self._configure_sed_task()) - self._sed_config_task_scheduled = True - _LOGGER.debug( - "set_sleep_duration | Device %s | config scheduled", - self.name, - ) - return True # endregion # region Properties @property - @raise_not_loaded + def dirty(self) -> bool: + """Battery configuration dirty flag.""" + return self._battery_config.dirty + + @property def awake_duration(self) -> int: """Duration in seconds a battery powered devices is awake.""" - if self._new_battery_config.awake_duration is not None: - return self._new_battery_config.awake_duration if self._battery_config.awake_duration is not None: return self._battery_config.awake_duration return SED_DEFAULT_AWAKE_DURATION @property - @raise_not_loaded def battery_config(self) -> BatteryConfig: """Battery related configuration settings.""" return BatteryConfig( @@ -398,55 +350,39 @@ def battery_config(self) -> BatteryConfig: clock_sync=self.clock_sync, maintenance_interval=self.maintenance_interval, sleep_duration=self.sleep_duration, + dirty=self.dirty, ) @property - @raise_not_loaded def clock_interval(self) -> int: """Return the clock interval value.""" - if self._new_battery_config.clock_interval is not None: - return self._new_battery_config.clock_interval if self._battery_config.clock_interval is not None: return self._battery_config.clock_interval return SED_DEFAULT_CLOCK_INTERVAL @property - @raise_not_loaded def clock_sync(self) -> bool: """Indicate if the internal clock must be synced.""" - if self._new_battery_config.clock_sync is not None: - return self._new_battery_config.clock_sync if self._battery_config.clock_sync is not None: return self._battery_config.clock_sync return SED_DEFAULT_CLOCK_SYNC @property - @raise_not_loaded def maintenance_interval(self) -> int: """Return the maintenance interval value. When value is scheduled to be changed the return value is the optimistic value. """ - if self._new_battery_config.maintenance_interval is not None: - return self._new_battery_config.maintenance_interval if self._battery_config.maintenance_interval is not None: return self._battery_config.maintenance_interval return SED_DEFAULT_MAINTENANCE_INTERVAL @property - def sed_config_task_scheduled(self) -> bool: - """Check if a configuration task is scheduled.""" - return self._sed_config_task_scheduled - - @property - @raise_not_loaded def sleep_duration(self) -> int: """Return the sleep duration value in minutes. When value is scheduled to be changed the return value is the optimistic value. """ - if self._new_battery_config.sleep_duration is not None: - return self._new_battery_config.sleep_duration if self._battery_config.sleep_duration is not None: return self._battery_config.sleep_duration return SED_DEFAULT_SLEEP_DURATION @@ -454,51 +390,18 @@ def sleep_duration(self) -> int: # endregion async def _configure_sed_task(self) -> bool: """Configure SED settings. Returns True if successful.""" - _LOGGER.debug( - "_configure_sed_task | Device %s | start", - self.name, - ) - self._sed_config_task_scheduled = False - 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, - ) + if not self._battery_config.dirty: return True - _LOGGER.debug( - "_configure_sed_task | Device %s | request change", - self.name, + "_configure_sed_task | Node %s | request change", + self._mac_in_str, ) - if not await self.sed_configure( - awake_duration=self.awake_duration, - clock_interval=self.clock_interval, - clock_sync=self.clock_sync, - maintenance_interval=self.maintenance_interval, - sleep_duration=self.sleep_duration, - ): + if not await self.sed_configure(): + _LOGGER.debug("Battery Configuration for %s failed", self._mac_in_str) return False return True - async def node_info_update( - self, node_info: NodeInfoResponse | None = None - ) -> NodeInfo | None: - """Update Node (hardware) information.""" - if node_info is not None and self.skip_update(self._node_info, 86400): - return self._node_info - return await super().node_info_update(node_info) - async def _awake_response(self, response: PlugwiseResponse) -> bool: """Process awake message.""" if not isinstance(response, NodeAwakeResponse): @@ -507,7 +410,7 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: ) _LOGGER.debug("Device %s is awake for %s", self.name, response.awake_type) - self._set_cache(CACHE_AWAKE_TIMESTAMP, response.timestamp) + self._set_cache(CACHE_SED_AWAKE_TIMESTAMP, response.timestamp) await self._available_update_state(True, response.timestamp) # Pre populate the last awake timestamp @@ -531,28 +434,27 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: self.save_cache(), ] self._delayed_task = self._loop.create_task( - self._send_tasks(), name=f"Delayed update for {self._mac_in_str}" + self._run_awake_tasks(), name=f"Delayed update for {self._mac_in_str}" ) if response.awake_type == NodeAwakeResponseType.MAINTENANCE: self._last_awake_reason = "Maintenance" - self._set_cache(CACHE_AWAKE_REASON, "Maintenance") - + self._set_cache(CACHE_SED_AWAKE_REASON, "Maintenance") if not self._maintenance_interval_restored_from_cache: self._detect_maintenance_interval(response.timestamp) if self._ping_at_awake: tasks.append(self.update_ping_at_awake()) elif response.awake_type == NodeAwakeResponseType.FIRST: self._last_awake_reason = "First" - self._set_cache(CACHE_AWAKE_REASON, "First") + self._set_cache(CACHE_SED_AWAKE_REASON, "First") elif response.awake_type == NodeAwakeResponseType.STARTUP: self._last_awake_reason = "Startup" - self._set_cache(CACHE_AWAKE_REASON, "Startup") + self._set_cache(CACHE_SED_AWAKE_REASON, "Startup") elif response.awake_type == NodeAwakeResponseType.STATE: self._last_awake_reason = "State update" - self._set_cache(CACHE_AWAKE_REASON, "State update") + self._set_cache(CACHE_SED_AWAKE_REASON, "State update") elif response.awake_type == NodeAwakeResponseType.BUTTON: self._last_awake_reason = "Button press" - self._set_cache(CACHE_AWAKE_REASON, "Button press") + self._set_cache(CACHE_SED_AWAKE_REASON, "Button press") if self._ping_at_awake: tasks.append(self.update_ping_at_awake()) @@ -585,14 +487,14 @@ def _detect_maintenance_interval(self, timestamp: datetime) -> None: self._battery_config = replace( self._battery_config, maintenance_interval=new_interval_in_min ) - self._set_cache(CACHE_MAINTENANCE_INTERVAL, new_interval_in_min) + self._set_cache(CACHE_SED_MAINTENANCE_INTERVAL, new_interval_in_min) elif (new_interval_in_sec - SED_MAX_MAINTENANCE_INTERVAL_OFFSET) > ( SED_DEFAULT_MAINTENANCE_INTERVAL * 60 ): self._battery_config = replace( self._battery_config, maintenance_interval=new_interval_in_min ) - self._set_cache(CACHE_MAINTENANCE_INTERVAL, new_interval_in_min) + self._set_cache(CACHE_SED_MAINTENANCE_INTERVAL, new_interval_in_min) else: # Within off-set margin of default, so use the default self._battery_config = replace( @@ -600,7 +502,7 @@ def _detect_maintenance_interval(self, timestamp: datetime) -> None: maintenance_interval=SED_DEFAULT_MAINTENANCE_INTERVAL, ) self._set_cache( - CACHE_MAINTENANCE_INTERVAL, SED_DEFAULT_MAINTENANCE_INTERVAL + CACHE_SED_MAINTENANCE_INTERVAL, SED_DEFAULT_MAINTENANCE_INTERVAL ) self._maintenance_interval_restored_from_cache = True @@ -643,74 +545,49 @@ async def _awake_timer(self) -> None: pass self._awake_future = None - async def _send_tasks(self) -> None: - """Send all tasks in queue.""" - if len(self._send_task_queue) == 0: - return - async with self._send_task_lock: - task_result = await gather(*self._send_task_queue) - if not all(task_result): - _LOGGER.warning( - "Executed %s tasks (result=%s) for %s", - len(self._send_task_queue), - task_result, - self.name, - ) - self._send_task_queue = [] + async def _run_awake_tasks(self) -> None: + """Execute all awake tasks.""" + _LOGGER.debug("_run_awake_tasks | Device %s", self.name) + if ( + self._sed_node_info_update_task_scheduled + and await self.node_info_update(None) is not None + ): + self._sed_node_info_update_task_scheduled = False - async def schedule_task_when_awake( - self, task_fn: Coroutine[Any, Any, bool] - ) -> None: - """Add task to queue to be executed when node is awake.""" - async with self._send_task_lock: - self._send_task_queue.append(task_fn) + if self._battery_config.dirty: + await self._configure_sed_task() - async def sed_configure( # pylint: disable=too-many-arguments - self, - awake_duration: int, - sleep_duration: int, - maintenance_interval: int, - clock_sync: bool, - clock_interval: int, - ) -> bool: + async def sed_configure(self) -> bool: """Reconfigure the sleep/awake settings for a SED send at next awake of SED.""" request = NodeSleepConfigRequest( self._send, self._mac_in_bytes, - awake_duration, - maintenance_interval, - sleep_duration, - clock_sync, - clock_interval, + self._battery_config.awake_duration, + self._battery_config.maintenance_interval, + self._battery_config.sleep_duration, + self._battery_config.clock_sync, + self._battery_config.clock_interval, ) _LOGGER.debug( "sed_configure | Device %s | awake_duration=%s | clock_interval=%s | clock_sync=%s | maintenance_interval=%s | sleep_duration=%s", self.name, - awake_duration, - clock_interval, - clock_sync, - maintenance_interval, - sleep_duration, + self._battery_config.awake_duration, + self._battery_config.clock_interval, + self._battery_config.clock_sync, + self._battery_config.maintenance_interval, + self._battery_config.sleep_duration, ) if (response := await request.send()) is None: - self._new_battery_config = BatteryConfig() _LOGGER.warning( "No response from %s to configure sleep settings request", self.name ) return False if response.response_type == NodeResponseType.SED_CONFIG_FAILED: - self._new_battery_config = BatteryConfig() _LOGGER.warning("Failed to configure sleep settings for %s", self.name) return False if response.response_type == NodeResponseType.SED_CONFIG_ACCEPTED: - await self._sed_configure_update( - awake_duration, - clock_interval, - clock_sync, - maintenance_interval, - sleep_duration, - ) - self._new_battery_config = BatteryConfig() + self._battery_config = replace(self._battery_config, dirty=False) + await self._sed_configure_update() return True _LOGGER.warning( "Unexpected response type %s for %s", @@ -719,31 +596,23 @@ async def sed_configure( # pylint: disable=too-many-arguments ) return False - # pylint: disable=too-many-arguments - async def _sed_configure_update( - self, - awake_duration: int = SED_DEFAULT_AWAKE_DURATION, - clock_interval: int = SED_DEFAULT_CLOCK_INTERVAL, - clock_sync: bool = SED_DEFAULT_CLOCK_SYNC, - maintenance_interval: int = SED_DEFAULT_MAINTENANCE_INTERVAL, - sleep_duration: int = SED_DEFAULT_SLEEP_DURATION, - ) -> None: + async def _sed_configure_update(self) -> None: """Process result of SED configuration update.""" - self._battery_config = BatteryConfig( - awake_duration=awake_duration, - clock_interval=clock_interval, - clock_sync=clock_sync, - maintenance_interval=maintenance_interval, - sleep_duration=sleep_duration, + self._set_cache( + CACHE_SED_MAINTENANCE_INTERVAL, + str(self._battery_config.maintenance_interval), ) - self._set_cache(CACHE_MAINTENANCE_INTERVAL, str(maintenance_interval)) - self._set_cache(CACHE_AWAKE_DURATION, str(awake_duration)) - self._set_cache(CACHE_CLOCK_INTERVAL, str(clock_interval)) - self._set_cache(CACHE_SLEEP_DURATION, str(sleep_duration)) - if clock_sync: - self._set_cache(CACHE_CLOCK_SYNC, "True") - else: - self._set_cache(CACHE_CLOCK_SYNC, "False") + self._set_cache( + CACHE_SED_AWAKE_DURATION, str(self._battery_config.awake_duration) + ) + self._set_cache( + CACHE_SED_CLOCK_INTERVAL, str(self._battery_config.clock_interval) + ) + self._set_cache( + CACHE_SED_SLEEP_DURATION, str(self._battery_config.sleep_duration) + ) + self._set_cache(CACHE_SED_CLOCK_SYNC, str(self._battery_config.clock_sync)) + self._set_cache(CACHE_SED_DIRTY, str(self._battery_config.dirty)) await gather( *[ self.save_cache(), @@ -754,7 +623,6 @@ async def _sed_configure_update( ] ) - @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 36fc06a6f..2b61a2c53 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -41,13 +41,12 @@ class PlugwiseSense(NodeSED): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, node_type, controller, loaded_callback) + super().__init__(mac, node_type, controller, loaded_callback) self._sense_statistics = SenseStatistics() @@ -95,10 +94,13 @@ async def _load_defaults(self) -> None: ) if self._node_info.model is None: self._node_info.model = "Sense" + self._sed_node_info_update_task_scheduled = True if self._node_info.name is None: self._node_info.name = f"Sense {self._node_info.mac[-5:]}" + self._sed_node_info_update_task_scheduled = True if self._node_info.firmware is None: self._node_info.firmware = DEFAULT_FIRMWARE + self._sed_node_info_update_task_scheduled = True # endregion diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 048ab72b3..12489f722 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -36,13 +36,12 @@ class PlugwiseSwitch(NodeSED): def __init__( self, mac: str, - address: int, node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, node_type, controller, loaded_callback) + super().__init__(mac, node_type, controller, loaded_callback) self._switch_subscription: Callable[[], None] | None = None self._switch = SwitchGroup() @@ -83,10 +82,13 @@ async def _load_defaults(self) -> None: await super()._load_defaults() if self._node_info.model is None: self._node_info.model = "Switch" + self._sed_node_info_update_task_scheduled = True if self._node_info.name is None: self._node_info.name = f"Switch {self._node_info.mac[-5:]}" + self._sed_node_info_update_task_scheduled = True if self._node_info.firmware is None: self._node_info.firmware = DEFAULT_FIRMWARE + self._sed_node_info_update_task_scheduled = True # endregion diff --git a/pyproject.toml b/pyproject.toml index 8dfa8545b..cb97c1a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.9" +version = "0.44.10" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ diff --git a/tests/test_usb.py b/tests/test_usb.py index 7044c4522..cfef1353e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -332,7 +332,7 @@ async def test_msg_properties(self) -> None: ) assert unix_timestamp.serialize() == b"4E08478A" with pytest.raises(pw_exceptions.MessageError): - assert unix_timestamp.value == dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC) + unix_timestamp.value unix_timestamp.deserialize(b"4E08478A") assert unix_timestamp.value == dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC) @@ -343,11 +343,11 @@ async def test_stick_connect_without_port(self) -> None: assert stick.nodes == {} assert stick.joined_nodes is None with pytest.raises(pw_exceptions.StickError): - assert stick.mac_stick + stick.mac_stick with pytest.raises(pw_exceptions.StickError): - assert stick.mac_coordinator + stick.mac_coordinator with pytest.raises(pw_exceptions.StickError): - assert stick.network_id + stick.network_id assert not stick.network_discovered assert not stick.network_state @@ -501,6 +501,7 @@ async def test_stick_connection_lost(self, monkeypatch: pytest.MonkeyPatch) -> N async def node_awake(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] """Handle awake event callback.""" + _LOGGER.debug("Node %s has event %s", mac, str(event)) if event == pw_api.NodeEvent.AWAKE: self.test_node_awake.set_result(mac) else: @@ -546,8 +547,20 @@ async def node_motion_state( ) ) + async def _wait_for_scan(self, stick) -> None: + """Wait for scan completion with timeout.""" + + async def wait_scan_completed(): + while not stick._network._register.scan_completed: + await asyncio.sleep(0.1) + + try: + await asyncio.wait_for(wait_scan_completed(), timeout=10) + except TimeoutError: + pytest.fail("Scan did not complete within 10 seconds") + @pytest.mark.asyncio - async def test_stick_node_discovered_subscription( + async def test_stick_node_discovered_subscription( # noqa: PLR0915 self, monkeypatch: pytest.MonkeyPatch ) -> None: """Testing "new_node" subscription for Scan.""" @@ -563,6 +576,7 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) + await self._wait_for_scan(stick) self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, @@ -688,9 +702,10 @@ async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - assert stick.joined_nodes == 11 + await self._wait_for_scan(stick) + assert stick.joined_nodes == 9 assert stick.nodes.get("0098765432101234") is not None - assert len(stick.nodes) == 6 # Discovered nodes + assert len(stick.nodes) == 7 # Discovered nodes await stick.disconnect() async def node_relay_state( @@ -762,6 +777,7 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) + await self._wait_for_scan(stick) # Validate if NodeError is raised when device is not loaded with pytest.raises(pw_exceptions.NodeError): @@ -827,7 +843,7 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No # Test non-support relay configuration with pytest.raises(pw_exceptions.FeatureError): - assert stick.nodes["0098765432101234"].relay_config is not None + stick.nodes["0098765432101234"].relay_config with pytest.raises(pw_exceptions.FeatureError): await stick.nodes["0098765432101234"].set_relay_init(True) with pytest.raises(pw_exceptions.FeatureError): @@ -899,6 +915,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) + await self._wait_for_scan(stick) # Check calibration in unloaded state assert not stick.nodes["0098765432101234"].calibrated @@ -1789,7 +1806,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign test_node = pw_sed.PlugwiseBaseNode( "1298347650AFBECD", - 1, pw_api.NodeType.CIRCLE, mock_stick_controller, load_callback, @@ -1800,33 +1816,28 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Validate to raise exception when node is not yet loaded with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_awake_duration(5) is not None + await test_node.set_awake_duration(5) with pytest.raises(pw_exceptions.NodeError): - assert test_node.battery_config is not None + test_node.battery_config with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_clock_interval(5) is not None + await test_node.set_clock_interval(5) with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_clock_sync(False) is not None + await test_node.set_clock_sync(False) with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_sleep_duration(5) is not None + await test_node.set_sleep_duration(5) with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_motion_daylight_mode(True) is not None + await test_node.set_motion_daylight_mode(True) with pytest.raises(pw_exceptions.NodeError): - assert ( - await test_node.set_motion_sensitivity_level( - pw_api.MotionSensitivity.HIGH - ) - is not None - ) + await test_node.set_motion_sensitivity_level(20) with pytest.raises(pw_exceptions.NodeError): - assert await test_node.set_motion_reset_timer(5) is not None + await test_node.set_motion_reset_timer(5) # Validate to raise NotImplementedError calling load() at basenode with pytest.raises(NotImplementedError): @@ -1836,64 +1847,54 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # Validate to raise exception when feature is not supported with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_awake_duration(5) is not None + await test_node.set_awake_duration(5) with pytest.raises(pw_exceptions.FeatureError): - assert test_node.battery_config is not None + test_node.battery_config with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_clock_interval(5) is not None + await test_node.set_clock_interval(5) with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_clock_sync(False) is not None + await test_node.set_clock_sync(False) with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_sleep_duration(5) is not None + await test_node.set_sleep_duration(5) with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_motion_daylight_mode(True) is not None + await test_node.set_motion_daylight_mode(True) with pytest.raises(pw_exceptions.FeatureError): - assert ( - await test_node.set_motion_sensitivity_level( - pw_api.MotionSensitivity.HIGH - ) - is not None - ) + await test_node.set_motion_sensitivity_level(20) with pytest.raises(pw_exceptions.FeatureError): - assert await test_node.set_motion_reset_timer(5) is not None + await test_node.set_motion_reset_timer(5) # Add battery feature to test raising not implemented # for battery related properties test_node._features += (pw_api.NodeFeature.BATTERY,) # pylint: disable=protected-access with pytest.raises(NotImplementedError): - assert await test_node.set_awake_duration(5) is not None + await test_node.set_awake_duration(5) with pytest.raises(NotImplementedError): - assert test_node.battery_config is not None + test_node.battery_config with pytest.raises(NotImplementedError): - assert await test_node.set_clock_interval(5) is not None + await test_node.set_clock_interval(5) with pytest.raises(NotImplementedError): - assert await test_node.set_clock_sync(False) is not None + await test_node.set_clock_sync(False) with pytest.raises(NotImplementedError): - assert await test_node.set_sleep_duration(5) is not None + await test_node.set_sleep_duration(5) test_node._features += (pw_api.NodeFeature.MOTION,) # pylint: disable=protected-access with pytest.raises(NotImplementedError): - assert await test_node.set_motion_daylight_mode(True) is not None + await test_node.set_motion_daylight_mode(True) with pytest.raises(NotImplementedError): - assert ( - await test_node.set_motion_sensitivity_level( - pw_api.MotionSensitivity.HIGH - ) - is not None - ) + await test_node.set_motion_sensitivity_level(20) with pytest.raises(NotImplementedError): - assert await test_node.set_motion_reset_timer(5) is not None + await test_node.set_motion_reset_timer(5) assert not test_node.cache_enabled assert test_node.mac == "1298347650AFBECD" @@ -1908,23 +1909,28 @@ def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 return "2011-6-27-8-55-44" if setting == pw_node.CACHE_HARDWARE: return "080007" - if setting == pw_node.CACHE_NODE_TYPE: - return "6" if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: return "2024-12-7-1-0-0" - if setting == pw_sed.CACHE_AWAKE_DURATION: + if setting == pw_sed.CACHE_SED_AWAKE_DURATION: return "20" - if setting == pw_sed.CACHE_CLOCK_INTERVAL: + if setting == pw_sed.CACHE_SED_CLOCK_INTERVAL: return "12600" - if setting == pw_sed.CACHE_CLOCK_SYNC: - return "True" - if setting == pw_sed.CACHE_MAINTENANCE_INTERVAL: - return "43200" - if setting == pw_sed.CACHE_SLEEP_DURATION: - return "120" + if setting == pw_sed.CACHE_SED_MAINTENANCE_INTERVAL: + return "60" + if setting == pw_sed.CACHE_SED_SLEEP_DURATION: + return "60" + return None + + def fake_cache_bool(dummy: object, setting: str) -> bool | None: + """Fake cache_bool retrieval.""" + if setting in (pw_sed.CACHE_SED_CLOCK_SYNC, pw_sed.CACHE_SED_DIRTY): + return False return None monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + monkeypatch.setattr( + pw_node.PlugwiseBaseNode, "_get_cache_as_bool", fake_cache_bool + ) mock_stick_controller = MockStickController() async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] @@ -1932,23 +1938,12 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign test_sed = pw_sed.NodeSED( "1298347650AFBECD", - 1, pw_api.NodeType.SCAN, mock_stick_controller, load_callback, ) assert not test_sed.cache_enabled - # Validate SED properties raise exception when node is not yet loaded - with pytest.raises(pw_exceptions.NodeError): - assert test_sed.battery_config is not None - - with pytest.raises(pw_exceptions.NodeError): - assert test_sed.battery_config is not None - - with pytest.raises(pw_exceptions.NodeError): - assert await test_sed.set_maintenance_interval(10) - assert test_sed.node_info.is_battery_powered assert test_sed.is_battery_powered await test_sed.load() @@ -1971,16 +1966,17 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) # test awake duration - assert test_sed.awake_duration == 10 - assert test_sed.battery_config.awake_duration == 10 + assert not test_sed.battery_config.dirty + assert test_sed.awake_duration == 20 + assert test_sed.battery_config.awake_duration == 20 with pytest.raises(ValueError): - assert await test_sed.set_awake_duration(0) + await test_sed.set_awake_duration(0) with pytest.raises(ValueError): - assert await test_sed.set_awake_duration(256) + await test_sed.set_awake_duration(256) assert await test_sed.set_awake_duration(10) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty assert await test_sed.set_awake_duration(15) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty assert test_sed.battery_config.awake_duration == 15 assert test_sed.awake_duration == 15 @@ -1992,9 +1988,9 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign mock_stick_controller.send_response = sed_config_failed await test_sed._awake_response(awake_response1) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled - assert test_sed.battery_config.awake_duration == 10 - assert test_sed.awake_duration == 10 + assert test_sed.battery_config.dirty + assert test_sed.battery_config.awake_duration == 15 + assert test_sed.awake_duration == 15 # Successful config awake_response2 = pw_responses.NodeAwakeResponse() @@ -2005,11 +2001,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign seconds=pw_sed.AWAKE_RETRY ) assert await test_sed.set_awake_duration(15) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty mock_stick_controller.send_response = sed_config_accepted await test_sed._awake_response(awake_response2) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled + assert not test_sed.battery_config.dirty assert test_sed.battery_config.awake_duration == 15 assert test_sed.awake_duration == 15 @@ -2017,12 +2013,13 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.maintenance_interval == 60 assert test_sed.battery_config.maintenance_interval == 60 with pytest.raises(ValueError): - assert await test_sed.set_maintenance_interval(0) + await test_sed.set_maintenance_interval(0) with pytest.raises(ValueError): - assert await test_sed.set_maintenance_interval(65536) + await test_sed.set_maintenance_interval(1500) assert not await test_sed.set_maintenance_interval(60) + assert not test_sed.battery_config.dirty assert await test_sed.set_maintenance_interval(30) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty awake_response3 = pw_responses.NodeAwakeResponse() awake_response3.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2032,20 +2029,21 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_sed._awake_response(awake_response3) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled + assert not test_sed.battery_config.dirty assert test_sed.battery_config.maintenance_interval == 30 assert test_sed.maintenance_interval == 30 # test clock interval - assert test_sed.clock_interval == 25200 - assert test_sed.battery_config.clock_interval == 25200 + assert test_sed.clock_interval == 12600 + assert test_sed.battery_config.clock_interval == 12600 with pytest.raises(ValueError): - assert await test_sed.set_clock_interval(0) + await test_sed.set_clock_interval(0) with pytest.raises(ValueError): - assert await test_sed.set_clock_interval(65536) - assert not await test_sed.set_clock_interval(25200) - assert await test_sed.set_clock_interval(12600) - assert test_sed.sed_config_task_scheduled + await test_sed.set_clock_interval(65536) + assert not await test_sed.set_clock_interval(12600) + assert not test_sed.battery_config.dirty + assert await test_sed.set_clock_interval(15000) + assert test_sed.battery_config.dirty awake_response4 = pw_responses.NodeAwakeResponse() awake_response4.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2055,16 +2053,16 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_sed._awake_response(awake_response4) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled - assert test_sed.battery_config.clock_interval == 12600 - assert test_sed.clock_interval == 12600 + assert not test_sed.battery_config.dirty + assert test_sed.battery_config.clock_interval == 15000 + assert test_sed.clock_interval == 15000 # test clock sync assert not test_sed.clock_sync assert not test_sed.battery_config.clock_sync assert not await test_sed.set_clock_sync(False) assert await test_sed.set_clock_sync(True) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty awake_response5 = pw_responses.NodeAwakeResponse() awake_response5.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2074,7 +2072,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_sed._awake_response(awake_response5) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled + assert not test_sed.battery_config.dirty assert test_sed.battery_config.clock_sync assert test_sed.clock_sync @@ -2082,12 +2080,12 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.sleep_duration == 60 assert test_sed.battery_config.sleep_duration == 60 with pytest.raises(ValueError): - assert await test_sed.set_sleep_duration(0) + await test_sed.set_sleep_duration(0) with pytest.raises(ValueError): - assert await test_sed.set_sleep_duration(65536) + await test_sed.set_sleep_duration(65536) assert not await test_sed.set_sleep_duration(60) assert await test_sed.set_sleep_duration(120) - assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.dirty awake_response6 = pw_responses.NodeAwakeResponse() awake_response6.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2097,7 +2095,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_sed._awake_response(awake_response6) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_sed.sed_config_task_scheduled + assert not test_sed.battery_config.dirty assert test_sed.battery_config.sleep_duration == 120 assert test_sed.sleep_duration == 120 @@ -2115,31 +2113,41 @@ def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 PLR0 return "True" if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: return "2024-12-7-1-0-0" - if setting == pw_sed.CACHE_AWAKE_DURATION: + if setting == pw_sed.CACHE_SED_AWAKE_DURATION: return "20" - if setting == pw_sed.CACHE_CLOCK_INTERVAL: + if setting == pw_sed.CACHE_SED_CLOCK_INTERVAL: return "12600" - if setting == pw_sed.CACHE_CLOCK_SYNC: - return "True" - if setting == pw_sed.CACHE_MAINTENANCE_INTERVAL: - return "43200" - if setting == pw_sed.CACHE_SLEEP_DURATION: - return "120" - if setting == pw_scan.CACHE_MOTION_STATE: - return "False" - if setting == pw_scan.CACHE_MOTION_TIMESTAMP: + if setting == pw_sed.CACHE_SED_MAINTENANCE_INTERVAL: + return "60" + if setting == pw_sed.CACHE_SED_SLEEP_DURATION: + return "60" + if setting == pw_scan.CACHE_SCAN_MOTION_TIMESTAMP: return "2024-12-6-1-0-0" - if setting == pw_scan.CACHE_MOTION_RESET_TIMER: + if setting == pw_scan.CACHE_SCAN_CONFIG_RESET_TIMER: return "10" - if setting == pw_scan.CACHE_SCAN_SENSITIVITY: + if setting == pw_scan.CACHE_SCAN_CONFIG_SENSITIVITY: return "MEDIUM" - if setting == pw_scan.CACHE_SCAN_DAYLIGHT_MODE: - return "True" + return None + + def fake_cache_bool(dummy: object, setting: str) -> bool | None: + """Fake cache_bool retrieval.""" + if setting == pw_sed.CACHE_SED_CLOCK_SYNC: + return True + if setting == pw_sed.CACHE_SED_DIRTY: + return False + if setting in ( + pw_scan.CACHE_SCAN_MOTION_STATE, + pw_scan.CACHE_SCAN_CONFIG_DAYLIGHT_MODE, + pw_scan.CACHE_SCAN_CONFIG_DIRTY, + ): + return False return None monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + monkeypatch.setattr( + pw_node.PlugwiseBaseNode, "_get_cache_as_bool", fake_cache_bool + ) mock_stick_controller = MockStickController() - scan_config_accepted = pw_responses.NodeAckResponse() scan_config_accepted.deserialize( construct_message(b"0100555555555555555500BE", b"0000") @@ -2154,7 +2162,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign test_scan = pw_scan.PlugwiseScan( "1298347650AFBECD", - 1, pw_api.NodeType.SCAN, mock_stick_controller, load_callback, @@ -2171,16 +2178,17 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign await test_scan.load() # test motion reset timer + assert not test_scan.battery_config.dirty assert test_scan.reset_timer == 10 assert test_scan.motion_config.reset_timer == 10 with pytest.raises(ValueError): - assert await test_scan.set_motion_reset_timer(0) + await test_scan.set_motion_reset_timer(0) with pytest.raises(ValueError): - assert await test_scan.set_motion_reset_timer(256) + await test_scan.set_motion_reset_timer(256) assert not await test_scan.set_motion_reset_timer(10) - assert test_scan.scan_config_task_scheduled + assert not test_scan.motion_config.dirty assert await test_scan.set_motion_reset_timer(15) - assert test_scan.scan_config_task_scheduled + assert test_scan.motion_config.dirty assert test_scan.reset_timer == 15 assert test_scan.motion_config.reset_timer == 15 @@ -2192,7 +2200,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign mock_stick_controller.send_response = scan_config_failed await test_scan._awake_response(awake_response1) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_scan.scan_config_task_scheduled + assert test_scan.motion_config.dirty # Successful config awake_response2 = pw_responses.NodeAwakeResponse() @@ -2204,10 +2212,10 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) mock_stick_controller.send_response = scan_config_accepted assert await test_scan.set_motion_reset_timer(25) - assert test_scan.scan_config_task_scheduled + assert test_scan.motion_config.dirty await test_scan._awake_response(awake_response2) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_scan.scan_config_task_scheduled + assert not test_scan.motion_config.dirty assert test_scan.reset_timer == 25 assert test_scan.motion_config.reset_timer == 25 @@ -2215,9 +2223,9 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert not test_scan.daylight_mode assert not test_scan.motion_config.daylight_mode assert not await test_scan.set_motion_daylight_mode(False) - assert not test_scan.scan_config_task_scheduled + assert not test_scan.motion_config.dirty assert await test_scan.set_motion_daylight_mode(True) - assert test_scan.scan_config_task_scheduled + assert test_scan.motion_config.dirty awake_response3 = pw_responses.NodeAwakeResponse() awake_response3.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2227,23 +2235,18 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_scan._awake_response(awake_response3) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_scan.scan_config_task_scheduled + assert not test_scan.motion_config.dirty assert test_scan.daylight_mode assert test_scan.motion_config.daylight_mode # test motion sensitivity level - assert test_scan.sensitivity_level == pw_api.MotionSensitivity.MEDIUM - assert ( - test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.MEDIUM - ) - assert not await test_scan.set_motion_sensitivity_level( - pw_api.MotionSensitivity.MEDIUM - ) - assert not test_scan.scan_config_task_scheduled - assert await test_scan.set_motion_sensitivity_level( - pw_api.MotionSensitivity.HIGH - ) - assert test_scan.scan_config_task_scheduled + assert test_scan.sensitivity_level == 30 + assert test_scan.motion_config.sensitivity_level == 30 + assert not await test_scan.set_motion_sensitivity_level(30) + + assert not test_scan.motion_config.dirty + assert await test_scan.set_motion_sensitivity_level(20) + assert test_scan.motion_config.dirty awake_response4 = pw_responses.NodeAwakeResponse() awake_response4.deserialize( construct_message(b"004F555555555555555500", b"FFFE") @@ -2253,17 +2256,14 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_scan._awake_response(awake_response4) # pylint: disable=protected-access await asyncio.sleep(0.001) # Ensure time for task to be executed - assert not test_scan.scan_config_task_scheduled - assert test_scan.sensitivity_level == pw_api.MotionSensitivity.HIGH - assert ( - test_scan.motion_config.sensitivity_level == pw_api.MotionSensitivity.HIGH - ) + assert not test_scan.motion_config.dirty + assert test_scan.sensitivity_level == 20 + assert test_scan.motion_config.sensitivity_level == 20 # scan with cache enabled mock_stick_controller.send_response = None test_scan = pw_scan.PlugwiseScan( "1298347650AFBECD", - 1, pw_api.NodeType.SCAN, mock_stick_controller, load_callback, @@ -2314,19 +2314,26 @@ def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 return "2024-12-7-1-0-0" if setting == pw_node.CACHE_RELAY: return "True" - if setting == pw_sed.CACHE_AWAKE_DURATION: + if setting == pw_sed.CACHE_SED_AWAKE_DURATION: return "15" - if setting == pw_sed.CACHE_CLOCK_INTERVAL: + if setting == pw_sed.CACHE_SED_CLOCK_INTERVAL: return "14600" - if setting == pw_sed.CACHE_CLOCK_SYNC: - return "False" - if setting == pw_sed.CACHE_MAINTENANCE_INTERVAL: + if setting == pw_sed.CACHE_SED_MAINTENANCE_INTERVAL: return "900" - if setting == pw_sed.CACHE_SLEEP_DURATION: + if setting == pw_sed.CACHE_SED_SLEEP_DURATION: return "180" return None + def fake_cache_bool(dummy: object, setting: str) -> bool | None: + """Fake cache_bool retrieval.""" + if setting in (pw_sed.CACHE_SED_CLOCK_SYNC, pw_sed.CACHE_SED_DIRTY): + return False + return None + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + monkeypatch.setattr( + pw_node.PlugwiseBaseNode, "_get_cache_as_bool", fake_cache_bool + ) mock_stick_controller = MockStickController() async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] @@ -2334,7 +2341,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign test_switch = pw_switch.PlugwiseSwitch( "1298347650AFBECD", - 1, pw_api.NodeType.SWITCH, mock_stick_controller, load_callback, @@ -2364,7 +2370,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # switch with cache enabled test_switch = pw_switch.PlugwiseSwitch( "1298347650AFBECD", - 1, pw_api.NodeType.SWITCH, mock_stick_controller, load_callback, @@ -2405,6 +2410,37 @@ async def test_node_discovery_and_load( # noqa: PLR0915 self, monkeypatch: pytest.MonkeyPatch ) -> None: """Testing discovery of nodes.""" + + def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 + """Fake cache retrieval.""" + if setting == pw_node.CACHE_FIRMWARE: + return "2011-5-13-7-26-54" + if setting == pw_node.CACHE_HARDWARE: + return "080029" + if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: + return "2024-12-7-1-0-0" + if setting == pw_node.CACHE_RELAY: + return "True" + if setting == pw_sed.CACHE_SED_AWAKE_DURATION: + return "10" + if setting == pw_sed.CACHE_SED_CLOCK_INTERVAL: + return "25200" + if setting == pw_sed.CACHE_SED_MAINTENANCE_INTERVAL: + return "60" + if setting == pw_sed.CACHE_SED_SLEEP_DURATION: + return "60" + return None + + def fake_cache_bool(dummy: object, setting: str) -> bool | None: + """Fake cache_bool retrieval.""" + if setting in (pw_sed.CACHE_SED_CLOCK_SYNC, pw_sed.CACHE_SED_DIRTY): + return False + return None + + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + monkeypatch.setattr( + pw_node.PlugwiseBaseNode, "_get_cache_as_bool", fake_cache_bool + ) mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -2427,8 +2463,8 @@ async def test_node_discovery_and_load( # noqa: PLR0915 with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await stick.initialize() await stick.discover_nodes(load=True) - - assert len(stick.nodes) == 6 + await self._wait_for_scan(stick) + assert len(stick.nodes) == 7 assert stick.nodes["0098765432101234"].is_loaded assert stick.nodes["0098765432101234"].name == "Circle + 01234" @@ -2442,7 +2478,6 @@ async def test_node_discovery_and_load( # noqa: PLR0915 assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.is_battery_powered assert not stick.nodes["0098765432101234"].is_battery_powered - assert stick.nodes["0098765432101234"].network_address == -1 assert stick.nodes["0098765432101234"].cache_folder == "" assert not stick.nodes["0098765432101234"].cache_folder_create assert stick.nodes["0098765432101234"].cache_enabled @@ -2496,7 +2531,6 @@ async def test_node_discovery_and_load( # noqa: PLR0915 # Check INFO assert state[pw_api.NodeFeature.INFO].mac == "0098765432101234" - assert state[pw_api.NodeFeature.INFO].zigbee_address == -1 assert not state[pw_api.NodeFeature.INFO].is_battery_powered assert sorted(state[pw_api.NodeFeature.INFO].features) == sorted( ( @@ -2531,6 +2565,7 @@ async def test_node_discovery_and_load( # noqa: PLR0915 # Check 1111111111111111 get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) + assert stick.nodes["1111111111111111"].is_loaded state = await stick.nodes["1111111111111111"].get_state( ( pw_api.NodeFeature.PING, @@ -2541,7 +2576,6 @@ async def test_node_discovery_and_load( # noqa: PLR0915 ) assert state[pw_api.NodeFeature.INFO].mac == "1111111111111111" - assert state[pw_api.NodeFeature.INFO].zigbee_address == 0 assert not state[pw_api.NodeFeature.INFO].is_battery_powered assert state[pw_api.NodeFeature.INFO].version == "070140" assert state[pw_api.NodeFeature.INFO].node_type == pw_api.NodeType.CIRCLE @@ -2633,14 +2667,7 @@ async def test_node_discovery_and_load( # noqa: PLR0915 # endregion # region Switch - self.test_node_loaded = asyncio.Future() - unsub_loaded = stick.subscribe_to_node_events( - node_event_callback=self.node_loaded, - events=(pw_api.NodeEvent.LOADED,), - ) mock_serial.inject_message(b"004F888888888888888800", b"FFFE") - assert await self.test_node_loaded - unsub_loaded() assert stick.nodes["8888888888888888"].node_info.firmware == dt( 2011, 6, 27, 9, 4, 10, tzinfo=UTC