From 03e51b233ccb622e2a5b914b7795b008aa3c231c Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 17:00:32 +0100 Subject: [PATCH 001/979] Full rewrite to async version - known to be not working ! Untested --- plugwise_usb/__init__.py | 1016 ++++------- plugwise_usb/api.py | 126 ++ plugwise_usb/connection/__init__.py | 186 ++ plugwise_usb/connection/manager.py | 181 ++ plugwise_usb/connection/queue.py | 116 ++ plugwise_usb/connection/receiver.py | 298 ++++ plugwise_usb/connection/sender.py | 165 ++ plugwise_usb/connections/__init__.py | 136 -- plugwise_usb/connections/serial.py | 88 - plugwise_usb/connections/socket.py | 88 - plugwise_usb/constants.py | 311 +--- plugwise_usb/controller.py | 442 ----- plugwise_usb/exceptions.py | 36 +- plugwise_usb/messages/__init__.py | 56 +- plugwise_usb/messages/requests.py | 879 +++++++--- plugwise_usb/messages/responses.py | 932 ++++++---- plugwise_usb/network/__init__.py | 579 +++++++ plugwise_usb/network/cache.py | 164 ++ plugwise_usb/network/registry.py | 319 ++++ plugwise_usb/nodes/__init__.py | 861 +++++++--- plugwise_usb/nodes/celsius.py | 78 + plugwise_usb/nodes/circle.py | 1796 +++++++++++--------- plugwise_usb/nodes/circle_plus.py | 267 +-- plugwise_usb/nodes/helpers/__init__.py | 37 + plugwise_usb/nodes/helpers/cache.py | 123 ++ plugwise_usb/nodes/helpers/counter.py | 330 ++++ plugwise_usb/nodes/helpers/pulses.py | 818 +++++++++ plugwise_usb/nodes/helpers/subscription.py | 67 + plugwise_usb/nodes/scan.py | 310 ++-- plugwise_usb/nodes/sed.py | 345 ++-- plugwise_usb/nodes/sense.py | 218 ++- plugwise_usb/nodes/switch.py | 140 +- plugwise_usb/parser.py | 137 -- plugwise_usb/util.py | 197 +-- pyproject.toml | 33 +- 35 files changed, 7841 insertions(+), 4034 deletions(-) create mode 100644 plugwise_usb/api.py create mode 100644 plugwise_usb/connection/__init__.py create mode 100644 plugwise_usb/connection/manager.py create mode 100644 plugwise_usb/connection/queue.py create mode 100644 plugwise_usb/connection/receiver.py create mode 100644 plugwise_usb/connection/sender.py delete mode 100644 plugwise_usb/connections/__init__.py delete mode 100644 plugwise_usb/connections/serial.py delete mode 100644 plugwise_usb/connections/socket.py delete mode 100644 plugwise_usb/controller.py create mode 100644 plugwise_usb/network/__init__.py create mode 100644 plugwise_usb/network/cache.py create mode 100644 plugwise_usb/network/registry.py create mode 100644 plugwise_usb/nodes/celsius.py create mode 100644 plugwise_usb/nodes/helpers/__init__.py create mode 100644 plugwise_usb/nodes/helpers/cache.py create mode 100644 plugwise_usb/nodes/helpers/counter.py create mode 100644 plugwise_usb/nodes/helpers/pulses.py create mode 100644 plugwise_usb/nodes/helpers/subscription.py delete mode 100644 plugwise_usb/parser.py diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a4bbe45b3..939eaf798 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -1,740 +1,390 @@ -"""Use of this source code is governed by the MIT license found in the LICENSE file. +""" +Use of this source code is governed by the MIT license found +in the LICENSE file. Main stick object to control associated plugwise plugs """ -from datetime import datetime, timedelta + +from __future__ import annotations + +from asyncio import get_running_loop +from collections.abc import Callable, Coroutine +from functools import wraps import logging -import sys -import threading -import time - -from .constants import ( - ACCEPT_JOIN_REQUESTS, - CB_JOIN_REQUEST, - CB_NEW_NODE, - MESSAGE_TIME_OUT, - NODE_TYPE_CELSIUS_NR, - NODE_TYPE_CELSIUS_SED, - NODE_TYPE_CIRCLE, - NODE_TYPE_CIRCLE_PLUS, - NODE_TYPE_SCAN, - NODE_TYPE_SENSE, - NODE_TYPE_STEALTH, - NODE_TYPE_SWITCH, - PRIORITY_LOW, - STATE_ACTIONS, - UTF8_DECODE, - WATCHDOG_DEAMON, -) -from .controller import StickMessageController -from .exceptions import ( - CirclePlusError, - NetworkDown, - PortError, - StickInitError, - TimeoutException, -) -from .messages.requests import ( - NodeAddRequest, - NodeAllowJoiningRequest, - NodeInfoRequest, - NodePingRequest, - NodeRemoveRequest, - StickInitRequest, -) -from .messages.responses import ( - NodeAckLargeResponse, - NodeAckResponse, - NodeInfoResponse, - NodeJoinAvailableResponse, - NodeRemoveResponse, - NodeResponse, - StickInitResponse, -) -from .nodes.circle import PlugwiseCircle -from .nodes.circle_plus import PlugwiseCirclePlus -from .nodes.scan import PlugwiseScan -from .nodes.sense import PlugwiseSense -from .nodes.stealth import PlugwiseStealth -from .util import validate_mac +from typing import Any, TypeVar, cast + +from .api import StickEvent +from .connection import StickController +from .network import NETWORK_EVENTS, StickNetwork +from .network.subscription import StickSubscription +from .exceptions import StickError, SubscriptionError +from .nodes import PlugwiseNode + +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) + +STICK_EVENTS = [ + StickEvent.CONNECTED, + StickEvent.DISCONNECTED, + StickEvent.MESSAGE_RECEIVED, + StickEvent.NODE_AWAKE, + StickEvent.NODE_LOADED, + StickEvent.NODE_DISCOVERED, + StickEvent.NODE_JOIN, + StickEvent.NETWORK_OFFLINE, + StickEvent.NETWORK_ONLINE, +] _LOGGER = logging.getLogger(__name__) +def raise_not_connected(func: FuncT) -> FuncT: + """ + Decorator function to validate existence of an active + connection to Stick. + Raise StickError when there is no active connection. + """ + @wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + if not args[0].is_connected: + raise StickError( + "Not connected to USB-Stick, connect to USB-stick first." + ) + return func(*args, **kwargs) + return cast(FuncT, decorated) + + +def raise_not_initialized(func: FuncT) -> FuncT: + """ + Decorator function to validate if active connection is + initialized. + Raise StickError when not initialized. + """ + @wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + if not args[0].is_initialized: + raise StickError( + "Connection to USB-Stick is not initialized, " + + "initialize USB-stick first." + ) + return func(*args, **kwargs) + return cast(FuncT, decorated) + + class Stick: """Plugwise connection stick.""" - def __init__(self, port, callback=None): - self.circle_plus_mac = None - self.init_callback = None - self.msg_controller = None - self.scan_callback = None - - self._accept_join_requests = ACCEPT_JOIN_REQUESTS - self._auto_update_manually = False - self._auto_update_timer = 0 - self._circle_plus_discovered = False - self._circle_plus_retries = 0 - self._device_nodes = {} - self._joined_nodes = 0 - self._mac_stick = None - self._messages_for_undiscovered_nodes = [] - self._network_id = None - self._network_online = False - self._nodes_discovered = None - self._nodes_not_discovered = {} - self._nodes_off_line = 0 - self._nodes_to_discover = {} + def __init__( + self, port: str | None = None, cache_enabled: bool = True + ) -> None: + """Initialize Stick.""" + self._loop = get_running_loop() + self._loop.set_debug(True) + self._controller = StickController() + self._network: StickNetwork | None = None + self._cache_enabled = cache_enabled self._port = port - self._run_update_thread = False - self._run_watchdog = None - self._stick_callbacks = {} - self._stick_initialized = False - self._update_thread = None - self._watchdog_thread = None + self._events_supported = STICK_EVENTS + self._cache_folder: str = "" + + @property + def cache_folder(self) -> str: + """Path to store cached data.""" + return self._cache_folder + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Set path to store cached data.""" + if cache_folder == self._cache_folder: + return + if self._network is not None: + self._network.cache_folder = cache_folder + return + self._cache_folder = cache_folder + + @property + def cache_enabled(self) -> bool: + """Return usage of cache.""" + return self._cache_enabled + + @cache_enabled.setter + def cache_enabled(self, enable: bool = True) -> None: + """Enable or disable usage of cache.""" + if self._network is not None: + self._network.cache_enabled = enable + self._cache_enabled = enable + + @property + def nodes(self) -> dict[str, PlugwiseNode]: + """ + All discovered and supported plugwise devices + with the MAC address as their key + """ + if self._network is None: + return {} + return self._network.nodes + + @property + def is_connected(self) -> bool: + """Return current connection state""" + return self._controller.is_connected + + @property + def is_initialized(self) -> bool: + """Return current initialization state""" + return self._controller.is_initialized - if callback: - self.auto_initialize(callback) + @property + def joined_nodes(self) -> int | None: + """ + Total number of nodes registered to Circle+ + including Circle+ itself. + """ + if ( + not self._controller.is_connected + or self._network is None + or self._network.registry is None + ): + return None + return len(self._network.registry) + 1 @property - def devices(self) -> dict: - """All discovered and supported plugwise devices with the MAC address as their key""" - return self._device_nodes + def mac_stick(self) -> str: + """ + Return mac address of USB-Stick. + Raises StickError is connection is missing. + """ + return self._controller.mac_stick @property - def joined_nodes(self) -> int: - """Return total number of nodes registered to Circle+ including Circle+ itself.""" - return self._joined_nodes + 1 + def mac_coordinator(self) -> str: + """ + Return mac address of the network coordinator (Circle+). + Raises StickError is connection is missing. + """ + return self._controller.mac_coordinator @property - def mac(self) -> str: - """Return the MAC address of the USB-Stick.""" - if self._mac_stick: - return self._mac_stick.decode(UTF8_DECODE) - return None + def network_discovered(self) -> bool: + """ + Return the discovery state of the Plugwise network. + Raises StickError is connection is missing. + """ + if self._network is None: + return False + return self._network.is_running @property def network_state(self) -> bool: """Return the state of the Plugwise network.""" - return self._network_online + if not self._controller.is_connected: + return False + return self._controller.network_online @property def network_id(self) -> int: - """Return the id of the Plugwise network.""" - return self._network_id + """ + Return the id of the Plugwise network. + Raises StickError is connection is missing. + """ + return self._controller.network_id @property - def port(self) -> str: + def port(self) -> str | None: """Return currently configured port to USB-Stick.""" return self._port @port.setter - def port(self, port: str): - """Set port to USB-Stick.""" - if self.msg_controller: - self.disconnect() - self._port = port - - def auto_initialize(self, callback=None): - """Automatic initialization of USB-stick and discovery of all registered nodes.""" - - def init_finished(): - if not self._network_online: - _LOGGER.Error("plugwise Zigbee network down") - else: - self.scan(callback) - - if not self.msg_controller: - self.msg_controller = StickMessageController( - self.port, self.message_processor, self.node_state_updates - ) - try: - self.msg_controller.connect_to_stick() - self.initialize_stick(init_finished) - except PortError as err: - _LOGGER.error("Failed to connect: '%s'", err) - except StickInitError as err: - _LOGGER.error("Failed to initialize USBstick: '%s'", err) - except NetworkDown: - _LOGGER.error("Failed to communicated: Plugwise Zigbee network") - except TimeoutException: - _LOGGER.error("Timeout exception while initializing USBstick") - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unknown error : %s", err) - - def connect(self, callback=None): - """Startup message controller and connect to stick.""" - if not self.msg_controller: - self.msg_controller = StickMessageController( - self.port, self.message_processor, self.node_state_updates - ) - if self.msg_controller.connect_to_stick(callback): - # update daemon - self._run_update_thread = False - self._auto_update_timer = 0 - self._update_thread = threading.Thread( - None, self._update_loop, "update_thread", (), {} - ) - self._update_thread.daemon = True - - def initialize_stick(self, callback=None, timeout=MESSAGE_TIME_OUT): - """Initialize the USB-stick, start watchdog thread and raise an error if this fails.""" - if not self.msg_controller.connection.is_connected(): - raise StickInitError - _LOGGER.debug("Send init request to Plugwise Zigbee stick") - self.msg_controller.send(StickInitRequest(), callback) - time_counter = 0 - while not self._stick_initialized and (time_counter < timeout): - time_counter += 0.1 - time.sleep(0.1) - if not self._stick_initialized: - raise StickInitError - if not self._network_online: - raise NetworkDown - - def initialize_circle_plus(self, callback=None, timeout=MESSAGE_TIME_OUT): - """Initialize connection from USB-Stick to the Circle+/Stealth+ node and raise an error if this fails.""" + def port(self, port: str) -> None: + """Path to serial port of USB-Stick.""" if ( - not self.msg_controller.connection.is_connected() - or not self._stick_initialized - or not self.circle_plus_mac + self._controller.is_connected + and port != self._port ): - raise StickInitError - self.discover_node(self.circle_plus_mac, callback) - - time_counter = 0 - while not self._circle_plus_discovered and (time_counter < timeout): - time_counter += 0.1 - time.sleep(0.1) - if not self._circle_plus_discovered: - raise CirclePlusError - - def disconnect(self): - """Disconnect from stick and raise error if it fails""" - self._run_watchdog = False - self._run_update_thread = False - self._auto_update_timer = 0 - if self.msg_controller: - self.msg_controller.disconnect_from_stick() - self.msg_controller = None - - def subscribe_stick_callback(self, callback, callback_type): - """Subscribe callback to execute.""" - if callback_type not in self._stick_callbacks: - self._stick_callbacks[callback_type] = [] - self._stick_callbacks[callback_type].append(callback) - - def unsubscribe_stick_callback(self, callback, callback_type): - """Register callback to execute.""" - if callback_type in self._stick_callbacks: - self._stick_callbacks[callback_type].remove(callback) - - def allow_join_requests(self, enable: bool, accept: bool): - """Enable or disable Plugwise network - Automatically accept new join request - """ - self.msg_controller.send(NodeAllowJoiningRequest(enable)) - if enable: - self._accept_join_requests = accept - else: - self._accept_join_requests = False - - def scan(self, callback=None): - """Scan and try to detect all registered nodes.""" - self.scan_callback = callback - self.scan_for_registered_nodes() - - def scan_circle_plus(self): - """Scan the Circle+ memory for registered nodes.""" - if self._device_nodes.get(self.circle_plus_mac): - _LOGGER.debug("Scan Circle+ for linked nodes...") - self._device_nodes[self.circle_plus_mac].scan_for_nodes(self.discover_nodes) - else: - _LOGGER.error("Circle+ is not discovered yet") - - def scan_for_registered_nodes(self): - """Discover Circle+ and all registered nodes at Circle+.""" - if self.circle_plus_mac: - if self._device_nodes.get(self.circle_plus_mac): - self.scan_circle_plus() - else: - _LOGGER.debug("Discover Circle+ at %s", self.circle_plus_mac) - self.discover_node(self.circle_plus_mac, self.scan_circle_plus) - else: - _LOGGER.error( - "Plugwise stick not properly initialized, Circle+ MAC is missing." + raise StickError( + "Unable to change port while connected. Disconnect first" ) + if self._port is None: + self._port = port + if port != self._port: + self._port = port - def discover_nodes(self, nodes_to_discover): - """Helper to discover all registered nodes.""" - _LOGGER.debug("Scan plugwise network finished") - self._nodes_discovered = 0 - self._nodes_to_discover = nodes_to_discover - self._joined_nodes = len(nodes_to_discover) - - # setup timeout for node discovery - discover_timeout = 10 + (len(nodes_to_discover) * 2) + (MESSAGE_TIME_OUT) - threading.Timer(discover_timeout, self.scan_timeout_expired).start() - _LOGGER.debug("Start discovery of linked node types...") - for mac in nodes_to_discover: - self.discover_node(mac, self.node_discovered_by_scan) - - def node_discovered_by_scan(self, nodes_off_line=False): - """Node discovered by initial scan.""" - if nodes_off_line: - self._nodes_off_line += 1 - self._nodes_discovered += 1 - _LOGGER.debug( - "Discovered Plugwise node %s (%s off-line) of %s", - str(len(self._device_nodes)), - str(self._nodes_off_line), - str(len(self._nodes_to_discover)), - ) - if (len(self._device_nodes) - 1 + self._nodes_off_line) >= len( - self._nodes_to_discover - ): - if self._nodes_off_line == 0: - self._nodes_to_discover = {} - self._nodes_not_discovered = {} - else: - for mac in self._nodes_to_discover: - if not self._device_nodes.get(mac): - _LOGGER.info( - "Failed to discover node type for registered MAC '%s'. This is expected for battery powered nodes, they will be discovered at their first awake", - str(mac), - ) - else: - if mac in self._nodes_not_discovered: - del self._nodes_not_discovered[mac] - self.msg_controller.discovery_finished = True - if self.scan_callback: - self.scan_callback() - - def scan_timeout_expired(self): - """Timeout for initial scan.""" - if not self.msg_controller.discovery_finished: - for mac in self._nodes_to_discover: - # TODO: 20220206 is there 'mac' in the dict? Otherwise it can be rewritten as below (twice as fast above .get) - # if mac not in self._device_nodes: - if not self._device_nodes.get(mac): - _LOGGER.info( - "Failed to discover node type for registered MAC '%s'. This is expected for battery powered nodes, they will be discovered at their first awake", - str(mac), - ) - else: - if mac in self._nodes_not_discovered: - del self._nodes_not_discovered[mac] - if self.scan_callback: - self.scan_callback() - - def _append_node(self, mac, address, node_type): - """Add node to list of controllable nodes""" - _LOGGER.debug( - "Add new node type (%s) with mac %s", - str(node_type), - mac, - ) - if node_type == NODE_TYPE_CIRCLE_PLUS: - self._device_nodes[mac] = PlugwiseCirclePlus( - mac, address, self.msg_controller.send - ) - elif node_type == NODE_TYPE_CIRCLE: - self._device_nodes[mac] = PlugwiseCircle( - mac, address, self.msg_controller.send - ) - elif node_type == NODE_TYPE_SWITCH: - self._device_nodes[mac] = None - elif node_type == NODE_TYPE_SENSE: - self._device_nodes[mac] = PlugwiseSense( - mac, address, self.msg_controller.send - ) - elif node_type == NODE_TYPE_SCAN: - self._device_nodes[mac] = PlugwiseScan( - mac, address, self.msg_controller.send + @property + def accept_join_request(self) -> bool | None: + """Automatically accept joining request of new nodes.""" + if not self._controller.is_connected: + return None + if self._network is None or not self._network.is_running: + return None + return self._network.accept_join_request + + @accept_join_request.setter + def accept_join_request(self, state: bool) -> None: + """Configure join requests""" + if not self._controller.is_connected: + raise StickError( + "Cannot accept joining node" + + " without an active USB-Stick connection." ) - elif node_type == NODE_TYPE_CELSIUS_SED: - self._device_nodes[mac] = None - elif node_type == NODE_TYPE_CELSIUS_NR: - self._device_nodes[mac] = None - elif node_type == NODE_TYPE_STEALTH: - self._device_nodes[mac] = PlugwiseStealth( - mac, address, self.msg_controller.send + if self._network is None or not self._network.is_running: + raise StickError( + "Cannot accept joining node" + + "without node discovery be activated. Call discover() first." ) - else: - _LOGGER.warning("Unsupported node type '%s'", str(node_type)) - self._device_nodes[mac] = None - - # process previous missed messages - msg_to_process = self._messages_for_undiscovered_nodes[:] - self._messages_for_undiscovered_nodes = [] - for msg in msg_to_process: - self.message_processor(msg) - - def node_state_updates(self, mac, state: bool): - """Update availability state of a node""" - if self._device_nodes.get(mac): - if not self._device_nodes[mac].battery_powered: - self._device_nodes[mac].available = state - - def node_join(self, mac: str, callback=None) -> bool: - """Accept node to join Plugwise network by register mac in Circle+ memory""" - if validate_mac(mac): - self.msg_controller.send( - NodeAddRequest(bytes(mac, UTF8_DECODE), True), callback + self._network.accept_join_request = state + + async def async_clear_cache(self) -> None: + """Clear current cache.""" + if self._network is not None: + await self._network.clear_cache() + + def subscribe_to_event( + self, + event: StickEvent, + callback: Callable[[Any], Coroutine[Any, Any, None]] + | Callable[[], Coroutine[Any, Any, None]], + ) -> int: + """Add subscription and returns the id to unsubscribe later.""" + + # Forward subscriptions for controller + if event in CONTROLLER_EVENTS: + return self._controller.subscribe_to_stick_events( + StickSubscription(event, callback) ) - return True - _LOGGER.warning("Invalid mac '%s' address, unable to join node manually.", mac) - return False - def node_unjoin(self, mac: str, callback=None) -> bool: - """Remove node from the Plugwise network by deleting mac from the Circle+ memory""" - if validate_mac(mac): - self.msg_controller.send( - NodeRemoveRequest(bytes(self.circle_plus_mac, UTF8_DECODE), mac), - callback, + # Forward subscriptions for network + if event in NETWORK_EVENTS: + if ( + not self._controller.is_connected + or self._network is None + ): + raise SubscriptionError( + "Unable to subscribe for stick event." + + " Connect to USB-stick first." + ) + return self._network.subscribe( + StickSubscription(event, callback) ) - return True - _LOGGER.warning( - "Invalid mac '%s' address, unable to unjoin node manually.", mac + raise SubscriptionError( + f"Unable to subscribe to unsupported {event} stick event." ) + + def unsubscribe(self, subscribe_id: int) -> bool: + """Remove subscription.""" + if self._controller.unsubscribe(subscribe_id): + return True + if self._network is not None and self._network.unsubscribe( + subscribe_id + ): + return True return False - def _remove_node(self, mac): - """Remove node from list of controllable nodes.""" - if self._device_nodes.get(mac): - del self._device_nodes[mac] - else: - _LOGGER.warning("Node %s does not exists, unable to remove node.", mac) - - def message_processor(self, message: NodeResponse): - """Received message from Plugwise network.""" - mac = message.mac.decode(UTF8_DECODE) - if isinstance(message, (NodeAckLargeResponse, NodeAckResponse)): - if message.ack_id in STATE_ACTIONS: - self._pass_message_to_node(message, mac) - elif isinstance(message, NodeInfoResponse): - self._process_node_info_response(message, mac) - elif isinstance(message, StickInitResponse): - self._process_stick_init_response(message) - elif isinstance(message, NodeJoinAvailableResponse): - self._process_node_join_request(message, mac) - elif isinstance(message, NodeRemoveResponse): - self._process_node_remove(message) - else: - self._pass_message_to_node(message, mac) - - def _process_stick_init_response(self, stick_init_response: StickInitResponse): - """Process StickInitResponse message.""" - self._mac_stick = stick_init_response.mac - if stick_init_response.network_is_online.value == 1: - self._network_online = True - else: - self._network_online = False - # Replace first 2 characters by 00 for mac of circle+ node - self.circle_plus_mac = "00" + stick_init_response.circle_plus_mac.value[ - 2: - ].decode(UTF8_DECODE) - self._network_id = stick_init_response.network_id.value - self._stick_initialized = True - if not self._run_watchdog: - self._run_watchdog = True - self._watchdog_thread = threading.Thread( - None, self._watchdog_loop, "watchdog_thread", (), {} - ) - self._watchdog_thread.daemon = True - self._watchdog_thread.start() - - def _process_node_info_response(self, node_info_response, mac): - """Process NodeInfoResponse message.""" - if not self._pass_message_to_node(node_info_response, mac, False): - _LOGGER.debug( - "Received NodeInfoResponse from currently unknown node with mac %s with sequence id %s", - mac, - str(node_info_response.seq_id), - ) - if node_info_response.node_type.value == NODE_TYPE_CIRCLE_PLUS: - self._circle_plus_discovered = True - self._append_node(mac, 0, node_info_response.node_type.value) - if mac in self._nodes_not_discovered: - del self._nodes_not_discovered[mac] - else: - if mac in self._nodes_to_discover: - _LOGGER.info( - "Node with mac %s discovered", - mac, - ) - self._append_node( - mac, - self._nodes_to_discover[mac], - node_info_response.node_type.value, - ) - self._pass_message_to_node(node_info_response, mac) - - def _process_node_join_request(self, node_join_request, mac): - """Process NodeJoinAvailableResponse message from a node that - is not part of a plugwise network yet and wants to join + def _validate_node_discovery(self) -> None: """ - if self._device_nodes.get(mac): - _LOGGER.debug( - "Received node available message for node %s which is already joined.", - mac, - ) - else: - if self._accept_join_requests: - # Send accept join request - _LOGGER.info( - "Accepting network join request for node with mac %s", - mac, - ) - self.msg_controller.send(NodeAddRequest(node_join_request.mac, True)) - self._nodes_not_discovered[mac] = (None, None) - else: - _LOGGER.debug( - "New node with mac %s requesting to join Plugwise network, do callback", - mac, - ) - self.do_callback(CB_JOIN_REQUEST, mac) + Validate if network discovery is running + Raises StickError if network is not active. + """ + if self._network is None or not self._network.is_running: + raise StickError("Plugwise network node discovery is not active.") + + async def async_setup( + self, discover: bool = True, load: bool = True + ) -> None: + """Setup connection to USB-Stick.""" + await self.async_connect() + await self.async_initialize() + if discover: + await self.async_start() + if load: + await self.async_load_nodes() - def _process_node_remove(self, node_remove_response): - """Process NodeRemoveResponse message with confirmation - if node is is removed from the Plugwise network. + async def connect_to_stick(self, port: str | None = None) -> None: """ - unjoined_mac = node_remove_response.node_mac_id.value - if node_remove_response.status.value == 1: - if self._device_nodes.get(unjoined_mac): - del self._device_nodes[unjoined_mac] - _LOGGER.info( - "Received NodeRemoveResponse from node %s it has been unjoined from Plugwise network", - unjoined_mac, - ) - else: - _LOGGER.debug( - "Unknown node with mac %s has been unjoined from Plugwise network", - unjoined_mac, - ) - else: - _LOGGER.warning( - "Node with mac %s failed to unjoin from Plugwise network ", - unjoined_mac, + Try to open connection. Does not initialize connection. + Raises StickError if failed to create connection. + """ + if self._controller.is_connected: + raise StickError( + f"Already connected to {self._port}, " + + "Close existing connection before (re)connect." ) - def _pass_message_to_node(self, message, mac, discover=True): - """Pass message to node class to take action on message + if port is not None: + self._port = port - Returns True if message has passed onto existing known node - """ - if self._device_nodes.get(mac): - self._device_nodes[mac].message_for_node(message) - return True - if discover: - _LOGGER.info( - "Queue %s from %s because node is not discovered yet.", - message.__class__.__name__, - mac, + if self._port is None: + raise StickError( + "Unable to connect. " + + "Path to USB-Stick is not defined, set port property first" ) - self._messages_for_undiscovered_nodes.append(message) - self.discover_node(mac, self._discover_after_scan, True) - return False + await self._controller.connect_to_stick( + self._port, + ) - def _watchdog_loop(self): - """Main worker loop to watch all other worker threads""" - time.sleep(5) - circle_plus_retry_counter = 0 - while self._run_watchdog: - # Connection - if self.msg_controller.connection.is_connected(): - # Connection reader daemon - if not self.msg_controller.connection.read_thread_alive(): - _LOGGER.warning("Unexpected halt of connection reader thread") - # Connection writer daemon - if not self.msg_controller.connection.write_thread_alive(): - _LOGGER.warning("Unexpected halt of connection writer thread") - # receive timeout daemon - if ( - self.msg_controller.receive_timeout_thread_state - and self.msg_controller.receive_timeout_thread_is_alive - ): - self.msg_controller.restart_receive_timeout_thread() - # send message daemon - if ( - self.msg_controller.send_message_thread_state - and self.msg_controller.send_message_thread_is_alive - ): - self.msg_controller.restart_send_message_thread() - # Update daemon - if self._run_update_thread: - if not self._update_thread.is_alive(): - _LOGGER.warning( - "Unexpected halt of update thread, restart thread", - ) - self._run_update_thread = True - self._update_thread = threading.Thread( - None, self._update_loop, "update_thread", (), {} - ) - self._update_thread.daemon = True - self._update_thread.start() - # Circle+ discovery - if not self._circle_plus_discovered: - # First hour every once an hour - if self._circle_plus_retries < 60 or circle_plus_retry_counter > 60: - _LOGGER.info( - "Circle+ not yet discovered, resubmit discovery request" - ) - self.discover_node(self.circle_plus_mac, self.scan) - self._circle_plus_retries += 1 - circle_plus_retry_counter = 0 - circle_plus_retry_counter += 1 - watchdog_loop_checker = 0 - while watchdog_loop_checker < WATCHDOG_DEAMON and self._run_watchdog: - time.sleep(1) - watchdog_loop_checker += 1 - _LOGGER.debug("watchdog loop stopped") - - def _update_loop(self): - """When node has not received any message during - last 2 update polls, reset availability + @raise_not_connected + async def initialize_stick(self) -> None: + """ + Try to initialize existing connection to USB-Stick. + Raises StickError if failed to communicate with USB-stick. """ - self._run_update_thread = True - _discover_counter = 0 - try: - while self._run_update_thread: - for mac, device in self._device_nodes.items(): - if device: - if device.battery_powered: - # Check availability state of SED's - self._check_availability_of_seds(mac) - elif device.measures_power: - # Request current power usage of those that reply on ping - device.do_ping(device.request_power_update) - else: - # Do ping request for all non SED's - device.do_ping() - - # Do a single ping for undiscovered nodes once per 10 update cycles - if _discover_counter == 10: - for mac in self._nodes_not_discovered: - self.msg_controller.send( - NodePingRequest(bytes(mac, UTF8_DECODE)), - None, - -1, - PRIORITY_LOW, - ) - _discover_counter = 0 - else: - _discover_counter += 1 - - if self._auto_update_timer and self._run_update_thread: - update_loop_checker = 0 - while ( - update_loop_checker < self._auto_update_timer - and self._run_update_thread - ): - time.sleep(1) - update_loop_checker += 1 - - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _exc_type, _exc_obj, exc_tb = sys.exc_info() - _LOGGER.error( - "Error at line %s of _update_loop : %s", exc_tb.tb_lineno, err + await self._controller.initialize_stick() + if self._network is None: + self._network = StickNetwork(self._controller) + self._network.cache_folder = self._cache_folder + self._network.cache_enabled = self._cache_enabled + + @raise_not_connected + @raise_not_initialized + async def async_start(self) -> None: + """Start zigbee network.""" + if self._network is None: + self._network = StickNetwork(self._controller) + self._network.cache_folder = self._cache_folder + self._network.cache_enabled = self._cache_enabled + await self._network.start() + + @raise_not_connected + @raise_not_initialized + async def async_load_nodes(self) -> bool: + """Load all discovered nodes.""" + if self._network is None: + raise StickError( + "Cannot load nodes when network is not initialized" ) - _LOGGER.debug("Update loop stopped") - - def auto_update(self, timer=None): - """Configure auto update polling daemon for power usage and availability state.""" - if timer: - self._auto_update_timer = timer - self._auto_update_manually = True - elif timer == 0: - self._auto_update_timer = 0 - self._run_update_thread = False - else: - # Timer based on a minimum of 5 seconds + 1 second for each node supporting power measurement - if not self._auto_update_manually: - count_nodes = 0 - for _, node in self._device_nodes.items(): - if node.measures_power: - count_nodes += 1 - self._auto_update_timer = 5 + (count_nodes * 1) - _LOGGER.info( - "Update interval is (re)set to %s seconds", - str(self._auto_update_timer), - ) - if not self._run_update_thread: - self._update_thread.start() - - # Helper functions - def do_callback(self, callback_type, callback_arg=None): - """Helper to execute registered callbacks for specified callback type.""" - if callback_type in self._stick_callbacks: - for callback in self._stick_callbacks[callback_type]: - try: - if callback_arg is None: - callback() - else: - callback(callback_arg) - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error while executing callback : %s", err) - - def _check_availability_of_seds(self, mac): - """Helper to check if SED device is still sending its hartbeat.""" - if self._device_nodes[mac].available: - if self._device_nodes[mac].last_update < ( - datetime.now() - - timedelta(minutes=(self._device_nodes[mac].maintenance_interval + 1)) - ): - _LOGGER.info( - "No messages received within (%s minutes) of expected maintenance interval from node %s, mark as unavailable [%s > %s]", - str(self._device_nodes[mac].maintenance_interval), - mac, - str(self._device_nodes[mac].last_update), - str( - datetime.now() - - timedelta( - minutes=(self._device_nodes[mac].maintenance_interval + 1) - ) - ), - ) - self._device_nodes[mac].available = False - - def _discover_after_scan(self): - """Helper to do callback for new node.""" - node_discovered = None - for mac in self._nodes_not_discovered: - if self._device_nodes.get(mac): - node_discovered = mac - break - if node_discovered: - del self._nodes_not_discovered[node_discovered] - self.do_callback(CB_NEW_NODE, node_discovered) - self.auto_update() - - def discover_node(self, mac: str, callback=None, force_discover=False): - """Helper to try to discovery the node (type) based on mac.""" - if not validate_mac(mac) or self._device_nodes.get(mac): - return - if mac not in self._nodes_not_discovered: - self._nodes_not_discovered[mac] = ( - None, - None, + if not self._network.is_running: + raise StickError( + "Cannot load nodes when network is not started" ) - self.msg_controller.send( - NodeInfoRequest(bytes(mac, UTF8_DECODE)), - callback, + return await self._network.discover_nodes(load=True) + + @raise_not_connected + @raise_not_initialized + async def async_discover_coordinator(self, load: bool = False) -> None: + """Setup connection to Zigbee network coordinator.""" + if self._network is None: + raise StickError( + "Cannot load nodes when network is not initialized" ) - else: - (firstrequest, lastrequest) = self._nodes_not_discovered[mac] - if not (firstrequest and lastrequest): - self.msg_controller.send( - NodeInfoRequest(bytes(mac, UTF8_DECODE)), - callback, - 0, - PRIORITY_LOW, - ) - elif force_discover: - self.msg_controller.send( - NodeInfoRequest(bytes(mac, UTF8_DECODE)), - callback, - ) + await self._network.discover_network_coordinator(load=load) + + @raise_not_connected + @raise_not_initialized + async def async_register_node(self, mac: str) -> bool: + """Add node to plugwise network.""" + if self._network is None: + return False + return await self._network.register_node(mac) + + @raise_not_connected + @raise_not_initialized + async def async_unregister_node(self, mac: str) -> None: + """Remove node to plugwise network.""" + if self._network is None: + return + await self._network.unregister_node(mac) + + async def disconnect_from_stick(self) -> None: + """Disconnect from USB-Stick.""" + if self._network is not None: + await self._network.stop() + await self._controller.disconnect_from_stick() diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py new file mode 100644 index 000000000..dc2667cec --- /dev/null +++ b/plugwise_usb/api.py @@ -0,0 +1,126 @@ +"""Plugwise USB-Stick API.""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum, auto + + +class StickEvent(Enum): + """Plugwise USB Stick events for callback subscription.""" + CONNECTED = auto() + DISCONNECTED = auto() + MESSAGE_RECEIVED = auto() + NETWORK_OFFLINE = auto() + NETWORK_ONLINE = auto() + + +class NodeEvent(Enum): + """Plugwise Node events for callback subscription.""" + AWAKE = auto() + DISCOVERED = auto() + LOADED = auto() + JOIN = auto() + + +class NodeType(Enum): + """USB Node types.""" + STICK = 0 + CIRCLE_PLUS = 1 # AME_NC + CIRCLE = 2 # AME_NR + SWITCH = 3 # AME_SEDSwitch + SENSE = 5 # AME_SEDSense + SCAN = 6 # AME_SEDScan + CELSUIS_SED = 7 # AME_CelsiusSED + CELSUIS_NR = 8 # AME_CelsiusNR + STEALTH = 9 # AME_STEALTH_ZE + + +# 10 AME_MSPBOOTLOAD +# 11 AME_STAR + + +class NodeFeature(str, Enum): + """USB Stick Node feature.""" + AVAILABLE = "available" + ENERGY = "energy" + HUMIDITY = "humidity" + INFO = "info" + MOTION = "motion" + PING = "ping" + POWER = "power" + RELAY = "relay" + RELAY_INIT = "relay_init" + SWITCH = "switch" + TEMPERATURE = "temperature" + + +PUSHING_FEATURES = ( + NodeFeature.HUMIDITY, + NodeFeature.MOTION, + NodeFeature.TEMPERATURE, + NodeFeature.SWITCH +) + + +@dataclass +class NodeInfo: + """Node hardware information.""" + mac: str + zigbee_address: int + battery_powered: bool = False + features: tuple[NodeFeature, ...] = (NodeFeature.INFO,) + firmware: datetime | None = None + name: str | None = None + model: str | None = None + type: NodeType | None = None + timestamp: datetime | None = None + version: str | None = None + + +@dataclass +class NetworkStatistics: + """Zigbee network information.""" + timestamp: datetime | None = None + rssi_in: int | None = None + rssi_out: int | None = None + rtt: int | None = None + + +@dataclass +class PowerStatistics: + """Power statistics collection.""" + last_second: float | None = None + last_8_seconds: float | None = None + timestamp: datetime | None = None + + +@dataclass +class RelayState: + """Status of relay.""" + relay_state: bool | None = None + timestamp: datetime | None = None + + +@dataclass +class MotionState: + """Status of motion sensor.""" + motion: bool | None = None + timestamp: datetime | None = None + + +@dataclass +class EnergyStatistics: + """Energy statistics collection.""" + + hour_consumption: float | None = None + hour_consumption_reset: datetime | None = None + day_consumption: float | None = None + day_consumption_reset: datetime | None = None + week_consumption: float | None = None + week_consumption_reset: datetime | None = None + hour_production: float | None = None + hour_production_reset: datetime | None = None + day_production: float | None = None + day_production_reset: datetime | None = None + week_production: float | None = None + week_production_reset: datetime | None = None diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py new file mode 100644 index 000000000..15f873388 --- /dev/null +++ b/plugwise_usb/connection/__init__.py @@ -0,0 +1,186 @@ +""" +The 'Connection ' manage the connection and communication +flow through the USB-Stick. +""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from concurrent import futures +import logging + +from ..api import StickEvent +from ..exceptions import StickError +from ..messages.requests import PlugwiseRequest, StickInitRequest +from ..messages.responses import PlugwiseResponse, StickInitResponse +from .manager import StickConnectionManager +from .queue import StickQueue + +_LOGGER = logging.getLogger(__name__) + + +class StickController(): + """Manage the connection and communication towards USB-Stick.""" + + def __init__(self) -> None: + """Initialize Stick controller.""" + self._manager = StickConnectionManager() + self._queue = StickQueue() + self._unsubscribe_stick_event = ( + self._manager.subscribe_to_stick_events( + self._handle_stick_event, None + ) + ) + + self._init_sequence_id: bytes | None = None + self._init_future: futures.Future | None = None + + self._is_initialized = False + self._mac_stick: str | None = None + self._mac_nc: str | None = None + self._network_id: int | None = None + self._network_online = False + + @property + def is_initialized(self) -> bool: + """Returns True if UBS-Stick connection is active and initialized.""" + if not self._manager.is_connected: + return False + return self._is_initialized + + @property + def is_connected(self) -> bool: + """Return connection state from connection manager""" + return self._manager.is_connected + + @property + def mac_stick(self) -> str: + """ + Returns the MAC address of USB-Stick. + Raises StickError when not connected. + """ + if not self._manager.is_connected or self._mac_stick is None: + raise StickError( + "No mac address available. " + + "Connect and initialize USB-Stick first." + ) + return self._mac_stick + + @property + def mac_coordinator(self) -> str: + """ + Return MAC address of the Zigbee network coordinator (Circle+). + Raises StickError when not connected. + """ + if not self._manager.is_connected or self._mac_nc is None: + raise StickError( + "No mac address available. " + + "Connect and initialize USB-Stick first." + ) + return self._mac_nc + + @property + def network_id(self) -> int: + """ + Returns the Zigbee network ID. + Raises StickError when not connected. + """ + if not self._manager.is_connected or self._network_id is None: + raise StickError( + "No network ID available. " + + "Connect and initialize USB-Stick first." + ) + return self._network_id + + @property + def network_online(self) -> bool: + """Return the network state.""" + if not self._manager.is_connected: + raise StickError( + "Network status not available. " + + "Connect and initialize USB-Stick first." + ) + return self._network_online + + async def connect_to_stick(self, serial_path: str) -> None: + """Setup connection to USB stick.""" + await self._manager.setup_connection_to_stick(serial_path) + + def subscribe_to_stick_events( + self, + stick_event_callback: Callable[[StickEvent], Awaitable[None]], + events: tuple[StickEvent], + ) -> Callable[[], None]: + """ + Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. + """ + return self._manager.subscribe_to_stick_events( + stick_event_callback, + events, + ) + + def subscribe_to_node_responses( + self, + node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + mac: bytes | None = None, + identifiers: tuple[bytes] | None = None, + ) -> Callable[[], None]: + return self._manager.subscribe_to_node_responses( + node_response_callback, + mac, + identifiers, + ) + + async def _handle_stick_event(self, event: StickEvent) -> None: + """Handle stick events""" + if event == StickEvent.CONNECTED: + if not self._queue.running: + self._queue.start(self._manager) + await self.initialize_stick() + elif event == StickEvent.DISCONNECTED: + if self._queue.running: + await self._queue.stop() + + async def initialize_stick(self) -> None: + """ + Initialize connection to the USB-stick. + Raises StickError if initialization fails. + """ + if not self._manager.is_connected: + raise StickError( + "Cannot initialize USB-stick, connected to USB-stick first" + ) + if not self._queue.running: + raise StickError("Cannot initialize, queue manager not running") + + try: + init_response: StickInitResponse = self._queue.submit( + StickInitRequest() + ) + except StickError as err: + raise StickError( + "No response from USB-Stick to initialization request." + + " Validate USB-stick is connected to port " + + f"' {self._manager.serial_path}'" + ) from err + self._mac_stick = init_response.mac_decoded + self._network_online = init_response.network_online + + # Replace first 2 characters by 00 for mac of circle+ node + self._mac_nc = init_response.mac_network_controller + self._network_id = init_response.network_id + + async def send(self, request: PlugwiseRequest) -> PlugwiseResponse: + """Submit request to queue and return response""" + return await self._queue.submit(request) + + def _reset_states(self) -> None: + """Reset internal connection information.""" + self._mac_stick = None + self._mac_nc = None + self._network_id = None + self._network_online = False + + async def disconnect_from_stick(self) -> None: + """Disconnect from USB-Stick.""" + await self._manager.disconnect_from_stick() diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py new file mode 100644 index 000000000..9b5aa44f3 --- /dev/null +++ b/plugwise_usb/connection/manager.py @@ -0,0 +1,181 @@ +""" +The 'connection controller' manage the communication flow through the USB-Stick +towards the Plugwise (propriety) Zigbee like network. +""" +from __future__ import annotations + +from asyncio import Future, get_event_loop, wait_for, sleep +from collections.abc import Awaitable, Callable +import logging +from typing import Any + +from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE +from serial import SerialException +import serial_asyncio + +from .sender import StickSender +from .receiver import STICK_RECEIVER_EVENTS, StickReceiver +from ..api import StickEvent +from ..exceptions import StickError +from ..messages.requests import PlugwiseRequest +from ..messages.responses import PlugwiseResponse, StickResponse + +_LOGGER = logging.getLogger(__name__) + + +class StickConnectionManager(): + """Manage the message flow to and from USB-Stick.""" + + def __init__(self) -> None: + """Initialize Stick controller.""" + self._sender: StickSender | None = None + self._receiver: StickReceiver | None = None + self._port = "" + self._connected: bool = False + + self._stick_event_subscribers: dict[ + Callable[[], None], + tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + ] = {} + + @property + def serial_path(self) -> str: + """Return current port""" + return self._port + + @property + def is_connected(self) -> bool: + """Returns True if UBS-Stick connection is active.""" + if not self._connected: + return False + if self._receiver is None: + return False + return self._receiver.is_connected + + def subscribe_to_stick_events( + self, + stick_event_callback: Callable[[StickEvent], Awaitable[None]], + event: StickEvent | None, + ) -> Callable[[], None]: + """ + Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. + """ + def remove_subscription() -> None: + """Remove stick event subscription.""" + self._stick_event_subscribers.pop(remove_subscription) + + if event in STICK_RECEIVER_EVENTS: + return self._receiver.subscribe_to_stick_events( + stick_event_callback, event + ) + self._stick_event_subscribers[ + remove_subscription + ] = (stick_event_callback, event) + return remove_subscription + + def subscribe_to_stick_replies( + self, + callback: Callable[ + [StickResponse], Awaitable[None] + ], + ) -> Callable[[], None]: + """Subscribe to response messages from stick.""" + if self._receiver is None or not self._receiver.is_connected: + raise StickError( + "Unable to subscribe to stick response when receiver " + + "is not loaded" + ) + return self._receiver.subscribe_to_stick_responses(callback) + + def subscribe_to_node_responses( + self, + node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + mac: bytes | None = None, + identifiers: tuple[bytes] | None = None, + ) -> Callable[[], None]: + """ + Subscribe to response messages from node(s). + Returns callable function to unsubscribe + """ + if self._receiver is None or not self._receiver.is_connected: + raise StickError( + "Unable to subscribe to node response when receiver " + + "is not loaded" + ) + return self._receiver.subscribe_to_node_responses( + node_response_callback, mac, identifiers + ) + + async def setup_connection_to_stick( + self, serial_path: str + ) -> None: + """Setup serial connection to USB-stick.""" + if self._connected: + raise StickError("Cannot setup connection, already connected") + loop = get_event_loop() + connected_future: Future[Any] = Future() + self._receiver = StickReceiver(connected_future) + self._port = serial_path + + try: + ( + self._sender, + self._receiver, + ) = await wait_for( + serial_asyncio.create_serial_connection( + loop, + lambda: self._receiver, + url=serial_path, + baudrate=115200, + bytesize=EIGHTBITS, + stopbits=STOPBITS_ONE, + parity=PARITY_NONE, + xonxoff=False, + ), + timeout=5, + ) + except SerialException as err: + raise StickError( + f"Failed to open serial connection to {serial_path}" + ) from err + except TimeoutError as err: + raise StickError( + f"Failed to open serial connection to {serial_path}" + ) from err + finally: + connected_future.cancel() + await sleep(0) + await wait_for(connected_future, 5) + self._connected = True + if self._receiver is None: + raise StickError("Protocol is not loaded") + + async def write_to_stick( + self, request: PlugwiseRequest + ) -> PlugwiseRequest: + """ + Write message to USB stick. + Returns the updated request object. + """ + if not request.resend: + raise StickError( + f"Failed to send {request.__class__.__name__} " + + f"to node {request.mac_decoded}, maximum number " + + f"of retries ({request.max_retries}) has been reached" + ) + if self._sender is None: + raise StickError( + f"Failed to send {request.__class__.__name__}" + + "because USB-Stick connection is not setup" + ) + return await self._sender.write_request_to_port(request) + + async def disconnect_from_stick(self) -> None: + """Disconnect from USB-Stick.""" + _LOGGER.debug("Disconnecting manager") + self._connected = False + if self._receiver is not None: + await self._receiver.close() + self._receiver = None + _LOGGER.debug("Manager disconnected") diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py new file mode 100644 index 000000000..e1c7569f7 --- /dev/null +++ b/plugwise_usb/connection/queue.py @@ -0,0 +1,116 @@ +""" +Manage the communication sessions towards the USB-Stick +""" +from __future__ import annotations + +from asyncio import ( + CancelledError, + InvalidStateError, + PriorityQueue, + Task, + get_running_loop, + sleep, +) +from dataclasses import dataclass +import logging + +from .manager import StickConnectionManager +from ..exceptions import StickError +from ..messages.requests import PlugwiseRequest +from ..messages.responses import PlugwiseResponse + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RequestState: + """Node hardware information.""" + session: bytes + zigbee_address: int + + +class StickQueue: + """Manage queue of all request sessions.""" + + def __init__(self) -> None: + """Initialize the message session controller.""" + self._stick: StickConnectionManager | None = None + self._loop = get_running_loop() + self._queue: PriorityQueue[PlugwiseRequest] = PriorityQueue() + self._submit_worker_task: Task | None = None + self._running = False + + @property + def running(self) -> bool: + """Return the state of the queue""" + return self._running + + def start( + self, + stick_connection_manager: StickConnectionManager + ) -> None: + """Start sending request from queue""" + if self._running: + raise StickError("Cannot start queue manager, already running") + self._stick = stick_connection_manager + + async def stop(self) -> None: + """Stop sending from queue.""" + _LOGGER.debug("Stop queue") + self._running = False + self._stick = None + if ( + self._submit_worker_task is not None and + not self._submit_worker_task.done() + ): + self._submit_worker_task.cancel() + try: + await self._submit_worker_task.result() + except (CancelledError, InvalidStateError): + pass + _LOGGER.debug("queue stopped") + + async def submit( + self, request: PlugwiseRequest + ) -> PlugwiseResponse: + """ + Add request to queue and return the response of node + Raises an error when something fails + """ + if not self._running or self._stick is None: + raise StickError( + f"Cannot send message {request.__class__.__name__} for" + + f"{request.mac_decoded} because queue manager is stopped" + ) + + await self._add_request_to_queue(request) + return await request.response_future() + + async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: + """Add request to send queue and return the session id.""" + await self._queue.put(request) + self._start_submit_worker() + + def _start_submit_worker(self) -> None: + """Start the submit worker if submit worker is not yet running""" + if self._submit_worker_task is None or self._submit_worker_task.done(): + self._submit_worker_task = self._loop.create_task( + self._submit_worker() + ) + + async def _submit_worker(self) -> None: + """Send messages from queue at the order of priority.""" + while self._queue.qsize() > 0: + # Get item with highest priority from queue first + request = await self._queue.get() + + # Guard for incorrect futures + if request.response is not None: + _LOGGER.error( + "%s has already a response", + request.__class__.__name__, + ) + break + + await self._stick.write_to_stick(request) + await sleep(0.0) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py new file mode 100644 index 000000000..a14c92023 --- /dev/null +++ b/plugwise_usb/connection/receiver.py @@ -0,0 +1,298 @@ +""" +Protocol receiver + +Process incoming data stream from the Plugwise USB-Stick and +convert it into response messages. + +Responsible to + + 1. Collect and buffer raw data received from Stick: data_received() + 2. Convert raw data into response message: parse_data() + 3. Forward response message to the message subscribers + +and publish detected connection status changes + + 1. Notify status subscribers to connection state changes + +""" + +from __future__ import annotations +from asyncio import ( + Future, + gather, + Lock, + Protocol, + Transport, + get_running_loop, +) +from collections.abc import Awaitable, Callable +from concurrent import futures +import logging +from typing import Any + +from ..api import StickEvent +from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER +from ..exceptions import MessageError +from ..messages.responses import ( + PlugwiseResponse, + StickInitResponse, + StickResponse, + get_message_object, +) + +_LOGGER = logging.getLogger(__name__) +STICK_RECEIVER_EVENTS = ( + StickEvent.CONNECTED, + StickEvent.DISCONNECTED +) + + +class StickReceiver(Protocol): + """ + Receive data from USB Stick connection and + convert it into response messages. + """ + + def __init__( + self, + connected_future: Future | None = None, + ) -> None: + """Initialize instance of the USB Stick connection.""" + super().__init__() + self._loop = get_running_loop() + self._connected_future = connected_future + self._transport: Transport | None = None + self._buffer: bytes = bytes([]) + self._connection_state = False + + self._stick_lock = Lock() + self._stick_future: futures.Future | None = None + self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} + + # Subscribers + self._stick_event_subscribers: dict[ + Callable[[], None], + tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + ] = {} + + self._stick_response_subscribers: dict[ + Callable[[], None], + Callable[[StickResponse | StickInitResponse], Awaitable[None]] + ] = {} + + self._node_response_subscribers: dict[ + Callable[[], None], + tuple[ + Callable[[PlugwiseResponse], Awaitable[None]], bytes | None, + tuple[bytes] | None, + ] + ] = {} + + def connection_lost(self, exc: Exception | None = None) -> None: + """Call when port was closed expectedly or unexpectedly.""" + _LOGGER.debug("Connection lost") + if ( + self._connected_future is not None + and not self._connected_future.done() + ): + if exc is None: + self._connected_future.set_result(True) + else: + self._connected_future.set_exception(exc) + self._loop.create_task( + self._notify_stick_event_subscribers(StickEvent.DISCONNECTED) + ) + self._transport = None + self._connection_state = False + + @property + def is_connected(self) -> bool: + """Return current connection state of the USB-Stick.""" + return self._connection_state + + def connection_made(self, transport: Any) -> None: + """Call when the serial connection to USB-Stick is established.""" + _LOGGER.debug("Connection made") + self._transport = transport + if ( + self._connected_future is not None + and not self._connected_future.done() + ): + self._connected_future.set_result(True) + self._connection_state = True + self._loop.create_task( + self._notify_stick_event_subscribers(StickEvent.CONNECTED) + ) + + async def close(self) -> None: + """Close connection.""" + if self._transport is None: + return + if self._stick_future is not None and not self._stick_future.done(): + self._stick_future.cancel() + self._transport.close() + + def data_received(self, data: bytes) -> None: + """ + Receive data from USB-Stick connection. + This function is called by inherited asyncio.Protocol class + """ + self._buffer += data + if len(self._buffer) < 8: + return + while self.extract_message_from_buffer(): + pass + + def extract_message_from_buffer(self) -> bool: + """ + Parse data in buffer and extract any message. + When buffer does not contain any message return False. + """ + # Lookup header of message + if (_header_index := self._buffer.find(MESSAGE_HEADER)) == -1: + return False + self._buffer = self._buffer[_header_index:] + + # Lookup footer of message + if (_footer_index := self._buffer.find(MESSAGE_FOOTER)) == -1: + return False + + # Detect response message type + _empty_message = get_message_object( + self._buffer[4:8], _footer_index, self._buffer[8:12] + ) + if _empty_message is None: + _raw_msg_data = self._buffer[2:][: _footer_index - 4] + self._buffer = self._buffer[_footer_index:] + _LOGGER.warning("Drop unknown message type %s", str(_raw_msg_data)) + return True + + # Populate response message object with data + response: PlugwiseResponse | None = None + response = self._populate_message( + _empty_message, self._buffer[: _footer_index + 2] + ) + + # Parse remaining buffer + self._reset_buffer(self._buffer[_footer_index:]) + + if response is not None: + self._forward_response(response) + + if len(self._buffer) > 0: + self.extract_message_from_buffer() + return False + + def _populate_message( + self, message: PlugwiseResponse, data: bytes + ) -> PlugwiseResponse | None: + """Return plugwise response message based on data.""" + try: + message.deserialize(data) + except MessageError as err: + _LOGGER.warning(err) + return None + return message + + def _forward_response(self, response: PlugwiseResponse) -> None: + """Receive and handle response messages.""" + if isinstance(response, StickResponse): + self._loop.create_task( + self._notify_stick_response_subscribers(response) + ) + else: + self._loop.create_task( + self._notify_node_response_subscribers(response) + ) + + def _reset_buffer(self, new_buffer: bytes) -> None: + if new_buffer[:2] == MESSAGE_FOOTER: + new_buffer = new_buffer[2:] + if new_buffer == b"\x83": + # Skip additional byte sometimes appended after footer + new_buffer = bytes([]) + self._buffer = new_buffer + + def subscribe_to_stick_events( + self, + stick_event_callback: Callable[[StickEvent], Awaitable[None]], + events: tuple[StickEvent], + ) -> Callable[[], None]: + """ + Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. + """ + def remove_subscription() -> None: + """Remove stick event subscription.""" + self._stick_event_subscribers.pop(remove_subscription) + + self._stick_event_subscribers[ + remove_subscription + ] = (stick_event_callback, events) + return remove_subscription + + async def _notify_stick_event_subscribers( + self, + event: StickEvent, + ) -> None: + """Call callback for stick event subscribers""" + callback_list: list[Callable] = [] + for callback, filtered_event in self._stick_event_subscribers.values(): + if filtered_event is None or filtered_event == event: + callback_list.append(callback(event)) + await gather(*callback_list) + + def subscribe_to_stick_responses( + self, + callback: Callable[ + [StickResponse | StickInitResponse], Awaitable[None] + ], + ) -> Callable[[], None]: + """Subscribe to response messages from stick.""" + def remove_subscription() -> None: + """Remove update listener.""" + self._stick_response_subscribers.pop(remove_subscription) + + self._stick_response_subscribers[ + remove_subscription + ] = callback + return remove_subscription + + async def _notify_stick_response_subscribers( + self, stick_response: StickResponse + ) -> None: + """Call callback for all stick response message subscribers""" + for callback in self._stick_response_subscribers.values(): + await callback(stick_response) + + def subscribe_to_node_responses( + self, + node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + mac: bytes | None = None, + identifiers: tuple[bytes] | None = None, + ) -> Callable[[], None]: + """ + Subscribe to response messages from node(s). + Returns callable function to unsubscribe + """ + def remove_listener() -> None: + """Remove update listener.""" + self._node_response_subscribers.pop(remove_listener) + + self._node_response_subscribers[ + remove_listener + ] = (node_response_callback, mac, identifiers) + return remove_listener + + async def _notify_node_response_subscribers( + self, node_response: PlugwiseResponse + ) -> None: + """Call callback for all node response message subscribers""" + for callback, mac, ids in self._node_response_subscribers.values(): + if mac is not None: + if mac != node_response.mac: + continue + if ids is not None: + if node_response.identifier not in ids: + continue + await callback(node_response) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py new file mode 100644 index 000000000..423839e2d --- /dev/null +++ b/plugwise_usb/connection/sender.py @@ -0,0 +1,165 @@ +""" +Serialize request message and pass data stream to legacy Plugwise USB-Stick +Wait for stick to respond. +When request is accepted by USB-Stick, return the Sequence ID of the session. + +process flow + +1. Send(request) +1. wait for lock +1. convert (serialize) request message into bytes +1. send data to serial port +1. wait for stick reply (accept, timeout, failed) +1. when accept, return sequence id for response message of node + +""" +from __future__ import annotations + +from asyncio import Future, Lock, Transport, get_running_loop, wait_for +import logging + +from .receiver import StickReceiver +from ..constants import STICK_TIME_OUT +from ..exceptions import StickError, StickFailed, StickTimeout +from ..messages.responses import StickResponse, StickResponseType +from ..messages.requests import PlugwiseRequest + +_LOGGER = logging.getLogger(__name__) + + +class StickSender(): + """Send request messages though USB Stick transport connection.""" + + def __init__( + self, stick_receiver: StickReceiver, transport: Transport + ) -> None: + """Initialize the Stick Sender class""" + self._loop = get_running_loop() + self._receiver = stick_receiver + self._transport = transport + self._expected_seq_id: bytes = b"FFFF" + self._stick_response: Future[bytes] | None = None + self._stick_lock = Lock() + self._current_request: None | PlugwiseRequest = None + self._open_requests: dict[bytes, PlugwiseRequest] = {} + self._unsubscribe_stick_response = ( + self._receiver.subscribe_to_stick_responses( + self._process_stick_response + ) + ) + + async def write_request_to_port( + self, request: PlugwiseRequest + ) -> PlugwiseRequest: + """ + Send message to serial port of USB stick. + Returns the updated request object. + Raises StickError + """ + await self._stick_lock.acquire() + self._current_request = request + + if self._transport is None: + raise StickError("USB-Stick transport missing.") + + self._stick_response: Future[bytes] = self._loop.create_future() + + serialized_data = request.serialize() + request.subscribe_to_responses( + self._receiver.subscribe_to_node_responses + ) + + # Write message to serial port buffer + self._transport.write(serialized_data) + request.add_send_attempt() + + # Wait for USB stick to accept request + try: + seq_id: bytes = await wait_for( + self._stick_response, timeout=STICK_TIME_OUT + ) + except TimeoutError as exc: + raise StickError( + f"Failed to send {request.__class__.__name__} because " + + f"USB-Stick did not respond within {STICK_TIME_OUT} seconds." + ) from exc + else: + # Update request with session id + request.seq_id = seq_id + self._expected_seq_id = self._next_seq_id(self._expected_seq_id) + finally: + self._stick_response = None + self._stick_lock.release() + + return request + + async def _process_stick_response(self, response: StickResponse) -> None: + """Process stick response.""" + if self._expected_seq_id == b"FFFF": + # First response, so accept current sequence id + self._expected_seq_id = response.seq_id + + if self._expected_seq_id != response.seq_id: + _LOGGER.warning( + "Stick response (ack_id=%s) received with invalid seq id, " + + "expected %s received %s", + str(response.ack_id), + str(self._expected_seq_id), + str(response.seq_id), + ) + return + + if ( + self._stick_response is None + or self._stick_response.done() + ): + _LOGGER.warning( + "Unexpected stick response (ack_id=%s, seq_id=%s) received", + str(response.ack_id), + str(response.seq_id), + ) + return + + if response.ack_id == StickResponseType.ACCEPT: + self._stick_response.set_result(response.seq_id) + elif response.ack_id == StickResponseType.FAILED: + self._stick_response.set_exception( + BaseException( + StickFailed( + "USB-Stick failed to submit " + + f"{self._current_request.__class__.__name__} to " + + f"node '{self._current_request.mac_decoded}'." + ) + ) + ) + elif response.ack_id == StickResponseType.TIMEOUT: + self._stick_response.set_exception( + BaseException( + StickTimeout( + "USB-Stick timeout to submit " + + f"{self._current_request.__class__.__name__} to " + + f"node '{self._current_request.mac_decoded}'." + ) + ) + ) + + def stop(self) -> None: + """Stop sender""" + self._unsubscribe_stick_response() + + @staticmethod + def _next_seq_id(seq_id: bytes) -> bytes: + """Increment sequence id by one, return 4 bytes.""" + # Max seq_id = b'FFFB' + # b'FFFC' reserved for message + # b'FFFD' reserved for 'NodeJoinAckResponse' message + # b'FFFE' reserved for 'NodeAwakeResponse' message + # b'FFFF' reserved for 'NodeSwitchGroupResponse' message + if seq_id == b"FFFF": + return b"FFFF" + if (temp_int := int(seq_id, 16) + 1) >= 65532: + temp_int = 0 + temp_str = str(hex(temp_int)).lstrip("0x").upper() + while len(temp_str) < 4: + temp_str = "0" + temp_str + return temp_str.encode() diff --git a/plugwise_usb/connections/__init__.py b/plugwise_usb/connections/__init__.py deleted file mode 100644 index cd63debe9..000000000 --- a/plugwise_usb/connections/__init__.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Base class for serial or socket connections to USB-Stick.""" -import logging -import queue -import threading -import time - -from ..constants import SLEEP_TIME -from ..messages.requests import NodeRequest - -_LOGGER = logging.getLogger(__name__) - - -class StickConnection: - """Generic Plugwise stick connection.""" - - def __init__(self, port, parser): - """Initialize StickConnection.""" - self.port = port - self.parser = parser - self.run_reader_thread = False - self.run_writer_thread = False - self._is_connected = False - self._writer = None - - self._reader_thread = None - self._write_queue = None - self._writer_thread = None - - ################################################ - # Open connection # - ################################################ - - def connect(self) -> bool: - """Open the connection.""" - if not self._is_connected: - self._open_connection() - return self._is_connected - - def _open_connection(self): - """Placeholder.""" - - ################################################ - # Reader # - ################################################ - - def _reader_start(self, name): - """Start the reader thread to receive data.""" - self._reader_thread = threading.Thread(None, self._reader_deamon, name, (), {}) - self.run_reader_thread = True - self._reader_thread.start() - - def _reader_deamon(self): - """Thread to collect available data from connection.""" - while self.run_reader_thread: - if data := self._read_data(): - self.parser(data) - time.sleep(0.01) - _LOGGER.debug("Reader daemon stopped") - - # TODO: 20220125 function instead of self - def _read_data(self): - """placeholder.""" - return b"0000" - - ################################################ - # Writer # - ################################################ - - def _writer_start(self, name: str): - """Start the writer thread to send data.""" - self._write_queue = queue.Queue() - self._writer_thread = threading.Thread(None, self._writer_daemon, name, (), {}) - self._writer_thread.daemon = True - self.run_writer_thread = True - self._writer_thread.start() - - def _writer_daemon(self): - """Thread to write data from queue to existing connection.""" - while self.run_writer_thread: - try: - (message, callback) = self._write_queue.get(block=True, timeout=1) - except queue.Empty: - time.sleep(SLEEP_TIME) - else: - _LOGGER.debug( - "Sending %s to plugwise stick (%s)", - message.__class__.__name__, - message.serialize(), - ) - self._write_data(message.serialize()) - time.sleep(SLEEP_TIME) - if callback: - callback() - _LOGGER.debug("Writer daemon stopped") - - def _write_data(self, data): - """Placeholder.""" - - def send(self, message: NodeRequest, callback=None): - """Add message to write queue.""" - self._write_queue.put_nowait((message, callback)) - - ################################################ - # Connection state # - ################################################ - - def is_connected(self): - """Return connection state.""" - return self._is_connected - - def read_thread_alive(self): - """Return state of write thread.""" - return self._reader_thread.is_alive() if self.run_reader_thread else False - - def write_thread_alive(self): - """Return state of write thread.""" - return self._writer_thread.is_alive() if self.run_writer_thread else False - - ################################################ - # Close connection # - ################################################ - - def disconnect(self): - """Close the connection.""" - if self._is_connected: - self._is_connected = False - self.run_writer_thread = False - self.run_reader_thread = False - max_wait = 5 * SLEEP_TIME - while self._writer_thread.is_alive(): - time.sleep(SLEEP_TIME) - max_wait -= SLEEP_TIME - self._close_connection() - - def _close_connection(self): - """Placeholder.""" diff --git a/plugwise_usb/connections/serial.py b/plugwise_usb/connections/serial.py deleted file mode 100644 index 082247627..000000000 --- a/plugwise_usb/connections/serial.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Serial connection.""" -import logging - -import serial - -from ..connections import StickConnection -from ..constants import BAUD_RATE, BYTE_SIZE, STOPBITS -from ..exceptions import PortError - -_LOGGER = logging.getLogger(__name__) - - -class PlugwiseUSBConnection(StickConnection): - """Simple wrapper around serial module.""" - - def __init__(self, port, parser): - super().__init__(port, parser) - self._baud = BAUD_RATE - self._byte_size = BYTE_SIZE - self._stopbits = STOPBITS - self._parity = serial.PARITY_NONE - - self._serial = None - - def _open_connection(self): - """Open serial port.""" - _LOGGER.debug("Open serial port %s", self.port) - try: - self._serial = serial.Serial( - port=self.port, - baudrate=self._baud, - bytesize=self._byte_size, - parity=self._parity, - stopbits=self._stopbits, - timeout=1, - ) - except serial.serialutil.SerialException as err: - _LOGGER.debug( - "Failed to connect to serial port %s, %s", - self.port, - err, - ) - raise PortError(err) - self._is_connected = self._serial.isOpen() - if self._is_connected: - self._reader_start("serial_reader_thread") - self._writer_start("serial_writer_thread") - _LOGGER.debug("Successfully connected to serial port %s", self.port) - else: - _LOGGER.error( - "Failed to open serial port %s", - self.port, - ) - - def _close_connection(self): - """Close serial port.""" - try: - self._serial.close() - except serial.serialutil.SerialException as err: - _LOGGER.debug( - "Failed to close serial port %s, %s", - self.port, - err, - ) - raise PortError(err) - - def _read_data(self): - """Read thread.""" - if self._is_connected: - try: - serial_data = self._serial.read_all() - except serial.serialutil.SerialException as err: - _LOGGER.debug("Error while reading data from serial port : %s", err) - self._is_connected = False - raise PortError(err) - except Exception as err: # pylint: disable=broad-except - _LOGGER.debug("Error _read_data : %s", err) - return serial_data - return None - - def _write_data(self, data): - """Write data to serial port.""" - try: - self._serial.write(data) - except serial.serialutil.SerialException as err: - _LOGGER.debug("Error while writing data to serial port : %s", err) - self._is_connected = False - raise PortError(err) diff --git a/plugwise_usb/connections/socket.py b/plugwise_usb/connections/socket.py deleted file mode 100644 index 990d3e0c1..000000000 --- a/plugwise_usb/connections/socket.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Socket connection.""" -import logging -import socket - -from ..connections import StickConnection -from ..exceptions import PortError - -_LOGGER = logging.getLogger(__name__) - - -class SocketConnection(StickConnection): - """Wrapper for Socket connection configuration.""" - - def __init__(self, port, parser): - super().__init__(port, parser) - # get the address from a : format - port_split = self.port.split(":") - self._socket_host = port_split[0] - self._socket_port = int(port_split[1]) - self._socket_address = (self._socket_host, self._socket_port) - - self._socket = None - - def _open_connection(self): - """Open socket.""" - _LOGGER.debug( - "Open socket to host '%s' at port %s", - self._socket_host, - str(self._socket_port), - ) - try: - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.connect(self._socket_address) - except Exception as err: - _LOGGER.debug( - "Failed to connect to host %s at port %s, %s", - self._socket_host, - str(self._socket_port), - err, - ) - raise PortError(err) - self._reader_start("socket_reader_thread") - self._writer_start("socket_writer_thread") - self._is_connected = True - _LOGGER.debug( - "Successfully connected to host '%s' at port %s", - self._socket_host, - str(self._socket_port), - ) - - def _close_connection(self): - """Close the socket.""" - try: - self._socket.close() - except Exception as err: - _LOGGER.debug( - "Failed to close socket to host %s at port %s, %s", - self._socket_host, - str(self._socket_port), - err, - ) - raise PortError(err) - - def _read_data(self): - """Read data from socket.""" - if self._is_connected: - try: - socket_data = self._socket.recv(9999) - except Exception as err: - _LOGGER.debug( - "Error while reading data from host %s at port %s : %s", - self._socket_host, - str(self._socket_port), - err, - ) - self._is_connected = False - raise PortError(err) - return socket_data - return None - - def _write_data(self, data): - """Write data to socket.""" - try: - self._socket.send(data) - except Exception as err: - _LOGGER.debug("Error while writing data to socket port : %s", err) - self._is_connected = False - raise PortError(err) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index d19b156eb..5ef2aa529 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -1,14 +1,18 @@ -"""Plugwise Stick (power_usb_ constants.""" +"""Plugwise Stick constants.""" from __future__ import annotations import datetime as dt +from enum import Enum, auto import logging from typing import Final LOGGER = logging.getLogger(__name__) +# Cache folder name +CACHE_DIR: Final = ".plugwise-cache" +CACHE_SEPARATOR: str = ";" + # Copied homeassistant.consts -ARBITRARY_DATE: Final = dt.datetime(2022, 5, 14) ATTR_NAME: Final = "name" ATTR_STATE: Final = "state" ATTR_STATE_CLASS: Final = "state_class" @@ -29,155 +33,52 @@ VOLUME_CUBIC_METERS: Final = "m³" VOLUME_CUBIC_METERS_PER_HOUR: Final = "m³/h" -UTF8_DECODE: Final = "utf-8" -SPECIAL_FORMAT: Final[tuple[str, ...]] = (ENERGY_KILO_WATT_HOUR, VOLUME_CUBIC_METERS) +LOCAL_TIMEZONE = dt.datetime.now(dt.UTC).astimezone().tzinfo +UTF8: Final = "utf-8" -# Serial connection settings for plugwise USB stick -BAUD_RATE: Final = 115200 -BYTE_SIZE: Final = 8 -PARITY: Final = "N" -STOPBITS: Final = 1 +# Time +DAY_IN_HOURS: Final = 24 +WEEK_IN_HOURS: Final = 168 +DAY_IN_MINUTES: Final = 1440 +HOUR_IN_MINUTES: Final = 60 +DAY_IN_SECONDS: Final = 86400 +HOUR_IN_SECONDS: Final = 3600 +MINUTE_IN_SECONDS: Final = 60 +SECOND_IN_NANOSECONDS: Final = 1000000000 # Plugwise message identifiers MESSAGE_FOOTER: Final = b"\x0d\x0a" MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" -MESSAGE_LARGE: Final = "LARGE" -MESSAGE_SMALL: Final = "SMALL" - -# Acknowledge message types - -# NodeAckSmallResponse -RESPONSE_TYPE_SUCCESS: Final = b"00C1" -RESPONSE_TYPE_ERROR: Final = b"00C2" -RESPONSE_TYPE_TIMEOUT: Final = b"00E1" - -# NodeAckLargeResponse -CLOCK_SET: Final = b"00D7" -JOIN_REQUEST_ACCEPTED: Final = b"00D9" -RELAY_SWITCHED_OFF: Final = b"00DE" -RELAY_SWITCHED_ON: Final = b"00D8" -RELAY_SWITCH_FAILED: Final = b"00E2" -SLEEP_SET: Final = b"00F6" -SLEEP_FAILED: Final = b"00F7" # TODO: Validate -REAL_TIME_CLOCK_ACCEPTED: Final = b"00DF" -REAL_TIME_CLOCK_FAILED: Final = b"00E7" - -# NodeAckResponse -SCAN_CONFIGURE_ACCEPTED: Final = b"00BE" -SCAN_CONFIGURE_FAILED: Final = b"00BF" -SCAN_LIGHT_CALIBRATION_ACCEPTED: Final = b"00BD" -SENSE_INTERVAL_ACCEPTED: Final = b"00B3" -SENSE_INTERVAL_FAILED: Final = b"00B4" -SENSE_BOUNDARIES_ACCEPTED: Final = b"00B5" -SENSE_BOUNDARIES_FAILED: Final = b"00B6" - -STATE_ACTIONS = ( - RELAY_SWITCHED_ON, - RELAY_SWITCHED_OFF, - SCAN_CONFIGURE_ACCEPTED, - SLEEP_SET, -) -REQUEST_SUCCESS = ( - CLOCK_SET, - JOIN_REQUEST_ACCEPTED, - REAL_TIME_CLOCK_ACCEPTED, - RELAY_SWITCHED_ON, - RELAY_SWITCHED_OFF, - SCAN_CONFIGURE_ACCEPTED, - SCAN_LIGHT_CALIBRATION_ACCEPTED, - SENSE_BOUNDARIES_ACCEPTED, - SENSE_INTERVAL_ACCEPTED, - SLEEP_SET, -) -REQUEST_FAILED = ( - REAL_TIME_CLOCK_FAILED, - RELAY_SWITCH_FAILED, - RESPONSE_TYPE_ERROR, - RESPONSE_TYPE_TIMEOUT, - SCAN_CONFIGURE_FAILED, - SENSE_BOUNDARIES_FAILED, - SENSE_INTERVAL_FAILED, - SLEEP_FAILED, -) -STATUS_RESPONSES: Final[dict[bytes, str]] = { - # NodeAckSmallResponse - RESPONSE_TYPE_SUCCESS: "success", - RESPONSE_TYPE_ERROR: "error", - RESPONSE_TYPE_TIMEOUT: "timeout", - # NodeAckLargeResponse - CLOCK_SET: "clock set", - JOIN_REQUEST_ACCEPTED: "join accepted", - REAL_TIME_CLOCK_ACCEPTED: "real time clock set", - REAL_TIME_CLOCK_FAILED: "real time clock failed", - RELAY_SWITCHED_ON: "relay on", - RELAY_SWITCHED_OFF: "relay off", - RELAY_SWITCH_FAILED: "relay switching failed", - SLEEP_SET: "sleep settings accepted", - SLEEP_FAILED: "sleep settings failed", - # NodeAckResponse - SCAN_CONFIGURE_ACCEPTED: "Scan settings accepted", - SCAN_CONFIGURE_FAILED: "Scan settings failed", - SENSE_INTERVAL_ACCEPTED: "Sense report interval accepted", - SENSE_INTERVAL_FAILED: "Sense report interval failed", - SENSE_BOUNDARIES_ACCEPTED: "Sense boundaries accepted", - SENSE_BOUNDARIES_FAILED: "Sense boundaries failed", - SCAN_LIGHT_CALIBRATION_ACCEPTED: "Scan light calibration accepted", -} - -# TODO: responses -ACK_POWER_CALIBRATION: Final = b"00DA" -ACK_CIRCLE_PLUS: Final = b"00DD" -ACK_POWER_LOG_INTERVAL_SET: Final = b"00F8" - -# SED Awake status ID -SED_AWAKE_MAINTENANCE: Final = 0 # SED awake for maintenance -SED_AWAKE_FIRST: Final = 1 # SED awake for the first time -SED_AWAKE_STARTUP: Final = ( - 2 # SED awake after restart, e.g. after reinserting a battery -) -SED_AWAKE_STATE: Final = 3 # SED awake to report state (Motion / Temperature / Humidity -SED_AWAKE_UNKNOWN: Final = 4 # TODO: Unknown -SED_AWAKE_BUTTON: Final = 5 # SED awake due to button press # Max timeout in seconds -MESSAGE_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10 sec. -MESSAGE_RETRY: Final = 2 +STICK_ACCEPT_TIME_OUT: Final = 6 # Stick accept respond. +STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. +QUEUE_TIME_OUT: Final = 45 # Total seconds to wait for queue +NODE_TIME_OUT: Final = 20 +DISCOVERY_TIME_OUT: Final = 45 +REQUEST_TIMEOUT: Final = 0.5 +MAX_RETRIES: Final = 3 + +# Default sleep between sending messages +SLEEP_TIME: Final = 0.01 # plugwise year information is offset from y2k PLUGWISE_EPOCH: Final = 2000 PULSES_PER_KW_SECOND: Final = 468.9385193 -LOGADDR_OFFSET: Final = 278528 -# Default sleep between sending messages -SLEEP_TIME: Final = 150 / 1000 - -# Message priority levels -PRIORITY_HIGH: Final = 1 -PRIORITY_LOW: Final = 3 -PRIORITY_MEDIUM: Final = 2 +# Energy log memory addresses +LOGADDR_OFFSET: Final = 278528 +LOGADDR_MAX: Final = 65535 # TODO: Determine last log address, not used yet # Max seconds the internal clock of plugwise nodes # are allowed to drift in seconds MAX_TIME_DRIFT: Final = 5 -# Default sleep time in seconds for watchdog daemon -WATCHDOG_DEAMON: Final = 60 +# Duration updates of node states +NODE_CACHE: Final = dt.timedelta(seconds=5) -# Automatically accept new join requests -ACCEPT_JOIN_REQUESTS = False - -# Node types -NODE_TYPE_STICK: Final = 0 -NODE_TYPE_CIRCLE_PLUS: Final = 1 # AME_NC -NODE_TYPE_CIRCLE: Final = 2 # AME_NR -NODE_TYPE_SWITCH: Final = 3 # AME_SEDSwitch -NODE_TYPE_SENSE: Final = 5 # AME_SEDSense -NODE_TYPE_SCAN: Final = 6 # AME_SEDScan -NODE_TYPE_CELSIUS_SED: Final = 7 # AME_CelsiusSED -NODE_TYPE_CELSIUS_NR: Final = 8 # AME_CelsiusNR -NODE_TYPE_STEALTH: Final = 9 # AME_STEALTH_ZE -# 10 AME_MSPBOOTLOAD -# 11 AME_STAR +# Minimal time between power updates in seconds +MINIMAL_POWER_UPDATE: Final = 5 # Hardware models based HW_MODELS: Final[dict[str, str]] = { @@ -212,144 +113,10 @@ "080029": "Switch", } -# Defaults for SED's (Sleeping End Devices) -SED_STAY_ACTIVE: Final = 10 # Time in seconds the SED keep itself awake to receive and respond to other messages -SED_SLEEP_FOR: Final = 60 # Time in minutes the SED will sleep -SED_MAINTENANCE_INTERVAL: Final = 1440 # 24 hours, Interval in minutes the SED will get awake and notify it's available for maintenance purposes -SED_CLOCK_SYNC = True # Enable or disable synchronizing clock -SED_CLOCK_INTERVAL: Final = ( - 25200 # 7 days, duration in minutes the node synchronize its clock -) - - -# Scan motion Sensitivity levels -SCAN_SENSITIVITY_HIGH: Final = "high" -SCAN_SENSITIVITY_MEDIUM: Final = "medium" -SCAN_SENSITIVITY_OFF: Final = "medium" - -# Defaults for Scan Devices -SCAN_MOTION_RESET_TIMER: Final = 5 # Time in minutes the motion sensor should not sense motion to report "no motion" state -SCAN_SENSITIVITY = SCAN_SENSITIVITY_MEDIUM # Default sensitivity of the motion sensors -SCAN_DAYLIGHT_MODE = False # Light override - -# Sense calculations -SENSE_HUMIDITY_MULTIPLIER: Final = 125 -SENSE_HUMIDITY_OFFSET: Final = 6 -SENSE_TEMPERATURE_MULTIPLIER: Final = 175.72 -SENSE_TEMPERATURE_OFFSET: Final = 46.85 -# Callback types -CB_NEW_NODE: Final = "NEW_NODE" -CB_JOIN_REQUEST: Final = "JOIN_REQUEST" +class MotionSensitivity(Enum): + """Motion sensitivity levels for Scan devices""" -# Stick device features -FEATURE_AVAILABLE: Final[dict[str, str]] = { - "id": "available", - "name": "Available", - "state": "available", - "unit": "state", -} -FEATURE_ENERGY_CONSUMPTION_TODAY: Final[dict[str, str]] = { - "id": "energy_consumption_today", - "name": "Energy consumption today", - "state": "Energy_consumption_today", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_HUMIDITY: Final[dict[str, str]] = { - "id": "humidity", - "name": "Humidity", - "state": "humidity", - "unit": "%", -} -FEATURE_MOTION: Final[dict[str, str]] = { - "id": "motion", - "name": "Motion", - "state": "motion", - "unit": "state", -} -FEATURE_PING: Final[dict[str, str]] = { - "id": "ping", - "name": "Ping roundtrip", - "state": "ping", - "unit": TIME_MILLISECONDS, -} -FEATURE_POWER_USE: Final[dict[str, str]] = { - "id": "power_1s", - "name": "Power usage", - "state": "current_power_usage", - "unit": POWER_WATT, -} -FEATURE_POWER_USE_LAST_8_SEC: Final[dict[str, str]] = { - "id": "power_8s", - "name": "Power usage 8 seconds", - "state": "current_power_usage_8_sec", - "unit": POWER_WATT, -} -FEATURE_POWER_CONSUMPTION_CURRENT_HOUR: Final[dict[str, str]] = { - "id": "power_con_cur_hour", - "name": "Power consumption current hour", - "state": "power_consumption_current_hour", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR: Final[dict[str, str]] = { - "id": "power_con_prev_hour", - "name": "Power consumption previous hour", - "state": "power_consumption_previous_hour", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_POWER_CONSUMPTION_TODAY: Final[dict[str, str]] = { - "id": "power_con_today", - "name": "Power consumption today", - "state": "power_consumption_today", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_POWER_CONSUMPTION_YESTERDAY: Final[dict[str, str]] = { - "id": "power_con_yesterday", - "name": "Power consumption yesterday", - "state": "power_consumption_yesterday", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_POWER_PRODUCTION_CURRENT_HOUR: Final[dict[str, str]] = { - "id": "power_prod_cur_hour", - "name": "Power production current hour", - "state": "power_production_current_hour", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_POWER_PRODUCTION_PREVIOUS_HOUR: Final[dict[str, str]] = { - "id": "power_prod_prev_hour", - "name": "Power production previous hour", - "state": "power_production_previous_hour", - "unit": ENERGY_KILO_WATT_HOUR, -} -FEATURE_RELAY: Final[dict[str, str]] = { - "id": "relay", - "name": "Relay state", - "state": "relay_state", - "unit": "state", -} -FEATURE_SWITCH: Final[dict[str, str]] = { - "id": "switch", - "name": "Switch state", - "state": "switch_state", - "unit": "state", -} -FEATURE_TEMPERATURE: Final[dict[str, str]] = { - "id": "temperature", - "name": "Temperature", - "state": "temperature", - "unit": TEMP_CELSIUS, -} - -# TODO: Need to validate RSSI sensors -FEATURE_RSSI_IN: Final[dict[str, str]] = { - "id": "RSSI_in", - "name": "RSSI in", - "state": "rssi_in", - "unit": "Unknown", -} -FEATURE_RSSI_OUT: Final[dict[str, str]] = { - "id": "RSSI_out", - "name": "RSSI out", - "state": "rssi_out", - "unit": "Unknown", -} + HIGH = auto() + MEDIUM = auto() + OFF = auto() diff --git a/plugwise_usb/controller.py b/plugwise_usb/controller.py deleted file mode 100644 index 6fee9e5bc..000000000 --- a/plugwise_usb/controller.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Message controller for USB-Stick - -The controller will: -- handle the connection (connect/disconnect) to the USB-Stick -- take care for message acknowledgements based on sequence id's -- resend message requests when timeouts occurs -- holds a sending queue and submit messages based on the message priority (high, medium, low) -- passes received messages back to message processor (stick.py) -- execution of callbacks after processing the response message - -""" - -from datetime import datetime, timedelta -import logging -from queue import Empty, SimpleQueue -import threading -import time - -from .connections.serial import PlugwiseUSBConnection -from .connections.socket import SocketConnection -from .constants import ( - MESSAGE_RETRY, - MESSAGE_TIME_OUT, - PRIORITY_MEDIUM, - REQUEST_FAILED, - REQUEST_SUCCESS, - SLEEP_TIME, - STATUS_RESPONSES, - UTF8_DECODE, -) -from .messages.requests import NodeInfoRequest, NodePingRequest, NodeRequest -from .messages.responses import ( - NodeAckLargeResponse, - NodeAckResponse, - NodeAckSmallResponse, -) -from .parser import PlugwiseParser -from .util import inc_seq_id - -_LOGGER = logging.getLogger(__name__) - - -class StickMessageController: - """Handle connection and message sending and receiving""" - - def __init__(self, port: str, message_processor, node_state): - """Initialize message controller""" - self.connection = None - self.discovery_finished = False - self.expected_responses = {} - self.lock_expected_responses = threading.Lock() - self.init_callback = None - self.last_seq_id = None - self.message_processor = message_processor - self.node_state = node_state - self.parser = PlugwiseParser(self.message_handler) - self.port = port - - self._send_message_queue = None - self._send_message_thread = None - self._receive_timeout_thread = False - self._receive_timeout_thread_state = False - self._send_message_thread_state = False - - @property - def receive_timeout_thread_state(self) -> bool: - """Required state of the receive timeout thread""" - return self._receive_timeout_thread_state - - @property - def receive_timeout_thread_is_alive(self) -> bool: - """Current state of the receive timeout thread""" - return self._send_message_thread.is_alive() - - @property - def send_message_thread_state(self) -> bool: - """Required state of the send message thread""" - return self._send_message_thread_state - - @property - def send_message_thread_is_alive(self) -> bool: - """Current state of the send message thread""" - return self._send_message_thread.is_alive() - - def connect_to_stick(self, callback=None) -> bool: - """Connect to USB-Stick and startup all worker threads - - Return: True when connection is successful. - """ - self.init_callback = callback - # Open connection to USB Stick - if ":" in self.port: - _LOGGER.debug( - "Open socket connection to %s hosting Plugwise USB stick", self.port - ) - self.connection = SocketConnection(self.port, self.parser.feed) - else: - _LOGGER.debug("Open USB serial connection to Plugwise USB stick") - self.connection = PlugwiseUSBConnection(self.port, self.parser.feed) - if self.connection.connect(): - _LOGGER.debug("Starting message controller threads...") - # send daemon - self._send_message_queue = SimpleQueue() - self._send_message_thread_state = True - self._send_message_thread = threading.Thread( - None, self._send_message_loop, "send_messages_thread", (), {} - ) - self._send_message_thread.daemon = True - self._send_message_thread.start() - # receive timeout daemon - self._receive_timeout_thread_state = True - self._receive_timeout_thread = threading.Thread( - None, self._receive_timeout_loop, "receive_timeout_thread", (), {} - ) - self._receive_timeout_thread.daemon = True - self._receive_timeout_thread.start() - _LOGGER.debug("All message controller threads started") - else: - _LOGGER.warning("Failed to connect to USB stick") - return self.connection.is_connected() - - def send( - self, - request: NodeRequest, - callback=None, - retry_counter=0, - priority=PRIORITY_MEDIUM, - ): - """Queue request message to be sent into Plugwise Zigbee network.""" - _LOGGER.debug( - "Queue %s to be send to %s with retry counter %s and priority %s", - request.__class__.__name__, - request.mac, - str(retry_counter), - str(priority), - ) - self._send_message_queue.put( - ( - priority, - retry_counter, - datetime.now(), - [ - request, - callback, - retry_counter, - None, - ], - ) - ) - - def resend(self, seq_id): - """Resend message.""" - _mac = "" - with self.lock_expected_responses: - if not self.expected_responses.get(seq_id): - _LOGGER.warning( - "Cannot resend unknown request %s", - str(seq_id), - ) - else: - if self.expected_responses[seq_id][0].mac: - _mac = self.expected_responses[seq_id][0].mac.decode(UTF8_DECODE) - _request = self.expected_responses[seq_id][0].__class__.__name__ - - if self.expected_responses[seq_id][2] == -1: - _LOGGER.debug("Drop single %s to %s ", _request, _mac) - elif self.expected_responses[seq_id][2] <= MESSAGE_RETRY: - if ( - isinstance(self.expected_responses[seq_id][0], NodeInfoRequest) - and not self.discovery_finished - ): - # Time out for node which is not discovered yet - # to speedup the initial discover phase skip retries and mark node as not discovered. - _LOGGER.debug( - "Skip retry %s to %s to speedup discover process", - _request, - _mac, - ) - if self.expected_responses[seq_id][1]: - self.expected_responses[seq_id][1]() - else: - _LOGGER.info( - "Resend %s for %s, retry %s of %s", - _request, - _mac, - str(self.expected_responses[seq_id][2] + 1), - str(MESSAGE_RETRY + 1), - ) - self.send( - self.expected_responses[seq_id][0], - self.expected_responses[seq_id][1], - self.expected_responses[seq_id][2] + 1, - ) - else: - _LOGGER.warning( - "Drop %s to %s because max retries %s reached", - _request, - _mac, - str(MESSAGE_RETRY + 1), - ) - # Report node as unavailable for missing NodePingRequest - if isinstance(self.expected_responses[seq_id][0], NodePingRequest): - self.node_state(_mac, False) - else: - _LOGGER.debug( - "Do a single ping request to %s to validate if node is reachable", - _mac, - ) - self.send( - NodePingRequest(self.expected_responses[seq_id][0].mac), - None, - MESSAGE_RETRY + 1, - ) - del self.expected_responses[seq_id] - - def _send_message_loop(self): - """Daemon to send messages waiting in queue.""" - while self._send_message_thread_state: - try: - _prio, _retry, _dt, request_set = self._send_message_queue.get( - block=True, timeout=1 - ) - except Empty: - time.sleep(SLEEP_TIME) - else: - # Calc next seq_id based last received ack message - # if previous seq_id is unknown use fake b"0000" - seq_id = inc_seq_id(self.last_seq_id) - with self.lock_expected_responses: - self.expected_responses[seq_id] = request_set - if self.expected_responses[seq_id][2] == 0: - _LOGGER.info( - "Send %s to %s using seq_id %s", - self.expected_responses[seq_id][0].__class__.__name__, - self.expected_responses[seq_id][0].mac, - str(seq_id), - ) - else: - _LOGGER.info( - "Resend %s to %s using seq_id %s, retry %s", - self.expected_responses[seq_id][0].__class__.__name__, - self.expected_responses[seq_id][0].mac, - str(seq_id), - str(self.expected_responses[seq_id][2]), - ) - self.expected_responses[seq_id][3] = datetime.now() - # Send request - self.connection.send(self.expected_responses[seq_id][0]) - time.sleep(SLEEP_TIME) - timeout_counter = 0 - # Wait max 1 second for acknowledge response from USB-stick - while ( - self.last_seq_id != seq_id - and timeout_counter < 10 - and seq_id != b"0000" - and self.last_seq_id is not None - ): - time.sleep(0.1) - timeout_counter += 1 - if timeout_counter >= 10 and self._send_message_thread_state: - self.resend(seq_id) - _LOGGER.debug("Send message loop stopped") - - def message_handler(self, message): - """Handle received message from Plugwise Zigbee network.""" - - # only save last seq_id and skip special ID's FFFD, FFFE, FFFF - if self.last_seq_id: - if int(self.last_seq_id, 16) < int(message.seq_id, 16) < 65533: - self.last_seq_id = message.seq_id - elif message.seq_id == b"0000" and self.last_seq_id == b"FFFB": - self.last_seq_id = b"0000" - - if isinstance(message, NodeAckSmallResponse): - self._log_status_message(message, message.ack_id) - self._post_message_action( - message.seq_id, message.ack_id, message.__class__.__name__ - ) - else: - if isinstance(message, (NodeAckResponse, NodeAckLargeResponse)): - self._log_status_message(message, message.ack_id) - else: - self._log_status_message(message) - self.message_processor(message) - if message.seq_id not in [b"FFFF", b"FFFE", b"FFFD"]: - self._post_message_action( - message.seq_id, None, message.__class__.__name__ - ) - - def _post_message_action(self, seq_id, ack_response=None, request="unknown"): - """Execute action if request has been successful.""" - resend_request = False - with self.lock_expected_responses: - if seq_id in self.expected_responses: - if ack_response in (*REQUEST_SUCCESS, None): - if self.expected_responses[seq_id][1]: - _LOGGER.debug( - "Execute action %s of request with seq_id %s", - self.expected_responses[seq_id][1].__name__, - str(seq_id), - ) - try: - self.expected_responses[seq_id][1]() - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _LOGGER.error( - "Execution of %s for request with seq_id %s failed: %s", - self.expected_responses[seq_id][1].__name__, - str(seq_id), - err, - ) - del self.expected_responses[seq_id] - elif ack_response in REQUEST_FAILED: - resend_request = True - else: - if not self.last_seq_id: - if b"0000" in self.expected_responses: - self.expected_responses[seq_id] = self.expected_responses[ - b"0000" - ] - del self.expected_responses[b"0000"] - self.last_seq_id = seq_id - else: - _LOGGER.info( - "Drop unexpected %s%s using seq_id %s", - STATUS_RESPONSES.get(ack_response, "") + " ", - request, - str(seq_id), - ) - # Still save it to try and get it back into sync - self.last_seq_id = seq_id - if resend_request: - self.resend(seq_id) - - def _receive_timeout_loop(self): - """Daemon to time out open requests without any (n)ack response message.""" - while self._receive_timeout_thread_state: - resend_list = [] - with self.lock_expected_responses: - for seq_id in list(self.expected_responses.keys()): - if self.expected_responses[seq_id][3] is not None: - if self.expected_responses[seq_id][3] < ( - datetime.now() - timedelta(seconds=MESSAGE_TIME_OUT) - ): - _mac = "" - if self.expected_responses[seq_id][0].mac: - _mac = self.expected_responses[seq_id][0].mac - _LOGGER.info( - "No response within %s seconds timeout for %s to %s with sequence ID %s", - str(MESSAGE_TIME_OUT), - self.expected_responses[seq_id][0].__class__.__name__, - _mac, - str(seq_id), - ) - resend_list.append(seq_id) - for seq_id in resend_list: - self.resend(seq_id) - receive_timeout_checker = 0 - while ( - receive_timeout_checker < MESSAGE_TIME_OUT - and self._receive_timeout_thread_state - ): - time.sleep(1) - receive_timeout_checker += 1 - _LOGGER.debug("Receive timeout loop stopped") - - def _log_status_message(self, message, status=None): - """Log status messages..""" - if status: - if status in STATUS_RESPONSES: - _LOGGER.debug( - "Received %s %s for request with seq_id %s", - STATUS_RESPONSES[status], - message.__class__.__name__, - str(message.seq_id), - ) - else: - with self.lock_expected_responses: - if self.expected_responses.get(message.seq_id): - _LOGGER.warning( - "Received unmanaged (%s) %s in response to %s with seq_id %s", - str(status), - message.__class__.__name__, - str( - self.expected_responses[message.seq_id][ - 1 - ].__class__.__name__ - ), - str(message.seq_id), - ) - else: - _LOGGER.warning( - "Received unmanaged (%s) %s for unknown request with seq_id %s", - str(status), - message.__class__.__name__, - str(message.seq_id), - ) - else: - _LOGGER.info( - "Received %s from %s with sequence id %s", - message.__class__.__name__, - message.mac.decode(UTF8_DECODE), - str(message.seq_id), - ) - - def disconnect_from_stick(self): - """Disconnect from stick and raise error if it fails""" - self._send_message_thread_state = False - self._receive_timeout_thread_state = False - self.connection.disconnect() - - def restart_receive_timeout_thread(self): - """Restart the receive timeout thread if not running""" - if not self._receive_timeout_thread.is_alive(): - _LOGGER.warning( - "Unexpected halt of receive thread, restart thread", - ) - self._receive_timeout_thread = threading.Thread( - None, - self._receive_timeout_loop, - "receive_timeout_thread", - (), - {}, - ) - self._receive_timeout_thread.daemon = True - self._receive_timeout_thread.start() - - def restart_send_message_thread(self): - """Restart the message sender thread if not running""" - if not self._send_message_thread.is_alive(): - _LOGGER.warning( - "Unexpected halt of send thread, restart thread", - ) - self._send_message_thread = threading.Thread( - None, - self._send_message_loop, - "send_messages_thread", - (), - {}, - ) - self._send_message_thread.daemon = True - self._send_message_thread.start() diff --git a/plugwise_usb/exceptions.py b/plugwise_usb/exceptions.py index c18b1a699..ff3710130 100644 --- a/plugwise_usb/exceptions.py +++ b/plugwise_usb/exceptions.py @@ -5,37 +5,37 @@ class PlugwiseException(Exception): """Base error class for this Plugwise library.""" -class PortError(PlugwiseException): - """Connection to USBstick failed.""" +class CacheError(PlugwiseException): + """Cache error.""" -class StickInitError(PlugwiseException): - """Initialization of USBstick failed.""" +class EnergyError(PlugwiseException): + """Energy error.""" -class NetworkDown(PlugwiseException): - """Zigbee network not online.""" +class MessageError(PlugwiseException): + """Message errors.""" -class CirclePlusError(PlugwiseException): - """Connection to Circle+ node failed.""" +class NodeError(PlugwiseException): + """Node failed to execute request.""" -class InvalidMessageLength(PlugwiseException): - """Invalid message length.""" +class NodeTimeout(PlugwiseException): + """No response from node.""" -class InvalidMessageHeader(PlugwiseException): - """Invalid message header.""" +class StickError(PlugwiseException): + """Error at USB stick connection.""" -class InvalidMessageFooter(PlugwiseException): - """Invalid message footer.""" +class StickFailed(PlugwiseException): + """USB stick failed to accept request.""" -class InvalidMessageChecksum(PlugwiseException): - """Invalid data checksum.""" +class StickTimeout(PlugwiseException): + """Response timed out from USB-Stick.""" -class TimeoutException(PlugwiseException): - """Timeout expired while waiting for response from node.""" +class SubscriptionError(PlugwiseException): + """Subscription Errors.""" diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 4f8d28efb..1bdec9a1a 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -1,30 +1,50 @@ """Plugwise messages.""" -from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8_DECODE +from __future__ import annotations +from typing import Any + +from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..util import crc_fun class PlugwiseMessage: - """Plugwise message base.""" + """Plugwise message base class.""" + + def __init__(self, identifier: bytes) -> None: + """Initialize a plugwise message""" + self._identifier = identifier + self._mac: bytes | None = None + self._checksum: bytes | None = None + self._args: list[Any] = [] + + @property + def identifier(self) -> bytes: + """Return the message ID""" + return self._identifier - ID = b"0000" + @property + def mac(self) -> bytes: + """Return mac in bytes""" + return self._mac - def __init__(self): - self.mac = "" - self.checksum = None - self.args = [] + @property + def mac_decoded(self) -> str: + """Return mac in decoded string format.""" + if self._mac is None: + return "not defined" + return self._mac.decode(UTF8) - def serialize(self): - """Return message in a serialized format that can be sent out on wire.""" - _args = b"".join(a.serialize() for a in self.args) - msg = self.ID - if self.mac != "": - msg += self.mac - msg += _args - self.checksum = self.calculate_checksum(msg) - return MESSAGE_HEADER + msg + self.checksum + MESSAGE_FOOTER + def serialize(self) -> bytes: + """Return message in a serialized format that can be sent out.""" + data = bytes() + data += self._identifier + if self._mac is not None: + data += self._mac + data += b"".join(a.serialize() for a in self._args) + self._checksum = self.calculate_checksum(data) + return MESSAGE_HEADER + data + self._checksum + MESSAGE_FOOTER @staticmethod - def calculate_checksum(something): + def calculate_checksum(data: bytes) -> bytes: """Calculate crc checksum.""" - return bytes("%04X" % crc_fun(something), UTF8_DECODE) + return bytes("%04X" % crc_fun(data), UTF8) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 9e43968ab..8b6ea402d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1,6 +1,23 @@ """All known request messages to be send to plugwise devices.""" -from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER -from ..messages import PlugwiseMessage +from __future__ import annotations + +from asyncio import Future, TimerHandle, get_running_loop +from collections.abc import Callable +from datetime import datetime, UTC +from enum import Enum +import logging + +from . import PlugwiseMessage +from ..constants import ( + DAY_IN_MINUTES, + HOUR_IN_MINUTES, + MAX_RETRIES, + MESSAGE_FOOTER, + MESSAGE_HEADER, + NODE_TIME_OUT, +) +from ..messages.responses import PlugwiseResponse +from ..exceptions import NodeError from ..util import ( DateTime, Int, @@ -12,360 +29,697 @@ Time, ) +_LOGGER = logging.getLogger(__name__) -class NodeRequest(PlugwiseMessage): - """Base class for request messages to be send from by USB-Stick.""" - - def __init__(self, mac): - PlugwiseMessage.__init__(self) - self.args = [] - self.mac = mac - - -class NodeNetworkInfoRequest(NodeRequest): - """TODO: PublicNetworkInfoRequest - - No arguments - """ - ID = b"0001" +class Priority(int, Enum): + """Message priority levels for USB-stick message requests.""" + HIGH = 1 + MEDIUM = 2 + LOW = 3 -class CirclePlusConnectRequest(NodeRequest): - """Request to connect a Circle+ to the Stick - Response message: CirclePlusConnectResponse - """ +class PlugwiseRequest(PlugwiseMessage): + """Base class for request messages to be send from by USB-Stick.""" - ID = b"0004" + arguments: list = [] + priority: Priority = Priority.MEDIUM + seq_id: bytes | None = None - # This message has an exceptional format and therefore need to override the serialize method - def serialize(self): - # This command has args: byte: key, byte: networkinfo.index, ulong: networkkey = 0 + def __init__( + self, + identifier: bytes, + mac: bytes | None, + ) -> None: + super().__init__(identifier) + + self._args = [] + self._mac = mac + self._send_counter: int = 0 + self._max_retries: int = MAX_RETRIES + self.timestamp = datetime.now(UTC) + self._loop = get_running_loop() + self._id = id(self) + self._reply_identifier: bytes = b"0000" + + self._unsubscribe_response: Callable[[], None] | None = None + self._response: PlugwiseResponse | None = None + self._response_timeout: TimerHandle | None = None + self._response_future: Future[PlugwiseResponse] = ( + self._loop.create_future() + ) + + def response_future(self) -> Future[PlugwiseResponse]: + """Return awaitable future with response message""" + return self._response_future + + @property + def response(self) -> PlugwiseResponse | None: + """Return response message""" + return self._response + + def subscribe_to_responses( + self, subscription_fn: Callable[[], None] + ) -> None: + """Register for response messages""" + self._unsubscribe_response = ( + subscription_fn( + self._update_response, + mac=self._mac, + identifiers=(self._reply_identifier,), + ) + ) + + def start_response_timeout(self) -> None: + """Start timeout for node response""" + if self._response_timeout is not None: + self._response_timeout.cancel() + self._response_timeout = self._loop.call_later( + NODE_TIME_OUT, self._response_timeout_expired + ) + + def _response_timeout_expired(self) -> None: + """Handle response timeout""" + if not self._response_future.done(): + self._unsubscribe_response() + self._response_future.set_exception( + NodeError( + f"No response within {NODE_TIME_OUT} from node " + + f"{self.mac_decoded}" + ) + ) + + def _update_response(self, response: PlugwiseResponse) -> None: + """Process incoming message from node""" + if self.seq_id is None: + pass + if self.seq_id == response.seq_id: + self._response = response + self._response_timeout.cancel() + self._response_future.set_result(response) + self._unsubscribe_response() + + @property + def object_id(self) -> int: + """return the object id""" + return self._id + + @property + def max_retries(self) -> int: + """Return the maximum retries""" + return self._max_retries + + @max_retries.setter + def max_retries(self, max_retries: int) -> None: + """Set maximum retries""" + self._max_retries = max_retries + + @property + def retries_left(self) -> int: + """Return number of retries left""" + return self._max_retries - self._send_counter + + @property + def resend(self) -> bool: + """Return true if retry counter is not reached yet.""" + return self._max_retries > self._send_counter + + def add_send_attempt(self): + """Decrease the number of retries""" + self._send_counter += 1 + + def __gt__(self, other: PlugwiseRequest) -> bool: + """Greater than""" + if self.priority.value == other.priority.value: + return self.timestamp > other.timestamp + if self.priority.value < other.priority.value: + return True + return False + + def __lt__(self, other: PlugwiseRequest) -> bool: + """Less than""" + if self.priority.value == other.priority.value: + return self.timestamp < other.timestamp + if self.priority.value > other.priority.value: + return True + return False + + def __ge__(self, other: PlugwiseRequest) -> bool: + """Greater than or equal""" + if self.priority.value == other.priority.value: + return self.timestamp >= other.timestamp + if self.priority.value < other.priority.value: + return True + return False + + def __le__(self, other: PlugwiseRequest) -> bool: + """Less than or equal""" + if self.priority.value == other.priority.value: + return self.timestamp <= other.timestamp + if self.priority.value > other.priority.value: + return True + return False + + +class StickNetworkInfoRequest(PlugwiseRequest): + """ + Request network information + + Supported protocols : 1.0, 2.0 + Response message : NodeNetworkInfoResponse + """ + + def __init__(self) -> None: + """Initialize StickNetworkInfoRequest message object""" + self._reply_identifier = b"0002" + super().__init__(b"0001", None) + + +class CirclePlusConnectRequest(PlugwiseRequest): + """ + Request to connect a Circle+ to the Stick + + Supported protocols : 1.0, 2.0 + Response message : CirclePlusConnectResponse + """ + + def __init__(self, mac: bytes) -> None: + """Initialize CirclePlusConnectRequest message object""" + self._reply_identifier = b"0005" + super().__init__(b"0004", mac) + + # This message has an exceptional format and therefore + # need to override the serialize method + def serialize(self) -> bytes: + # This command has + # args: byte + # key, byte + # networkinfo.index, ulong + # networkkey = 0 args = b"00000000000000000000" - msg = self.ID + args + self.mac + msg: bytes = self._identifier + args + if self._mac is not None: + msg += self._mac checksum = self.calculate_checksum(msg) return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER -class NodeAddRequest(NodeRequest): - """Inform node it is added to the Plugwise Network it to memory of Circle+ node - - Response message: [acknowledge message] +class NodeAddRequest(PlugwiseRequest): """ + Add node to the Plugwise Network and add it to memory of Circle+ node - ID = b"0007" + Supported protocols : 1.0, 2.0 + Response message : TODO + """ - def __init__(self, mac, accept: bool): - super().__init__(mac) + def __init__(self, mac: bytes, accept: bool) -> None: + """Initialize NodeAddRequest message object""" + super().__init__(b"0007", mac) accept_value = 1 if accept else 0 - self.args.append(Int(accept_value, length=2)) + self._args.append(Int(accept_value, length=2)) # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method - def serialize(self): - args = b"".join(a.serialize() for a in self.args) - msg = self.ID + args + self.mac + def serialize(self) -> bytes: + args = b"".join(a.serialize() for a in self._args) + msg: bytes = self._identifier + args + if self._mac is not None: + msg += self._mac checksum = self.calculate_checksum(msg) return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER + def validate_reply(self, node_response: PlugwiseResponse) -> bool: + """"Validate node response""" + return True -class NodeAllowJoiningRequest(NodeRequest): - """Enable or disable receiving joining request of unjoined nodes. - Circle+ node will respond with an acknowledge message - Response message: NodeAckLargeResponse +class CirclePlusAllowJoiningRequest(PlugwiseRequest): """ + Enable or disable receiving joining request of unjoined nodes. + Circle+ node will respond - ID = b"0008" - - def __init__(self, accept: bool): - super().__init__("") - # TODO: Make sure that '01' means enable, and '00' disable joining - val = 1 if accept else 0 - self.args.append(Int(val, length=2)) + Supported protocols : 1.0, 2.0, + 2.6 (has extra 'AllowThirdParty' field) + Response message : NodeAckResponse + """ + def __init__(self, enable: bool) -> None: + """Initialize NodeAddRequest message object""" + super().__init__(b"0008", None) + self._reply_identifier = b"0003" + val = 1 if enable else 0 + self._args.append(Int(val, length=2)) -class NodeResetRequest(NodeRequest): - """TODO: Some kind of reset request - Response message: ??? +class NodeResetRequest(PlugwiseRequest): """ + TODO: Some kind of reset request - ID = b"0009" + Supported protocols : 1.0, 2.0, 2.1 + Response message : + """ - def __init__(self, mac, moduletype, timeout): - super().__init__(mac) - self.args += [ + def __init__(self, mac: bytes, moduletype: int, timeout: int) -> None: + """Initialize NodeResetRequest message object""" + super().__init__(b"0009", mac) + self._args += [ Int(moduletype, length=2), Int(timeout, length=2), ] -class StickInitRequest(NodeRequest): - """Initialize USB-Stick +class StickInitRequest(PlugwiseRequest): + """ + Initialize USB-Stick. + + Supported protocols : 1.0, 2.0 + Response message : StickInitResponse + """ + + def __init__(self) -> None: + """Initialize StickInitRequest message object""" + super().__init__(b"000A", None) + self._reply_identifier = b"0011" + self._max_retries = 1 + - Response message: StickInitResponse +class NodeImagePrepareRequest(PlugwiseRequest): """ + TODO: Some kind of request to prepare node for a firmware image. - ID = b"000A" + Supported protocols : 1.0, 2.0 + Response message : + """ - def __init__(self): - """Message for that initializes the Stick""" - # init is the only request message that doesn't send MAC address - super().__init__("") + def __init__(self) -> None: + """Initialize NodeImagePrepareRequest message object""" + super().__init__(b"000B", None) -class NodeImagePrepareRequest(NodeRequest): - """TODO: PWEswImagePrepareRequestV1_0 +class NodeImageValidateRequest(PlugwiseRequest): + """ + TODO: Some kind of request to validate a firmware image for a node. - Response message: TODO: + Supported protocols : 1.0, 2.0 + Response message : NodeImageValidationResponse """ - ID = b"000B" + def __init__(self) -> None: + """Initialize NodeImageValidateRequest message object""" + super().__init__(b"000C", None) + self._reply_identifier = b"0010" -class NodePingRequest(NodeRequest): - """Ping node +class NodePingRequest(PlugwiseRequest): + """ + Ping node + + Supported protocols : 1.0, 2.0 + Response message : NodePingResponse + """ + + def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: + """Initialize NodePingRequest message object""" + super().__init__(b"000D", mac) + self._reply_identifier = b"000E" + self._max_retries = retries + + +class NodeImageActivateRequest(PlugwiseRequest): + """ + TODO: Some kind of request to activate a firmware image for a node. + + Supported protocols : 1.0, 2.0 + Response message : + """ + + def __init__( + self, mac: bytes, request_type: int, reset_delay: int + ) -> None: + """Initialize NodeImageActivateRequest message object""" + super().__init__(b"000F", mac) + _type = Int(request_type, 2) + _reset_delay = Int(reset_delay, 2) + self._args += [_type, _reset_delay] + - Response message: NodePingResponse +class CirclePowerUsageRequest(PlugwiseRequest): """ + Request current power usage. - ID = b"000D" + Supported protocols : 1.0, 2.0, 2.1, 2.3 + Response message : CirclePowerUsageResponse + """ + def __init__(self, mac: bytes) -> None: + """Initialize CirclePowerUsageRequest message object""" + super().__init__(b"0012", mac) + self._reply_identifier = b"0013" -class CirclePowerUsageRequest(NodeRequest): - """Request current power usage - Response message: CirclePowerUsageResponse +class CircleLogDataRequest(PlugwiseRequest): """ + TODO: Some kind of request to get log data from a node. + Only supported at protocol version 1.0 ! + + + - ID = b"0012" + Supported protocols : 1.0 + Response message : CircleLogDataResponse + """ + def __init__(self, mac: bytes, start: datetime, end: datetime) -> None: + """Initialize CircleLogDataRequest message object""" + super().__init__(b"0014", mac) + self._reply_identifier = b"0015" + passed_days_start = start.day - 1 + month_minutes_start = ( + (passed_days_start * DAY_IN_MINUTES) + + (start.hour * HOUR_IN_MINUTES) + + start.minute + ) + from_abs = DateTime(start.year, start.month, month_minutes_start) + passed_days_end = end.day - 1 + month_minutes_end = ( + (passed_days_end * DAY_IN_MINUTES) + + (end.hour * HOUR_IN_MINUTES) + + end.minute + ) + to_abs = DateTime(end.year, end.month, month_minutes_end) + self._args += [from_abs, to_abs] -class CircleClockSetRequest(NodeRequest): - """Set internal clock of node - Response message: [Acknowledge message] +class CircleClockSetRequest(PlugwiseRequest): """ + Set internal clock of node and flash address - ID = b"0016" + Supported protocols : 1.0, 2.0 + Response message : NodeResponse + """ - def __init__(self, mac, dt, reset=False): - super().__init__(mac) - passed_days = dt.day - 1 - month_minutes = (passed_days * 24 * 60) + (dt.hour * 60) + dt.minute - this_date = DateTime(dt.year, dt.month, month_minutes) + def __init__( + self, + mac: bytes, + dt: datetime, + flash_address: str = "FFFFFFFF", + protocol_version: str = "2.0", + ) -> None: + """Initialize CircleLogDataRequest message object""" + super().__init__(b"0016", mac) + self._reply_identifier = b"0000" + self.priority = Priority.HIGH + if protocol_version == "1.0": + pass + # FIXME: Define "absoluteHour" variable + elif protocol_version == "2.0": + passed_days = dt.day - 1 + month_minutes = ( + (passed_days * DAY_IN_MINUTES) + + (dt.hour * HOUR_IN_MINUTES) + + dt.minute + ) + this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) - # FIXME: use LogAddr instead - if reset: - log_buf_addr = String("00044000", 8) - else: - log_buf_addr = String("FFFFFFFF", 8) - self.args += [this_date, log_buf_addr, this_time, day_of_week] + log_buf_addr = String(flash_address, 8) + self._args += [this_date, log_buf_addr, this_time, day_of_week] -class CircleSwitchRelayRequest(NodeRequest): - """switches relay on/off +class CircleRelaySwitchRequest(PlugwiseRequest): + """ + Request to switches relay on/off - Response message: NodeAckLargeResponse + Supported protocols : 1.0, 2.0 + Response message : NodeResponse """ ID = b"0017" - def __init__(self, mac, on): - super().__init__(mac) + def __init__(self, mac: bytes, on: bool) -> None: + """Initialize CircleRelaySwitchRequest message object""" + super().__init__(b"0017", mac) + self._reply_identifier = b"0000" + self.priority = Priority.HIGH val = 1 if on else 0 - self.args.append(Int(val, length=2)) - + self._args.append(Int(val, length=2)) -class CirclePlusScanRequest(NodeRequest): - """Get all linked Circle plugs from Circle+ - a Plugwise network can have 64 devices the node ID value has a range from 0 to 63 - Response message: CirclePlusScanResponse +class CirclePlusScanRequest(PlugwiseRequest): """ + Request all linked Circle plugs from Circle+ + a Plugwise network (Circle+) can have 64 devices the node ID value + has a range from 0 to 63 - ID = b"0018" + Supported protocols : 1.0, 2.0 + Response message : CirclePlusScanResponse + """ - def __init__(self, mac, node_address): - super().__init__(mac) - self.args.append(Int(node_address, length=2)) - self.node_address = node_address + def __init__(self, mac: bytes, network_address: int) -> None: + """Initialize CirclePlusScanRequest message object""" + super().__init__(b"0018", mac) + self._reply_identifier = b"0019" + self._args.append(Int(network_address, length=2)) + self.network_address = network_address -class NodeRemoveRequest(NodeRequest): - """Request node to be removed from Plugwise network by +class NodeRemoveRequest(PlugwiseRequest): + """ + Request node to be removed from Plugwise network by removing it from memory of Circle+ node. - Response message: NodeRemoveResponse + Supported protocols : 1.0, 2.0 + Response message : NodeRemoveResponse """ - ID = b"001C" - - def __init__(self, mac_circle_plus, mac_to_unjoined): - super().__init__(mac_circle_plus) - self.args.append(String(mac_to_unjoined, length=16)) - + def __init__(self, mac_circle_plus: bytes, mac_to_unjoined: str) -> None: + """Initialize NodeRemoveRequest message object""" + super().__init__(b"001C", mac_circle_plus) + self._reply_identifier = b"001D" + self._args.append(String(mac_to_unjoined, length=16)) -class NodeInfoRequest(NodeRequest): - """Request status info of node - Response message: NodeInfoResponse +class NodeInfoRequest(PlugwiseRequest): """ + Request status info of node - ID = b"0023" + Supported protocols : 1.0, 2.0, 2.3 + Response message : NodeInfoResponse + """ + def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: + """Initialize NodeInfoRequest message object""" + super().__init__(b"0023", mac) + self._reply_identifier = b"0024" + self._max_retries = retries -class CircleCalibrationRequest(NodeRequest): - """Request power calibration settings of node - Response message: CircleCalibrationResponse +class EnergyCalibrationRequest(PlugwiseRequest): """ + Request power calibration settings of node - ID = b"0026" + Supported protocols : 1.0, 2.0 + Response message : EnergyCalibrationResponse + """ + def __init__(self, mac: bytes) -> None: + """Initialize EnergyCalibrationRequest message object""" + super().__init__(b"0026", mac) + self._reply_identifier = b"0027" -class CirclePlusRealTimeClockSetRequest(NodeRequest): - """Set real time clock of CirclePlus - Response message: [Acknowledge message] +class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): """ + Set real time clock of Circle+ - ID = b"0028" + Supported protocols : 1.0, 2.0 + Response message : NodeResponse + """ - def __init__(self, mac, dt): - super().__init__(mac) + def __init__(self, mac: bytes, dt: datetime): + """Initialize CirclePlusRealTimeClockSetRequest message object""" + super().__init__(b"0028", mac) + self._reply_identifier = b"0000" + self.priority = Priority.HIGH this_time = RealClockTime(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) this_date = RealClockDate(dt.day, dt.month, dt.year) - self.args += [this_time, day_of_week, this_date] + self._args += [this_time, day_of_week, this_date] -class CirclePlusRealTimeClockGetRequest(NodeRequest): - """Request current real time clock of CirclePlus +class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): + """ + Request current real time clock of CirclePlus - Response message: CirclePlusRealTimeClockResponse + Supported protocols : 1.0, 2.0 + Response message : CirclePlusRealTimeClockResponse """ - ID = b"0029" + def __init__(self, mac: bytes): + """Initialize CirclePlusRealTimeClockGetRequest message object""" + super().__init__(b"0029", mac) + self._reply_identifier = b"003A" +# TODO : Insert +# +# ID = b"003B" = Get Schedule request +# ID = b"003C" = Set Schedule request -class CircleClockGetRequest(NodeRequest): - """Request current internal clock of node - Response message: CircleClockResponse +class CircleClockGetRequest(PlugwiseRequest): """ + Request current internal clock of node - ID = b"003E" + Supported protocols : 1.0, 2.0 + Response message : CircleClockResponse + """ + def __init__(self, mac: bytes): + """Initialize CircleClockGetRequest message object""" + super().__init__(b"003E", mac) + self._reply_identifier = b"003F" -class CircleEnableScheduleRequest(NodeRequest): - """Request to switch Schedule on or off - Response message: TODO: +class CircleActivateScheduleRequest(PlugwiseRequest): """ + Request to switch Schedule on or off - ID = b"0040" + Supported protocols : 1.0, 2.0 + Response message : TODO: + """ - def __init__(self, mac, on): - super().__init__(mac) + def __init__(self, mac: bytes, on: bool) -> None: + """Initialize CircleActivateScheduleRequest message object""" + super().__init__(b"0040", mac) val = 1 if on else 0 - self.args.append(Int(val, length=2)) + self._args.append(Int(val, length=2)) # the second parameter is always 0x01 - self.args.append(Int(1, length=2)) + self._args.append(Int(1, length=2)) -class NodeAddToGroupRequest(NodeRequest): - """Add node to group +class NodeAddToGroupRequest(PlugwiseRequest): + """ + Add node to group Response message: TODO: """ - ID = b"0045" - - def __init__(self, mac, group_mac, task_id, port_mask): - super().__init__(mac) + def __init__( + self, mac: bytes, group_mac: bytes, task_id: str, port_mask: str + ) -> None: + """Initialize NodeAddToGroupRequest message object""" + super().__init__(b"0045", mac) group_mac_val = String(group_mac, length=16) task_id_val = String(task_id, length=16) port_mask_val = String(port_mask, length=16) - self.args += [group_mac_val, task_id_val, port_mask_val] + self._args += [group_mac_val, task_id_val, port_mask_val] -class NodeRemoveFromGroupRequest(NodeRequest): - """Remove node from group +class NodeRemoveFromGroupRequest(PlugwiseRequest): + """ + Remove node from group Response message: TODO: """ - ID = b"0046" - - def __init__(self, mac, group_mac): - super().__init__(mac) + def __init__(self, mac: bytes, group_mac: bytes) -> None: + """Initialize NodeRemoveFromGroupRequest message object""" + super().__init__(b"0046", mac) group_mac_val = String(group_mac, length=16) - self.args += [group_mac_val] + self._args += [group_mac_val] -class NodeBroadcastGroupSwitchRequest(NodeRequest): - """Broadcast to group to switch +class NodeBroadcastGroupSwitchRequest(PlugwiseRequest): + """ + Broadcast to group to switch Response message: TODO: """ - ID = b"0047" - - def __init__(self, group_mac, switch_state: bool): - super().__init__(group_mac) + def __init__(self, group_mac: bytes, switch_state: bool) -> None: + """Initialize NodeBroadcastGroupSwitchRequest message object""" + super().__init__(b"0047", group_mac) val = 1 if switch_state else 0 - self.args.append(Int(val, length=2)) + self._args.append(Int(val, length=2)) + + +class CircleEnergyLogsRequest(PlugwiseRequest): + """ + Request energy usage counters stored a given memory address + + Response message: CircleEnergyLogsResponse + """ + + def __init__(self, mac: bytes, log_address: int) -> None: + """Initialize CircleEnergyLogsRequest message object""" + super().__init__(b"0048", mac) + self._reply_identifier = b"0049" + self.priority = Priority.LOW + self._args.append(LogAddr(log_address, 8)) -class CircleEnergyCountersRequest(NodeRequest): - """Request energy usage counters storaged a given memory address +class CircleHandlesOffRequest(PlugwiseRequest): + """ + ?PWSetHandlesOffRequestV1_0 - Response message: CircleEnergyCountersResponse + Response message: ? """ - ID = b"0048" + def __init__(self, mac: bytes) -> None: + """Initialize CircleHandlesOffRequest message object""" + super().__init__(b"004D", mac) - def __init__(self, mac, log_address): - super().__init__(mac) - self.args.append(LogAddr(log_address, 8)) +class CircleHandlesOnRequest(PlugwiseRequest): + """ + ?PWSetHandlesOnRequestV1_0 + + Response message: ? + """ -class NodeSleepConfigRequest(NodeRequest): - """Configure timers for SED nodes to minimize battery usage + def __init__(self, mac: bytes) -> None: + """Initialize CircleHandlesOnRequest message object""" + super().__init__(b"004E", mac) - stay_active : Duration in seconds the SED will be awake for receiving commands - sleep_for : Duration in minutes the SED will be in sleeping mode and not able to respond any command - maintenance_interval : Interval in minutes the node will wake up and able to receive commands + +class NodeSleepConfigRequest(PlugwiseRequest): + """ + Configure timers for SED nodes to minimize battery usage + + stay_active : Duration in seconds the SED will be + awake for receiving commands + sleep_for : Duration in minutes the SED will be + in sleeping mode and not able to respond + any command + maintenance_interval : Interval in minutes the node will wake up + and able to receive commands clock_sync : Enable/disable clock sync - clock_interval : Duration in minutes the node synchronize its clock + clock_interval : Duration in minutes the node synchronize + its clock Response message: Ack message with SLEEP_SET """ - ID = b"0050" - def __init__( self, - mac, + mac: bytes, stay_active: int, maintenance_interval: int, sleep_for: int, sync_clock: bool, clock_interval: int, ): - super().__init__(mac) - + """Initialize NodeSleepConfigRequest message object""" + super().__init__(b"0050", mac) + self._reply_identifier = b"0100" stay_active_val = Int(stay_active, length=2) sleep_for_val = Int(sleep_for, length=4) maintenance_interval_val = Int(maintenance_interval, length=4) val = 1 if sync_clock else 0 clock_sync_val = Int(val, length=2) clock_interval_val = Int(clock_interval, length=4) - self.args += [ + self._args += [ stay_active_val, maintenance_interval_val, sleep_for_val, @@ -374,8 +728,11 @@ def __init__( ] -class NodeSelfRemoveRequest(NodeRequest): - """ +class NodeSelfRemoveRequest(PlugwiseRequest): + """ + TODO: + @@ -383,117 +740,143 @@ class NodeSelfRemoveRequest(NodeRequest): """ - ID = b"0051" + def __init__(self, mac: bytes) -> None: + """Initialize NodeSelfRemoveRequest message object""" + super().__init__(b"0051", mac) -class NodeMeasureIntervalRequest(NodeRequest): - """Configure the logging interval of power measurement in minutes +class CircleMeasureIntervalRequest(PlugwiseRequest): + """ + Configure the logging interval of energy measurement in minutes - Response message: TODO: + FIXME: Make sure production interval is a multiply of consumption !! + + Response message: Ack message with ??? TODO: """ - ID = b"0057" + def __init__(self, mac: bytes, consumption: int, production: int): + """Initialize CircleMeasureIntervalRequest message object""" + super().__init__(b"0057", mac) + self._args.append(Int(consumption, length=4)) + self._args.append(Int(production, length=4)) - def __init__(self, mac, usage, production): - super().__init__(mac) - self.args.append(Int(usage, length=4)) - self.args.append(Int(production, length=4)) +class NodeClearGroupMacRequest(PlugwiseRequest): + """ + TODO: -class NodeClearGroupMacRequest(NodeRequest): - """TODO: Response message: ???? """ - ID = b"0058" - - def __init__(self, mac, taskId): - super().__init__(mac) - self.args.append(Int(taskId, length=2)) + def __init__(self, mac: bytes, taskId: int) -> None: + """Initialize NodeClearGroupMacRequest message object""" + super().__init__(b"0058", mac) + self._args.append(Int(taskId, length=2)) -class CircleSetScheduleValueRequest(NodeRequest): - """Send chunk of On/Off/StandbyKiller Schedule to Circle(+) +class CircleSetScheduleValueRequest(PlugwiseRequest): + """ + Send chunk of On/Off/StandbyKiller Schedule to Circle(+) Response message: TODO: """ - ID = b"0059" + def __init__(self, mac: bytes, val: int) -> None: + """Initialize CircleSetScheduleValueRequest message object""" + super().__init__(b"0059", mac) + self._args.append(SInt(val, length=4)) - def __init__(self, mac, val): - super().__init__(mac) - self.args.append(SInt(val, length=4)) - -class NodeFeaturesRequest(NodeRequest): - """Request feature set node supports +class NodeFeaturesRequest(PlugwiseRequest): + """ + Request feature set node supports Response message: NodeFeaturesResponse """ - ID = b"005F" - + def __init__(self, mac: bytes, val: int) -> None: + """Initialize NodeFeaturesRequest message object""" + super().__init__(b"005F", mac) + self._reply_identifier = b"0060" + self._args.append(SInt(val, length=4)) -class ScanConfigureRequest(NodeRequest): - """Configure a Scan node - reset_timer : Delay in minutes when signal is send when no motion is detected - sensitivity : Sensitivity of Motion sensor (High, Medium, Off) - light : Daylight override to only report motion when lightlevel is below calibrated level - - Response message: [Acknowledge message] +class ScanConfigureRequest(PlugwiseRequest): """ + Configure a Scan node - ID = b"0101" + reset_timer : Delay in minutes when signal is send + when no motion is detected + sensitivity : Sensitivity of Motion sensor + (High, Medium, Off) + light : Daylight override to only report motion + when light level is below calibrated level - def __init__(self, mac, reset_timer: int, sensitivity: int, light: bool): - super().__init__(mac) + Response message: NodeAckResponse + """ + def __init__( + self, mac: bytes, reset_timer: int, sensitivity: int, light: bool + ): + """Initialize ScanConfigureRequest message object""" + super().__init__(b"0101", mac) + self._reply_identifier = b"0100" reset_timer_value = Int(reset_timer, length=2) # Sensitivity: HIGH(0x14), MEDIUM(0x1E), OFF(0xFF) sensitivity_value = Int(sensitivity, length=2) light_temp = 1 if light else 0 light_value = Int(light_temp, length=2) - self.args += [ + self._args += [ sensitivity_value, light_value, reset_timer_value, ] -class ScanLightCalibrateRequest(NodeRequest): - """Calibrate light sensitivity - - Response message: [Acknowledge message] +class ScanLightCalibrateRequest(PlugwiseRequest): """ + Calibrate light sensitivity - ID = b"0102" + Response message: NodeAckResponse + """ + def __init__(self, mac: bytes): + """Initialize ScanLightCalibrateRequest message object""" + super().__init__(b"0102", mac) + self._reply_identifier = b"0100" -class SenseReportIntervalRequest(NodeRequest): - """Sets the Sense temperature and humidity measurement report interval in minutes. - Based on this interval, periodically a 'SenseReportResponse' message is sent by the Sense node - Response message: [Acknowledge message] +class SenseReportIntervalRequest(PlugwiseRequest): """ + Sets the Sense temperature and humidity measurement + report interval in minutes. Based on this interval, periodically + a 'SenseReportResponse' message is sent by the Sense node - ID = b"0102" + Response message: NodeAckResponse + """ - def __init__(self, mac, interval): - super().__init__(mac) - self.args.append(Int(interval, length=2)) + ID = b"0103" + def __init__(self, mac: bytes, interval: int): + """Initialize ScanLightCalibrateRequest message object""" + super().__init__(b"0103", mac) + self._reply_identifier = b"0100" + self._args.append(Int(interval, length=2)) -class CircleInitialRelaisStateRequest(NodeRequest): - """Get or set initial Relais state - Response message: CircleInitialRelaisStateResponse +class CircleRelayInitStateRequest(PlugwiseRequest): """ + Get or set initial relay state after power-up of Circle. - ID = b"0138" + Supported protocols : 2.6 + Response message : CircleInitRelayStateResponse + """ - def __init__(self, mac, configure: bool, relais_state: bool): - super().__init__(mac) - set_or_get = Int(1 if configure else 0, length=2) - relais = Int(1 if relais_state else 0, length=2) - self.args += [set_or_get, relais] + def __init__(self, mac: bytes, configure: bool, relay_state: bool) -> None: + """Initialize CircleRelayInitStateRequest message object""" + super().__init__(b"0138", mac) + self._reply_identifier = b"0139" + self.priority = Priority.LOW + self.set_or_get = Int(1 if configure else 0, length=2) + self.relay = Int(1 if relay_state else 0, length=2) + self._args += [self.set_or_get, self.relay] diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index a3bae1f3a..b7edb72f4 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,14 +1,13 @@ """All known response messages to be received from plugwise devices.""" -from datetime import datetime - -from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, MESSAGE_LARGE, MESSAGE_SMALL -from ..exceptions import ( - InvalidMessageChecksum, - InvalidMessageFooter, - InvalidMessageHeader, - InvalidMessageLength, -) -from ..messages import PlugwiseMessage +from __future__ import annotations + +from datetime import datetime, UTC +from enum import Enum +from typing import Any, Final + +from . import PlugwiseMessage +from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 +from ..exceptions import MessageError from ..util import ( DateTime, Float, @@ -21,108 +20,227 @@ UnixTimestamp, ) +NODE_AWAKE_RESPONSE_ID: Final = b"004F" +NODE_SWITCH_GROUP_ID: Final = b"0056" +SENSE_REPORT_ID: Final = b"0105" + +JOIN_AVAILABLE_SEQ_ID: Final = b"FFFC" +REJOIN_RESPONSE_SEQ_ID: Final = b"FFFD" +AWAKE_RESPONSE_SEQ_ID: Final = b"FFFE" +SWITCH_GROUP_RESPONSE_SEQ_ID: Final = b"FFFF" + +BROADCAST_IDS: Final = ( + JOIN_AVAILABLE_SEQ_ID, + REJOIN_RESPONSE_SEQ_ID, + AWAKE_RESPONSE_SEQ_ID, + SWITCH_GROUP_RESPONSE_SEQ_ID, +) + + +class StickResponseType(bytes, Enum): + """Response message types for stick.""" + + # Minimal value = b"00C0", maximum value = b"00F3" + # Below the currently known values: + + ACCEPT = b"00C1" + FAILED = b"00C2" + TIMEOUT = b"00E1" + + +class NodeResponseType(bytes, Enum): + """Response types of a 'NodeResponse' reply message.""" + + CLOCK_ACCEPTED = b"00D7" + JOIN_ACCEPTED = b"00D9" + RELAY_SWITCHED_OFF = b"00DE" + RELAY_SWITCHED_ON = b"00D8" + RELAY_SWITCH_FAILED = b"00E2" + SLEEP_CONFIG_ACCEPTED = b"00F6" + REAL_TIME_CLOCK_ACCEPTED = b"00DF" + REAL_TIME_CLOCK_FAILED = b"00E7" -class NodeResponse(PlugwiseMessage): - """Base class for response messages received by USB-Stick.""" - - def __init__(self, format_size=None): - super().__init__() - self.format_size = format_size - self.params = [] - self.timestamp = None - self.seq_id = None - self.msg_id = None - self.ack_id = None - if self.format_size == MESSAGE_SMALL: - self.len_correction = -12 - elif self.format_size == MESSAGE_LARGE: - self.len_correction = 4 - else: - self.len_correction = 0 - - def deserialize(self, response): - self.timestamp = datetime.now() + # TODO: Validate these response types + SLEEP_CONFIG_FAILED = b"00F7" + POWER_LOG_INTERVAL_ACCEPTED = b"00F8" + POWER_CALIBRATION_ACCEPTED = b"00DA" + CIRCLE_PLUS = b"00DD" + + +class NodeAckResponseType(bytes, Enum): + """Response types of a 'NodeAckResponse' reply message.""" + + SCAN_CONFIG_ACCEPTED = b"00BE" + SCAN_CONFIG_FAILED = b"00BF" + SCAN_LIGHT_CALIBRATION_ACCEPTED = b"00BD" + SENSE_INTERVAL_ACCEPTED = b"00B3" + SENSE_INTERVAL_FAILED = b"00B4" + SENSE_BOUNDARIES_ACCEPTED = b"00B5" + SENSE_BOUNDARIES_FAILED = b"00B6" + + +class NodeAwakeResponseType(int, Enum): + """Response types of a 'NodeAwakeResponse' reply message.""" + + MAINTENANCE = 0 # SED awake for maintenance + FIRST = 1 # SED awake for the first time + STARTUP = 2 # SED awake after restart, e.g. after reinserting a battery + STATE = 3 # SED awake to report state (Motion / Temperature / Humidity + UNKNOWN = 4 + BUTTON = 5 # SED awake due to button press + + +class PlugwiseResponse(PlugwiseMessage): + """ + Base class for response messages received by USB-Stick. + """ + + timestamp: datetime | None = None + + def __init__( + self, + identifier: bytes, + decode_ack: bool = False, + decode_mac: bool = True, + ) -> None: + """Initialize a response message""" + super().__init__(identifier) + self._ack_id: bytes | None = None + self._decode_ack = decode_ack + self._decode_mac = decode_mac + self._params: list[Any] = [] + self._seq_id: bytes = b"FFFF" + + @property + def ack_id(self) -> bytes | None: + """Return the acknowledge id""" + return self._ack_id + + @property + def seq_id(self) -> bytes: + """Sequence ID""" + return self._seq_id + + def deserialize(self, response: bytes) -> None: + """Deserialize bytes to actual message properties.""" + self.timestamp = datetime.now(UTC) + # Header if response[:4] != MESSAGE_HEADER: - raise InvalidMessageHeader( - f"Invalid message header {str(response[:4])} for {self.__class__.__name__}" + raise MessageError( + "Invalid message header" + + str({response[:4]}) + + " for " + + self.__class__.__name__ ) + response = response[4:] + + # Footer if response[-2:] != MESSAGE_FOOTER: - raise InvalidMessageFooter( - f"Invalid message footer {str(response[-2:])} for {self.__class__.__name__}" + raise MessageError( + "Invalid message footer " + + str(response[-2:]) + + " for " + + self.__class__.__name__ ) - _calculated_checksum = self.calculate_checksum(response[4:-6]) - _message_checksum = response[-6:-2] - if _calculated_checksum != _message_checksum: - raise InvalidMessageChecksum( - f"Invalid checksum for {self.__class__.__name__}, expected {str(_calculated_checksum)} got {str(_message_checksum)}", + response = response[:-2] + + # Checksum + calculated_checksum = self.calculate_checksum(response[:-4]) + if calculated_checksum != response[-4:]: + raise MessageError( + f"Invalid checksum for {self.__class__.__name__}, " + + "expected {calculated_checksum} got " + + str(response[-4:]), ) - if len(response) != len(self): - raise InvalidMessageLength( - f"Invalid message length received for {self.__class__.__name__}, expected {str(len(self))} bytes got {str(len(response))}" + response = response[:-4] + + # ID and Sequence number + if self._identifier != response[:4]: + raise MessageError( + "Invalid message identifier received " + + f"expected {self._identifier} " + + f"got {response[:4]}" ) + self._seq_id = response[4:8] + response = response[8:] - self.msg_id = response[4:8] - self.seq_id = response[8:12] - response = response[12:] - if self.format_size in [MESSAGE_SMALL, MESSAGE_LARGE]: - self.ack_id = response[:4] + # Message data + if len(response) != len(self): + raise MessageError( + "Invalid message length received for " + + f"{self.__class__.__name__}, expected " + + f"{len(self)} bytes got {len(response)}" + ) + if self._decode_ack: + self._ack_id = response[:4] response = response[4:] - if self.format_size != MESSAGE_SMALL: - self.mac = response[:16] + if self._decode_mac: + self._mac = response[:16] response = response[16:] - response = self._parse_params(response) - - _args = b"".join(a.serialize() for a in self.args) - msg = self.ID - if self.mac != "": - msg += self.mac - msg += _args - - def _parse_params(self, response): - for param in self.params: + if len(response) > 0: + try: + response = self._parse_params(response) + except ValueError as ve: + raise MessageError( + "Failed to parse data " + + str(response) + + "for message " + + self.__class__.__name__ + ) from ve + + def _parse_params(self, response: bytes) -> bytes: + for param in self._params: my_val = response[: len(param)] param.deserialize(my_val) - response = response[len(my_val) :] + response = response[len(my_val):] return response - def __len__(self): - arglen = sum(len(x) for x in self.params) - return 34 + arglen + self.len_correction + def __len__(self) -> int: + """Return the size of response message.""" + offset_ack = 4 if self._decode_ack else 0 + offset_mac = 16 if self._decode_mac else 0 + return offset_ack + offset_mac + sum(len(x) for x in self._params) -class NodeAckSmallResponse(NodeResponse): - """Acknowledge message without source MAC - - Response to: Any message +class StickResponse(PlugwiseResponse): """ + Response message from USB-Stick - ID = b"0000" - - def __init__(self): - super().__init__(MESSAGE_SMALL) + Response to: Any message request + """ + def __init__(self) -> None: + """Initialize StickResponse message object""" + super().__init__(b"0000", decode_ack=True, decode_mac=False) -class NodeAckLargeResponse(NodeResponse): - """Acknowledge message with source MAC - Response to: Any message +class NodeResponse(PlugwiseResponse): """ + Report status from node to a specific request - ID = b"0000" + Supported protocols : 1.0, 2.0 + Response to requests: TODO: complete list + CircleClockSetRequest + CirclePlusRealTimeClockSetRequest + CircleRelaySwitchRequest + """ - def __init__(self): - super().__init__(MESSAGE_LARGE) + def __init__(self) -> None: + """Initialize NodeResponse message object""" + super().__init__(b"0000", decode_ack=True) -class CirclePlusQueryResponse(NodeResponse): - """TODO: - Response to : ??? +class StickNetworkInfoResponse(PlugwiseResponse): """ + Report status of zigbee network - ID = b"0002" + Supported protocols : 1.0, 2.0 + Response to request : NodeNetworkInfoRequest + """ - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize NodeNetworkInfoResponse message object""" + super().__init__(b"0002") self.channel = String(None, length=2) self.source_mac_id = String(None, length=16) self.extended_pan_id = String(None, length=16) @@ -130,7 +248,7 @@ def __init__(self): self.new_node_mac_id = String(None, length=16) self.pan_id = String(None, length=4) self.idx = Int(0, length=2) - self.params += [ + self._params += [ self.channel, self.source_mac_id, self.extended_pan_id, @@ -140,267 +258,433 @@ def __init__(self): self.idx, ] - def __len__(self): - arglen = sum(len(x) for x in self.params) - return 18 + arglen - - def deserialize(self, response): + def deserialize(self, response: bytes) -> None: super().deserialize(response) - # Clear first two characters of mac ID, as they contain part of the short PAN-ID + # Clear first two characters of mac ID, as they contain + # part of the short PAN-ID self.new_node_mac_id.value = b"00" + self.new_node_mac_id.value[2:] -class CirclePlusQueryEndResponse(NodeResponse): - """TODO: - PWAckReplyV1_0 - - - Response to : ??? +class NodeSpecificResponse(PlugwiseResponse): """ + TODO: Report some sort of status from node - ID = b"0003" - - def __init__(self): - super().__init__() - self.status = Int(0, 4) - self.params += [self.status] + PWAckReplyV1_0 + - def __len__(self): - arglen = sum(len(x) for x in self.params) - return 18 + arglen + Supported protocols : 1.0, 2.0 + Response to requests: Unknown: TODO + """ + def __init__(self) -> None: + """Initialize NodeSpecificResponse message object""" + super().__init__(b"0003") + self.status = Int(0, 4) + self._params += [self.status] -class CirclePlusConnectResponse(NodeResponse): - """CirclePlus connected to the network - Response to : CirclePlusConnectRequest +class CirclePlusConnectResponse(PlugwiseResponse): """ + CirclePlus connected to the network - ID = b"0005" + Supported protocols : 1.0, 2.0 + Response to request : CirclePlusConnectRequest + """ - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize CirclePlusConnectResponse message object""" + super().__init__(b"0005") self.existing = Int(0, 2) self.allowed = Int(0, 2) - self.params += [self.existing, self.allowed] + self._params += [self.existing, self.allowed] + + +class NodeJoinAvailableResponse(PlugwiseResponse): + """ + Request from Node to join a plugwise network + + Supported protocols : 1.0, 2.0 + Response to request : No request as every unjoined node is requesting + to be added automatically + """ + + def __init__(self) -> None: + """Initialize NodeJoinAvailableResponse message object""" + super().__init__(b"0006") + + +class NodePingResponse(PlugwiseResponse): + """ + Ping and RSSI (Received Signal Strength Indicator) response from node + + - rssi_in : Incoming last hop RSSI target + - rssi_out : Last hop RSSI source + - time difference in ms - def __len__(self): - arglen = sum(len(x) for x in self.params) - return 18 + arglen + Supported protocols : 1.0, 2.0 + Response to request : NodePingRequest + """ + + def __init__(self) -> None: + """Initialize NodePingResponse message object""" + super().__init__(b"000E") + self._rssi_in = Int(0, length=2) + self._rssi_out = Int(0, length=2) + self._rtt = Int(0, 4, False) + self._params += [ + self._rssi_in, + self._rssi_out, + self._rtt, + ] + + @property + def rssi_in(self) -> int: + """Return inbound RSSI level""" + return self._rssi_in.value + @property + def rssi_out(self) -> int: + """Return outbound RSSI level""" + return self._rssi_out.value -class NodeJoinAvailableResponse(NodeResponse): - """Message from an unjoined node to notify it is available to join a plugwise network + @property + def rtt(self) -> int: + """Return round trip time""" + return self._rtt.value - Response to : + +class NodeImageValidationResponse(PlugwiseResponse): """ + TODO: Some kind of response to validate a firmware image for a node. - ID = b"0006" + Supported protocols : 1.0, 2.0 + Response to request : NodeImageValidationRequest + """ + def __init__(self) -> None: + """Initialize NodePingResponse message object""" + super().__init__(b"0010") + self.image_timestamp = UnixTimestamp(0) + self._params += [self.image_timestamp] -class StickInitResponse(NodeResponse): - """Returns the configuration and status of the USB-Stick + +class StickInitResponse(PlugwiseResponse): + """ + Returns the configuration and status of the USB-Stick Optional: - circle_plus_mac - network_id + - TODO: Two unknown parameters - - - - - Response to: StickInitRequest + Supported protocols : 1.0, 2.0 + Response to request : StickInitRequest """ - ID = b"0011" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize StickInitResponse message object""" + super().__init__(b"0011") self.unknown1 = Int(0, length=2) - self.network_is_online = Int(0, length=2) - self.circle_plus_mac = String(None, length=16) - self.network_id = Int(0, 4, False) + self._network_online = Int(0, length=2) + self._mac_nc = String(None, length=16) + self._network_id = Int(0, 4, False) self.unknown2 = Int(0, length=2) - self.params += [ + self._params += [ self.unknown1, - self.network_is_online, - self.circle_plus_mac, - self.network_id, + self._network_online, + self._mac_nc, + self._network_id, self.unknown2, ] + @property + def mac_network_controller(self) -> str: + """Return the mac of the network controller (Circle+)""" + # Replace first 2 characters by 00 for mac of circle+ node + return "00" + self._mac_nc.value[2:].decode(UTF8) -class NodePingResponse(NodeResponse): - """Ping response from node + @property + def network_id(self) -> int: + """Return network ID""" + return self._network_id.value - - incomingLastHopRssiTarget (received signal strength indicator) - - lastHopRssiSource - - timediffInMs + @property + def network_online(self) -> bool: + """Return state of network.""" + return True if self._network_online.value == 1 else False - Response to : NodePingRequest - """ - ID = b"000E" +class CirclePowerUsageResponse(PlugwiseResponse): + """ + Returns power usage as impulse counters for several different time frames - def __init__(self): - super().__init__() - self.rssi_in = Int(0, length=2) - self.rssi_out = Int(0, length=2) - self.ping_ms = Int(0, 4, False) - self.params += [ - self.rssi_in, - self.rssi_out, - self.ping_ms, - ] + Supported protocols : 1.0, 2.0, 2.1, 2.3 + Response to request : CirclePowerUsageRequest + """ + def __init__(self, protocol_version: str = "2.3") -> None: + """Initialize CirclePowerUsageResponse message object""" + super().__init__(b"0013") + self._pulse_1s = Int(0, 4) + self._pulse_8s = Int(0, 4) + self._nanosecond_offset = Int(0, 4) + self._params += [self._pulse_1s, self._pulse_8s] + if protocol_version == "2.3": + self._pulse_counter_consumed = Int(0, 8) + self._pulse_counter_produced = Int(0, 8) + self._params += [ + self._pulse_counter_consumed, + self._pulse_counter_produced, + ] + self._params += [self._nanosecond_offset] + + @property + def pulse_1s(self) -> int: + """Return pulses last second""" + return self._pulse_1s.value + + @property + def pulse_8s(self) -> int: + """Return pulses last 8 seconds""" + return self._pulse_8s.value + + @property + def offset(self) -> int: + """Return offset in nanoseconds""" + return self._nanosecond_offset.value + + @property + def consumed_counter(self) -> int: + """Return consumed pulses""" + return self._pulse_counter_consumed.value + + @property + def produced_counter(self) -> int: + """Return consumed pulses""" + return self._pulse_counter_produced.value + + +class CircleLogDataResponse(PlugwiseResponse): + """ + TODO: Returns some kind of log data from a node. + Only supported at protocol version 1.0 ! -class CirclePowerUsageResponse(NodeResponse): - """Returns power usage as impulse counters for several different timeframes + + + + - Response to : CirclePowerUsageRequest + Supported protocols : 1.0 + Response to: CircleLogDataRequest """ - ID = b"0013" - - def __init__(self): - super().__init__() - self.pulse_1s = Int(0, 4) - self.pulse_8s = Int(0, 4) - self.pulse_hour_consumed = Int(0, 8) - self.pulse_hour_produced = Int(0, 8) - self.nanosecond_offset = Int(0, 4) - self.params += [ - self.pulse_1s, - self.pulse_8s, - self.pulse_hour_consumed, - self.pulse_hour_produced, - self.nanosecond_offset, + def __init__(self) -> None: + """Initialize CircleLogDataResponse message object""" + super().__init__(b"0015") + self.stored_abs = DateTime() + self.powermeterinfo = Int(0, 8, False) + self.flashaddress = LogAddr(0, length=8) + self._params += [ + self.stored_abs, + self.powermeterinfo, + self.flashaddress, ] -class CirclePlusScanResponse(NodeResponse): - """Returns the MAC of a registered node at the specified memory address +class CirclePlusScanResponse(PlugwiseResponse): + """ + Returns the MAC of a registered node at the specified memory address + of a Circle+ - Response to: CirclePlusScanRequest + Supported protocols : 1.0, 2.0 + Response to request : CirclePlusScanRequest """ - ID = b"0019" + def __init__(self) -> None: + """Initialize CirclePlusScanResponse message object""" + super().__init__(b"0019") + self._registered_mac = String(None, length=16) + self._network_address = Int(0, 2, False) + self._params += [self._registered_mac, self._network_address] - def __init__(self): - super().__init__() - self.node_mac = String(None, length=16) - self.node_address = Int(0, 2, False) - self.params += [self.node_mac, self.node_address] + @property + def registered_mac(self) -> str: + """Return the mac of the node""" + return self._registered_mac.value.decode(UTF8) + @property + def network_address(self) -> int: + """Return the network address""" + return self._network_address.value -class NodeRemoveResponse(NodeResponse): - """Returns conformation (or not) if node is removed from the Plugwise network - by having it removed from the memory of the Circle+ - Response to: NodeRemoveRequest +class NodeRemoveResponse(PlugwiseResponse): """ + Returns conformation (or not) if node is removed from the Plugwise network + by having it removed from the memory of the Circle+ - ID = b"001D" + Supported protocols : 1.0, 2.0 + Response to request : NodeRemoveRequest + """ - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize NodeRemoveResponse message object""" + super().__init__(b"001D") self.node_mac_id = String(None, length=16) self.status = Int(0, 2) - self.params += [self.node_mac_id, self.status] + self._params += [self.node_mac_id, self.status] -class NodeInfoResponse(NodeResponse): - """Returns the status information of Node - - Response to: NodeInfoRequest +class NodeInfoResponse(PlugwiseResponse): """ + Returns the status information of Node - ID = b"0024" + Supported protocols : 1.0, 2.0, 2.3 + Response to request : NodeInfoRequest + """ - def __init__(self): - super().__init__() - self.datetime = DateTime() - self.last_logaddr = LogAddr(0, length=8) - self.relay_state = Int(0, length=2) - # TODO: 20220126 snake-style - # pylint: disable=invalid-name - self.hz = Int(0, length=2) + def __init__(self, protocol_version: str = "2.0") -> None: + """Initialize NodeInfoResponse message object""" + super().__init__(b"0024") + + self.last_logaddress = LogAddr(0, length=8) + if protocol_version == "1.0": + pass + # FIXME: Define "absoluteHour" variable + self.datetime = DateTime() + self.relay_state = Int(0, length=2) + self._params += [ + self.datetime, + self.last_logaddress, + self.relay_state, + ] + elif protocol_version == "2.0": + self.datetime = DateTime() + self.relay_state = Int(0, length=2) + self._params += [ + self.datetime, + self.last_logaddress, + self.relay_state, + ] + elif protocol_version == "2.3": + # FIXME: Define "State_mask" variable + self.state_mask = Int(0, length=2) + self._params += [ + self.datetime, + self.last_logaddress, + self.state_mask, + ] + self.frequency = Int(0, length=2) self.hw_ver = String(None, length=12) self.fw_ver = UnixTimestamp(0) self.node_type = Int(0, length=2) - self.params += [ - self.datetime, - self.last_logaddr, - self.relay_state, - self.hz, + self._params += [ + self.frequency, self.hw_ver, self.fw_ver, self.node_type, ] -class CircleCalibrationResponse(NodeResponse): - """returns the calibration settings of node +class EnergyCalibrationResponse(PlugwiseResponse): + """ + Returns the calibration settings of node - Response to: CircleCalibrationRequest + Supported protocols : 1.0, 2.0 + Response to request : EnergyCalibrationRequest """ - ID = b"0027" + def __init__(self) -> None: + """Initialize EnergyCalibrationResponse message object""" + super().__init__(b"0027") + self._gain_a = Float(0, 8) + self._gain_b = Float(0, 8) + self._off_tot = Float(0, 8) + self._off_noise = Float(0, 8) + self._params += [ + self._gain_a, + self._gain_b, + self._off_tot, + self._off_noise + ] - def __init__(self): - super().__init__() - self.gain_a = Float(0, 8) - self.gain_b = Float(0, 8) - self.off_tot = Float(0, 8) - self.off_noise = Float(0, 8) - self.params += [self.gain_a, self.gain_b, self.off_tot, self.off_noise] + @property + def gain_a(self) -> float: + """Return the gain A""" + return self._gain_a.value + @property + def gain_b(self) -> float: + """Return the gain B""" + return self._gain_b.value -class CirclePlusRealTimeClockResponse(NodeResponse): - """returns the real time clock of CirclePlus node + @property + def off_tot(self) -> float: + """Return the offset""" + return self._off_tot.value + + @property + def off_noise(self) -> float: + """Return the offset""" + return self._off_noise.value - Response to: CirclePlusRealTimeClockGetRequest - """ - ID = b"003A" +class CirclePlusRealTimeClockResponse(PlugwiseResponse): + """ + returns the real time clock of CirclePlus node - def __init__(self): - super().__init__() + Supported protocols : 1.0, 2.0 + Response to request : CirclePlusRealTimeClockGetRequest + """ + def __init__(self) -> None: + """Initialize CirclePlusRealTimeClockResponse message object""" + super().__init__(b"003A") self.time = RealClockTime() self.day_of_week = Int(0, 2, False) self.date = RealClockDate() - self.params += [self.time, self.day_of_week, self.date] + self._params += [self.time, self.day_of_week, self.date] -class CircleClockResponse(NodeResponse): - """Returns the current internal clock of Node +# TODO : Insert +# +# ID = b"003D" = Schedule response - Response to: CircleClockGetRequest + +class CircleClockResponse(PlugwiseResponse): """ + Returns the current internal clock of Node - ID = b"003F" + Supported protocols : 1.0, 2.0 + Response to request : CircleClockGetRequest + """ - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize CircleClockResponse message object""" + super().__init__(b"003F") self.time = Time() self.day_of_week = Int(0, 2, False) self.unknown = Int(0, 2) self.unknown2 = Int(0, 4) - self.params += [self.time, self.day_of_week, self.unknown, self.unknown2] + self._params += [ + self.time, + self.day_of_week, + self.unknown, + self.unknown2, + ] -class CircleEnergyCountersResponse(NodeResponse): - """Returns historical energy usage of requested memory address +class CircleEnergyLogsResponse(PlugwiseResponse): + """ + Returns historical energy usage of requested memory address Each response contains 4 energy counters at specified 1 hour timestamp - Response to: CircleEnergyCountersRequest + Response to: CircleEnergyLogsRequest """ - ID = b"0049" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize CircleEnergyLogsResponse message object""" + super().__init__(b"0049") self.logdate1 = DateTime() self.pulses1 = Int(0, 8) self.logdate2 = DateTime() @@ -410,7 +694,7 @@ def __init__(self): self.logdate4 = DateTime() self.pulses4 = Int(0, 8) self.logaddr = LogAddr(0, length=8) - self.params += [ + self._params += [ self.logdate1, self.pulses1, self.logdate2, @@ -423,157 +707,173 @@ def __init__(self): ] -class NodeAwakeResponse(NodeResponse): - """A sleeping end device (SED: Scan, Sense, Switch) sends +class NodeAwakeResponse(PlugwiseResponse): + """ + A sleeping end device (SED: Scan, Sense, Switch) sends this message to announce that is awake. Awake types: - 0 : The SED joins the network for maintenance - 1 : The SED joins a network for the first time - - 2 : The SED joins a network it has already joined, e.g. after reinserting a battery - - 3 : When a SED switches a device group or when reporting values such as temperature/humidity + - 2 : The SED joins a network it has already joined, e.g. after + reinserting a battery + - 3 : When a SED switches a device group or when reporting values + such as temperature/humidity - 4 : TODO: Unknown - 5 : A human pressed the button on a SED to wake it up Response to: """ - ID = b"004F" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize NodeAwakeResponse message object""" + super().__init__(NODE_AWAKE_RESPONSE_ID) self.awake_type = Int(0, 2, False) - self.params += [self.awake_type] + self._params += [self.awake_type] -class NodeSwitchGroupResponse(NodeResponse): - """A sleeping end device (SED: Scan, Sense, Switch) sends +class NodeSwitchGroupResponse(PlugwiseResponse): + """ + A sleeping end device (SED: Scan, Sense, Switch) sends this message to switch groups on/off when the configured switching conditions have been met. Response to: """ - ID = b"0056" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize NodeSwitchGroupResponse message object""" + super().__init__(NODE_SWITCH_GROUP_ID) self.group = Int(0, 2, False) self.power_state = Int(0, length=2) - self.params += [ + self._params += [ self.group, self.power_state, ] -class NodeFeaturesResponse(NodeResponse): - """Returns supported features of node - TODO: FeatureBitmask +class NodeFeaturesResponse(PlugwiseResponse): + """ + Returns supported features of node + TODO: Feature Bit mask Response to: NodeFeaturesRequest """ - ID = b"0060" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize NodeFeaturesResponse message object""" + super().__init__(b"0060") self.features = String(None, length=16) - self.params += [self.features] + self._params += [self.features] + +class NodeRejoinResponse(PlugwiseResponse): + """ + Notification message when node (re)joined existing network again. + Sent when a SED (re)joins the network e.g. when you reinsert + the battery of a Scan -class NodeJoinAckResponse(NodeResponse): - """Notification message when node (re)joined existing network again. - Sent when a SED (re)joins the network e.g. when you reinsert the battery of a Scan + sequence number is always FFFD Response to: or NodeAddRequest """ - ID = b"0061" - - def __init__(self): - super().__init__() - # sequence number is always FFFD + def __init__(self) -> None: + """Initialize NodeRejoinResponse message object""" + super().__init__(b"0061") -class NodeAckResponse(NodeResponse): - """Acknowledge message in regular format +class NodeAckResponse(PlugwiseResponse): + """ + Acknowledge message in regular format Sent by nodes supporting plugwise 2.4 protocol version - Response to: + Response to: ? """ - ID = b"0100" - - def __init__(self): - super().__init__() - self.ack_id = Int(0, 2, False) + def __init__(self) -> None: + """Initialize NodeAckResponse message object""" + super().__init__(b"0100") -class SenseReportResponse(NodeResponse): - """Returns the current temperature and humidity of a Sense node. - The interval this report is sent is configured by the 'SenseReportIntervalRequest' request +class SenseReportResponse(PlugwiseResponse): + """ + Returns the current temperature and humidity of a Sense node. + The interval this report is sent is configured by + the 'SenseReportIntervalRequest' request Response to: """ - ID = b"0105" - - def __init__(self): - super().__init__() + def __init__(self) -> None: + """Initialize SenseReportResponse message object""" + super().__init__(SENSE_REPORT_ID) self.humidity = Int(0, length=4) self.temperature = Int(0, length=4) - self.params += [self.humidity, self.temperature] - + self._params += [self.humidity, self.temperature] -class CircleInitialRelaisStateResponse(NodeResponse): - """Returns the initial relais state. - Response to: CircleInitialRelaisStateRequest +class CircleRelayInitStateResponse(PlugwiseResponse): """ + Returns the configured relay state after power-up of Circle - ID = b"0139" + Supported protocols : 2.6 + Response to request : CircleRelayInitStateRequest + """ - def __init__(self): - super().__init__() - set_or_get = Int(0, length=2) - relais = Int(0, length=2) - self.params += [set_or_get, relais] + def __init__(self) -> None: + """Initialize CircleRelayInitStateResponse message object""" + super().__init__(b"0139") + self.is_get = Int(0, length=2) + self.relay = Int(0, length=2) + self._params += [self.is_get, self.relay] -id_to_message = { - b"0002": CirclePlusQueryResponse(), - b"0003": CirclePlusQueryEndResponse(), +ID_TO_MESSAGE = { + b"0002": StickNetworkInfoResponse(), + b"0003": NodeSpecificResponse(), b"0005": CirclePlusConnectResponse(), b"0006": NodeJoinAvailableResponse(), b"000E": NodePingResponse(), + b"0010": NodeImageValidationResponse(), b"0011": StickInitResponse(), b"0013": CirclePowerUsageResponse(), + b"0015": CircleLogDataResponse(), b"0019": CirclePlusScanResponse(), b"001D": NodeRemoveResponse(), b"0024": NodeInfoResponse(), - b"0027": CircleCalibrationResponse(), + b"0027": EnergyCalibrationResponse(), b"003A": CirclePlusRealTimeClockResponse(), b"003F": CircleClockResponse(), - b"0049": CircleEnergyCountersResponse(), + b"0049": CircleEnergyLogsResponse(), + NODE_SWITCH_GROUP_ID: NodeSwitchGroupResponse(), b"0060": NodeFeaturesResponse(), b"0100": NodeAckResponse(), - b"0105": SenseReportResponse(), + SENSE_REPORT_ID: SenseReportResponse(), + b"0139": CircleRelayInitStateResponse(), } -def get_message_response(message_id, length, seq_id): - """Return message class based on sequence ID, Length of message or message ID.""" +def get_message_object( + identifier: bytes, length: int, seq_id: bytes +) -> PlugwiseResponse | None: + """ + Return message class based on sequence ID, Length of message or message ID. + """ + # First check for known sequence ID's - if seq_id == b"FFFD": - return NodeJoinAckResponse() - if seq_id == b"FFFE": + if seq_id == REJOIN_RESPONSE_SEQ_ID: + return NodeRejoinResponse() + if seq_id == AWAKE_RESPONSE_SEQ_ID: return NodeAwakeResponse() - if seq_id == b"FFFF": + if seq_id == SWITCH_GROUP_RESPONSE_SEQ_ID: return NodeSwitchGroupResponse() + if seq_id == JOIN_AVAILABLE_SEQ_ID: + return NodeJoinAvailableResponse() # No fixed sequence ID, continue at message ID - if message_id == b"0000": + if identifier == b"0000": if length == 20: - return NodeAckSmallResponse() + return StickResponse() if length == 36: - return NodeAckLargeResponse() + return NodeResponse() return None - return id_to_message.get(message_id, None) + return ID_TO_MESSAGE.get(identifier, None) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py new file mode 100644 index 000000000..b03ab3104 --- /dev/null +++ b/plugwise_usb/network/__init__.py @@ -0,0 +1,579 @@ +""" Plugwise network """ +# region - Imports + +from __future__ import annotations +from asyncio import ( + create_task, + gather, + sleep, +) +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +import logging + +from .registry import StickNetworkRegister +from ..api import NodeEvent, NodeType, StickEvent +from ..connection import StickController +from ..constants import UTF8 +from ..exceptions import MessageError, NodeError, StickError, StickTimeout +from ..messages.requests import ( + CirclePlusAllowJoiningRequest, + NodeInfoRequest, + NodePingRequest, +) +from ..messages.responses import ( + NODE_AWAKE_RESPONSE_ID, + NodeAckResponse, + NodeAwakeResponse, + NodeInfoResponse, + # NodeJoinAvailableResponse, + NodePingResponse, + NodeResponseType, +) +from ..nodes import PlugwiseNode +from ..nodes.circle import PlugwiseCircle +from ..nodes.circle_plus import PlugwiseCirclePlus +from ..nodes.scan import PlugwiseScan +from ..nodes.sense import PlugwiseSense +from ..nodes.stealth import PlugwiseStealth +from ..nodes.switch import PlugwiseSwitch +from ..util import validate_mac + +_LOGGER = logging.getLogger(__name__) +# endregion + + +class StickNetwork(): + """USB-Stick zigbee network class.""" + + accept_join_request = False + join_available: Callable | None = None + _event_subscriptions: dict[StickEvent, int] = {} + + def __init__( + self, + controller: StickController, + ) -> None: + """Initialize the USB-Stick zigbee network class.""" + self._controller = controller + self._register = StickNetworkRegister( + controller.mac_coordinator, + controller.send, + ) + self._is_running: bool = False + + self._cache_folder: str = "" + self._cache_enabled: bool = False + + self._discover: bool = False + self._nodes: dict[str, PlugwiseNode] = {} + self._awake_discovery: dict[str, datetime] = {} + + self._node_event_subscribers: dict[ + Callable[[], None], + tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + ] = {} + + self._unsubscribe_stick_event: Callable[[], None] | None = None + self._unsubscribe_node_awake: Callable[[], None] | None = None + +# region - Properties + + @property + def cache_enabled(self) -> bool: + """Return usage of cache of network register.""" + return self._cache_enabled + + @cache_enabled.setter + def cache_enabled(self, enable: bool = True) -> None: + """Enable or disable usage of cache of network register.""" + self._register.cache_enabled = enable + self._cache_enabled = enable + + @property + def cache_folder(self) -> str: + """path to cache data of network register.""" + return self._cache_folder + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Set path to cache data of network register.""" + self._cache_folder = cache_folder + self._register.cache_folder = cache_folder + for node in self._nodes.values(): + node.cache_folder = cache_folder + + @property + def controller_active(self) -> bool: + """ + Return True if network controller (Circle+) is discovered and active. + """ + if self._controller.mac_coordinator in self._nodes: + return self._nodes[self._controller.mac_coordinator].available + return False + + @property + def is_running(self) -> bool: + """Return state of network discovery.""" + return self._is_running + + @property + def nodes( + self, + ) -> dict[str, PlugwiseNode]: + """ + Return dictionary with all discovered network nodes + with the mac address as the key. + """ + return self._nodes + + @property + def registry(self) -> dict[int, tuple[str, NodeType | None]]: + """Return dictionary with all registered (joined) nodes.""" + return self._register.registry +# endregion + + async def register_node(self, mac: str) -> None: + """Register node to Plugwise network.""" + if not validate_mac(mac): + raise NodeError(f"Invalid mac '{mac}' to register") + address = await self._register.register_node(mac) + self._discover_node(address, mac, None) + + async def clear_cache(self) -> None: + """Clear register""" + await self._register.clear_register_cache() + + async def unregister_node(self, mac: str) -> None: + """Unregister node from current Plugwise network.""" + await self._register.unregister_node(mac) + await self._nodes[mac].async_unload() + self._nodes.pop(mac) + +# region - Handle stick connect/disconnect events + def _subscribe_to_protocol_events(self) -> None: + """Subscribe to events from protocol.""" + self._unsubscribe_stick_event = ( + self._controller.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED), + ) + ) + self._unsubscribe_node_awake = ( + self._controller.subscribe_to_node_responses( + self.node_awake_message, + None, + (NODE_AWAKE_RESPONSE_ID,), + ) + ) + # self._unsubscribe_node_join = ( + # self._controller.subscribe_to_node_responses( + # self.node_join_available_message, + # None, + # (b"0006",), + # ) + # ) + + async def _handle_stick_event(self, event: StickEvent) -> None: + """Handle stick events""" + if event == StickEvent.CONNECTED: + await gather( + *[ + node.reconnect() + for node in self._nodes.values() + if not node.available + ] + ) + self._is_running = True + await self.discover_nodes() + elif event == StickEvent.DISCONNECTED: + await gather( + *[ + node.disconnect() + for node in self._nodes.values() + ] + ) + self._is_running = False + + async def node_awake_message(self, response: NodeAwakeResponse) -> None: + """Handle NodeAwakeResponse message.""" + mac = response.mac_decoded + if mac in self._nodes: + return + address: int | None = self._register.network_address(mac) + if address is None: + _LOGGER.warning( + "Skip node awake message for %s because network " + + "registry address is unknown", + mac + ) + return + if self._awake_discovery.get(mac) is None: + _LOGGER.info( + "Node Awake Response from undiscovered node with mac %s" + + ", start discovery", + mac + ) + self._awake_discovery[mac] = datetime.now(UTC) + if self._nodes.get(mac) is None: + await self._discover_and_load_node(address, mac, None) + await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + else: + # Skip multiple node awake messages for same node within 10 sec. + + if self._awake_discovery[mac] < ( + datetime.now(UTC) - timedelta(seconds=10) + ): + _LOGGER.info( + "Node Awake Response from previously undiscovered node " + + "with mac %s, start discovery", + mac + ) + self._awake_discovery[mac] = datetime.now(UTC) + if self._nodes.get(mac) is None: + create_task( + self._discover_and_load_node(address, mac, None) + ) + await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + else: + _LOGGER.debug( + "Skip second Node Awake Response within 10 seconds for " + + "undiscovered node with mac %s", + mac + ) + + def _unsubscribe_to_protocol_events(self) -> None: + """Unsubscribe to events from protocol.""" + if self._unsubscribe_node_awake is not None: + self._unsubscribe_node_awake() + self._unsubscribe_node_awake = None + if self._unsubscribe_stick_event is not None: + self._unsubscribe_stick_event() + self._unsubscribe_stick_event = None + +# endregion + +# region - Coordinator + async def discover_network_coordinator( + self, load: bool = False + ) -> bool: + """Discover the Zigbee network coordinator (Circle+/Stealth+).""" + if self._controller.mac_coordinator is None: + raise NodeError("Unknown mac address for network coordinator.") + if load and await self._load_node(self._controller.mac_coordinator): + return True + + # Validate the network controller is online + # try to ping first and raise error at stick timeout + ping_response: NodePingResponse | None = None + try: + ping_response = await self._controller.send( + NodePingRequest( + bytes(self._controller.mac_coordinator, UTF8), + retries=0 + ), + ) # type: ignore [assignment] + except StickTimeout as err: + raise StickError( + "The zigbee network coordinator (Circle+/Stealth+) with mac " + + "'%s' did not respond to ping request. Make " + + "sure the Circle+/Stealth+ is within reach of the USB-stick !", + self._controller.mac_coordinator + ) from err + if ping_response is None: + return False + + address, node_type = self._register.network_controller() + if await self._discover_node( + address, self._controller.mac_coordinator, node_type, + ): + if load: + return await self._load_node(self._controller.mac_coordinator) + return True + return False +# endregion + +# region - Nodes + def _create_node_object( + self, + mac: str, + address: int, + node_type: NodeType, + ) -> None: + """Create node object and update network registry""" + if self._nodes.get(mac) is not None: + _LOGGER.warning( + "Skip creating node object because node object for mac " + + "%s already exists", + mac + ) + return + supported_type = True + if node_type == NodeType.CIRCLE_PLUS: + self._nodes[mac] = PlugwiseCirclePlus( + mac, + address, + self._controller, + ) + _LOGGER.debug("Circle+ node %s added", mac) + elif node_type == NodeType.CIRCLE: + self._nodes[mac] = PlugwiseCircle( + mac, + address, + self._controller, + ) + _LOGGER.debug("Circle node %s added", mac) + elif node_type == NodeType.SWITCH: + self._nodes[mac] = PlugwiseSwitch( + mac, + address, + self._controller, + ) + _LOGGER.debug("Switch node %s added", mac) + elif node_type == NodeType.SENSE: + self._nodes[mac] = PlugwiseSense( + mac, + address, + self._controller, + ) + _LOGGER.debug("Sense node %s added", mac) + elif node_type == NodeType.SCAN: + self._nodes[mac] = PlugwiseScan( + mac, + address, + self._controller, + ) + _LOGGER.debug("Scan node %s added", mac) + elif node_type == NodeType.STEALTH: + self._nodes[mac] = PlugwiseStealth( + mac, + address, + self._controller, + ) + _LOGGER.debug("Stealth node %s added", mac) + else: + supported_type = False + _LOGGER.warning( + "Node %s of type %s is unsupported", + mac, + str(node_type) + ) + if supported_type: + self._register.update_network_registration(address, mac, node_type) + + if self._cache_enabled and supported_type: + _LOGGER.debug( + "Enable caching for node %s to folder '%s'", + mac, + self._cache_folder, + ) + self._nodes[mac].cache_folder = self._cache_folder + self._nodes[mac].cache_enabled = True + + async def get_node_details( + self, mac: str, ping_first: bool + ) -> tuple[NodeInfoResponse | None, NodePingResponse | None]: + """Return node discovery type.""" + ping_response: NodePingResponse | None = None + if ping_first: + # Define ping request with custom timeout + ping_request = NodePingRequest(bytes(mac, UTF8), retries=0) + # ping_request.timeout = 3 + + ping_response = await self._controller.submit( + ping_request + ) # type: ignore [assignment] + if ping_response is None: + return (None, None) + info_response: NodeInfoResponse | None = await self._controller.submit( + NodeInfoRequest(bytes(mac, UTF8), retries=1) + ) # type: ignore [assignment] + return (info_response, ping_response) + + async def _discover_and_load_node( + self, + address: int, + mac: str, + node_type: NodeType | None + ) -> bool: + """Discover and load node""" + await self._discover_node(address, mac, node_type) + await self._load_node(mac) + + async def _discover_node( + self, + address: int, + mac: str, + node_type: NodeType | None + ) -> bool: + """ + Discover node and add it to list of nodes + Return True if discovery succeeded + """ + if self._nodes.get(mac) is not None: + _LOGGER.warning("Skip discovery of already known node %s ", mac) + return True + + if node_type is not None: + self._create_node_object(mac, address, node_type) + _LOGGER.debug("Publish NODE_DISCOVERED for %s", mac) + await self._notify_node_event_subscribers( + NodeEvent.DISCOVERED, mac + ) + return True + + # Node type is unknown, so we need to discover it first + _LOGGER.debug("Starting the discovery of node %s", mac) + node_info, node_ping = await self.get_node_details(mac, True) + if node_info is None: + return False + node_type = NodeType(node_info.node_type.value) + self._create_node_object(mac, address, node_type) + + # Forward received NodeInfoResponse message to node object + await self._nodes[mac].async_node_info_update(node_info) + if node_ping is not None: + await self._nodes[mac].async_ping_update(node_ping) + _LOGGER.debug("Publish NODE_DISCOVERED for %s", mac) + await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) + + async def _discover_registered_nodes(self) -> None: + """Discover nodes""" + _LOGGER.debug("Start discovery of registered nodes") + counter = 0 + for address, registration in self._register.registry.items(): + mac, node_type = registration + if mac != "": + if self._nodes.get(mac) is None: + await self._discover_node( + address, mac, node_type + ) + counter += 1 + _LOGGER.debug( + "Total %s registered node(s)", + str(counter) + ) + + async def _load_node(self, mac: str) -> bool: + """Load node""" + if self._nodes.get(mac) is None: + return False + if self._nodes[mac].loaded: + return True + if await self._nodes[mac].async_load(): + await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) + return True + return False + + async def _load_discovered_nodes(self) -> None: + """Load all nodes currently discovered""" + await gather( + *[ + self._load_node(mac) + for mac, node in self._nodes.items() + if not node.loaded + ] + ) + + async def _unload_discovered_nodes(self) -> None: + """Unload all nodes""" + await gather( + *[ + node.async_unload() + for node in self._nodes.values() + ] + ) + +# endregion + +# region - Network instance + async def start(self) -> None: + """Start and activate network""" + self._register.quick_scan_finished(self._discover_registered_nodes()) + self._register.full_scan_finished(self._discover_registered_nodes()) + await self._register.start() + self._subscribe_to_protocol_events() + self._is_running = True + + async def discover_nodes(self, load: bool = True) -> None: + """Discover nodes""" + if not self._is_running: + await self._register.start() + self._subscribe_to_protocol_events() + await self._discover_registered_nodes() + await sleep(0) + if load: + await self._load_discovered_nodes() + + async def stop(self) -> None: + """Stop network discovery.""" + _LOGGER.debug("Stopping") + self._is_running = False + self._unsubscribe_to_protocol_events() + await sleep(0) + await self._unload_discovered_nodes() + await sleep(0) + await self._register.stop() + _LOGGER.debug("Stopping finished") + +# endregion + # async def node_join_available_message( + # self, response: NodeJoinAvailableResponse + # ) -> None: + # """Receive NodeJoinAvailableResponse messages.""" + # mac = response.mac_decoded + # await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + # if self.join_available is not None: + # self.join_available(response.mac_decoded) + # if not self.accept_join_request: + # # TODO: Add debug logging + # return + # if not await self.register_network_node(response.mac_decoded): + # # TODO: Add warning logging + # pass + + async def allow_join_requests(self, state: bool) -> None: + """Enable or disable Plugwise network.""" + response: NodeAckResponse | None = await self._controller.submit( + CirclePlusAllowJoiningRequest(state) + ) # type: ignore [assignment] + if response is None: + raise NodeError( + "No response to get notifications for join request." + ) + if response.ack_id != NodeResponseType.JOIN_ACCEPTED: + raise MessageError( + f"Unknown NodeResponseType '{response.ack_id!r}' received" + ) + + def subscribe_to_network_events( + self, + node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], + events: tuple[NodeEvent], + ) -> Callable[[], None]: + """ + Subscribe callback when specified NodeEvent occurs. + Returns the function to be called to unsubscribe later. + """ + def remove_subscription() -> None: + """Remove stick event subscription.""" + self._node_event_subscribers.pop(remove_subscription) + + self._node_event_subscribers[ + remove_subscription + ] = (node_event_callback, events) + return remove_subscription + + async def _notify_node_event_subscribers( + self, + event: NodeEvent, + mac: str + ) -> None: + """Call callback for node event subscribers""" + callback_list: list[Callable] = [] + for callback, filtered_event in ( + self._node_event_subscribers.values() + ): + if filtered_event is None or filtered_event == event: + callback_list.append(callback(event, mac)) + await gather(*callback_list) diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py new file mode 100644 index 000000000..846876f4b --- /dev/null +++ b/plugwise_usb/network/cache.py @@ -0,0 +1,164 @@ +"""Caching for plugwise network""" + +from __future__ import annotations + +import aiofiles +import aiofiles.os +import logging +from pathlib import Path, PurePath + +from ..util import get_writable_cache_dir +from ..api import NodeType +from ..constants import CACHE_SEPARATOR, UTF8 +from ..exceptions import CacheError + +_LOGGER = logging.getLogger(__name__) + + +class NetworkRegistrationCache: + """Class to cache node network information""" + + def __init__(self, cache_root_dir: str = "") -> None: + """Initialize NetworkCache class.""" + self._registrations: dict[int, tuple[str, NodeType | None]] = {} + self._cache_file: PurePath | None = None + self._cache_root_dir = cache_root_dir + self._set_cache_file(cache_root_dir) + + @property + def cache_root_directory(self) -> str: + """Root directory to store the plugwise cache directory.""" + return self._cache_root_dir + + @cache_root_directory.setter + def cache_root_directory(self, cache_root_dir: str = "") -> None: + """Root directory to store the plugwise cache directory.""" + self._cache_root_dir = cache_root_dir + self._set_cache_file(cache_root_dir) + + def _set_cache_file(self, cache_root_dir: str) -> None: + """Set (and create) the plugwise cache directory to store cache.""" + self._cache_root_dir = get_writable_cache_dir(cache_root_dir) + Path(self._cache_root_dir).mkdir(parents=True, exist_ok=True) + self._cache_file = Path(f"{self._cache_root_dir}/nodes.cache") + _LOGGER.info( + "Start using network cache file: %s/nodes.cache", + self._cache_root_dir + ) + + @property + def registrations(self) -> dict[int, tuple[str, NodeType]]: + """Cached network information""" + return self._registrations + + async def async_save_cache(self) -> None: + """Save the node information to file.""" + _LOGGER.debug("Save network cache %s", str(self._cache_file)) + counter = 0 + async with aiofiles.open( + file=self._cache_file, + mode="w", + encoding=UTF8, + ) as file_data: + for address in sorted(self._registrations.keys()): + counter += 1 + mac, node_reg = self._registrations[address] + if node_reg is None: + node_type = "" + else: + node_type = str(node_reg) + await file_data.write( + f"{address}{CACHE_SEPARATOR}" + + f"{mac}{CACHE_SEPARATOR}" + + f"{node_type}\n" + ) + _LOGGER.info( + "Saved %s lines to network cache %s", + str(counter), + str(self._cache_file) + ) + + async def async_clear_cache(self) -> None: + """Clear current cache.""" + self._registrations = {} + await self.async_delete_cache_file() + + async def async_restore_cache(self) -> bool: + """Load the previously stored information.""" + if self._cache_file is None: + raise CacheError( + "Cannot restore cached information " + + "without reference to cache file" + ) + if not await aiofiles.os.path.exists(self._cache_file): + _LOGGER.warning( + "Unable to restore from cache because " + + "file '%s' does not exists", + self._cache_file.name, + ) + return False + try: + async with aiofiles.open( + file=self._cache_file, + mode="r", + encoding=UTF8, + ) as file_data: + lines = await file_data.readlines() + except OSError: + _LOGGER.warning( + "Failed to read cache file %s", str(self._cache_file) + ) + return False + else: + self._registrations = {} + for line in lines: + data = line.strip().split(CACHE_SEPARATOR) + if len(data) != 3: + _LOGGER.warning( + "Skip invalid line '%s' in cache file %s", + line, + self._cache_file.name, + ) + break + address = int(data[0]) + mac = data[1] + node_type: NodeType | None = None + if data[2] != "": + try: + node_type = NodeType[data[2][9:]] + except KeyError: + _LOGGER.warning( + "Skip invalid NodeType '%s' " + + "in data '%s' in cache file '%s'", + data[2][9:], + line, + self._cache_file.name, + ) + break + self._registrations[address] = (mac, node_type) + _LOGGER.debug( + "Restore registry address %s with mac %s " + + "with node type %s", + address, + mac if mac != "" else "", + str(node_type), + ) + return True + + async def async_delete_cache_file(self) -> None: + """Delete cache file""" + if self._cache_file is None: + return + if not await aiofiles.os.path.exists(self._cache_file): + return + await aiofiles.os.remove(self._cache_file) + + def update_registration( + self, address: int, mac: str, node_type: NodeType | None + ) -> None: + """Save node information in cache""" + if self._registrations.get(address) is not None: + _, current_node_type = self._registrations[address] + if current_node_type is not None and node_type is None: + return + self._registrations[address] = (mac, node_type) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py new file mode 100644 index 000000000..118dba38f --- /dev/null +++ b/plugwise_usb/network/registry.py @@ -0,0 +1,319 @@ +"""Network register""" + +from __future__ import annotations +from asyncio import Task, create_task, sleep +from collections.abc import Awaitable, Callable, Coroutine +from copy import deepcopy +import logging +from typing import Any + +from .cache import NetworkRegistrationCache +from ..api import NodeType +from ..constants import UTF8 +from ..exceptions import NodeError +from ..messages.responses import ( + CirclePlusScanResponse, + NodeRemoveResponse, + NodeResponse, + NodeResponseType, + PlugwiseResponse, +) +from ..messages.requests import ( + CirclePlusScanRequest, + NodeRemoveRequest, + NodeAddRequest, +) +from ..util import validate_mac + +_LOGGER = logging.getLogger(__name__) + + +class StickNetworkRegister(): + """Network register""" + + def __init__( + self, + mac_network_controller: bytes, + send_fn: Callable[[Any], Coroutine[Any, Any, PlugwiseResponse]] + ) -> None: + """Initialize network register""" + self._mac_nc = mac_network_controller + self._send_to_controller = send_fn + self._cache_folder: str = "" + self._cache_restored = False + 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._registration_task: Task | None = None + self._quick_scan_finished: Awaitable | None = None + self._full_scan_finished: Awaitable | None = None +# region Properties + + @property + def cache_enabled(self) -> bool: + """Return usage of cache.""" + return self._cache_enabled + + @cache_enabled.setter + def cache_enabled(self, enable: bool = True) -> None: + """Enable or disable usage of cache.""" + if enable and not self._cache_enabled: + _LOGGER.debug("Cache is enabled") + self._network_cache = NetworkRegistrationCache(self._cache_folder) + elif not enable and self._cache_enabled: + if self._network_cache is not None: + create_task( + self._network_cache.async_delete_cache_file() + ) + _LOGGER.debug("Cache is disabled") + self._cache_enabled = enable + + @property + def cache_folder(self) -> str: + """path to cache data""" + return self._cache_folder + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Set path to cache data""" + if cache_folder == self._cache_folder: + return + self._cache_folder = cache_folder + + @property + def registry(self) -> dict[int, tuple[str, NodeType | None]]: + """Return dictionary with all joined nodes.""" + return deepcopy(self._registry) + + def quick_scan_finished(self, callback: Awaitable) -> None: + """Register method to be called when quick scan is finished""" + self._quick_scan_finished = callback + + def full_scan_finished(self, callback: Awaitable) -> None: + """Register method to be called when full scan is finished""" + self._full_scan_finished = callback + +# endregion + + async def start(self) -> None: + """Initialize load the network registry""" + if self._cache_enabled: + await self.restore_network_cache() + await sleep(0) + await self.load_registry_from_cache() + await sleep(0) + await self.update_missing_registrations(quick=True) + + async def restore_network_cache(self) -> None: + """Restore previously saved cached network and node information""" + if self._network_cache is None: + _LOGGER.error( + "Unable to restore cache when cache is not initialized" + ) + return + if not self._cache_restored: + await self._network_cache.async_restore_cache() + self._cache_restored = True + + async def load_registry_from_cache(self) -> None: + """Load network registry from cache""" + if self._network_cache is None: + _LOGGER.error( + "Unable to restore network registry because " + + "cache is not initialized" + ) + return + if self._cache_restored: + return + for address, registration in self._network_cache.registrations.items(): + mac, node_type = registration + if self._registry.get(address) is None: + self._registry[address] = (mac, node_type) + + async def retrieve_network_registration( + self, address: int, retry: bool = True + ) -> tuple[int, str] | None: + """Return the network mac registration of specified address.""" + response: CirclePlusScanResponse | None = ( + await self._send_to_controller( + CirclePlusScanRequest(self._mac_nc, address), + ) # type: ignore [assignment] + ) + if response is None: + if retry: + return await self.retrieve_network_registration( + address, retry=False + ) + return None + address = response.network_address + mac_of_node = response.registered_mac + if (mac_of_node := response.registered_mac) == 'FFFFFFFFFFFFFFFF': + mac_of_node = "" + return (address, mac_of_node) + + def network_address(self, mac: str) -> int | None: + """Return the network registration address for given mac""" + for address, registration in self._registry.items(): + registered_mac, _ = registration + if mac == registered_mac: + return address + return None + + def network_controller(self) -> tuple[int, NodeType | None]: + """Return the registration for the network controller.""" + if self._registry.get(-1) is not None: + return self.registry[-1] + return (-1, None) + + def update_network_registration( + self, address: int, mac: str, node_type: NodeType | None + ) -> None: + """Add a network registration""" + if self._registry.get(address) is not None: + _, current_type = self._registry[address] + if current_type is not None and node_type is None: + return + self._registry[address] = (mac, node_type) + if self._network_cache is not None: + self._network_cache.update_registration(address, mac, node_type) + + async def update_missing_registrations( + self, quick: bool = False + ) -> None: + """ + Retrieve all unknown network registrations + from network controller + """ + for address in range(0, 64): + if self._registry.get(address) is not None and not quick: + 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: + address, mac = registration + if mac == "": + self._first_free_address = min( + self._first_free_address, address + ) + if quick: + break + _LOGGER.debug( + "Network registration at address %s is %s", + str(address), + "'empty'" if mac == "" else f"set to {mac}", + ) + self.update_network_registration(address, mac, None) + await sleep(0.1) + if not quick: + await sleep(10) + if quick: + if ( + self._registration_task is None or + self._registration_task.done() + ): + self._registration_task = create_task( + self.update_missing_registrations(quick=False) + ) + if self._quick_scan_finished is not None: + await self._quick_scan_finished + _LOGGER.info("Quick network registration discovery finished") + else: + _LOGGER.debug("Full network registration finished, save to cache") + if self._cache_enabled: + _LOGGER.debug("Full network registration finished, pre") + await self.save_registry_to_cache() + _LOGGER.debug("Full network registration finished, post") + _LOGGER.info("Full network registration discovery completed") + if self._full_scan_finished is not None: + await self._full_scan_finished + + def _stop_registration_task(self) -> None: + """Stop the background registration task""" + if self._registration_task is None: + return + self._registration_task.cancel() + + async def save_registry_to_cache(self) -> None: + """Save network registry to cache""" + if self._network_cache is None: + _LOGGER.error( + "Unable to save network registry because " + + "cache is not initialized" + ) + return + _LOGGER.debug( + "save_registry_to_cache starting for %s items", + str(len(self._registry)) + ) + for address, registration in self._registry.items(): + mac, node_type = registration + self._network_cache.update_registration(address, mac, node_type) + await self._network_cache.async_save_cache() + _LOGGER.debug( + "save_registry_to_cache finished" + ) + + async def register_node(self, mac: str) -> int: + """ + Register node to Plugwise network. + Return network address + """ + if not validate_mac(mac): + raise NodeError(f"Invalid mac '{mac}' to register") + + response: NodeResponse | None = await self._send_to_controller( + NodeAddRequest(bytes(mac, UTF8), True) + ) # type: ignore [assignment] + if ( + response is None or + response.ack_id != NodeResponseType.JOIN_ACCEPTED + ): + raise NodeError(f"Failed to register node {mac}") + self.update_network_registration(self._first_free_address, mac, None) + self._first_free_address += 1 + return self._first_free_address - 1 + + async def unregister_node(self, mac: str) -> None: + """Unregister node from current Plugwise network.""" + if not validate_mac(mac): + raise NodeError(f"Invalid mac '{mac}' to unregister") + if mac not in self._registry: + raise NodeError( + f"No existing registration '{mac}' found to unregister" + ) + + response: NodeRemoveResponse | None = await self._send_to_controller( + NodeRemoveRequest(self._mac_nc, mac) + ) # type: ignore [assignment] + if response is None: + raise NodeError( + f"The Zigbee network coordinator '{self._mac_nc}'" + + f" did not respond to unregister node '{mac}'" + ) + if response.status.value != 1: + raise NodeError( + f"The Zigbee network coordinator '{self._mac_nc}'" + + f" failed to unregister node '{mac}'" + ) + if (address := self.network_address(mac)) is not None: + self.update_network_registration(address, mac, None) + + async def clear_register_cache(self) -> None: + """Clear current cache.""" + if self._network_cache is not None: + await self._network_cache.async_clear_cache() + self._cache_restored = False + + async def stop(self) -> None: + """Unload the network registry""" + self._stop_registration_task() + if self._cache_enabled: + await self.save_registry_to_cache() diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 8aac298cf..e3d749457 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -1,295 +1,670 @@ -"""Plugwise nodes.""" -from datetime import datetime -import logging +"""Plugwise devices linked to USB-stick.""" + +from __future__ import annotations -from ..constants import ( - FEATURE_AVAILABLE, - FEATURE_PING, - FEATURE_RELAY, - FEATURE_RSSI_IN, - FEATURE_RSSI_OUT, - PRIORITY_LOW, - UTF8_DECODE, +from abc import ABC +from asyncio import create_task, sleep +from collections.abc import Callable +from datetime import datetime, timedelta, UTC +import logging +from typing import Any + +from ..api import ( + EnergyStatistics, + MotionState, + NetworkStatistics, + NodeFeature, + NodeInfo, + NodeType, + PowerStatistics, + RelayState, ) -from ..messages.requests import NodeFeaturesRequest, NodeInfoRequest, NodePingRequest -from ..messages.responses import ( - NodeFeaturesResponse, - NodeInfoResponse, - NodeJoinAckResponse, - NodePingResponse, +from ..connection import StickController +from ..constants import UTF8, MotionSensitivity +from ..exceptions import NodeError, StickError +from ..messages.requests import ( + NodeInfoRequest, + NodePingRequest, ) -from ..util import validate_mac, version_to_model +from ..messages.responses import NodeInfoResponse, NodePingResponse +from ..util import version_to_model +from .helpers.cache import NodeCache +from .helpers.counter import EnergyCalibration, EnergyCounters +from .helpers.subscription import NodePublisher _LOGGER = logging.getLogger(__name__) +NODE_FEATURES = ( + NodeFeature.AVAILABLE, + NodeFeature.INFO, + NodeFeature.PING, +) -class PlugwiseNode: - """Base class for a Plugwise node.""" - - def __init__(self, mac, address, message_sender): - mac = mac.upper() - if not validate_mac(mac): - _LOGGER.warning( - "MAC address is in unexpected format: %s", - str(mac), - ) - self._mac = bytes(mac, encoding=UTF8_DECODE) - self.message_sender = message_sender - self._features = () - self._address = address - self._callbacks = {} - self._last_update = None - self._available = False - self._battery_powered = False - self._measures_power = False - self._rssi_in = None - self._rssi_out = None - self._ping = None - self._node_type = None - self._hardware_version = None - self._firmware_version = None - self._relay_state = False - self._last_log_address = None - self._device_features = None +class PlugwiseNode(NodePublisher, ABC): + """Abstract Base Class for a Plugwise node.""" + + def __init__( + self, + mac: str, + address: int, + controller: StickController, + ): + self._features = NODE_FEATURES + self._last_update = datetime.now(UTC) + self._node_info = NodeInfo(mac, address) + self._ping = NetworkStatistics() + self._power = PowerStatistics() + + self._mac_in_bytes = bytes(mac, encoding=UTF8) + self._mac_in_str = mac + self._send = controller.send + self._node_cache: NodeCache | None = None + self._cache_enabled: bool = False + self._cache_folder: str = "" + + # Sensors + self._available: bool = False + self._humidity: float | None = None + self._motion: bool | None = None + + self._switch: bool | None = None + self._temperature: float | None = None + + self._connected: bool = False + self._initialized: bool = False + self._loaded: bool = False + self._node_protocols: tuple[str, str] | None = None + self._node_last_online: datetime | None = None + + # Motion + self._motion = False + self._motion_state = MotionState() + self._motion_reset_timer: int | None = None + self._scan_subscription: Callable[[], None] | None = None + self._motion_reset_timer = None + self._daylight_mode: bool | None = None + self._sensitivity_level: MotionSensitivity | None = None + self._new_motion_reset_timer: int | None = None + self._new_daylight_mode: bool | None = None + self._new_sensitivity: MotionSensitivity | None = None + + # Node info + self._last_log_address: int | None = None + + # Relay + self._relay: bool | None = None + self._relay_state = RelayState() + self._relay_init_state: bool | None = None + + # Local power & energy + self._calibration: EnergyCalibration | None = None + self._next_power: datetime | None = None + + # Energy + self._energy_counters = EnergyCounters(mac) + + def update_registry_address(self, address: int) -> None: + """Update network registration address""" + self._node_info.zigbee_address = address @property - def available(self) -> bool: - """Current network state of plugwise node.""" - return self._available + def cache_folder(self) -> str: + """Return path to cache folder.""" + return self._cache_folder + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Set path to cache folder""" + if cache_folder == self._cache_folder: + return + self._cache_folder = cache_folder + if self._cache_enabled: + if self._node_cache is None: + self._node_cache = NodeCache(self._cache_folder) + else: + self._node_cache.cache_root_directory = cache_folder - @available.setter - def available(self, state: bool): - """Set current network availability state of plugwise node.""" - if state: - if not self._available: - self._available = True - _LOGGER.info( - "Marking node %s available", - self.mac, - ) - self.do_callback(FEATURE_AVAILABLE["id"]) + @property + def cache_enabled(self) -> bool: + """Return usage of cache.""" + return self._cache_enabled + + @cache_enabled.setter + def cache_enabled(self, enable: bool) -> None: + """Enable or disable usage of cache.""" + if enable == self._cache_enabled: + return + if enable: + if self._node_cache is None: + self._node_cache = NodeCache(self.mac, self._cache_folder) + else: + self._node_cache.cache_root_directory = self._cache_folder else: - if self._available: - self._available = False - _LOGGER.info( - "Marking node %s unavailable", - self.mac, - ) - self.do_callback(FEATURE_AVAILABLE["id"]) + self._node_cache = None + self._cache_enabled = enable @property - def battery_powered(self) -> bool: - """Return True if node is a SED (battery powered) device.""" - return self._battery_powered + def available(self) -> bool: + """Return network availability state""" + return self._available @property - def hardware_model(self) -> str: - """Return hardware model.""" - if self._hardware_version: - return version_to_model(self._hardware_version) - return None + def energy(self) -> EnergyStatistics | None: + """"Return energy statistics.""" + raise NotImplementedError() @property - def hardware_version(self) -> str: - """Return hardware version.""" - if self._hardware_version is not None: - return self._hardware_version - return "Unknown" + def features(self) -> tuple[NodeFeature, ...]: + """"Return tuple with all supported feature types.""" + return self._features @property - def features(self) -> tuple: - """Return the abstracted features supported by this plugwise device.""" - return self._features + def node_info(self) -> NodeInfo: + """"Return node information""" + return self._node_info @property - def firmware_version(self) -> str: - """Return firmware version.""" - if self._firmware_version is not None: - return str(self._firmware_version) - return "Unknown" + def humidity(self) -> float | None: + """"Return humidity state.""" + if NodeFeature.HUMIDITY not in self._features: + raise NodeError( + f"Humidity state is not supported for node {self.mac}" + ) + return self._humidity @property def last_update(self) -> datetime: - """Return datetime of last received update.""" + """"Return timestamp of last update.""" return self._last_update + @property + def loaded(self) -> bool: + """Return load status. """ + return self._loaded + @property def mac(self) -> str: - """Return the MAC address in string.""" - return self._mac.decode(UTF8_DECODE) + """Return mac address of node.""" + return self._mac_in_str @property - def measures_power(self) -> bool: - """Return True if node can measure power usage.""" - return self._measures_power + def motion(self) -> bool | None: + """Return motion detection state.""" + if NodeFeature.MOTION not in self._features: + raise NodeError( + f"Motion state is not supported for node {self.mac}" + ) + return self._motion @property - def name(self) -> str: - """Return unique name.""" - return self.hardware_model + " (" + str(self._address) + ")" + def motion_state(self) -> MotionState: + """Return last known state of motion sensor""" + if NodeFeature.MOTION not in self._features: + raise NodeError( + f"Motion state is not supported for node {self.mac}" + ) + return self._motion_state @property - def ping(self) -> int: - """Return ping roundtrip in ms.""" - if self._ping is not None: - return self._ping - return 0 + def ping(self) -> NetworkStatistics: + return self._ping @property - def rssi_in(self) -> int: - """Return inbound RSSI level.""" - if self._rssi_in is not None: - return self._rssi_in - return 0 + def power(self) -> PowerStatistics: + if NodeFeature.POWER not in self._features: + raise NodeError( + f"Power state is not supported for node {self.mac}" + ) + return self._power @property - def rssi_out(self) -> int: - """Return outbound RSSI level, based on inbound RSSI level of neighbor node.""" - if self._rssi_out is not None: - return self._rssi_out - return 0 - - def do_ping(self, callback=None): - """Send network ping message to node.""" - self._request_ping(callback, True) - - def _request_info(self, callback=None): - """Request info from node.""" - self.message_sender( - NodeInfoRequest(self._mac), - callback, - 0, - PRIORITY_LOW, - ) + def switch(self) -> bool | None: + if NodeFeature.SWITCH not in self._features: + raise NodeError( + f"Switch state is not supported for node {self.mac}" + ) + return self._switch - def _request_features(self, callback=None): - """Request supported features for this node.""" - self.message_sender( - NodeFeaturesRequest(self._mac), - callback, - ) + @property + def relay_state(self) -> RelayState: + """Return last known state of relay""" + if NodeFeature.RELAY not in self._features: + raise NodeError( + f"Relay state is not supported for node {self.mac}" + ) + return self._relay_state - def _request_ping(self, callback=None, ignore_sensor=True): - """Ping node.""" - if ignore_sensor or FEATURE_PING["id"] in self._callbacks: - self.message_sender( - NodePingRequest(self._mac), - callback, + @property + def relay(self) -> bool: + """Return state of relay""" + if NodeFeature.RELAY not in self._features: + raise NodeError( + f"Relay state is not supported for node {self.mac}" ) + if self._relay is None: + raise NodeError(f"Relay state is unknown for node {self.mac}") + return self._relay - def message_for_node(self, message): - """Process received message.""" - if message.mac == self._mac: - if message.timestamp is not None: - _LOGGER.debug( - "Previous update %s of node %s, last message %s", - str(self._last_update), - self.mac, - str(message.timestamp), - ) - self._last_update = message.timestamp - if not self._available: - self.available = True - self._request_info() - if isinstance(message, NodePingResponse): - self._process_ping_response(message) - elif isinstance(message, NodeInfoResponse): - self._process_info_response(message) - elif isinstance(message, NodeFeaturesResponse): - self._process_features_response(message) - elif isinstance(message, NodeJoinAckResponse): - self._process_join_ack_response(message) - else: - self.message_for_circle(message) - self.message_for_sed(message) - else: - _LOGGER.debug( - "Skip message, mac of node (%s) != mac at message (%s)", - message.mac.decode(UTF8_DECODE), - self.mac, + @relay.setter + def relay(self, state: bool) -> None: + """Request the relay to switch state.""" + raise NotImplementedError() + + @property + def temperature(self) -> float | None: + """Temperature sensor""" + if NodeFeature.TEMPERATURE not in self._features: + raise NodeError( + f"Temperature state is not supported for node {self.mac}" ) + return self._temperature + + @property + def relay_init( + self, + ) -> bool | None: + """Request the relay states at startup/power-up.""" + raise NotImplementedError() + + @relay_init.setter + def relay_init(self, state: bool) -> None: + """Request to configure relay states at startup/power-up.""" + raise NotImplementedError() + + def _setup_protocol( + self, firmware: dict[datetime, tuple[str, str]] + ) -> None: + """Extract protocol version from firmware version""" + if self._node_info.firmware is not None: + self._node_protocols = firmware.get(self._node_info.firmware, None) + if self._node_protocols is None: + _LOGGER.warning( + "Failed to determine the protocol version for node %s (%s)" + + " based on firmware version %s of list %s", + self._node_info.mac, + self.__class__.__name__, + self._node_info.firmware, + str(firmware.keys()), + ) - def message_for_circle(self, message): - """Pass messages to PlugwiseCircle class""" + async def reconnect(self) -> None: + """Reconnect node to Plugwise Zigbee network.""" + if await self.async_ping_update() is not None: + self._connected = True - def message_for_sed(self, message): - """Pass messages to NodeSED class""" + async def disconnect(self) -> None: + """Disconnect node from Plugwise Zigbee network.""" + self._connected = False + if self._available: + self._available = False + await self.publish_event(NodeFeature.AVAILABLE, False) - def subscribe_callback(self, callback, sensor) -> bool: - """Subscribe callback to execute when state change happens.""" - if sensor in self._features: - if sensor not in self._callbacks: - self._callbacks[sensor] = [] - self._callbacks[sensor].append(callback) + @property + def maintenance_interval(self) -> int | None: + """ + Return the maintenance interval (seconds) + a battery powered node sends it heartbeat. + """ + raise NotImplementedError() + + async def async_relay_init(self, state: bool) -> None: + """Request to configure relay states at startup/power-up.""" + raise NotImplementedError() + + async def scan_calibrate_light(self) -> bool: + """ + Request to calibration light sensitivity of Scan device. + Returns True if successful. + """ + raise NotImplementedError() + + async def scan_configure( + self, + motion_reset_timer: int, + sensitivity_level: MotionSensitivity, + daylight_mode: bool, + ) -> bool: + """Configure Scan device settings. Returns True if successful.""" + raise NotImplementedError() + + async def async_load(self) -> bool: + """Load and activate node features.""" + raise NotImplementedError() + + async def _async_load_cache_file(self) -> bool: + """Load states from previous cached information.""" + if self._loaded: return True - return False + if not self._cache_enabled: + _LOGGER.warning( + "Unable to load node %s from cache " + + "because caching is disabled", + self.mac, + ) + return False + if self._node_cache is None: + _LOGGER.warning( + "Unable to load node %s from cache " + + "because cache configuration is not loaded", + self.mac, + ) + return False + return await self._node_cache.async_restore_cache() + + async def async_clear_cache(self) -> None: + """Clear current cache.""" + if self._node_cache is not None: + await self._node_cache.async_clear_cache() + + async def _async_load_from_cache(self) -> bool: + """ + Load states from previous cached information. + Return True if successful. + """ + if self._loaded: + return True + if not await self._async_load_cache_file(): + _LOGGER.debug("Node %s failed to load cache file", self.mac) + return False - def unsubscribe_callback(self, callback, sensor): - """Unsubscribe callback to execute when state change happens.""" - if sensor in self._callbacks: - self._callbacks[sensor].remove(callback) - - def do_callback(self, sensor): - """Execute callbacks registered for specified callback type.""" - if sensor in self._callbacks: - for callback in self._callbacks[sensor]: - try: - callback(None) - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _LOGGER.error( - "Error while executing all callback : %s", - err, - ) + # Node Info + if not await self._async_node_info_load_from_cache(): + _LOGGER.debug( + "Node %s failed to load node_info from cache", + self.mac + ) + return False + self._load_features() + return True + + async def async_initialize(self) -> bool: + """Initialize node.""" + raise NotImplementedError() + + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + raise NotImplementedError() + + def _available_update_state(self, available: bool) -> None: + """Update the node availability state.""" + if self._available == available: + return + if available: + _LOGGER.info("Mark node %s to be available", self.mac) + self._available = True + create_task(self.publish_event(NodeFeature.AVAILABLE, True)) + return + _LOGGER.info("Mark node %s to be NOT available", self.mac) + self._available = False + create_task(self.publish_event(NodeFeature.AVAILABLE, False)) + + async def async_node_info_update( + self, node_info: NodeInfoResponse | None = None + ) -> bool: + """Update Node hardware information.""" + if node_info is None: + node_info = await self._send( + NodeInfoRequest(self._mac_in_bytes) + ) + if node_info is None: + _LOGGER.debug( + "No response for async_node_info_update() for %s", + self.mac + ) + self._available_update_state(False) + return False + if node_info.mac_decoded != self.mac: + raise NodeError( + f"Incorrect node_info {node_info.mac_decoded} " + + f"!= {self.mac}, id={node_info}" + ) - def _process_join_ack_response(self, message): - """Process join acknowledge response message""" - _LOGGER.info( - "Node %s has (re)joined plugwise network", - self.mac, - ) + self._available_update_state(True) - def _process_ping_response(self, message): - """Process ping response message.""" - if self._rssi_in != message.rssi_in.value: - self._rssi_in = message.rssi_in.value - self.do_callback(FEATURE_RSSI_IN["id"]) - if self._rssi_out != message.rssi_out.value: - self._rssi_out = message.rssi_out.value - self.do_callback(FEATURE_RSSI_OUT["id"]) - if self._ping != message.ping_ms.value: - self._ping = message.ping_ms.value - self.do_callback(FEATURE_PING["id"]) - - def _process_info_response(self, message): - """Process info response message.""" - _LOGGER.debug( - "Response info message for node %s, last log address %s", - self.mac, - str(message.last_logaddr.value), + self._node_info_update_state( + firmware=node_info.fw_ver.value, + hardware=node_info.hw_ver.value.decode(UTF8), + node_type=node_info.node_type.value, + timestamp=node_info.timestamp, ) - if message.relay_state.serialize() == b"01": - if not self._relay_state: - self._relay_state = True - self.do_callback(FEATURE_RELAY["id"]) - else: - if self._relay_state: - self._relay_state = False - self.do_callback(FEATURE_RELAY["id"]) - self._hardware_version = message.hw_ver.value.decode(UTF8_DECODE) - self._firmware_version = message.fw_ver.value - self._node_type = message.node_type.value - if self._last_log_address != message.last_logaddr.value: - self._last_log_address = message.last_logaddr.value - _LOGGER.debug("Node type = %s", self.hardware_model) - if not self._battery_powered: - _LOGGER.debug("Relay state = %s", str(self._relay_state)) - _LOGGER.debug("Hardware version = %s", str(self._hardware_version)) - _LOGGER.debug("Firmware version = %s", str(self._firmware_version)) - - def _process_features_response(self, message): - """Process features message.""" - _LOGGER.warning( - "Node %s supports features %s", self.mac, str(message.features.value) + return True + + async def _async_node_info_load_from_cache(self) -> bool: + """Load node info settings from cache.""" + firmware: datetime | None = None + node_type: int | None = None + hardware: str | None = self._get_cache("hardware") + timestamp: datetime | None = None + if (firmware_str := self._get_cache("firmware")) is not None: + data = firmware_str.split("-") + if len(data) == 6: + firmware = datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC + ) + if (node_type_str := self._get_cache("node_type")) is not None: + node_type = int(node_type_str) + if ( + timestamp_str := self._get_cache("node_info_timestamp") + ) is not None: + data = timestamp_str.split("-") + if len(data) == 6: + timestamp = datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC + ) + return self._node_info_update_state( + firmware=firmware, + hardware=hardware, + node_type=node_type, + timestamp=timestamp, ) - self._device_features = message.features.value + + def _node_info_update_state( + self, + firmware: datetime | None, + hardware: str | None, + node_type: int | None, + timestamp: datetime | None, + ) -> bool: + """ + Process new node info and return true if + all fields are updated. + """ + complete = True + if firmware is None: + complete = False + else: + self._node_info.firmware = firmware + self._set_cache("firmware", firmware) + if hardware is None: + complete = False + else: + if self._node_info.version != hardware: + self._node_info.version = hardware + # Generate modelname based on hardware version + hardware_model = version_to_model(hardware) + if hardware_model == "Unknown": + _LOGGER.warning( + "Failed to detect hardware model for %s based on '%s'", + self.mac, + hardware, + ) + self._node_info.model = hardware_model + if hardware_model is not None: + self._node_info.name = str(self._node_info.mac[-5:]) + self._set_cache("hardware", hardware) + if timestamp is None: + complete = False + else: + self._node_info.timestamp = timestamp + self._set_cache("node_info_timestamp", timestamp) + if node_type is None: + complete = False + else: + self._node_info.type = NodeType(node_type) + self._set_cache("node_type", self._node_info.type.value) + if self._loaded and self._initialized: + create_task(self.async_save_cache()) + return complete + + async def async_is_online(self) -> bool: + """Check if node is currently online.""" + try: + ping_response: NodePingResponse | None = await self._send( + NodePingRequest( + self._mac_in_bytes, retries=0 + ) + ) + except StickError: + _LOGGER.warning( + "StickError for async_is_online() for %s", + self.mac + ) + self._available_update_state(False) + return False + except NodeError: + _LOGGER.warning( + "NodeError for async_is_online() for %s", + self.mac + ) + self._available_update_state(False) + return False + else: + if ping_response is None: + _LOGGER.info( + "No response to ping for %s", + self.mac + ) + self._available_update_state(False) + return False + await self.async_ping_update(ping_response) + return True + + async def async_ping_update( + self, ping_response: NodePingResponse | None = None, retries: int = 0 + ) -> NetworkStatistics | None: + """Update ping statistics.""" + if ping_response is None: + ping_response = await self._send( + NodePingRequest( + self._mac_in_bytes, retries + ) + ) + if ping_response is None: + self._available_update_state(False) + return None + self._available_update_state(True) + + self._ping.timestamp = ping_response.timestamp + self._ping.rssi_in = ping_response.rssi_in + self._ping.rssi_out = ping_response.rssi_out + self._ping.rtt = ping_response.rtt + + create_task(self.publish_event(NodeFeature.PING, self._ping)) + return self._ping + + async def async_relay(self, state: bool) -> bool | None: + """Switch relay state.""" + raise NodeError(f"Relay control is not supported for node {self.mac}") + + async def async_get_state( + self, features: tuple[NodeFeature] + ) -> dict[NodeFeature, Any]: + """ + Retrieve latest state for given feature + + Return dict with values per feature.""" + states: dict[NodeFeature, Any] = {} + for feature in features: + await sleep(0) + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.INFO: + states[NodeFeature.INFO] = self._node_info + elif feature == NodeFeature.AVAILABLE: + states[NodeFeature.AVAILABLE] = self.available + elif feature == NodeFeature.PING: + states[NodeFeature.PING] = await self.async_ping_update() + else: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + return states + + async def async_unload(self) -> None: + """Deactivate and unload node features.""" + raise NotImplementedError() + + def _get_cache(self, setting: str) -> str | None: + """Retrieve value of specified setting from cache memory.""" + if not self._cache_enabled or self._node_cache is None: + return None + return self._node_cache.get_state(setting) + + def _set_cache(self, setting: str, value: Any) -> None: + """Store setting with value in cache memory.""" + if not self._cache_enabled: + return + if self._node_cache is None: + _LOGGER.warning( + "Failed to update '%s' in cache " + + "because cache is not initialized yet", + setting + ) + return + if isinstance(value, datetime): + self._node_cache.add_state( + setting, + f"{value.year}-{value.month}-{value.day}-{value.hour}" + + f"-{value.minute}-{value.second}" + ) + elif isinstance(value, str): + self._node_cache.add_state(setting, value) + else: + self._node_cache.add_state(setting, str(value)) + + async def async_save_cache(self) -> None: + """Save current cache to cache file.""" + if not self._cache_enabled: + return + if self._node_cache is None: + _LOGGER.warning( + "Failed to save cache to disk " + + "because cache is not initialized yet" + ) + return + _LOGGER.debug("Save cache file for node %s", self.mac) + await self._node_cache.async_save_cache() + + @staticmethod + def skip_update(data_class: Any, seconds: int) -> bool: + """ + Return True if timestamp attribute of given dataclass + is less than given seconds old. + """ + if data_class is None: + return False + if not hasattr(data_class, "timestamp"): + return False + if data_class.timestamp is None: + return False + if data_class.timestamp + timedelta( + seconds=seconds + ) > datetime.now(UTC): + return True + return False diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py new file mode 100644 index 000000000..862d15d4f --- /dev/null +++ b/plugwise_usb/nodes/celsius.py @@ -0,0 +1,78 @@ +""" +Plugwise Celsius node object. + +TODO: Finish node +""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Final + +from ..api import NodeFeature +from ..nodes.sed import NodeSED + +_LOGGER = logging.getLogger(__name__) + +# Minimum and maximum supported (custom) zigbee protocol version based +# on utc timestamp of firmware +# Extracted from "Plugwise.IO.dll" file of Plugwise source installation +FIRMWARE_CELSIUS: Final = { + # Celsius Proto + datetime(2013, 9, 25, 15, 9, 44): ("2.0", "2.6"), + + datetime(2013, 10, 11, 15, 15, 58): ("2.0", "2.6"), + datetime(2013, 10, 17, 10, 13, 12): ("2.0", "2.6"), + datetime(2013, 11, 19, 17, 35, 48): ("2.0", "2.6"), + datetime(2013, 12, 5, 16, 25, 33): ("2.0", "2.6"), + datetime(2013, 12, 11, 10, 53, 55): ("2.0", "2.6"), + datetime(2014, 1, 30, 8, 56, 21): ("2.0", "2.6"), + datetime(2014, 2, 3, 10, 9, 27): ("2.0", "2.6"), + datetime(2014, 3, 7, 16, 7, 42): ("2.0", "2.6"), + datetime(2014, 3, 24, 11, 12, 23): ("2.0", "2.6"), + + # MSPBootloader Image - Required to allow + # a MSPBootload image for OTA update + datetime(2014, 4, 14, 15, 45, 26): ( + "2.0", + "2.6", + ), + + # CelsiusV Image + datetime(2014, 7, 23, 19, 24, 18): ("2.0", "2.6"), + + # CelsiusV Image + datetime(2014, 9, 12, 11, 36, 40): ("2.0", "2.6"), + + # New Flash Update + datetime(2017, 7, 11, 16, 2, 50): ("2.0", "2.6"), +} +CELSIUS_FEATURES: Final = ( + NodeFeature.INFO, + NodeFeature.TEMPERATURE, + NodeFeature.HUMIDITY, +) + + +class PlugwiseCelsius(NodeSED): + """provides interface to the Plugwise Celsius nodes""" + + async def async_load( + self, lazy_load: bool = False, from_cache: bool = False + ) -> bool: + """Load and activate node features.""" + if self._loaded: + return True + if lazy_load: + _LOGGER.debug( + "Lazy loading Celsius node %s...", + self._node_info.mac + ) + else: + _LOGGER.debug("Loading Celsius node %s...", self._node_info.mac) + + self._setup_protocol(FIRMWARE_CELSIUS) + self._features += CELSIUS_FEATURES + self._node_info.features = self._features + + return await super().async_load(lazy_load, from_cache) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 97081bd4b..414d8c3d9 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1,882 +1,1132 @@ -"""Plugwise Circle node object.""" -from datetime import datetime, timedelta +"""Plugwise Circle node class.""" + +from __future__ import annotations + +from asyncio import create_task, gather, sleep +from collections.abc import Awaitable, Callable +from datetime import datetime, UTC, timedelta +from functools import wraps import logging +from typing import Any, Final, TypeVar, cast +from ..api import NodeFeature from ..constants import ( - FEATURE_ENERGY_CONSUMPTION_TODAY, - FEATURE_PING, - FEATURE_POWER_CONSUMPTION_CURRENT_HOUR, - FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR, - FEATURE_POWER_CONSUMPTION_TODAY, - FEATURE_POWER_CONSUMPTION_YESTERDAY, - FEATURE_POWER_PRODUCTION_CURRENT_HOUR, - FEATURE_POWER_USE, - FEATURE_POWER_USE_LAST_8_SEC, - FEATURE_RELAY, - FEATURE_RSSI_IN, - FEATURE_RSSI_OUT, MAX_TIME_DRIFT, - MESSAGE_TIME_OUT, - PRIORITY_HIGH, - PRIORITY_LOW, + MINIMAL_POWER_UPDATE, PULSES_PER_KW_SECOND, - RELAY_SWITCHED_OFF, - RELAY_SWITCHED_ON, + SECOND_IN_NANOSECONDS, + UTF8, ) +from .helpers import EnergyCalibration, raise_not_loaded +from .helpers.pulses import PulseLogRecord +from ..exceptions import NodeError from ..messages.requests import ( - CircleCalibrationRequest, CircleClockGetRequest, CircleClockSetRequest, - CircleEnergyCountersRequest, + CircleEnergyLogsRequest, CirclePowerUsageRequest, - CircleSwitchRelayRequest, + CircleRelayInitStateRequest, + CircleRelaySwitchRequest, + EnergyCalibrationRequest, + NodeInfoRequest, ) from ..messages.responses import ( - CircleCalibrationResponse, CircleClockResponse, - CircleEnergyCountersResponse, + CircleEnergyLogsResponse, CirclePowerUsageResponse, - NodeAckLargeResponse, + CircleRelayInitStateResponse, + EnergyCalibrationResponse, + NodeInfoResponse, + NodeResponse, + NodeResponseType, +) +from ..nodes import ( + EnergyStatistics, + PlugwiseNode, + PowerStatistics, ) -from ..nodes import PlugwiseNode + +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) +# Minimum and maximum supported (custom) zigbee protocol version based +# on utc timestamp of firmware +# Extracted from "Plugwise.IO.dll" file of Plugwise source installation +CIRCLE_FEATURES: Final = ( + NodeFeature.ENERGY, + NodeFeature.INFO, + NodeFeature.POWER, + NodeFeature.RELAY, +) +CIRCLE_FIRMWARE: Final = { + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): ("1.0", "1.1"), + datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): ("2.0", "2.5"), + datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): ("2.0", "2.4"), + datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): ("2.0", "2.5"), + # Legrand + datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): ("2.0", "2.6"), + # Radio Test + datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): ("2.0", "2.5"), + # Beta release + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): ( + "2.0", + "2.6", + ), + # Proto release + datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): ( + "2.0", + "2.6", + ), + datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): ( + "2.0", + "2.6", + ), + # New Flash Update + datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): ( + "2.0", + "2.6", + ), +} + + +def raise_calibration_missing(func: FuncT) -> FuncT: + """ + Decorator function to make sure energy calibration settings are available. + """ + @wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + + if args[0].calibrated is None: + raise NodeError("Energy calibration settings are missing") + return func(*args, **kwargs) + return cast(FuncT, decorated) + class PlugwiseCircle(PlugwiseNode): - """provides interface to the Plugwise Circle nodes and base class for Circle+ nodes""" - - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._features = ( - FEATURE_ENERGY_CONSUMPTION_TODAY["id"], - FEATURE_PING["id"], - FEATURE_POWER_USE["id"], - FEATURE_POWER_USE_LAST_8_SEC["id"], - FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"], - FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"], - FEATURE_POWER_CONSUMPTION_TODAY["id"], - FEATURE_POWER_CONSUMPTION_YESTERDAY["id"], - FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"], - # FEATURE_POWER_PRODUCTION_PREVIOUS_HOUR["id"], - FEATURE_RSSI_IN["id"], - FEATURE_RSSI_OUT["id"], - FEATURE_RELAY["id"], - ) - self._last_collected_address = None - self._last_collected_address_slot = 0 - self._last_collected_address_timestamp = datetime(2000, 1, 1) - self._energy_consumption_today_reset = datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 - ) - self._energy_memory = {} - self._energy_history = {} - self._energy_history_failed_address = [] - self._energy_last_collected_timestamp = datetime(2000, 1, 1) - self._energy_last_collected_count = 0 - self._energy_ratelimit_collection_timestamp = datetime.utcnow() - self._energy_last_rollover_timestamp = datetime.utcnow() - self._energy_pulses_midnight_rollover = datetime.utcnow() - self._energy_last_local_hour = datetime.now().hour - self._energy_last_populated_slot = 0 - self._energy_pulses_current_hour = None - self._energy_pulses_prev_hour = None - self._energy_pulses_today_hourly = None - self._energy_pulses_today_now = None - self._energy_pulses_yesterday = None - self._new_relay_state = False - self._new_relay_stamp = datetime.now() - timedelta(seconds=MESSAGE_TIME_OUT) - self._pulses_1s = None - self._pulses_8s = None - self._pulses_produced_1h = None - self.calibration = False - self._gain_a = None - self._gain_b = None - self._off_noise = None - self._off_tot = None - self._measures_power = True - self._last_log_collected = False - self.timezone_delta = datetime.now().replace( - minute=0, second=0, microsecond=0 - ) - datetime.utcnow().replace(minute=0, second=0, microsecond=0) - self._clock_offset = None - self._last_clock_sync_day = datetime.now().day - self.get_clock(self.sync_clock) - self._request_calibration() + """ + Provides interface to the Plugwise Circle nodes + and base class for Circle+ nodes + """ + _retrieve_energy_logs_task: None | Awaitable = None @property - def current_power_usage(self): - """Returns power usage during the last second in Watts - Based on last received power usage information - """ - if self._pulses_1s is not None: - return self.pulses_to_kws(self._pulses_1s) * 1000 - return None + def calibrated(self) -> bool: + """Return calibration retrieval state""" + if self._calibration is not None: + return True + return False @property - def current_power_usage_8_sec(self): - """Returns power usage during the last 8 second in Watts - Based on last received power usage information - """ - if self._pulses_8s is not None: - return self.pulses_to_kws(self._pulses_8s, 8) * 1000 - return None + def energy(self) -> EnergyStatistics | None: + """"Return energy statistics.""" + return self._energy_counters.energy_statistics @property - def energy_consumption_today(self) -> float: - """Returns total energy consumption since midnight in kWh""" - if self._energy_pulses_today_now is not None: - return self.pulses_to_kws(self._energy_pulses_today_now, 3600) - return None + @raise_not_loaded + def relay(self) -> bool: + return bool(self._relay) - @property - def energy_consumption_today_last_reset(self): - """Last reset of total energy consumption today""" - return self._energy_consumption_today_reset + @relay.setter + @raise_not_loaded + def relay(self, state: bool) -> None: + """Request the relay to switch state.""" + create_task(self.async_relay(state)) + + @raise_not_loaded + async def async_relay_off(self) -> None: + """Switch relay off""" + await self.async_relay(False) + + @raise_not_loaded + async def async_relay_on(self) -> None: + """Switch relay on""" + await self.async_relay(True) @property - def power_consumption_current_hour(self): - """Returns the power usage during this running hour in kWh - Based on last received power usage information + def relay_init( + self, + ) -> bool | None: + """Request the relay states at startup/power-up.""" + if NodeFeature.RELAY_INIT not in self._features: + raise NodeError( + "Initial state of relay is not supported for device " + + self.mac + ) + return self._relay_init_state + + @relay_init.setter + def relay_init(self, state: bool) -> None: + """Request to configure relay states at startup/power-up.""" + if NodeFeature.RELAY_INIT not in self._features: + raise NodeError( + "Configuring initial state of relay" + + f"is not supported for device {self.mac}" + ) + create_task(self.async_relay_init_set(state)) + + async def async_calibration_update(self) -> bool: """ - if self._energy_pulses_current_hour is not None: - return self.pulses_to_kws(self._energy_pulses_current_hour, 3600) - return None + Retrieve and update calibration settings. + Returns True if successful. + """ + _LOGGER.debug( + "Start updating energy calibration for node %s", + self._node_info.mac, + ) + calibration_response: EnergyCalibrationResponse | None = ( + await self._send(EnergyCalibrationRequest(self._mac_in_bytes)) + ) + if calibration_response is None: + _LOGGER.warning( + "Updating energy calibration for node %s failed", + self._node_info.mac, + ) + self._available_update_state(False) + return False + self._available_update_state(True) - @property - def power_consumption_previous_hour(self): - """Returns power consumption during the previous hour in kWh""" - if self._energy_pulses_prev_hour is not None: - return self.pulses_to_kws(self._energy_pulses_prev_hour, 3600) - return None + self._async_calibration_update_state( + calibration_response.gain_a, + calibration_response.gain_b, + calibration_response.off_noise, + calibration_response.off_tot, + ) + _LOGGER.debug( + "Updating energy calibration for node %s succeeded", + self._node_info.mac, + ) + return True - @property - def power_consumption_today(self): - """Total power consumption during today in kWh""" - if self._energy_pulses_today_hourly is not None: - return self.pulses_to_kws(self._energy_pulses_today_hourly, 3600) - return None + async def _async_calibration_load_from_cache(self) -> bool: + """Load calibration settings from cache.""" + cal_gain_a: float | None = None + cal_gain_b: float | None = None + cal_noise: float | None = None + cal_tot: float | None = None + if (gain_a := self._get_cache("calibration_gain_a")) is not None: + cal_gain_a = float(gain_a) + if (gain_b := self._get_cache("calibration_gain_b")) is not None: + cal_gain_b = float(gain_b) + if (noise := self._get_cache("calibration_noise")) is not None: + cal_noise = float(noise) + if (tot := self._get_cache("calibration_tot")) is not None: + cal_tot = float(tot) - @property - def power_consumption_yesterday(self): - """Total power consumption of yesterday in kWh""" - if self._energy_pulses_yesterday is not None: - return self.pulses_to_kws(self._energy_pulses_yesterday, 3600) - return None + # Restore calibration + result = self._async_calibration_update_state( + cal_gain_a, + cal_gain_b, + cal_noise, + cal_tot, + ) + if result: + _LOGGER.debug( + "Restore calibration settings from cache for node %s", + self.mac + ) + return True + _LOGGER.info( + "Failed to restore calibration settings from cache for node %s", + self.mac + ) + return False - @property - def power_production_current_hour(self): - """Returns the power production during this running hour in kWh - Based on last received power usage information + def _async_calibration_update_state( + self, + gain_a: float | None, + gain_b: float | None, + off_noise: float | None, + off_tot: float | None, + ) -> bool: """ - if self._pulses_produced_1h is not None: - return self.pulses_to_kws(self._pulses_produced_1h, 3600) - return None + Process new energy calibration settings. + Returns True if successful. + """ + if ( + gain_a is None or + gain_b is None or + off_noise is None or + off_tot is None + ): + return False + self._calibration = EnergyCalibration( + gain_a=gain_a, + gain_b=gain_b, + off_noise=off_noise, + off_tot=off_tot + ) + # Forward calibration config to energy collection + self._energy_counters.calibration = self._calibration - @property - def relay_state(self) -> bool: - """Return last known relay state or the new switch state by anticipating - the acknowledge for new state is getting in before message timeout. + if self._cache_enabled: + self._set_cache("calibration_gain_a", gain_a) + self._set_cache("calibration_gain_b", gain_b) + self._set_cache("calibration_noise", off_noise) + self._set_cache("calibration_tot", off_tot) + if self._loaded and self._initialized: + create_task(self.async_save_cache()) + return True + + @raise_calibration_missing + async def async_power_update(self) -> PowerStatistics | None: """ - if self._new_relay_stamp + timedelta(seconds=MESSAGE_TIME_OUT) > datetime.now(): - return self._new_relay_state - return self._relay_state + Update the current power usage statistics. - @relay_state.setter - def relay_state(self, state): - """Request the relay to switch state.""" - self._request_switch(state) - self._new_relay_state = state - self._new_relay_stamp = datetime.now() - if state != self._relay_state: - self.do_callback(FEATURE_RELAY["id"]) - - def _request_calibration(self, callback=None): - """Request calibration info""" - self.message_sender( - CircleCalibrationRequest(self._mac), - callback, - 0, - PRIORITY_HIGH, - ) + Return power usage or None if retrieval failed + """ + # Debounce power + if self.skip_update(self._power, MINIMAL_POWER_UPDATE): + return self._power - def _request_switch(self, state, callback=None): - """Request to switch relay state and request state info""" - self.message_sender( - CircleSwitchRelayRequest(self._mac, state), - callback, - 0, - PRIORITY_HIGH, + request = CirclePowerUsageRequest(self._mac_in_bytes) + response: CirclePowerUsageResponse | None = await self._send( + request ) + if response is None or response.timestamp is None: + _LOGGER.debug( + "No response for async_power_update() for %s", + self.mac + ) + self._available_update_state(False) + return None + if response.mac_decoded != self.mac: + raise NodeError( + f"Incorrect power response for {response.mac_decoded} " + + f"!= {self.mac} = {self._mac_in_str} | {request.mac_decoded}" + ) + self._available_update_state(True) - def request_power_update(self, callback=None): - """Request power usage and update energy counters""" - if self._available: - self.message_sender( - CirclePowerUsageRequest(self._mac), - callback, - ) - _timestamp_utcnow = datetime.utcnow() - # Request new energy counters if last one is more than one hour ago - if self._energy_last_collected_timestamp < _timestamp_utcnow.replace( - minute=0, second=0, microsecond=0 - ): - _LOGGER.info( - "Queue _last_log_address for %s at %s last_collected %s", - str(self.mac), - str(self._last_log_address), - self._energy_last_collected_timestamp, - ) - self._request_info(self.push_last_log_address) - - if len(self._energy_history_failed_address) > 0: - _mem_address = self._energy_history_failed_address.pop(0) - if self._energy_memory.get(_mem_address, 0) < 4: - _LOGGER.info( - "Collect EnergyCounters for %s at %s", - str(self.mac), - str(_mem_address), - ) - self.request_energy_counters(_mem_address) - self._energy_ratelimit_collection_timestamp = _timestamp_utcnow - else: - _LOGGER.info( - "Drop known request_energy_counters for %s at %s and clock sync", - str(self.mac), - str(_mem_address), - ) - self.get_clock(self.sync_clock) - if datetime.now().day != self._last_clock_sync_day: - self._last_clock_sync_day = datetime.now().day - self.get_clock(self.sync_clock) - - def push_last_log_address(self): - if self._energy_history_failed_address.count(self._last_log_address) == 0: - self._energy_history_failed_address.append(self._last_log_address) - - def message_for_circle(self, message): - """Process received message""" - if isinstance(message, CirclePowerUsageResponse): - if self.calibration: - self._response_power_usage(message) - _LOGGER.debug( - "Power update for %s, last update %s", - str(self.mac), - str(self._last_update), - ) - else: - _LOGGER.info( - "Received power update for %s before calibration information is known", - str(self.mac), - ) - self._request_calibration(self.request_power_update) - elif isinstance(message, NodeAckLargeResponse): - self._node_ack_response(message) - elif isinstance(message, CircleCalibrationResponse): - self._response_calibration(message) - elif isinstance(message, CircleEnergyCountersResponse): - if self.calibration: - self._response_energy_counters(message) - else: - _LOGGER.debug( - "Received power buffer log for %s before calibration information is known", - str(self.mac), - ) - self._request_calibration(self.request_energy_counters) - elif isinstance(message, CircleClockResponse): - self._response_clock(message) - else: - self.message_for_circle_plus(message) + # Update power stats + self._power.last_second = self._calc_watts( + response.pulse_1s, 1, response.nanosecond_offset + ) + self._power.last_8_seconds = self._calc_watts( + response.pulse_8s.value, 8, response.nanosecond_offset + ) + self._power.timestamp = response.timestamp + create_task(self.publish_event(NodeFeature.POWER, self._power)) - def message_for_circle_plus(self, message): - """Pass messages to PlugwiseCirclePlus class""" + # Forward pulse interval counters to pulse Collection + self._energy_counters.add_pulse_stats( + response.consumed_counter, + response.produced_counter, + response.timestamp, + ) + create_task( + self.publish_event( + NodeFeature.ENERGY, + self._energy_counters.energy_statistics + ) + ) + response = None + return self._power - def _node_ack_response(self, message): - """Process switch response message""" - if message.ack_id == RELAY_SWITCHED_ON: - if not self._relay_state: - _LOGGER.debug( - "Switch relay on for %s", - str(self.mac), - ) - self._relay_state = True - self.do_callback(FEATURE_RELAY["id"]) - elif message.ack_id == RELAY_SWITCHED_OFF: - if self._relay_state: - _LOGGER.debug( - "Switch relay off for %s", - str(self.mac), - ) - self._relay_state = False - self.do_callback(FEATURE_RELAY["id"]) + @raise_not_loaded + @raise_calibration_missing + async def async_energy_update( + self + ) -> EnergyStatistics | None: + """Update energy usage statistics, returns True if successful.""" + if self._last_log_address is None: + _LOGGER.warning( + "Unable to update energy logs for node %s " + + "because last_log_address is unknown.", + self._node_info.mac, + ) + if not await self.async_node_info_update(): + return None else: + if self._node_info.timestamp < ( + datetime.now(tz=UTC) - timedelta(hours=1) + ): + if not await self.async_node_info_update(): + return None + + if self._energy_counters.log_rollover: _LOGGER.debug( - "Unmanaged _node_ack_response %s received for %s", - str(message.ack_id), - str(self.mac), + "async_energy_update | Log rollover for %s", + self._node_info.mac, ) + if await self.async_node_info_update(): + await self.async_energy_log_update(self._last_log_address) - def _response_power_usage(self, message: CirclePowerUsageResponse): - # Sometimes the circle returns -1 for some of the pulse counters - # likely this means the circle measures very little power and is suffering from - # rounding errors. Zero these out. However, negative pulse values are valid - # for power producing appliances, like solar panels, so don't complain too loudly. + missing_addresses = self._energy_counters.log_addresses_missing + if missing_addresses is not None: + if len(missing_addresses) == 0: + await self.async_power_update() + _LOGGER.debug( + "async_energy_update for %s | .. == 0 | %s", + self.mac, + missing_addresses, + ) + return self._energy_counters.energy_statistics + if len(missing_addresses) == 1: + if await self.async_energy_log_update(missing_addresses[0]): + await self.async_power_update() + _LOGGER.debug( + "async_energy_update for %s | .. == 1 | %s", + self.mac, + missing_addresses, + ) + return self._energy_counters.energy_statistics - # Power consumption last second - if message.pulse_1s.value == -1: - message.pulse_1s.value = 0 - _LOGGER.debug( - "1 sec power pulse counter for node %s has value of -1, corrected to 0", - str(self.mac), - ) - self._pulses_1s = message.pulse_1s.value - if message.pulse_1s.value != 0: - if message.nanosecond_offset.value != 0: - pulses_1s = ( - message.pulse_1s.value - * (1000000000 + message.nanosecond_offset.value) - ) / 1000000000 - else: - pulses_1s = message.pulse_1s.value - self._pulses_1s = pulses_1s - else: - self._pulses_1s = 0 - self.do_callback(FEATURE_POWER_USE["id"]) - # Power consumption last 8 seconds - if message.pulse_8s.value == -1: - message.pulse_8s.value = 0 + # Create task to request remaining missing logs + if ( + self._retrieve_energy_logs_task is None + or self._retrieve_energy_logs_task.done() + ): _LOGGER.debug( - "8 sec power pulse counter for node %s has value of -1, corrected to 0", - str(self.mac), - ) - if message.pulse_8s.value != 0: - if message.nanosecond_offset.value != 0: - pulses_8s = ( - message.pulse_8s.value - * (1000000000 + message.nanosecond_offset.value) - ) / 1000000000 - else: - pulses_8s = message.pulse_8s.value - self._pulses_8s = pulses_8s + "Create task to update energy logs for node %s", + self._node_info.mac, + ) + await self.async_get_missing_energy_logs() else: - self._pulses_8s = 0 - self.do_callback(FEATURE_POWER_USE_LAST_8_SEC["id"]) - # Power consumption current hour - if message.pulse_hour_consumed.value == -1: _LOGGER.debug( - "1 hour consumption power pulse counter for node %s has value of -1, drop value", - str(self.mac), + "Skip creating task to update energy logs for node %s", + self._node_info.mac, ) - else: - self._update_energy_current_hour(message.pulse_hour_consumed.value) + return None - # Power produced current hour - if message.pulse_hour_produced.value == -1: - message.pulse_hour_produced.value = 0 + async def async_get_missing_energy_logs(self) -> None: + """Task to retrieve missing energy logs""" + self._energy_counters.update() + missing_addresses = self._energy_counters.log_addresses_missing + if missing_addresses is None: _LOGGER.debug( - "1 hour power production pulse counter for node %s has value of -1, corrected to 0", - str(self.mac), - ) - if self._pulses_produced_1h != message.pulse_hour_produced.value: - self._pulses_produced_1h = message.pulse_hour_produced.value - self.do_callback(FEATURE_POWER_PRODUCTION_CURRENT_HOUR["id"]) - - def _response_calibration(self, message: CircleCalibrationResponse): - """Store calibration properties""" - for calibration in ("gain_a", "gain_b", "off_noise", "off_tot"): - val = getattr(message, calibration).value - setattr(self, "_" + calibration, val) - self.calibration = True - - def pulses_to_kws(self, pulses, seconds=1): - """Converts the amount of pulses to kWs using the calaboration offsets""" - if pulses is None: - return None - if pulses == 0 or not self.calibration: - return 0.0 - pulses_per_s = pulses / float(seconds) - corrected_pulses = seconds * ( - ( - (((pulses_per_s + self._off_noise) ** 2) * self._gain_b) - + ((pulses_per_s + self._off_noise) * self._gain_a) + "Start with initial energy request for the last 10 log" + + " addresses for node %s.", + self._node_info.mac, ) - + self._off_tot + for address in range( + self._last_log_address, + self._last_log_address - 11, + -1, + ): + if not await self.async_energy_log_update(address): + _LOGGER.warning( + "Failed to update energy log %s for %s", + str(address), + self._mac_in_str + ) + break + if self._cache_enabled: + await self._async_energy_log_records_save_to_cache() + return + if len(missing_addresses) == 0: + return + _LOGGER.debug( + "Request %s missing energy logs for node %s | %s", + str(len(missing_addresses)), + self._node_info.mac, + str(missing_addresses), ) - calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds - # Fix minor miscalculations - if -0.001 < calc_value < 0.001: - calc_value = 0.0 - return calc_value + if len(missing_addresses) > 10: + _LOGGER.warning( + "Limit requesting max 10 energy logs %s for node %s", + str(len(missing_addresses)), + self._node_info.mac, + ) + missing_addresses = sorted(missing_addresses, reverse=True)[:10] + await gather( + *[ + self.async_energy_log_update(address) + for address in missing_addresses + ] + ) + if self._cache_enabled: + await self._async_energy_log_records_save_to_cache() - def _collect_energy_pulses(self, start_utc: datetime, end_utc: datetime): - """Return energy pulses of given hours""" + async def async_energy_log_update(self, address: int) -> bool: + """ + Request energy log statistics from node. + Return true if successful + """ + if address <= 0: + return False + request = CircleEnergyLogsRequest(self._mac_in_bytes, address) + _LOGGER.debug( + "Request of energy log at address %s for node %s", + str(address), + self._mac_in_str, + ) + response: CircleEnergyLogsResponse | None = await self._send( + request + ) + await sleep(0) + if response is None: + _LOGGER.warning( + "Retrieving of energy log at address %s for node %s failed", + str(address), + self._mac_in_str, + ) + return False - if start_utc == end_utc: - hours = 0 - else: - hours = int((end_utc - start_utc).seconds / 3600) - _energy_pulses = 0 - for hour in range(0, hours + 1): - _log_timestamp = start_utc + timedelta(hours=hour) - if self._energy_history.get(_log_timestamp) is not None: - _energy_pulses += self._energy_history[_log_timestamp] - _LOGGER.debug( - "_collect_energy_pulses for %s | %s : %s, total = %s", - str(self.mac), - str(_log_timestamp), - str(self._energy_history[_log_timestamp]), - str(_energy_pulses), + self._available_update_state(True) + + # Forward historical energy log information to energy counters + # Each response message contains 4 log counters (slots) of the + # energy pulses collected during the previous hour of given timestamp + for _slot in range(4, 0, -1): + _log_timestamp: datetime = getattr( + response, "logdate%d" % (_slot,) + ).value + _log_pulses: int = getattr(response, "pulses%d" % (_slot,)).value + if _log_timestamp is not None: + await self._async_energy_log_record_update_state( + response.logaddr.value, + _slot, + _log_timestamp.replace(tzinfo=UTC), + _log_pulses, + import_only=True ) - else: - _mem_address = self._energy_timestamp_memory_address(_log_timestamp) - if _mem_address is not None and _mem_address >= 0: - _LOGGER.info( - "_collect_energy_pulses for %s at %s | %s not found", - str(self.mac), - str(_log_timestamp), - str(_mem_address), - ) - if self._energy_history_failed_address.count(_mem_address) == 0: - self._energy_history_failed_address.append(_mem_address) - else: - _LOGGER.info( - "_collect_energy_pulses ignoring negative _mem_address %s", - str(_mem_address), - ) + await sleep(0) + self._energy_counters.update() + if self._cache_enabled: + create_task(self.async_save_cache()) + response = None + return True - # Validate all history values where present - if len(self._energy_history_failed_address) == 0: - return _energy_pulses - return None + async def _async_energy_log_records_load_from_cache(self) -> bool: + """Load energy_log_record from cache.""" + cached_energy_log_data = self._get_cache("energy_collection") + if cached_energy_log_data is None: + _LOGGER.info( + "Failed to restore energy log records from cache for node %s", + self.mac + ) + return False - def _update_energy_current_hour(self, _pulses_cur_hour): - """Update energy consumption (pulses) of current hour""" - _LOGGER.info( - "_update_energy_current_hour for %s | counter = %s, update= %s", - str(self.mac), - str(self._energy_pulses_current_hour), - str(_pulses_cur_hour), - ) - if self._energy_pulses_current_hour is None: - self._energy_pulses_current_hour = _pulses_cur_hour - self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"]) - else: - if self._energy_pulses_current_hour != _pulses_cur_hour: - self._energy_pulses_current_hour = _pulses_cur_hour - self.do_callback(FEATURE_POWER_CONSUMPTION_CURRENT_HOUR["id"]) + restored_logs: dict[int, list[int]] = {} - if self._last_collected_address_timestamp > datetime(2000, 1, 1): - # Update today after lastlog has been retrieved - self._update_energy_today_now() + log_data = cached_energy_log_data.split("|") + for log_record in log_data: + log_fields = log_record.split(":") + if len(log_fields) == 4: + timestamp_energy_log = log_fields[2].split("-") + if len(timestamp_energy_log) == 6: + address = int(log_fields[0]) + slot = int(log_fields[1]) + self._energy_counters.add_pulse_log( + address=address, + slot=slot, + timestamp=datetime( + year=int(timestamp_energy_log[0]), + month=int(timestamp_energy_log[1]), + day=int(timestamp_energy_log[2]), + hour=int(timestamp_energy_log[3]), + minute=int(timestamp_energy_log[4]), + second=int(timestamp_energy_log[5]), + tzinfo=UTC + ), + pulses=int(log_fields[3]), + import_only=True, + ) + if restored_logs.get(address) is None: + restored_logs[address] = [] + restored_logs[address].append(slot) - def _update_energy_today_now(self): - """Update energy consumption (pulses) of today up to now""" + self._energy_counters.update() + + # Create task to retrieve remaining (missing) logs + if self._energy_counters.log_addresses_missing is None: + return False + if len(self._energy_counters.log_addresses_missing) > 0: + missing_addresses = sorted( + self._energy_counters.log_addresses_missing, reverse=True + )[:5] + for address in missing_addresses: + _LOGGER.debug( + "Create task to request energy log %s for %s", + address, + self._mac_in_bytes + ) + create_task(self.async_energy_log_update(address)) + return False + return True + + async def _async_energy_log_records_save_to_cache(self) -> None: + """Save currently collected energy logs to cached file""" + if not self._cache_enabled: + return + logs: dict[int, dict[int, PulseLogRecord]] = ( + self._energy_counters.get_pulse_logs() + ) + cached_logs = "" + for address in sorted(logs.keys(), reverse=True): + for slot in sorted(logs[address].keys(), reverse=True): + log = logs[address][slot] + if cached_logs != "": + cached_logs += "|" + cached_logs += f"{address}:{slot}:{log.timestamp.year}" + cached_logs += f"-{log.timestamp.month}-{log.timestamp.day}" + cached_logs += f"-{log.timestamp.hour}-{log.timestamp.minute}" + cached_logs += f"-{log.timestamp.second}:{log.pulses}" + self._set_cache("energy_collection", cached_logs) - _pulses_today_now = None + async def _async_energy_log_record_update_state( + self, + address: int, + slot: int, + timestamp: datetime, + pulses: int, + import_only: bool = False, + ) -> None: + """Process new energy log record.""" + self._energy_counters.add_pulse_log( + address, + slot, + timestamp, + pulses, + import_only=import_only + ) + if not self._cache_enabled: + return + log_cache_record = f"{address}:{slot}:{timestamp.year}" + log_cache_record += f"-{timestamp.month}-{timestamp.day}" + log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" + log_cache_record += f"-{timestamp.second}:{pulses}" + cached_logs = self._get_cache("energy_collection") + if cached_logs is None: + _LOGGER.debug( + "No existing energy collection log cached for %s", + self.mac + ) + self._set_cache("energy_collection", log_cache_record) + elif log_cache_record not in cached_logs: + _LOGGER.info( + "Add logrecord (%s, %s) to log cache of %s", + str(address), + str(slot), + self.mac + ) + self._set_cache( + "energy_collection", cached_logs + "|" + log_cache_record + ) - # Regular update + async def async_relay(self, state: bool) -> bool | None: + """ + Switch state of relay. + Return new state of relay + """ + _LOGGER.debug("async_relay() start") + response: NodeResponse | None = await self._send( + CircleRelaySwitchRequest(self._mac_in_bytes, state), + ) + await sleep(0) if ( - self._energy_pulses_today_hourly is not None - and self._energy_pulses_current_hour is not None + response is None + or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED ): - _pulses_today_now = ( - self._energy_pulses_today_hourly + self._energy_pulses_current_hour + _LOGGER.warning( + "Request to switch relay for node %s failed", + self._node_info.mac, ) + return None - _utc_hour_timestamp = datetime.utcnow().replace( - minute=0, second=0, microsecond=0 + if response.ack_id == NodeResponseType.RELAY_SWITCHED_OFF: + await self._async_relay_update_state( + state=False, timestamp=response.timestamp + ) + return False + if response.ack_id == NodeResponseType.RELAY_SWITCHED_ON: + await self._async_relay_update_state( + state=True, timestamp=response.timestamp + ) + return True + _LOGGER.warning( + "Unexpected NodeResponseType %s response " + + "for CircleRelaySwitchRequest at node %s...", + str(response.ack_id), + self.mac, ) - _local_hour = datetime.now().hour - _utc_midnight_timestamp = _utc_hour_timestamp - timedelta(hours=_local_hour) - _local_midnight_timestamp = datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 + return None + + async def _async_relay_load_from_cache(self) -> bool: + """Load relay state from cache.""" + if self._relay is not None: + # State already known, no need to load from cache + return True + cached_relay_data = self._get_cache("relay") + if cached_relay_data is not None: + _LOGGER.debug( + "Restore relay state cache for node %s", + self.mac + ) + relay_state = False + if cached_relay_data == "True": + relay_state = True + await self._async_relay_update_state(relay_state) + return True + _LOGGER.info( + "Failed to restore relay state from cache for node %s, " + + "try to request node info", + self.mac ) + return await self.async_node_info_update() - if _pulses_today_now is None: - if self._energy_pulses_today_hourly is None: - self._update_energy_today_hourly( - _utc_midnight_timestamp + timedelta(hours=1), - _utc_hour_timestamp, + async def _async_relay_update_state( + self, state: bool, timestamp: datetime | None = None + ) -> None: + """Process relay state update.""" + self._relay_state.relay_state = state + self._relay_state.timestamp = timestamp + state_update = False + if state: + self._set_cache("relay", "True") + if (self._relay is None or not self._relay): + state_update = True + if not state: + self._set_cache("relay", "False") + if (self._relay is None or self._relay): + state_update = False + self._relay = state + if state_update: + create_task( + self.publish_event( + NodeFeature.RELAY, + self._relay_state ) - elif ( - self._energy_pulses_today_now is not None - and self._energy_pulses_today_now > _pulses_today_now - and self._energy_pulses_midnight_rollover < _local_midnight_timestamp - ): - _LOGGER.info( - "_update_energy_today_now for %s midnight rollover started old=%s, new=%s", - str(self.mac), - str(self._energy_pulses_today_now), - str(_pulses_today_now), - ) - self._energy_pulses_today_now = 0 - self._energy_pulses_midnight_rollover = _local_midnight_timestamp - self._update_energy_today_hourly( - _utc_midnight_timestamp + timedelta(hours=1), - _utc_hour_timestamp, - ) - self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"]) - elif ( - self._energy_pulses_today_now is not None - and self._energy_pulses_today_now > _pulses_today_now - and int( - (self._energy_pulses_today_now - _pulses_today_now) - / (self._energy_pulses_today_now + 1) - * 100 - ) - > 1 - ): - _LOGGER.info( - "_update_energy_today_now for %s hour rollover started old=%s, new=%s", - str(self.mac), - str(self._energy_pulses_today_now), - str(_pulses_today_now), ) - self._update_energy_today_hourly( - _utc_midnight_timestamp + timedelta(hours=1), - _utc_hour_timestamp, - ) - else: - _LOGGER.info( - "_update_energy_today_now for %s | counter = %s, update= %s (%s + %s)", - str(self.mac), - str(self._energy_pulses_today_now), - str(_pulses_today_now), - str(self._energy_pulses_today_hourly), - str(self._energy_pulses_current_hour), - ) - if self._energy_pulses_today_now is None: - self._energy_pulses_today_now = _pulses_today_now - if self._energy_pulses_today_now is not None: - self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"]) - else: - if self._energy_pulses_today_now != _pulses_today_now: - self._energy_pulses_today_now = _pulses_today_now - self.do_callback(FEATURE_ENERGY_CONSUMPTION_TODAY["id"]) + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.async_save_cache()) - def _update_energy_previous_hour(self, prev_hour: datetime): - """Update energy consumption (pulses) of previous hour""" - _pulses_prev_hour = self._collect_energy_pulses(prev_hour, prev_hour) - _LOGGER.info( - "_update_energy_previous_hour for %s | counter = %s, update= %s, timestamp %s", - str(self.mac), - str(self._energy_pulses_yesterday), - str(_pulses_prev_hour), - str(prev_hour), + async def async_clock_synchronize(self) -> bool: + """Synchronize clock. Returns true if successful""" + clock_response: CircleClockResponse | None = await self._send( + CircleClockGetRequest(self._mac_in_bytes) ) - if self._energy_pulses_prev_hour is None: - self._energy_pulses_prev_hour = _pulses_prev_hour - if self._energy_pulses_prev_hour is not None: - self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"]) - else: - if self._energy_pulses_prev_hour != _pulses_prev_hour: - self._energy_pulses_prev_hour = _pulses_prev_hour - self.do_callback(FEATURE_POWER_CONSUMPTION_PREVIOUS_HOUR["id"]) - - def _update_energy_yesterday( - self, start_yesterday: datetime, end_yesterday: datetime - ): - """Update energy consumption (pulses) of yesterday""" - _pulses_yesterday = self._collect_energy_pulses(start_yesterday, end_yesterday) - _LOGGER.debug( - "_update_energy_yesterday for %s | counter = %s, update= %s, range %s to %s", - str(self.mac), - str(self._energy_pulses_yesterday), - str(_pulses_yesterday), - str(start_yesterday), - str(end_yesterday), + if clock_response is None or clock_response.timestamp is None: + return False + _dt_of_circle = datetime.utcnow().replace( + hour=clock_response.time.hour.value, + minute=clock_response.time.minute.value, + second=clock_response.time.second.value, + microsecond=0, + tzinfo=UTC, ) - if self._energy_pulses_yesterday is None: - self._energy_pulses_yesterday = _pulses_yesterday - if self._energy_pulses_yesterday is not None: - self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"]) - else: - if self._energy_pulses_yesterday != _pulses_yesterday: - self._energy_pulses_yesterday = _pulses_yesterday - self.do_callback(FEATURE_POWER_CONSUMPTION_YESTERDAY["id"]) - - def _update_energy_today_hourly(self, start_today: datetime, end_today: datetime): - """Update energy consumption (pulses) of today up to last hour""" - if start_today > end_today: - _pulses_today_hourly = 0 - else: - _pulses_today_hourly = self._collect_energy_pulses(start_today, end_today) - _LOGGER.info( - "_update_energy_today_hourly for %s | counter = %s, update= %s, range %s to %s", - str(self.mac), - str(self._energy_pulses_today_hourly), - str(_pulses_today_hourly), - str(start_today), - str(end_today), + clock_offset = ( + clock_response.timestamp.replace(microsecond=0) - _dt_of_circle ) - if self._energy_pulses_today_hourly is None: - self._energy_pulses_today_hourly = _pulses_today_hourly - if self._energy_pulses_today_hourly is not None: - self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"]) - else: - if self._energy_pulses_today_hourly != _pulses_today_hourly: - self._energy_pulses_today_hourly = _pulses_today_hourly - self.do_callback(FEATURE_POWER_CONSUMPTION_TODAY["id"]) - - def request_energy_counters(self, log_address=None, callback=None): - """Request power log of specified address""" - _LOGGER.debug( - "request_energy_counters for %s of address %s", - str(self.mac), - str(log_address), - ) - if not self._available: - _LOGGER.debug( - "Skip request_energy_counters for % is unavailable", - str(self.mac), + if (clock_offset.seconds > MAX_TIME_DRIFT) or ( + clock_offset.seconds < -(MAX_TIME_DRIFT) + ): + _LOGGER.info( + "Reset clock of node %s because time has drifted %s sec", + self._node_info.mac, + str(clock_offset.seconds), + ) + node_response: NodeResponse | None = await self._send( + CircleClockSetRequest(self._mac_in_bytes, datetime.utcnow()), ) - return - if log_address is None: - log_address = self._last_log_address - if log_address is not None: - # Energy history already collected if ( - log_address == self._last_log_address - and self._energy_last_populated_slot == 4 + node_response is None + or node_response.ack_id != NodeResponseType.CLOCK_ACCEPTED ): - # Rollover of energy counter slot, get new memory address first - self._energy_last_populated_slot = 0 - self._request_info(self.request_energy_counters) - else: - # Request new energy counters - if self._energy_memory.get(log_address, 0) < 4: - self.message_sender( - CircleEnergyCountersRequest(self._mac, log_address), - None, - 0, - PRIORITY_LOW, - ) - else: - _LOGGER.info( - "Drop known request_energy_counters for %s of address %s", - str(self.mac), - str(log_address), - ) + _LOGGER.warning( + "Failed to (re)set the internal clock of node %s", + self._node_info.mac, + ) + return False + return True + + async def async_load(self) -> bool: + """Load and activate Circle node features.""" + if self._loaded: + return True + if self._cache_enabled: + _LOGGER.debug( + "Load Circle node %s from cache", self._node_info.mac + ) + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return await self.async_initialize() + _LOGGER.warning( + "Load Circle node %s from cache failed", + self._node_info.mac, + ) else: - self._request_info(self.request_energy_counters) + _LOGGER.debug("Load Circle node %s", self._node_info.mac) - def _response_energy_counters(self, message: CircleEnergyCountersResponse): - """Save historical energy information in local counters - Each response message contains 4 log counters (slots) - of the energy pulses collected during the previous hour of given timestamp - """ - if message.logaddr.value == (self._last_log_address): - self._energy_last_populated_slot = 0 + # Check if node is online + if not self._available and not await self.async_is_online(): + _LOGGER.warning( + "Failed to load Circle node %s because it is not online", + self._node_info.mac + ) + return False - # Collect energy history pulses from received log address - # Store pulse in self._energy_history using the timestamp in UTC as index - _utc_hour_timestamp = datetime.utcnow().replace( - minute=0, second=0, microsecond=0 - ) - _local_midnight_timestamp = datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 - ) - _local_hour = datetime.now().hour - _utc_midnight_timestamp = _utc_hour_timestamp - timedelta(hours=_local_hour) - _midnight_rollover = False - _history_rollover = False - for _slot in range(1, 5): - if ( - _log_timestamp := getattr(message, "logdate%d" % (_slot,)).value - ) is None: - break - # Register collected history memory - if _slot > self._energy_memory.get(message.logaddr.value, 0): - self._energy_memory[message.logaddr.value] = _slot - - self._energy_history[_log_timestamp] = getattr( - message, "pulses%d" % (_slot,) - ).value + # Get node info + if not await self.async_node_info_update(): + _LOGGER.warning( + "Failed to load Circle node %s because it is not responding" + + " to information request", + self._node_info.mac + ) + return False + self._loaded = True + self._load_features() + return await self.async_initialize() - _LOGGER.info( - "push _energy_memory for %s address %s slot %s stamp %s", - str(self.mac), - str(message.logaddr.value), - str(_slot), - str(_log_timestamp), + async def _async_load_from_cache(self) -> bool: + """ + Load states from previous cached information. + Return True if successful. + """ + if not await super()._async_load_from_cache(): + return False + + # Calibration settings + if not await self._async_calibration_load_from_cache(): + _LOGGER.debug( + "Node %s failed to load calibration from cache", + self.mac + ) + return False + # Energy collection + if await self._async_energy_log_records_load_from_cache(): + _LOGGER.debug( + "Node %s failed to load energy_log_records from cache", + self.mac, ) + # Relay + if await self._async_relay_load_from_cache(): + _LOGGER.debug( + "Node %s failed to load relay state from cache", + self.mac, + ) + # Relay init config if feature is enabled + if ( + NodeFeature.RELAY_INIT in self._features + ): + if await self._async_relay_init_load_from_cache(): + _LOGGER.debug( + "Node %s failed to load relay_init state from cache", + self.mac, + ) + return True - # Store last populated _slot - if message.logaddr.value == (self._last_log_address): - self._energy_last_populated_slot = _slot + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize node.""" + if self._initialized: + _LOGGER.debug("Already initialized node %s", self.mac) + return True + self._initialized = True - # Store most recent timestamp of collected pulses - self._energy_last_collected_timestamp = max( - self._energy_last_collected_timestamp, _log_timestamp + if not self._calibration and not await self.async_calibration_update(): + _LOGGER.debug( + "Failed to initialized node %s, no calibration", + self.mac + ) + self._initialized = False + return False + if not await self.async_node_info_update(): + _LOGGER.debug( + "Failed to retrieve node info for %s", + self.mac ) + if not await self.async_clock_synchronize(): + _LOGGER.debug( + "Failed to initialized node %s, failed clock sync", + self.mac + ) + self._initialized = False + return False + if ( + NodeFeature.RELAY_INIT in self._features and + self._relay_init_state is None and + not await self.async_relay_init_update() + ): + _LOGGER.debug( + "Failed to initialized node %s, relay init", + self.mac + ) + self._initialized = False + return False + return True - # Keep track of the most recent timestamp, _last_log_address might be corrupted - if _log_timestamp > self._last_collected_address_timestamp: - self._last_collected_address = message.logaddr.value - self._last_collected_address_slot = _slot - self._last_collected_address_timestamp = _log_timestamp + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + self._setup_protocol(CIRCLE_FIRMWARE) + self._features += CIRCLE_FEATURES + if ( + self._node_protocols is not None and + "2.6" in self._node_protocols + ): + self._features += (NodeFeature.RELAY_INIT,) + self._node_info.features = self._features - # Trigger history rollover - _LOGGER.info( - "history_rollover %s %s %s", - str(_log_timestamp), - str(_utc_hour_timestamp), - str(self._energy_last_rollover_timestamp), + async def async_node_info_update( + self, node_info: NodeInfoResponse | None = None + ) -> bool: + """ + Update Node hardware information. + Returns true if successful. + """ + if node_info is None: + node_info = await self._send( + NodeInfoRequest(self._mac_in_bytes) ) - if ( - _log_timestamp == _utc_hour_timestamp - and self._energy_last_rollover_timestamp < _utc_hour_timestamp - ): - self._energy_last_rollover_timestamp = _utc_hour_timestamp - _history_rollover = True - _LOGGER.info( - "_response_energy_counters for %s | history rollover, reset date to %s", - str(self.mac), - str(_utc_hour_timestamp), + else: + if node_info.mac_decoded != self.mac: + raise NodeError( + f"Incorrect node_info {node_info.mac_decoded} " + + f"!= {self.mac}={self._mac_in_str}" ) + if node_info is None: + return False - # Trigger midnight rollover - if ( - _log_timestamp == _utc_midnight_timestamp - and self._energy_consumption_today_reset < _local_midnight_timestamp - ): - _LOGGER.info( - "_response_energy_counters for %s | midnight rollover, reset date to %s", - str(self.mac), - str(_local_midnight_timestamp), - ) - self._energy_consumption_today_reset = _local_midnight_timestamp - _midnight_rollover = True - if self._energy_last_collected_timestamp == datetime.utcnow().replace( - minute=0, second=0, microsecond=0 + self._node_info_update_state( + firmware=node_info.fw_ver.value, + hardware=node_info.hw_ver.value.decode(UTF8), + node_type=node_info.node_type.value, + timestamp=node_info.timestamp, + ) + await self._async_relay_update_state( + node_info.relay_state.value == 1, timestamp=node_info.timestamp + ) + if ( + self._last_log_address is not None and + self._last_log_address > node_info.last_logaddress.value ): - self._update_energy_previous_hour(_utc_hour_timestamp) - self._update_energy_today_hourly( - _utc_midnight_timestamp + timedelta(hours=1), - _utc_hour_timestamp, + # Rollover of log address + _LOGGER.warning( + "Rollover log address from %s into %s for node %s", + self._last_log_address, + node_info.last_logaddress.value, + self.mac ) - self._update_energy_yesterday( - _utc_midnight_timestamp - timedelta(hours=23), - _utc_midnight_timestamp, + if self._last_log_address != node_info.last_logaddress.value: + self._last_log_address = node_info.last_logaddress.value + self._set_cache( + "last_log_address", node_info.last_logaddress.value ) - else: - _LOGGER.info( - "CircleEnergyCounter failed for %s at %s|%s count %s", - str(self.mac), - str(message.logaddr.value), - str(self._last_log_address), - str(self._energy_last_collected_count), + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.async_save_cache()) + node_info = None + return True + + async def _async_node_info_load_from_cache(self) -> bool: + """Load node info settings from cache.""" + result = await super()._async_node_info_load_from_cache() + if ( + last_log_address := self._get_cache("last_log_address") + ) is not None: + self._last_log_address = int(last_log_address) + return result + return False + + async def async_unload(self) -> None: + """Deactivate and unload node features.""" + if self._cache_enabled: + await self._async_energy_log_records_save_to_cache() + await self.async_save_cache() + self._loaded = False + + async def async_relay_init_update(self) -> bool: + """ + Update current configuration of the power-up state of the relay + + Returns True if retrieval of state was successful + """ + if NodeFeature.RELAY_INIT not in self._features: + raise NodeError( + "Update of initial state of relay is not " + + f"supported for device {self.mac}" ) - self._energy_last_collected_count += 1 + if await self.async_relay_init_get() is None: + return False + return True - if ( - message.logaddr.value == self._last_log_address - and self._energy_last_collected_count > 3 - ): - if ( - self._energy_history_failed_address.count( - self._last_log_address - 1 - ) - == 0 - ): - self._energy_history_failed_address.append( - self._last_log_address - 1 - ) - _LOGGER.info("Resetting CircleEnergyCounter due to logaddress offset") - - # Cleanup energy history for more than 48 hours - _48_hours_ago = datetime.utcnow().replace( - minute=0, second=0, microsecond=0 - ) - timedelta(hours=48) - for log_timestamp in list(self._energy_history.keys()): - if log_timestamp < _48_hours_ago: - del self._energy_history[log_timestamp] - - def _response_clock(self, message: CircleClockResponse): - log_date = datetime( - datetime.now().year, - datetime.now().month, - datetime.now().day, - message.time.value.hour, - message.time.value.minute, - message.time.value.second, - ) - clock_offset = message.timestamp.replace(microsecond=0) - ( - log_date + self.timezone_delta - ) - if clock_offset.days == -1: - self._clock_offset = clock_offset.seconds - 86400 - else: - self._clock_offset = clock_offset.seconds - _LOGGER.debug( - "Clock of node %s has drifted %s sec", - str(self.mac), - str(self._clock_offset), - ) + async def async_relay_init_get(self) -> bool | None: + """ + Get current configuration of the power-up state of the relay. - def get_clock(self, callback=None): - """Get current datetime of internal clock of Circle.""" - self.message_sender( - CircleClockGetRequest(self._mac), - callback, - 0, - PRIORITY_LOW, + Returns None if retrieval failed + """ + if NodeFeature.RELAY_INIT not in self._features: + raise NodeError( + "Retrieval of initial state of relay is not " + + f"supported for device {self.mac}" + ) + response: CircleRelayInitStateResponse | None = await self._send( + CircleRelayInitStateRequest(self._mac_in_bytes, False, False), ) + if response is None: + return None + await self._async_relay_init_update_state(response.relay.value == 1) + return self._relay_init_state - def set_clock(self, callback=None): - """Set internal clock of Circle.""" - self.message_sender( - CircleClockSetRequest(self._mac, datetime.utcnow()), - callback, + async def async_relay_init_set(self, state: bool) -> bool | None: + """Switch relay state.""" + if NodeFeature.RELAY_INIT not in self._features: + raise NodeError( + "Configuring of initial state of relay is not" + + f"supported for device {self.mac}" + ) + response: CircleRelayInitStateResponse | None = await self._send( + CircleRelayInitStateRequest(self._mac_in_bytes, True, state), ) + if response is None: + return None + await self._async_relay_init_update_state(response.relay.value == 1) + return self._relay_init_state + + async def _async_relay_init_load_from_cache(self) -> bool: + """ + Load relay init state from cache. + Return True if retrieval was successful. + """ + if (cached_relay_data := self._get_cache("relay_init")) is not None: + relay_init_state = False + if cached_relay_data == "True": + relay_init_state = True + await self._async_relay_init_update_state(relay_init_state) + return True + return False - def sync_clock(self, max_drift=0): - """Resync clock of node if time has drifted more than MAX_TIME_DRIFT""" - if self._clock_offset is not None: - if max_drift == 0: - max_drift = MAX_TIME_DRIFT - if (self._clock_offset > max_drift) or (self._clock_offset < -(max_drift)): - _LOGGER.info( - "Reset clock of node %s because time has drifted %s sec", - str(self.mac), - str(self._clock_offset), + async def _async_relay_init_update_state(self, state: bool) -> None: + """Process relay init state update.""" + state_update = False + if state: + self._set_cache("relay_init", "True") + if self._relay_init_state is None or not self._relay_init_state: + state_update = True + if not state: + self._set_cache("relay_init", "False") + if self._relay_init_state is None or self._relay_init_state: + state_update = True + if state_update: + self._relay_init_state = state + create_task( + self.publish_event( + NodeFeature.RELAY_INIT, self._relay_init_state ) - self.set_clock() + ) + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.async_save_cache()) - def _energy_timestamp_memory_address(self, utc_timestamp: datetime): - """Return memory address for given energy counter timestamp""" - if self._last_collected_address is None: - return None - # Should already be hour timestamp, but just to be sure. - _utc_now_timestamp = self._last_collected_address_timestamp.replace( - minute=0, second=0, microsecond=0 - ) - if utc_timestamp > _utc_now_timestamp: + @raise_calibration_missing + def _calc_watts( + self, pulses: int, seconds: int, nano_offset: int + ) -> float | None: + """Calculate watts based on energy usages.""" + if self._calibration is None: return None - _seconds_offset = (_utc_now_timestamp - utc_timestamp).total_seconds() - _hours_offset = _seconds_offset / 3600 - - if (_slot := self._last_collected_address_slot) == 0: - _slot = 4 - _address = self._last_collected_address - _sslot = _slot - - # last known - _hours = 1 - while _hours <= _hours_offset: - _slot -= 1 - if _slot == 0: - _address -= 1 - _slot = 4 - _hours += 1 - _LOGGER.info( - "Calculated address %s at %s from %s at %s with %s|%s", - _address, - utc_timestamp, - self._last_log_address, - _utc_now_timestamp, - _sslot, - _hours_offset, + pulses_per_s = self._correct_power_pulses(pulses, nano_offset) / float( + seconds + ) + corrected_pulses = seconds * ( + ( + ( + ((pulses_per_s + self._calibration.off_noise) ** 2) + * self._calibration.gain_b + ) + + ( + (pulses_per_s + self._calibration.off_noise) + * self._calibration.gain_a + ) + ) + + self._calibration.off_tot ) - return _address + calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000 + # Fix minor miscalculations + if calc_value < 0.0: + _LOGGER.debug( + "FIX negative power miscalc from %s to 0.0 for %s", + str(calc_value), + self.mac + ) + calc_value = 0.0 + + return calc_value + + def _correct_power_pulses(self, pulses: int, offset: int) -> float: + """Correct pulses based on given measurement time offset (ns)""" + + # Sometimes the circle returns -1 for some of the pulse counters + # likely this means the circle measures very little power and is + # suffering from rounding errors. Zero these out. However, negative + # pulse values are valid for power producing appliances, like + # solar panels, so don't complain too loudly. + if pulses == -1: + _LOGGER.warning( + "Power pulse counter for node %s of " + + "value of -1, corrected to 0", + self._node_info.mac, + ) + return 0.0 + if pulses != 0: + if offset != 0: + return ( + pulses * (SECOND_IN_NANOSECONDS + offset) + ) / SECOND_IN_NANOSECONDS + return pulses + return 0.0 + + async def async_get_state( + self, features: tuple[NodeFeature] + ) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + if not self._loaded: + if not await self.async_load(): + _LOGGER.warning( + "Unable to update state because load node %s failed", + self.mac + ) + states: dict[NodeFeature, Any] = {} + if not self._available: + if not await self.async_is_online(): + _LOGGER.warning( + "Node %s does not respond, unable to update state", + self.mac + ) + for feature in features: + states[feature] = None + return states + + for feature in features: + await sleep(0) + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.ENERGY: + states[feature] = await self.async_energy_update() + _LOGGER.debug( + "async_get_state %s - energy: %s", + self.mac, + states[feature], + ) + elif feature == NodeFeature.RELAY: + states[feature] = self._relay_state + _LOGGER.debug( + "async_get_state %s - relay: %s", + self.mac, + states[feature], + ) + elif feature == NodeFeature.RELAY_INIT: + states[feature] = self._relay_init_state + elif feature == NodeFeature.POWER: + states[feature] = await self.async_power_update() + _LOGGER.debug( + "async_get_state %s - power: %s", + self.mac, + states[feature], + ) + else: + state_result = await super().async_get_state([feature]) + states[feature] = state_result[feature] + return states diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 39b7b5361..0c571e5b3 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -1,137 +1,182 @@ """Plugwise Circle+ node object.""" -from datetime import datetime + +from __future__ import annotations + +from datetime import datetime, UTC import logging +from typing import Final -from ..constants import MAX_TIME_DRIFT, PRIORITY_LOW, UTF8_DECODE +from .helpers import raise_not_loaded +from ..api import NodeFeature +from ..constants import MAX_TIME_DRIFT from ..messages.requests import ( CirclePlusRealTimeClockGetRequest, CirclePlusRealTimeClockSetRequest, - CirclePlusScanRequest, ) -from ..messages.responses import CirclePlusRealTimeClockResponse, CirclePlusScanResponse -from ..nodes.circle import PlugwiseCircle +from ..messages.responses import ( + CirclePlusRealTimeClockResponse, + NodeResponse, + NodeResponseType, +) +from .circle import CIRCLE_FEATURES, PlugwiseCircle _LOGGER = logging.getLogger(__name__) +# Minimum and maximum supported (custom) zigbee protocol version based +# on utc timestamp of firmware +# Extracted from "Plugwise.IO.dll" file of Plugwise source installation +CIRCLE_PLUS_FIRMWARE: Final = { + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): ("1.0", "1.1"), + datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): ("2.0", "2.4"), + datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): ("2.0", "2.5"), + # Legrand + datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): ("2.0", "2.6"), + # Radio Test + datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): ("2.0", "2.5"), + # SMA firmware 2015-06-16 + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): ( + "2.0", + "2.6", + ), + # New Flash Update + datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): ( + "2.0", + "2.6", + ), +} + class PlugwiseCirclePlus(PlugwiseCircle): """provides interface to the Plugwise Circle+ nodes""" - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._plugwise_nodes = {} - self._scan_response = {} - self._scan_for_nodes_callback = None - self._realtime_clock_offset = None - self.get_real_time_clock(self.sync_realtime_clock) - - def message_for_circle_plus(self, message): - """Process received message""" - if isinstance(message, CirclePlusRealTimeClockResponse): - self._response_realtime_clock(message) - elif isinstance(message, CirclePlusScanResponse): - self._process_scan_response(message) + async def async_load(self) -> bool: + """Load and activate Circle+ node features.""" + if self._loaded: + return True + if self._cache_enabled: + _LOGGER.debug( + "Load Circle node %s from cache", self._node_info.mac + ) + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return await self.async_initialize() + _LOGGER.warning( + "Load Circle+ node %s from cache failed", + self._node_info.mac, + ) else: - _LOGGER.waning( - "Unsupported message type '%s' received from circle with mac %s", - str(message.__class__.__name__), - self.mac, + _LOGGER.debug("Load Circle+ node %s", self._node_info.mac) + + # Check if node is online + if not self._available and not await self.async_is_online(): + _LOGGER.warning( + "Failed to load Circle+ node %s because it is not online", + self._node_info.mac ) + return False - def scan_for_nodes(self, callback=None): - """Scan for registered nodes.""" - self._scan_for_nodes_callback = callback - for node_address in range(0, 64): - self.message_sender(CirclePlusScanRequest(self._mac, node_address)) - self._scan_response[node_address] = False + # Get node info + if not await self.async_node_info_update(): + _LOGGER.warning( + "Failed to load Circle+ node %s because it is not responding" + + " to information request", + self._node_info.mac + ) + return False + self._loaded = True + self._load_features() + return await self.async_initialize() + + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize node.""" + if self._initialized: + return True + self._initialized = True + if not self._available: + self._initialized = False + return False + if not self._calibration and not await self.async_calibration_update(): + self._initialized = False + return False + if not await self.async_realtime_clock_synchronize(): + self._initialized = False + return False + if ( + NodeFeature.RELAY_INIT in self._features and + self._relay_init_state is None and + not await self.async_relay_init_update() + ): + self._initialized = False + return False + self._initialized = True + return True + + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + self._setup_protocol(CIRCLE_PLUS_FIRMWARE) + self._features += CIRCLE_FEATURES + if ( + self._node_protocols is not None and + "2.6" in self._node_protocols + ): + self._features += (NodeFeature.RELAY_INIT,) + self._node_info.features = self._features - def _process_scan_response(self, message): - """Process scan response message.""" - _LOGGER.debug( - "Process scan response for address %s", message.node_address.value + async def async_realtime_clock_synchronize(self) -> bool: + """Synchronize realtime clock.""" + clock_response: CirclePlusRealTimeClockResponse | None = ( + await self._send( + CirclePlusRealTimeClockGetRequest(self._mac_in_bytes) + ) ) - if message.node_mac.value != b"FFFFFFFFFFFFFFFF": + if clock_response is None: _LOGGER.debug( - "Linked plugwise node with mac %s found", - message.node_mac.value.decode(UTF8_DECODE), + "No response for async_realtime_clock_synchronize() for %s", + self.mac ) - # TODO: 20220206 is there 'mac' in the dict? Otherwise it can be rewritten to just if message... in - if not self._plugwise_nodes.get(message.node_mac.value.decode(UTF8_DECODE)): - self._plugwise_nodes[ - message.node_mac.value.decode(UTF8_DECODE) - ] = message.node_address.value - if self._scan_for_nodes_callback: - # Check if scan is complete before execute callback - scan_complete = False - self._scan_response[message.node_address.value] = True - for node_address in range(0, 64): - if not self._scan_response[node_address]: - if node_address < message.node_address.value: - # Apparently missed response so send new scan request if it's not in queue yet - _LOGGER.debug( - "Resend missing scan request for address %s", - str(node_address), - ) - self.message_sender( - CirclePlusScanRequest(self._mac, node_address) - ) - break - if node_address == 63: - scan_complete = True - if scan_complete and self._scan_for_nodes_callback: - self._scan_for_nodes_callback(self._plugwise_nodes) - self._scan_for_nodes_callback = None - self._plugwise_nodes = {} + self._available_update_state(False) + return False + self._available_update_state(True) - def get_real_time_clock(self, callback=None): - """Get current datetime of internal clock of CirclePlus.""" - self.message_sender( - CirclePlusRealTimeClockGetRequest(self._mac), - callback, - 0, - PRIORITY_LOW, + _dt_of_circle: datetime = datetime.utcnow().replace( + hour=clock_response.time.value.hour, + minute=clock_response.time.value.minute, + second=clock_response.time.value.second, + microsecond=0, + tzinfo=UTC, ) - - def _response_realtime_clock(self, message): - realtime_clock_dt = datetime( - datetime.now().year, - datetime.now().month, - datetime.now().day, - message.time.value.hour, - message.time.value.minute, - message.time.value.second, + clock_offset = ( + clock_response.timestamp.replace(microsecond=0) - _dt_of_circle ) - realtime_clock_offset = message.timestamp.replace(microsecond=0) - ( - realtime_clock_dt + self.timezone_delta + if (clock_offset.seconds < MAX_TIME_DRIFT) or ( + clock_offset.seconds > -(MAX_TIME_DRIFT) + ): + return True + _LOGGER.info( + "Reset realtime clock of node %s because time has drifted" + + " %s seconds while max drift is set to %s seconds)", + self._node_info.mac, + str(clock_offset.seconds), + str(MAX_TIME_DRIFT), ) - if realtime_clock_offset.days == -1: - self._realtime_clock_offset = realtime_clock_offset.seconds - 86400 - else: - self._realtime_clock_offset = realtime_clock_offset.seconds - _LOGGER.debug( - "Realtime clock of node %s has drifted %s sec", - self.mac, - str(self._clock_offset), - ) - - def set_real_time_clock(self, callback=None): - """Set internal clock of CirclePlus.""" - self.message_sender( - CirclePlusRealTimeClockSetRequest(self._mac, datetime.utcnow()), - callback, + node_response: NodeResponse | None = await self._send( + CirclePlusRealTimeClockSetRequest( + self._mac_in_bytes, + datetime.utcnow() + ), ) - - def sync_realtime_clock(self, max_drift=0): - """Sync real time clock of node if time has drifted more than max drifted.""" - if self._realtime_clock_offset is not None: - if max_drift == 0: - max_drift = MAX_TIME_DRIFT - if (self._realtime_clock_offset > max_drift) or ( - self._realtime_clock_offset < -(max_drift) - ): - _LOGGER.info( - "Reset realtime clock of node %s because time has drifted %s sec", - self.mac, - str(self._clock_offset), - ) - self.set_real_time_clock() + if node_response is None: + return False + if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED: + return True + return False diff --git a/plugwise_usb/nodes/helpers/__init__.py b/plugwise_usb/nodes/helpers/__init__.py new file mode 100644 index 000000000..5a8886025 --- /dev/null +++ b/plugwise_usb/nodes/helpers/__init__.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import wraps +import logging +from typing import Any, TypeVar, cast + +from ...exceptions import NodeError + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EnergyCalibration: + """Definition of a calibration for Plugwise devices (Circle, Stealth).""" + + gain_a: float + gain_b: float + off_noise: float + off_tot: float + + +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) + + +def raise_not_loaded(func: FuncT) -> FuncT: + """ + Decorator function to raise NodeError when node is not loaded. + """ + @wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + + if not args[0].loaded: + raise NodeError(f"Node {args[0].mac} is not loaded yet") + return func(*args, **kwargs) + return cast(FuncT, decorated) diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py new file mode 100644 index 000000000..8c0b82268 --- /dev/null +++ b/plugwise_usb/nodes/helpers/cache.py @@ -0,0 +1,123 @@ +"""Caching for plugwise node""" + +from __future__ import annotations + +import logging +from pathlib import Path, PurePath + +import aiofiles +import aiofiles.os + +from ...constants import CACHE_SEPARATOR, UTF8 +from ...util import get_writable_cache_dir + +_LOGGER = logging.getLogger(__name__) + + +class NodeCache: + """Class to cache specific node configuration and states""" + + def __init__(self, mac: str, cache_root_dir: str = "") -> None: + """Initialize NodeCache class.""" + self._mac = mac + self._states: dict[str, str] = {} + self._cache_file: PurePath | None = None + self._cache_root_dir = cache_root_dir + self._set_cache_file(cache_root_dir) + + @property + def cache_root_directory(self) -> str: + """Root directory to store the plugwise cache directory.""" + return self._cache_root_dir + + @cache_root_directory.setter + def cache_root_directory(self, cache_root_dir: str = "") -> None: + """Root directory to store the plugwise cache directory.""" + self._cache_root_dir = cache_root_dir + self._set_cache_file(cache_root_dir) + + def _set_cache_file(self, cache_root_dir: str) -> None: + """Set (and create) the plugwise cache directory to store cache.""" + self._cache_root_dir = get_writable_cache_dir(cache_root_dir) + Path(self._cache_root_dir).mkdir(parents=True, exist_ok=True) + self._cache_file = Path(f"{self._cache_root_dir}/{self._mac}.cache") + + @property + def states(self) -> dict[str, str]: + """cached node state information""" + return self._states + + def add_state(self, state: str, value: str) -> None: + """Add configuration state to cache.""" + self._states[state] = value + + def remove_state(self, state: str) -> None: + """Remove configuration state from cache.""" + if self._states.get(state) is not None: + self._states.pop(state) + + def get_state(self, state: str) -> str | None: + """Return current value for state""" + return self._states.get(state, None) + + async def async_save_cache(self) -> None: + """Save the node configuration to file.""" + async with aiofiles.open( + file=self._cache_file, + mode="w", + encoding=UTF8, + ) as file_data: + for key, state in self._states.copy().items(): + await file_data.write( + f"{key}{CACHE_SEPARATOR}{state}\n" + ) + _LOGGER.debug( + "Cached settings saved to cache file %s", + str(self._cache_file), + ) + + async def async_clear_cache(self) -> None: + """Clear current cache.""" + self._states = {} + await self.async_delete_cache_file() + + async def async_restore_cache(self) -> bool: + """Load the previously store state information.""" + try: + async with aiofiles.open( + file=self._cache_file, + mode="r", + encoding=UTF8, + ) as file_data: + lines = await file_data.readlines() + except OSError: + _LOGGER.warning( + "Failed to read cache file %s", str(self._cache_file) + ) + return False + else: + self._states.clear() + for line in lines: + data = line.strip().split(CACHE_SEPARATOR) + if len(data) != 2: + _LOGGER.warning( + "Skip invalid line '%s' in cache file %s", + line, + str(self._cache_file) + ) + break + self._states[data[0]] = data[1] + _LOGGER.debug( + "Cached settings restored %s lines from cache file %s", + str(len(self._states)), + str(self._cache_file), + ) + return True + + async def async_delete_cache_file(self) -> None: + """Delete cache file""" + if self._cache_file is None: + return + if not await aiofiles.os.path.exists(self._cache_file): + return + await aiofiles.os.remove(self._cache_file) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py new file mode 100644 index 000000000..4562a8a00 --- /dev/null +++ b/plugwise_usb/nodes/helpers/counter.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from enum import Enum, auto +import logging +from typing import Final + +from .pulses import PulseCollection, PulseLogRecord +from ..helpers import EnergyCalibration +from ...api import EnergyStatistics +from ...constants import HOUR_IN_SECONDS, LOCAL_TIMEZONE, PULSES_PER_KW_SECOND +from ...exceptions import EnergyError + + +class EnergyType(Enum): + """Energy collection types""" + CONSUMPTION_HOUR = auto() + PRODUCTION_HOUR = auto() + CONSUMPTION_DAY = auto() + PRODUCTION_DAY = auto() + CONSUMPTION_WEEK = auto() + PRODUCTION_WEEK = auto() + + +ENERGY_COUNTERS: Final = ( + EnergyType.CONSUMPTION_HOUR, + EnergyType.PRODUCTION_HOUR, + EnergyType.CONSUMPTION_DAY, + EnergyType.PRODUCTION_DAY, + EnergyType.CONSUMPTION_WEEK, + EnergyType.PRODUCTION_WEEK, +) +ENERGY_HOUR_COUNTERS: Final = ( + EnergyType.CONSUMPTION_HOUR, + EnergyType.PRODUCTION_HOUR, +) +ENERGY_DAY_COUNTERS: Final = ( + EnergyType.CONSUMPTION_DAY, + EnergyType.PRODUCTION_DAY, +) +ENERGY_WEEK_COUNTERS: Final = ( + EnergyType.CONSUMPTION_WEEK, + EnergyType.PRODUCTION_WEEK, +) + +ENERGY_CONSUMPTION_COUNTERS: Final = ( + EnergyType.CONSUMPTION_HOUR, + EnergyType.CONSUMPTION_DAY, + EnergyType.CONSUMPTION_WEEK, +) +ENERGY_PRODUCTION_COUNTERS: Final = ( + EnergyType.PRODUCTION_HOUR, + EnergyType.PRODUCTION_DAY, + EnergyType.PRODUCTION_WEEK, +) + +_LOGGER = logging.getLogger(__name__) + + +class EnergyCounters: + """ + Class to hold all energy counters. + """ + + def __init__(self, mac: str) -> None: + """Initialize EnergyCounter class.""" + self._mac = mac + self._calibration: EnergyCalibration | None = None + self._counters: dict[EnergyType, EnergyCounter] = {} + for energy_type in ENERGY_COUNTERS: + self._counters[energy_type] = EnergyCounter(energy_type) + self._pulse_collection = PulseCollection(mac) + self._energy_statistics = EnergyStatistics() + + @property + def collected_logs(self) -> int: + """Total collected logs""" + return self._pulse_collection.collected_logs + + def add_pulse_log( + self, + address: int, + slot: int, + timestamp: datetime, + pulses: int, + import_only: bool = False + ) -> None: + """Add pulse log""" + if self._pulse_collection.add_log( + address, + slot, + timestamp, + pulses, + import_only + ): + if not import_only: + self.update() + + def get_pulse_logs(self) -> dict[int, dict[int, PulseLogRecord]]: + """Return currently collected pulse logs""" + return self._pulse_collection.logs + + def add_pulse_stats( + self, pulses_consumed: int, pulses_produced: int, timestamp: datetime + ) -> None: + """Add pulse statistics""" + _LOGGER.debug("add_pulse_stats | consumed=%s, for %s", str(pulses_consumed), self._mac) + self._pulse_collection.update_pulse_counter( + pulses_consumed, pulses_produced, timestamp + ) + self.update() + + @property + def energy_statistics(self) -> EnergyStatistics: + """Return collection with energy statistics.""" + return self._energy_statistics + + @property + def consumption_interval(self) -> int | None: + """Measurement interval for energy consumption.""" + return self._pulse_collection.log_interval_consumption + + @property + def production_interval(self) -> int | None: + """Measurement interval for energy production.""" + return self._pulse_collection.log_interval_production + + @property + def log_addresses_missing(self) -> list[int] | None: + """Return list of addresses of energy logs""" + return self._pulse_collection.log_addresses_missing + + @property + def log_rollover(self) -> bool: + """Indicate if new log is required due to rollover.""" + return self._pulse_collection.log_rollover + + @property + def calibration(self) -> EnergyCalibration | None: + """Energy calibration configuration.""" + return self._calibration + + @calibration.setter + def calibration(self, calibration: EnergyCalibration) -> None: + """Energy calibration configuration.""" + for node_event in ENERGY_COUNTERS: + self._counters[node_event].calibration = calibration + self._calibration = calibration + + def update(self) -> None: + """Update counter collection""" + if self._calibration is None: + return + ( + self._energy_statistics.hour_consumption, + self._energy_statistics.hour_consumption_reset, + ) = self._counters[EnergyType.CONSUMPTION_HOUR].update( + self._pulse_collection + ) + ( + self._energy_statistics.day_consumption, + self._energy_statistics.day_consumption_reset, + ) = self._counters[EnergyType.CONSUMPTION_DAY].update( + self._pulse_collection + ) + ( + self._energy_statistics.week_consumption, + self._energy_statistics.week_consumption_reset, + ) = self._counters[EnergyType.CONSUMPTION_WEEK].update( + self._pulse_collection + ) + + ( + self._energy_statistics.hour_production, + self._energy_statistics.hour_production_reset, + ) = self._counters[EnergyType.PRODUCTION_HOUR].update( + self._pulse_collection + ) + ( + self._energy_statistics.day_production, + self._energy_statistics.day_production_reset, + ) = self._counters[EnergyType.PRODUCTION_DAY].update( + self._pulse_collection + ) + ( + self._energy_statistics.week_production, + self._energy_statistics.week_production_reset, + ) = self._counters[EnergyType.PRODUCTION_WEEK].update( + self._pulse_collection + ) + + @property + def timestamp(self) -> datetime | None: + """Return the last valid timestamp or None""" + if self._calibration is None: + return None + if self._pulse_collection.log_addresses_missing is None: + return None + if len(self._pulse_collection.log_addresses_missing) > 0: + return None + return self._pulse_collection.last_update + + +class EnergyCounter: + """ + Energy counter to convert pulses into energy + """ + + def __init__( + self, + energy_id: EnergyType, + ) -> None: + """Initialize energy counter based on energy id.""" + if energy_id not in ENERGY_COUNTERS: + raise EnergyError( + f"Invalid energy id '{energy_id}' for Energy counter" + ) + self._calibration: EnergyCalibration | None = None + self._duration = "hour" + if energy_id in ENERGY_DAY_COUNTERS: + self._duration = "day" + elif energy_id in ENERGY_WEEK_COUNTERS: + self._duration = "week" + self._energy_id: EnergyType = energy_id + self._is_consumption = True + self._direction = "consumption" + if self._energy_id in ENERGY_PRODUCTION_COUNTERS: + self._direction = "production" + self._is_consumption = False + self._last_reset: datetime | None = None + self._last_update: datetime | None = None + self._pulses: int | None = None + + @property + def direction(self) -> str: + """Energy direction (consumption or production)""" + return self._direction + + @property + def duration(self) -> str: + """Energy timespan""" + return self._duration + + @property + def calibration(self) -> EnergyCalibration | None: + """Energy calibration configuration.""" + return self._calibration + + @calibration.setter + def calibration(self, calibration: EnergyCalibration) -> None: + """Energy calibration configuration.""" + self._calibration = calibration + + @property + def is_consumption(self) -> bool: + """Indicate the energy direction.""" + return self._is_consumption + + @property + def energy(self) -> float | None: + """Total energy (in kWh) since last reset.""" + if self._pulses is None or self._calibration is None: + return None + if self._pulses == 0: + return 0.0 + pulses_per_s = self._pulses / float(HOUR_IN_SECONDS) + corrected_pulses = HOUR_IN_SECONDS * ( + ( + ( + ((pulses_per_s + self._calibration.off_noise) ** 2) + * self._calibration.gain_b + ) + + ( + (pulses_per_s + self._calibration.off_noise) + * self._calibration.gain_a + ) + ) + + self._calibration.off_tot + ) + calc_value = corrected_pulses / PULSES_PER_KW_SECOND / HOUR_IN_SECONDS + # Fix minor miscalculations? + # if -0.001 < calc_value < 0.001: + # calc_value = 0.0 + if calc_value < 0: + calc_value = calc_value * -1 + return calc_value + + @property + def last_reset(self) -> datetime | None: + """Last reset of energy counter.""" + return self._last_reset + + @property + def last_update(self) -> datetime | None: + """Last update of energy counter.""" + return self._last_update + + def update( + self, pulse_collection: PulseCollection + ) -> tuple[float | None, datetime | None]: + """Get pulse update""" + last_reset = datetime.now(tz=LOCAL_TIMEZONE) + if self._energy_id in ENERGY_HOUR_COUNTERS: + last_reset = last_reset.replace(minute=0, second=0, microsecond=0) + elif self._energy_id in ENERGY_DAY_COUNTERS: + last_reset = last_reset.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + elif self._energy_id in ENERGY_WEEK_COUNTERS: + last_reset = last_reset - timedelta(days=last_reset.weekday()) + last_reset = last_reset.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + + pulses, last_update = pulse_collection.collected_pulses( + last_reset, self._is_consumption + ) + _LOGGER.debug("collected_pulses : pulses=%s | last_update=%s", pulses, last_update) + if pulses is None or last_update is None: + return (None, None) + self._last_update = last_update + self._last_reset = last_reset + self._pulses = pulses + + energy = self.energy + _LOGGER.debug("energy=%s or last_update=%s", energy, last_update) + return (energy, last_reset) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py new file mode 100644 index 000000000..98c3f3151 --- /dev/null +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -0,0 +1,818 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, UTC +import logging +from typing import Final + +from ...constants import MINUTE_IN_SECONDS, WEEK_IN_HOURS + +_LOGGER = logging.getLogger(__name__) +CONSUMED: Final = True +PRODUCED: Final = False + +MAX_LOG_HOURS = WEEK_IN_HOURS + + +def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: + """Calculate address and slot for log based for specified offset""" + + # FIXME: Handle max address (max is currently unknown) to guard + # against address rollovers + if offset < 0: + while offset + slot < 1: + address -= 1 + offset += 4 + if offset > 0: + while offset + slot > 4: + address += 1 + offset -= 4 + return (address, slot + offset) + + +@dataclass +class PulseLogRecord: + """Total pulses collected at specific timestamp.""" + + timestamp: datetime + pulses: int + is_consumption: bool + + +class PulseCollection: + """ + Class to store consumed and produced energy pulses of + the current interval and past (history log) intervals. + """ + + def __init__(self, mac: str) -> None: + """Initialize PulseCollection class.""" + self._mac = mac + self._log_interval_consumption: int | None = None + self._log_interval_production: int | None = None + + self._last_log_address: int | None = None + self._last_log_slot: int | None = None + self._last_log_timestamp: datetime | None = None + self._first_log_address: int | None = None + self._first_log_slot: int | None = None + self._first_log_timestamp: datetime | None = None + + self._last_log_consumption_timestamp: datetime | None = None + self._last_log_consumption_address: int | None = None + self._last_log_consumption_slot: int | None = None + self._first_log_consumption_timestamp: datetime | None = None + self._first_log_consumption_address: int | None = None + self._first_log_consumption_slot: int | None = None + self._next_log_consumption_timestamp: datetime | None = None + + self._last_log_production_timestamp: datetime | None = None + self._last_log_production_address: int | None = None + self._last_log_production_slot: int | None = None + self._first_log_production_timestamp: datetime | None = None + self._first_log_production_address: int | None = None + self._first_log_production_slot: int | None = None + self._next_log_production_timestamp: datetime | None = None + + self._rollover_log_consumption = False + self._rollover_log_production = False + self._rollover_pulses_consumption = False + self._rollover_pulses_production = False + + self._logs: dict[int, dict[int, PulseLogRecord]] | None = None + self._log_addresses_missing: list[int] | None = None + self._log_production: bool | None = None + self._pulses_consumption: int | None = None + self._pulses_production: int | None = None + self._last_update: datetime | None = None + + @property + def collected_logs(self) -> int: + """Total collected logs""" + counter = 0 + if self._logs is None: + return counter + for address in self._logs: + counter += len(self._logs[address]) + return counter + + @property + def logs(self) -> dict[int, dict[int, PulseLogRecord]]: + """Return currently collected pulse logs in reversed order""" + if self._logs is None: + return {} + sorted_log: dict[int, dict[int, PulseLogRecord]] = {} + skip_before = datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + sorted_addresses = sorted(self._logs.keys(), reverse=True) + for address in sorted_addresses: + sorted_slots = sorted(self._logs[address].keys(), reverse=True) + for slot in sorted_slots: + if self._logs[address][slot].timestamp > skip_before: + if sorted_log.get(address) is None: + sorted_log[address] = {} + sorted_log[address][slot] = self._logs[address][slot] + return sorted_log + + @property + def last_log(self) -> tuple[int, int] | None: + """Return address and slot of last imported log""" + return (self._last_log_consumption_address, self._last_log_consumption_slot) + + @property + def production_logging(self) -> bool | None: + """Indicate if production logging is active""" + return self._log_production + + @property + def log_interval_consumption(self) -> int | None: + """Interval in minutes between last consumption pulse logs.""" + return self._log_interval_consumption + + @property + def log_interval_production(self) -> int | None: + """Interval in minutes between last production pulse logs.""" + return self._log_interval_production + + @property + def log_rollover(self) -> bool: + """Indicate if new log is required""" + return ( + self._rollover_log_consumption or self._rollover_log_production + ) + + @property + def last_update(self) -> datetime | None: + """Return timestamp of last update.""" + return self._last_update + + def collected_pulses( + self, from_timestamp: datetime, is_consumption: bool + ) -> tuple[int | None, datetime | None]: + """Calculate total pulses from given timestamp""" + + # _LOGGER.debug("collected_pulses | %s | is_cons=%s, from=%s", self._mac, is_consumption, from_timestamp) + + if not is_consumption: + if self._log_production is None or not self._log_production: + return (None, None) + + if is_consumption and ( + self._rollover_log_consumption or self._rollover_pulses_consumption + ): + _LOGGER.debug("collected_pulses | %s | is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", self._mac, self._rollover_log_consumption, self._rollover_pulses_consumption) + return (None, None) + if not is_consumption and ( + self._rollover_log_production or self._rollover_pulses_production + ): + _LOGGER.debug("collected_pulses | %s | NOT is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", self._mac, self._rollover_log_consumption, self._rollover_pulses_consumption) + return (None, None) + + log_pulses = self._collect_pulses_from_logs( + from_timestamp, is_consumption + ) + if log_pulses is None: + _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) + return (None, None) + # _LOGGER.debug("collected_pulses | %s | log_pulses=%s", self._mac, log_pulses) + + pulses: int | None = None + timestamp: datetime | None = None + if is_consumption and self._pulses_consumption is not None: + pulses = self._pulses_consumption + timestamp = self._last_update + if not is_consumption and self._pulses_production is not None: + pulses = self._pulses_production + timestamp = self._last_update + # _LOGGER.debug("collected_pulses | %s | pulses=%s", self._mac, pulses) + + if pulses is None: + _LOGGER.debug("collected_pulses | %s | is_consumption=%s, pulses=None", self._mac, is_consumption) + return (None, None) + return (pulses + log_pulses, timestamp) + + def _collect_pulses_from_logs( + self, from_timestamp: datetime, is_consumption: bool + ) -> int | None: + """Collect all pulses from logs""" + if self._logs is None: + _LOGGER.debug("_collect_pulses_from_logs | %s | self._logs=None", self._mac) + return None + if is_consumption: + if self._last_log_consumption_timestamp is None: + _LOGGER.debug("_collect_pulses_from_logs | %s | self._last_log_consumption_timestamp=None", self._mac) + return None + if from_timestamp > self._last_log_consumption_timestamp: + return 0 + else: + if self._last_log_production_timestamp is None: + _LOGGER.debug("_collect_pulses_from_logs | %s | self._last_log_production_timestamp=None", self._mac) + return None + if from_timestamp > self._last_log_production_timestamp: + return 0 + missing_logs = self._logs_missing(from_timestamp) + if missing_logs is None or missing_logs: + _LOGGER.debug("_collect_pulses_from_logs | %s | missing_logs=%s", self._mac, missing_logs) + return None + + log_pulses = 0 + + for log_item in self._logs.values(): + for slot_item in log_item.values(): + if ( + slot_item.is_consumption == is_consumption + and slot_item.timestamp >= from_timestamp + ): + log_pulses += slot_item.pulses + return log_pulses + + def update_pulse_counter( + self, pulses_consumed: int, pulses_produced: int, timestamp: datetime + ) -> None: + """Update pulse counter""" + if self._pulses_consumption is None: + self._pulses_consumption = pulses_consumed + if self._pulses_production is None: + self._pulses_production = pulses_produced + self._last_update = timestamp + + if self._next_log_consumption_timestamp is None: + return + if ( + self._log_production + and self._next_log_production_timestamp is None + ): + return + + if ( + self._log_addresses_missing is None or + len(self._log_addresses_missing) > 0 + ): + return + + # Rollover of logs first + if ( + self._rollover_log_consumption + and pulses_consumed <= self._pulses_consumption + ): + self._rollover_log_consumption = False + if ( + self._log_production + and self._rollover_log_production + and self._pulses_production >= pulses_produced + ): + self._rollover_log_production = False + + # Rollover of pulses first + if pulses_consumed < self._pulses_consumption: + _LOGGER.debug("update_pulse_counter | %s | pulses_consumed=%s, _pulses_consumption=%s", self._mac, pulses_consumed, self._pulses_consumption) + self._rollover_pulses_consumption = True + else: + if self._log_interval_consumption is not None and timestamp > ( + self._next_log_consumption_timestamp + + timedelta(minutes=self._log_interval_consumption) + ): + _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) + self._rollover_pulses_consumption = True + + if self._log_production: + if self._pulses_production < pulses_produced: + self._rollover_pulses_production = True + else: + if ( + self._next_log_production_timestamp is not None + and self._log_interval_production is not None + and timestamp + > ( + self._next_log_production_timestamp + + timedelta(minutes=self._log_interval_production) + ) + ): + self._rollover_pulses_production = True + + self._pulses_consumption = pulses_consumed + self._pulses_production = pulses_produced + + def add_log( + self, + address: int, + slot: int, + timestamp: datetime, + pulses: int, + import_only: bool = False + ) -> bool: + """Store pulse log.""" + log_record = PulseLogRecord(timestamp, pulses, CONSUMED) + if not self._add_log_record(address, slot, log_record): + return False + self._update_log_direction(address, slot, timestamp) + self._update_log_interval() + self._update_log_references(address, slot) + self._update_log_rollover(address, slot) + if not import_only: + self.recalculate_missing_log_addresses() + return True + + def recalculate_missing_log_addresses(self) -> None: + """Recalculate missing log addresses""" + self._log_addresses_missing = self._logs_missing( + datetime.now(UTC) - timedelta( + hours=MAX_LOG_HOURS + ) + ) + + def _add_log_record( + self, address: int, slot: int, log_record: PulseLogRecord + ) -> bool: + """Add log record and return True if log did not exists.""" + if self._logs is None: + self._logs = {address: {slot: log_record}} + return True + if self._log_exists(address, slot): + return False + # Drop unused log records + if log_record.timestamp < ( + datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + ): + return False + if self._logs.get(address) is None: + self._logs[address] = {slot: log_record} + else: + self._logs[address][slot] = log_record + return True + + def _update_log_direction( + self, address: int, slot: int, timestamp: datetime + ) -> None: + """ + Update Energy direction of log record. + Two subsequential logs with the same timestamp indicates the first + is consumption and second production. + """ + if self._logs is None: + return + + prev_address, prev_slot = calc_log_address(address, slot, -1) + if self._log_exists(prev_address, prev_slot): + if self._logs[prev_address][prev_slot].timestamp == timestamp: + # Given log is the second log with same timestamp, + # mark direction as production + self._logs[address][slot].is_consumption = False + self._log_production = True + + next_address, next_slot = calc_log_address(address, slot, 1) + if self._log_exists(next_address, next_slot): + if self._logs[next_address][next_slot].timestamp == timestamp: + # Given log the first log with same timestamp, + # mark direction as production of next log + self._logs[next_address][next_slot].is_consumption = False + self._log_production = True + else: + if self._log_production is None: + self._log_production = False + + def _update_log_rollover(self, address: int, slot: int) -> None: + if self._last_update is None: + return + if self._logs is None: + return + if ( + self._next_log_consumption_timestamp is not None + and self._rollover_pulses_consumption + and self._next_log_consumption_timestamp > self._last_update + ): + self._rollover_pulses_consumption = False + + if ( + self._next_log_production_timestamp is not None + and self._rollover_pulses_production + and self._next_log_production_timestamp > self._last_update + ): + self._rollover_pulses_production = False + + if self._logs[address][slot].timestamp > self._last_update: + if self._logs[address][slot].is_consumption: + self._rollover_log_consumption = True + else: + self._rollover_log_production = True + + def _update_log_interval(self) -> None: + """ + Update the detected log interval based on + the most recent two logs. + """ + if self._logs is None or self._log_production is None: + _LOGGER.debug("_update_log_interval | %s | _logs=%s, _log_production=%s", self._mac, self._logs, self._log_production) + return + last_address, last_slot = self._last_log_reference() + if last_address is None or last_slot is None: + return + + last_timestamp = self._logs[last_address][last_slot].timestamp + last_direction = self._logs[last_address][last_slot].is_consumption + address1, slot1 = calc_log_address(last_address, last_slot, -1) + while self._log_exists(address1, slot1): + if last_direction == self._logs[address1][slot1].is_consumption: + delta1: timedelta = ( + last_timestamp - self._logs[address1][slot1].timestamp + ) + if last_direction: + self._log_interval_consumption = int( + delta1.total_seconds() / MINUTE_IN_SECONDS + ) + else: + self._log_interval_production = int( + delta1.total_seconds() / MINUTE_IN_SECONDS + ) + break + if not self._log_production: + return + address1, slot1 = calc_log_address(address1, slot1, -1) + + # update interval of other direction too + address2, slot2 = self._last_log_reference(not last_direction) + if address2 is None or slot2 is None: + return + timestamp = self._logs[address2][slot2].timestamp + address3, slot3 = calc_log_address(address2, slot2, -1) + while self._log_exists(address3, slot3): + if last_direction != self._logs[address3][slot3].is_consumption: + delta2: timedelta = ( + timestamp - self._logs[address3][slot3].timestamp + ) + if last_direction: + self._log_interval_production = int( + delta2.total_seconds() / MINUTE_IN_SECONDS + ) + else: + self._log_interval_consumption = int( + delta2.total_seconds() / MINUTE_IN_SECONDS + ) + break + address3, slot3 = calc_log_address(address3, slot3, -1) + + def _log_exists(self, address: int, slot: int) -> bool: + if self._logs is None: + return False + if self._logs.get(address) is None: + return False + if self._logs[address].get(slot) is not None: + return True + return False + + def _update_last_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to last (most recent) log record""" + if ( + self._last_log_timestamp is None or + self._last_log_timestamp < timestamp + ): + self._last_log_address = address + self._last_log_slot = slot + self._last_log_timestamp = timestamp + + def _update_last_consumption_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to last (most recent) log consumption record.""" + if ( + self._last_log_consumption_timestamp is None or + self._last_log_consumption_timestamp < timestamp + ): + self._last_log_consumption_timestamp = timestamp + self._last_log_consumption_address = address + self._last_log_consumption_slot = slot + if self._log_interval_consumption is not None: + self._next_log_consumption_timestamp = ( + timestamp + timedelta( + minutes=self.log_interval_consumption + ) + ) + + def _update_last_production_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to last (most recent) log production record""" + if ( + self._last_log_production_timestamp is None or + self._last_log_production_timestamp < timestamp + ): + self._last_log_production_timestamp = timestamp + self._last_log_production_address = address + self._last_log_production_slot = slot + if self._log_interval_production is not None: + self._next_log_production_timestamp = ( + timestamp + timedelta(minutes=self.log_interval_production) + ) + + def _update_first_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to first (oldest) log record""" + if ( + self._first_log_timestamp is None or + self._first_log_timestamp > timestamp + ): + self._first_log_address = address + self._first_log_slot = slot + self._first_log_timestamp = timestamp + + def _update_first_consumption_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to first (oldest) log consumption record.""" + if ( + self._first_log_consumption_timestamp is None or + self._first_log_consumption_timestamp > timestamp + ): + self._first_log_consumption_timestamp = timestamp + self._first_log_consumption_address = address + self._first_log_consumption_slot = slot + + def _update_first_production_log_reference( + self, address: int, slot: int, timestamp + ) -> None: + """Update references to first (oldest) log production record.""" + if ( + self._first_log_production_timestamp is None or + self._first_log_production_timestamp > timestamp + ): + self._first_log_production_timestamp = timestamp + self._first_log_production_address = address + self._first_log_production_slot = slot + + def _update_log_references(self, address: int, slot: int) -> None: + """Update next expected log timestamps.""" + if self._logs is None: + return + if not self._log_exists(address, slot): + return + log_record = self.logs[address][slot] + + # Update log references + self._update_first_log_reference(address, slot, log_record.timestamp) + self._update_last_log_reference(address, slot, log_record.timestamp) + + if log_record.is_consumption: + # Consumption + self._update_first_consumption_log_reference( + address, slot, log_record.timestamp + ) + self._update_last_consumption_log_reference( + address, slot, log_record.timestamp + ) + else: + # production + self._update_first_production_log_reference( + address, slot, log_record.timestamp + ) + self._update_last_production_log_reference( + address, slot, log_record.timestamp + ) + + @property + def log_addresses_missing(self) -> list[int] | None: + """Return the addresses of missing logs""" + return self._log_addresses_missing + + def _last_log_reference( + self, is_consumption: bool | None = None + ) -> tuple[int | None, int | None]: + """Address and slot of last log""" + if is_consumption is None: + return ( + self._last_log_address, + self._last_log_slot + ) + if is_consumption: + return ( + self._last_log_consumption_address, + self._last_log_consumption_slot + ) + return ( + self._last_log_production_address, + self._last_log_production_slot + ) + + def _first_log_reference( + self, is_consumption: bool | None = None + ) -> tuple[int | None, int | None]: + """Address and slot of first log""" + if is_consumption is None: + return ( + self._first_log_address, + self._first_log_slot + ) + if is_consumption: + return ( + self._first_log_consumption_address, + self._first_log_consumption_slot + ) + return ( + self._first_log_production_address, + self._first_log_production_slot + ) + + def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: + """ + Calculate list of missing log addresses + """ + if self._logs is None: + self._log_addresses_missing = None + return None + last_address, last_slot = self._last_log_reference() + if last_address is None or last_slot is None: + _LOGGER.debug("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) + return None + if self._logs[last_address][last_slot].timestamp <= from_timestamp: + return [] + + first_address, first_slot = self._first_log_reference() + if first_address is None or first_slot is None: + _LOGGER.debug("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) + return None + + missing = [] + _LOGGER.debug("_logs_missing | %s | first_address=%s, last_address=%s", self._mac, first_address, last_address) + + if last_address <= first_address: + _LOGGER.warning("_logs_missing | %s | first_address=%s >= last_address=%s", self._mac, first_address, last_address) + return [] + + finished = False + # Collect any missing address in current range + for address in range(last_address - 1, first_address, -1): + for slot in range(4, 0, -1): + if address in missing: + break + if not self._log_exists(address, slot): + missing.append(address) + break + if self.logs[address][slot].timestamp < from_timestamp: + finished = True + break + if finished: + break + if finished: + return missing + + # return missing logs in range first + if len(missing) > 0: + _LOGGER.debug("_logs_missing | %s | missing in range=%s", self._mac, missing) + return missing + + if self.logs[first_address][first_slot].timestamp < from_timestamp: + return missing + + # calculate missing log addresses prior to first collected log + address, slot = calc_log_address(first_address, first_slot, -1) + calculated_timestamp = self.logs[first_address][first_slot].timestamp - timedelta(hours=1) + while from_timestamp < calculated_timestamp: + if address not in missing: + missing.append(address) + address, slot = calc_log_address(address, slot, -1) + calculated_timestamp -= timedelta(hours=1) + + missing.sort(reverse=True) + _LOGGER.debug("_logs_missing | %s | calculated missing=%s", self._mac, missing) + return missing + + def _last_known_duration(self) -> timedelta: + """Duration for last known logs""" + if len(self.logs) < 2: + return timedelta(hours=1) + address, slot = self._last_log_reference() + last_known_timestamp = self.logs[address][slot].timestamp + address, slot = calc_log_address(address, slot, -1) + while ( + self._log_exists(address, slot) or + self.logs[address][slot].timestamp == last_known_timestamp + ): + address, slot = calc_log_address(address, slot, -1) + return self.logs[address][slot].timestamp - last_known_timestamp + + def _missing_addresses_before( + self, address: int, slot: int, target: datetime + ) -> list[int]: + """Return list of missing address(es) prior to given log timestamp.""" + addresses: list[int] = [] + if self._logs is None or target >= self._logs[address][slot].timestamp: + return addresses + + # default interval + calc_interval_cons = timedelta(hours=1) + if ( + self._log_interval_consumption is not None + and self._log_interval_consumption > 0 + ): + # Use consumption interval + calc_interval_cons = timedelta( + minutes=self._log_interval_consumption + ) + if self._log_interval_consumption == 0: + pass + + if self._log_production is not True: + expected_timestamp = ( + self._logs[address][slot].timestamp - calc_interval_cons + ) + address, slot = calc_log_address(address, slot, -1) + while expected_timestamp > target and address > 0: + if address not in addresses: + addresses.append(address) + expected_timestamp -= calc_interval_cons + address, slot = calc_log_address(address, slot, -1) + else: + # Production logging active + calc_interval_prod = timedelta(hours=1) + if ( + self._log_interval_production is not None + and self._log_interval_production > 0 + ): + calc_interval_prod = timedelta( + minutes=self._log_interval_production + ) + + expected_timestamp_cons = ( + self._logs[address][slot].timestamp - calc_interval_cons + ) + expected_timestamp_prod = ( + self._logs[address][slot].timestamp - calc_interval_prod + ) + + address, slot = calc_log_address(address, slot, -1) + while ( + expected_timestamp_cons > target + or expected_timestamp_prod > target + ) and address > 0: + if address not in addresses: + addresses.append(address) + if expected_timestamp_prod > expected_timestamp_cons: + expected_timestamp_prod -= calc_interval_prod + else: + expected_timestamp_cons -= calc_interval_cons + address, slot = calc_log_address(address, slot, -1) + + return addresses + + def _missing_addresses_after( + self, address: int, slot: int, target: datetime + ) -> list[int]: + """Return list of any missing address(es) after given log timestamp.""" + addresses: list[int] = [] + + if self._logs is None: + return addresses + + # default interval + calc_interval_cons = timedelta(hours=1) + if ( + self._log_interval_consumption is not None + and self._log_interval_consumption > 0 + ): + # Use consumption interval + calc_interval_cons = timedelta( + minutes=self._log_interval_consumption + ) + + if self._log_production is not True: + expected_timestamp = ( + self._logs[address][slot].timestamp + calc_interval_cons + ) + address, slot = calc_log_address(address, slot, 1) + while expected_timestamp < target: + address, slot = calc_log_address(address, slot, 1) + expected_timestamp += timedelta(hours=1) + if address not in addresses: + addresses.append(address) + return addresses + else: + # Production logging active + calc_interval_prod = timedelta(hours=1) + if ( + self._log_interval_production is not None + and self._log_interval_production > 0 + ): + calc_interval_prod = timedelta( + minutes=self._log_interval_production + ) + + expected_timestamp_cons = ( + self._logs[address][slot].timestamp + calc_interval_cons + ) + expected_timestamp_prod = ( + self._logs[address][slot].timestamp + calc_interval_prod + ) + address, slot = calc_log_address(address, slot, 1) + while ( + expected_timestamp_cons < target + or expected_timestamp_prod < target + ): + if address not in addresses: + addresses.append(address) + if expected_timestamp_prod < expected_timestamp_cons: + expected_timestamp_prod += calc_interval_prod + else: + expected_timestamp_cons += calc_interval_cons + address, slot = calc_log_address(address, slot, 1) + return addresses diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py new file mode 100644 index 000000000..499a030a6 --- /dev/null +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -0,0 +1,67 @@ +"""Base class for plugwise node publisher.""" + +from __future__ import annotations +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from ...api import NodeFeature +from ...exceptions import SubscriptionError + + +@dataclass +class NodeSubscription: + """Class to subscribe a callback to node events.""" + + event: NodeFeature + callback: Callable[[Any], Coroutine[Any, Any, None]] | Callable[ + [], Coroutine[Any, Any, None] + ] + + +class NodePublisher(): + """Base Class to call awaitable of subscription when event happens.""" + + _subscribers: dict[int, NodeSubscription] = {} + _features: tuple[NodeFeature, ...] = () + + def subscribe(self, subscription: NodeSubscription) -> int: + """Add subscription and returns the id to unsubscribe later.""" + if subscription.event not in self._features: + raise SubscriptionError( + f"Subscription event {subscription.event} is not supported" + ) + if id(subscription) in self._subscribers: + raise SubscriptionError("Subscription already exists") + self._subscribers[id(subscription)] = subscription + return id(subscription) + + def subscribe_to_event( + self, + event: NodeFeature, + callback: Callable[[Any], Coroutine[Any, Any, None]] + | Callable[[], Coroutine[Any, Any, None]], + ) -> int: + """Subscribe callback to events.""" + return self.subscribe( + NodeSubscription( + event=event, + callback=callback, + ) + ) + + def unsubscribe(self, subscription_id: int) -> bool: + """Remove subscription. Returns True if unsubscribe was successful.""" + if subscription_id in self._subscribers: + del self._subscribers[subscription_id] + return True + return False + + async def publish_event(self, event: NodeFeature, value: Any) -> None: + """Publish feature to applicable subscribers.""" + if event not in self._features: + return + for subscription in list(self._subscribers.values()): + if subscription.event != event: + continue + await subscription.callback(event, value) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index b9d2cdc3d..2c50d70ab 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -1,137 +1,235 @@ """Plugwise Scan node object.""" + +from __future__ import annotations +from asyncio import create_task + +from datetime import datetime, UTC import logging +from typing import Any, Final -from ..constants import ( - FEATURE_MOTION, - FEATURE_PING, - FEATURE_RSSI_IN, - FEATURE_RSSI_OUT, - SCAN_CONFIGURE_ACCEPTED, - SCAN_DAYLIGHT_MODE, - SCAN_MOTION_RESET_TIMER, - SCAN_SENSITIVITY_HIGH, - SCAN_SENSITIVITY_MEDIUM, - SCAN_SENSITIVITY_OFF, -) +from .helpers import raise_not_loaded +from ..api import NodeFeature +from ..constants import MotionSensitivity +from ..exceptions import MessageError, NodeError, NodeTimeout from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest -from ..messages.responses import NodeAckResponse, NodeSwitchGroupResponse +from ..messages.responses import ( + NODE_SWITCH_GROUP_ID, + NodeAckResponse, + NodeAckResponseType, + NodeSwitchGroupResponse, +) from ..nodes.sed import NodeSED _LOGGER = logging.getLogger(__name__) +# Defaults for Scan Devices + +# Time in minutes the motion sensor should not sense motion to +# report "no motion" state +SCAN_MOTION_RESET_TIMER: Final = 5 + +# Default sensitivity of the motion sensors +SCAN_SENSITIVITY = MotionSensitivity.MEDIUM + +# Light override +SCAN_DAYLIGHT_MODE: Final = False + +# Minimum and maximum supported (custom) zigbee protocol version based on +# utc timestamp of firmware extracted from "Plugwise.IO.dll" file of Plugwise +# source installation +SCAN_FIRMWARE: Final = { + datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): ( + "2.0", + "2.6", + ), # Beta Scan Release + datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): ( + "2.0", + "2.5", + ), # Beta Scan Release + datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): ("2.0", "2.5"), # Scan RC1 + datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): ("2.0", "2.6"), # Legrand + datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): ("2.0", "2.5"), + datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): ( + "2.0", + "2.6", + ), # New Flash Update +} +SCAN_FEATURES: Final = (NodeFeature.INFO, NodeFeature.MOTION) + + class PlugwiseScan(NodeSED): """provides interface to the Plugwise Scan nodes""" - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._features = ( - FEATURE_MOTION["id"], - FEATURE_PING["id"], - FEATURE_RSSI_IN["id"], - FEATURE_RSSI_OUT["id"], - ) - self._motion_state = False - self._motion_reset_timer = None - self._daylight_mode = None - self._sensitivity = None - self._new_motion_reset_timer = None - self._new_daylight_mode = None - self._new_sensitivity = None - - @property - def motion(self) -> bool: - """Return the last known motion state""" - return self._motion_state - - def message_for_scan(self, message): - """Process received message""" - if isinstance(message, NodeSwitchGroupResponse): + async def async_load(self) -> bool: + """Load and activate Scan node features.""" + if self._loaded: + return True + self._node_info.battery_powered = True + if self._cache_enabled: _LOGGER.debug( - "Switch group %s to state %s received from %s", - str(message.group.value), - str(message.power_state.value), - self.mac, - ) - self._process_switch_group(message) - elif isinstance(message, NodeAckResponse): - self._process_ack_message(message) - else: - _LOGGER.info( - "Unsupported message %s received from %s", - message.__class__.__name__, - self.mac, + "Load Scan node %s from cache", self._node_info.mac ) + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return await self.async_initialize() - def _process_ack_message(self, message): - """Process acknowledge message""" - if message.ack_id == SCAN_CONFIGURE_ACCEPTED: - self._motion_reset_timer = self._new_motion_reset_timer - self._daylight_mode = self._new_daylight_mode - self._sensitivity = self._new_sensitivity - else: - _LOGGER.info( - "Unsupported ack message %s received for %s", - str(message.ack_id), - self.mac, - ) + _LOGGER.debug("Load of Scan node %s failed", self._node_info.mac) + return False - def _process_switch_group(self, message): - """Switch group request from Scan""" + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize Scan node.""" + if self._initialized: + return True + self._initialized = True + if not await super().async_initialize(): + self._initialized = False + return False + self._scan_subscription = self._message_subscribe( + self._switch_group, + self._mac_in_bytes, + (NODE_SWITCH_GROUP_ID,), + ) + self._initialized = True + return True + + async def async_unload(self) -> None: + """Unload node.""" + if self._scan_subscription is not None: + self._scan_subscription() + await super().async_unload() + + async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: + """Switch group request from Scan.""" + self._available_update_state(True) if message.power_state.value == 0: # turn off => clear motion - if self._motion_state: - self._motion_state = False - self.do_callback(FEATURE_MOTION["id"]) + await self.async_motion_state_update(False, message.timestamp) elif message.power_state.value == 1: # turn on => motion - if not self._motion_state: - self._motion_state = True - self.do_callback(FEATURE_MOTION["id"]) + await self.async_motion_state_update(True, message.timestamp) else: - _LOGGER.warning( - "Unknown power_state (%s) received from %s", - str(message.power_state.value), - self.mac, + raise MessageError( + f"Unknown power_state '{message.power_state.value}' " + + f"received from {self.mac}" ) - # TODO: 20220125 snakestyle name - # pylint: disable=invalid-name - def CalibrateLight(self, callback=None): - """Queue request to calibration light sensitivity""" - self._queue_request(ScanLightCalibrateRequest(self._mac), callback) + async def async_motion_state_update( + self, motion_state: bool, timestamp: datetime | None = None + ) -> None: + """Process motion state update.""" + self._motion_state.motion = motion_state + self._motion_state.timestamp = timestamp + state_update = False + if motion_state: + self._set_cache("motion", "True") + if self._motion is None or not self._motion: + state_update = True + if not motion_state: + self._set_cache("motion", "False") + if self._motion is None or self._motion: + state_update = True + if state_update: + self._motion = motion_state + create_task( + self.publish_event( + NodeFeature.MOTION, + self._motion_state, + ) + ) + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.async_save_cache()) - # TODO: 20220125 snakestyle name - # pylint: disable=invalid-name - def Configure_scan( + async def scan_configure( self, - motion_reset_timer=SCAN_MOTION_RESET_TIMER, - sensitivity_level=SCAN_SENSITIVITY_MEDIUM, - daylight_mode=SCAN_DAYLIGHT_MODE, - callback=None, - ): - """Queue request to set motion reporting settings""" - self._new_motion_reset_timer = motion_reset_timer - self._new_daylight_mode = daylight_mode - if sensitivity_level == SCAN_SENSITIVITY_HIGH: + motion_reset_timer: int = SCAN_MOTION_RESET_TIMER, + sensitivity_level: MotionSensitivity = MotionSensitivity.MEDIUM, + daylight_mode: bool = SCAN_DAYLIGHT_MODE, + ) -> bool: + """Configure Scan device settings. Returns True if successful.""" + # Default to medium: + sensitivity_value = 30 # b'1E' + if sensitivity_level == MotionSensitivity.HIGH: sensitivity_value = 20 # b'14' - elif sensitivity_level == SCAN_SENSITIVITY_OFF: + if sensitivity_level == MotionSensitivity.OFF: sensitivity_value = 255 # b'FF' - else: - # Default to medium: - sensitivity_value = 30 # b'1E' - self._new_sensitivity = sensitivity_level - self._queue_request( + + response: NodeAckResponse | None = await self._send( ScanConfigureRequest( - self._mac, motion_reset_timer, sensitivity_value, daylight_mode - ), - callback, + self._mac_in_bytes, + motion_reset_timer, + sensitivity_value, + daylight_mode, + ) ) + if response is None: + raise NodeTimeout( + f"No response from Scan device {self.mac} " + + "for configuration request." + ) + if response.ack_id == NodeAckResponseType.SCAN_CONFIG_FAILED: + raise NodeError( + f"Scan {self.mac} failed to configure scan settings" + ) + if response.ack_id == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: + self._motion_reset_timer = motion_reset_timer + self._sensitivity_level = sensitivity_level + self._daylight_mode = daylight_mode + return True + return False - # TODO: 20220125 snakestyle name - # pylint: disable=invalid-name - def SetMotionAction(self, callback=None): - """Queue Configure Scan to signal motion""" - # TODO: + async def scan_calibrate_light(self) -> bool: + """Request to calibration light sensitivity of Scan device.""" + response: NodeAckResponse | None = await self._send( + ScanLightCalibrateRequest(self._mac_in_bytes) + ) + if response is None: + raise NodeTimeout( + f"No response from Scan device {self.mac} " + + "to light calibration request." + ) + if ( + response.ack_id + == NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED + ): + return True + return False - # self._queue_request(NodeSwitchGroupRequest(self._mac), callback) + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + self._setup_protocol(SCAN_FIRMWARE) + self._features += SCAN_FEATURES + self._node_info.features = self._features + + async def async_get_state( + self, features: tuple[NodeFeature] + ) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + if not self._loaded: + if not await self.async_load(): + _LOGGER.warning( + "Unable to update state because load node %s failed", + self.mac + ) + states: dict[NodeFeature, Any] = {} + for feature in features: + _LOGGER.debug( + "Updating node %s - feature '%s'", + self._node_info.mac, + feature, + ) + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.MOTION: + states[NodeFeature.MOTION] = self._motion_state + else: + state_result = await super().async_get_state([feature]) + states[feature] = state_result[feature] + return states diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 9bb6265ad..e838b15fb 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -1,162 +1,229 @@ """Plugwise SED (Sleeping Endpoint Device) base object.""" -# TODO: -# - Expose awake state as sensor -# - Set available state after 2 missed awake messages - +from __future__ import annotations + +from asyncio import ( + CancelledError, + create_task, + Future, + get_event_loop, + wait_for, +) +from asyncio import TimeoutError as AsyncTimeOutError +from collections.abc import Callable +from datetime import datetime import logging - -from ..constants import ( - FEATURE_PING, - FEATURE_RSSI_IN, - FEATURE_RSSI_OUT, - PRIORITY_HIGH, - SED_AWAKE_BUTTON, - SED_AWAKE_FIRST, - SED_AWAKE_MAINTENANCE, - SED_AWAKE_STARTUP, - SED_AWAKE_STATE, - SED_CLOCK_INTERVAL, - SED_CLOCK_SYNC, - SED_MAINTENANCE_INTERVAL, - SED_SLEEP_FOR, - SED_STAY_ACTIVE, - SLEEP_SET, +from typing import Final + +from plugwise_usb.connection import StickController + +from .helpers import raise_not_loaded +from .helpers.subscription import NodeSubscription +from ..api import NodeFeature +from ..exceptions import NodeError, NodeTimeout +from ..messages.requests import NodeSleepConfigRequest +from ..messages.responses import ( + NODE_AWAKE_RESPONSE_ID, + NodeAwakeResponse, + NodeAwakeResponseType, + NodePingResponse, + NodeResponse, + NodeResponseType, ) -from ..messages.requests import NodeInfoRequest, NodePingRequest, NodeSleepConfigRequest -from ..messages.responses import NodeAckLargeResponse, NodeAwakeResponse from ..nodes import PlugwiseNode +# Defaults for 'Sleeping End Devices' + +# Time in seconds the SED keep itself awake to receive +# and respond to other messages +SED_STAY_ACTIVE: Final = 10 + +# Time in minutes the SED will sleep +SED_SLEEP_FOR: Final = 60 + +# 24 hours, Interval in minutes the SED will get awake and notify +# it's available for maintenance purposes +SED_MAINTENANCE_INTERVAL: Final = 1440 + +# Enable or disable synchronizing clock +SED_CLOCK_SYNC: Final = True + +# 7 days, duration in minutes the node synchronize its clock +SED_CLOCK_INTERVAL: Final = 25200 + + _LOGGER = logging.getLogger(__name__) class NodeSED(PlugwiseNode): """provides base class for SED based nodes like Scan, Sense & Switch""" - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._sed_requests = {} - self.maintenance_interval = SED_MAINTENANCE_INTERVAL - self._new_maintenance_interval = None - self._wake_up_interval = None - self._battery_powered = True - - def message_for_sed(self, message): - """Process received message""" - if isinstance(message, NodeAwakeResponse): - self._process_awake_response(message) - elif isinstance(message, NodeAckLargeResponse): - if message.ack_id == SLEEP_SET: - self.maintenance_interval = self._new_maintenance_interval - else: - self.message_for_scan(message) - self.message_for_switch(message) - self.message_for_sense(message) - else: - self.message_for_scan(message) - self.message_for_switch(message) - self.message_for_sense(message) - - def message_for_scan(self, message): - """Pass messages to PlugwiseScan class""" - - def message_for_switch(self, message): - """Pass messages to PlugwiseSwitch class""" - - def message_for_sense(self, message): - """Pass messages to PlugwiseSense class""" - - def _process_awake_response(self, message): - """ "Process awake message""" - _LOGGER.debug( - "Awake message type '%s' received from %s", - str(message.awake_type.value), - self.mac, - ) - if message.awake_type.value in [ - SED_AWAKE_MAINTENANCE, - SED_AWAKE_FIRST, - SED_AWAKE_STARTUP, - SED_AWAKE_BUTTON, - ]: - for pending_request in self._sed_requests.values(): - request_message, callback = pending_request - _LOGGER.info( - "Send queued %s message to SED node %s", - request_message.__class__.__name__, - self.mac, - ) - self.message_sender(request_message, callback, -1, PRIORITY_HIGH) - self._sed_requests = {} - else: - if message.awake_type.value == SED_AWAKE_STATE: - _LOGGER.debug("Node %s awake for state change", self.mac) - else: - _LOGGER.info( - "Unknown awake message type (%s) received for node %s", - str(message.awake_type.value), - self.mac, - ) + # SED configuration + _sed_configure_at_awake = False + _sed_config_stay_active: int | None = None + _sed_config_sleep_for: int | None = None + _sed_config_maintenance_interval: int | None = None + _sed_config_clock_sync: bool | None = None + _sed_config_clock_interval: int | None = None - def _queue_request(self, request_message, callback=None): - """Queue request to be sent when SED is awake. Last message wins.""" - self._sed_requests[request_message.ID] = ( - request_message, - callback, - ) + # Maintenance + _maintenance_interval: int | None = None + _maintenance_last_awake: datetime | None = None + _maintenance_future: Future | None = None - def _request_info(self, callback=None): - """Request info from node""" - self._queue_request( - NodeInfoRequest(self._mac), - callback, - ) + _ping_at_awake: bool = False + + _awake_subscription: Callable[[], None] | None = None - def _request_ping(self, callback=None, ignore_sensor=True): - """Ping node""" + def __init__( + self, + mac: str, + address: int, + controller: StickController, + ): + """Initialize SED""" + super().__init__(mac, address, controller) + self._message_subscribe = controller.subscribe_to_node_responses + + def subscribe(self, subscription: NodeSubscription) -> int: + if subscription.event == NodeFeature.PING: + self._ping_at_awake = True + return super().subscribe(subscription) + + def unsubscribe(self, subscription_id: int) -> bool: + if super().unsubscribe(subscription_id): + keep_ping = False + for subscription in self._subscribers.values(): + if subscription.event == NodeFeature.PING: + keep_ping = True + break + self._ping_at_awake = keep_ping + return True + return False + + async def async_unload(self) -> None: + """Deactivate and unload node features.""" + if self._maintenance_future is not None: + self._maintenance_future.cancel() + if self._awake_subscription is not None: + self._awake_subscription() + await self.async_save_cache() + self._loaded = False + + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize SED node.""" + if self._initialized: + return True + self._awake_subscription = self._message_subscribe( + self._awake_response, + self._mac_in_bytes, + NODE_AWAKE_RESPONSE_ID, + ) + return True + + @property + def maintenance_interval(self) -> int | None: + """ + Return the maintenance interval (seconds) a + battery powered node sends it heartbeat. + """ + return self._maintenance_interval + + async def _awake_response(self, message: NodeAwakeResponse) -> None: + """Process awake message.""" + self._node_last_online = message.timestamp + self._available_update_state(True) + if message.timestamp is None: + return if ( - ignore_sensor - or self._callbacks.get(FEATURE_PING["id"]) - or self._callbacks.get(FEATURE_RSSI_IN["id"]) - or self._callbacks.get(FEATURE_RSSI_OUT["id"]) + NodeAwakeResponseType(message.awake_type.value) + == NodeAwakeResponseType.MAINTENANCE ): - self._queue_request( - NodePingRequest(self._mac), - callback, + if self._ping_at_awake: + ping_response: NodePingResponse | None = ( + await self.async_ping_update() # type: ignore [assignment] + ) + if ping_response is not None: + self._ping_at_awake = False + create_task( + self.reset_maintenance_awake(message.timestamp) ) - else: - _LOGGER.debug( - "Drop ping request for SED %s because no callback is registered", - self.mac, + + async def reset_maintenance_awake(self, last_alive: datetime) -> None: + """Reset node alive state.""" + if self._maintenance_last_awake is None: + self._maintenance_last_awake = last_alive + return + self._maintenance_interval = ( + last_alive - self._maintenance_last_awake + ).seconds + + # Finish previous maintenance timer + if self._maintenance_future is not None: + self._maintenance_future.set_result(True) + + # Setup new maintenance timer + self._maintenance_future = get_event_loop().create_future() + + # wait for next maintenance timer + try: + await wait_for( + self._maintenance_future, + timeout=(self._maintenance_interval * 1.05), ) + except AsyncTimeOutError: + # No maintenance awake message within expected time frame + # Mark node as unavailable + if self._available: + _LOGGER.info( + "No maintenance awake message received for %s within " + + "expected %s seconds. Mark node to be unavailable", + self.mac, + str(self._maintenance_interval * 1.05), + ) + self._available_update_state(False) + except CancelledError: + pass - def _wake_up_interval_accepted(self): - """Callback after wake up interval is received and accepted by SED.""" - self._wake_up_interval = self._new_maintenance_interval + self._maintenance_future = None - # TODO: 20220125 snakestyle name - # pylint: disable=invalid-name - def Configure_SED( + async def sed_configure( self, - stay_active=SED_STAY_ACTIVE, - sleep_for=SED_SLEEP_FOR, - maintenance_interval=SED_MAINTENANCE_INTERVAL, - clock_sync=SED_CLOCK_SYNC, - clock_interval=SED_CLOCK_INTERVAL, - ): - """Reconfigure the sleep/awake settings for a SED send at next awake of SED""" - message = NodeSleepConfigRequest( - self._mac, - stay_active, - maintenance_interval, - sleep_for, - clock_sync, - clock_interval, - ) - self._queue_request(message, self._wake_up_interval_accepted) - self._new_maintenance_interval = maintenance_interval - _LOGGER.info( - "Queue %s message to be send at next awake of SED node %s", - message.__class__.__name__, - self.mac, + stay_active: int = SED_STAY_ACTIVE, + sleep_for: int = SED_SLEEP_FOR, + maintenance_interval: int = SED_MAINTENANCE_INTERVAL, + clock_sync: bool = SED_CLOCK_SYNC, + clock_interval: int = SED_CLOCK_INTERVAL, + awake: bool = False, + ) -> None: + """ + Reconfigure the sleep/awake settings for a SED + send at next awake of SED. + """ + if not awake: + self._sed_configure_at_awake = True + self._sed_config_stay_active = stay_active + self._sed_config_sleep_for = sleep_for + self._sed_config_maintenance_interval = maintenance_interval + self._sed_config_clock_sync = clock_sync + self._sed_config_clock_interval = clock_interval + return + response: NodeResponse | None = await self._send( + NodeSleepConfigRequest( + self._mac_in_bytes, + stay_active, + maintenance_interval, + sleep_for, + clock_sync, + clock_interval, + ) ) + if response is None: + raise NodeTimeout( + "No response to 'NodeSleepConfigRequest' from node " + self.mac + ) + if response.ack_id == NodeResponseType.SLEEP_CONFIG_FAILED: + raise NodeError("SED failed to configure sleep settings") + if response.ack_id == NodeResponseType.SLEEP_CONFIG_ACCEPTED: + self._maintenance_interval = maintenance_interval diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 88b75e0a7..7ee5b5e1d 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -1,84 +1,176 @@ """Plugwise Sense node object.""" +from __future__ import annotations + +from asyncio import create_task +from collections.abc import Callable +from datetime import datetime, UTC import logging +from typing import Any, Final -from ..constants import ( - FEATURE_HUMIDITY, - FEATURE_PING, - FEATURE_RSSI_IN, - FEATURE_RSSI_OUT, - FEATURE_TEMPERATURE, - SENSE_HUMIDITY_MULTIPLIER, - SENSE_HUMIDITY_OFFSET, - SENSE_TEMPERATURE_MULTIPLIER, - SENSE_TEMPERATURE_OFFSET, -) -from ..messages.responses import SenseReportResponse +from .helpers import raise_not_loaded +from ..api import NodeFeature +from ..exceptions import NodeError +from ..messages.responses import SENSE_REPORT_ID, SenseReportResponse from ..nodes.sed import NodeSED _LOGGER = logging.getLogger(__name__) +# Sense calculations +SENSE_HUMIDITY_MULTIPLIER: Final = 125 +SENSE_HUMIDITY_OFFSET: Final = 6 +SENSE_TEMPERATURE_MULTIPLIER: Final = 175.72 +SENSE_TEMPERATURE_OFFSET: Final = 46.85 + +# Minimum and maximum supported (custom) zigbee protocol version based +# on utc timestamp of firmware +# Extracted from "Plugwise.IO.dll" file of Plugwise source installation +SENSE_FIRMWARE: Final = { + + # pre - internal test release - fixed version + datetime(2010, 12, 3, 10, 17, 7): ( + "2.0", + "2.5", + ), + + # Proto release, with reset and join bug fixed + datetime(2011, 1, 11, 14, 19, 36): ( + "2.0", + "2.5", + ), + + datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): ("2.0", "2.5"), + + # Legrand + datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): ("2.0", "2.6"), + + # Radio Test + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): ( + "2.0", + "2.5", + ), + + # New Flash Update + datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): ( + "2.0", + "2.6", + ), +} +SENSE_FEATURES: Final = ( + NodeFeature.INFO, + NodeFeature.TEMPERATURE, + NodeFeature.HUMIDITY, +) + + class PlugwiseSense(NodeSED): """provides interface to the Plugwise Sense nodes""" - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._features = ( - FEATURE_HUMIDITY["id"], - FEATURE_PING["id"], - FEATURE_RSSI_IN["id"], - FEATURE_RSSI_OUT["id"], - FEATURE_TEMPERATURE["id"], - ) - self._temperature = None - self._humidity = None - - @property - def humidity(self) -> int: - """Return the current humidity.""" - return self._humidity - - @property - def temperature(self) -> int: - """Return the current temperature.""" - return self._temperature - - def message_for_sense(self, message): - """Process received message""" - if isinstance(message, SenseReportResponse): - self._process_sense_report(message) - else: - _LOGGER.info( - "Unsupported message %s received from %s", - message.__class__.__name__, - self.mac, + _sense_subscription: Callable[[], None] | None = None + + async def async_load(self) -> bool: + """Load and activate Sense node features.""" + if self._loaded: + return True + self._node_info.battery_powered = True + if self._cache_enabled: + _LOGGER.debug( + "Load Sense node %s from cache", self._node_info.mac ) + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return True - def _process_sense_report(self, message): - """Process sense report message to extract current temperature and humidity values.""" + _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) + return False + + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize Sense node.""" + if self._initialized: + return True + if not await super().async_initialize(): + return False + self._sense_subscription = self._message_subscribe( + self._sense_report, + self._mac_in_bytes, + SENSE_REPORT_ID, + ) + self._initialized = True + return True + + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + self._setup_protocol(SENSE_FIRMWARE) + self._features += SENSE_FEATURES + self._node_info.features = self._features + + async def async_unload(self) -> None: + """Unload node.""" + if self._sense_subscription is not None: + self._sense_subscription() + await super().async_unload() + + async def _sense_report(self, message: SenseReportResponse) -> None: + """ + process sense report message to extract + current temperature and humidity values. + """ + self._available_update_state(True) if message.temperature.value != 65535: - new_temperature = int( - SENSE_TEMPERATURE_MULTIPLIER * (message.temperature.value / 65536) + self._temperature = int( + SENSE_TEMPERATURE_MULTIPLIER * ( + message.temperature.value / 65536 + ) - SENSE_TEMPERATURE_OFFSET ) - if self._temperature != new_temperature: - self._temperature = new_temperature - _LOGGER.debug( - "Sense report received from %s with new temperature level of %s", - self.mac, - str(self._temperature), - ) - self.do_callback(FEATURE_TEMPERATURE["id"]) + create_task( + self.publish_event(NodeFeature.TEMPERATURE, self._temperature) + ) + if message.humidity.value != 65535: - new_humidity = int( + self._humidity = int( SENSE_HUMIDITY_MULTIPLIER * (message.humidity.value / 65536) - SENSE_HUMIDITY_OFFSET ) - if self._humidity != new_humidity: - self._humidity = new_humidity - _LOGGER.debug( - "Sense report received from %s with new humidity level of %s", - self.mac, - str(self._humidity), + create_task( + self.publish_event(NodeFeature.HUMIDITY, self._humidity) + ) + + async def async_get_state( + self, features: tuple[NodeFeature] + ) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + if not self._loaded: + if not await self.async_load(): + _LOGGER.warning( + "Unable to update state because load node %s failed", + self.mac ) - self.do_callback(FEATURE_HUMIDITY["id"]) + states: dict[NodeFeature, Any] = {} + for feature in features: + _LOGGER.debug( + "Updating node %s - feature '%s'", + self._node_info.mac, + feature, + ) + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.TEMPERATURE: + states[NodeFeature.TEMPERATURE] = self._temperature + elif feature == NodeFeature.HUMIDITY: + states[NodeFeature.HUMIDITY] = self._humidity + elif feature == NodeFeature.PING: + states[NodeFeature.PING] = await self.async_ping_update() + else: + state_result = await super().async_get_state([feature]) + states[feature] = state_result[feature] + + return states diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 8bde56176..74d20761c 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -1,57 +1,117 @@ """Plugwise switch node object.""" + +from __future__ import annotations +from collections.abc import Callable + +from datetime import datetime, UTC import logging +from typing import Final -from ..constants import FEATURE_PING, FEATURE_RSSI_IN, FEATURE_RSSI_OUT, FEATURE_SWITCH -from ..messages.responses import NodeSwitchGroupResponse +from .helpers import raise_not_loaded +from ..api import NodeFeature +from ..exceptions import MessageError +from ..messages.responses import NODE_SWITCH_GROUP_ID, NodeSwitchGroupResponse from ..nodes.sed import NodeSED _LOGGER = logging.getLogger(__name__) +# Minimum and maximum supported (custom) zigbee protocol version based +# on utc timestamp of firmware +# Extracted from "Plugwise.IO.dll" file of Plugwise source installation +SWITCH_FIRMWARE: Final = { + datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): ("2.0", "2.4"), + datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): ("2.0", "2.4"), + datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): ("2.0", "2.5"), + datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): ("2.0", "2.5"), + + # Legrand + datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): ("2.0", "2.6"), + + # Radio Test + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): ( + "2.0", + "2.5", + ), + + # New Flash Update + datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): ( + "2.0", + "2.6", + ), +} +SWITCH_FEATURES: Final = (NodeFeature.INFO, NodeFeature.SWITCH) + class PlugwiseSwitch(NodeSED): """provides interface to the Plugwise Switch nodes""" - def __init__(self, mac, address, message_sender): - super().__init__(mac, address, message_sender) - self._features = ( - FEATURE_PING["id"], - FEATURE_RSSI_IN["id"], - FEATURE_RSSI_OUT["id"], - FEATURE_SWITCH["id"], - ) - self._switch_state = False - - @property - def switch(self) -> bool: - """Return the last known switch state""" - return self._switch_state + _switch_subscription: Callable[[], None] | None = None + _switch_state: bool | None = None - def message_for_switch(self, message): - """Process received message""" - if isinstance(message, NodeSwitchGroupResponse): + async def async_load(self) -> bool: + """Load and activate Switch node features.""" + if self._loaded: + return True + self._node_info.battery_powered = True + if self._cache_enabled: _LOGGER.debug( - "Switch group request %s received from %s for group id %s", - str(message.power_state), - self.mac, - str(message.group), + "Load Switch node %s from cache", self._node_info.mac ) - self._process_switch_group(message) - - def _process_switch_group(self, message): - """Switch group request from Scan""" - if message.power_state == 0: - # turn off => clear motion - if self._switch_state: - self._switch_state = False - self.do_callback(FEATURE_SWITCH["id"]) - elif message.power_state == 1: - # turn on => motion - if not self._switch_state: + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return True + + _LOGGER.debug("Load of Switch node %s failed", self._node_info.mac) + return False + + @raise_not_loaded + async def async_initialize(self) -> bool: + """Initialize Switch node.""" + if self._initialized: + return True + if not await super().async_initialize(): + return False + self._switch_subscription = self._message_subscribe( + b"0056", + self._switch_group, + self._mac_in_bytes, + NODE_SWITCH_GROUP_ID, + ) + self._initialized = True + return True + + def _load_features(self) -> None: + """Enable additional supported feature(s)""" + self._setup_protocol(SWITCH_FIRMWARE) + self._features += SWITCH_FEATURES + self._node_info.features = self._features + + async def async_unload(self) -> None: + """Unload node.""" + if self._switch_subscription is not None: + self._switch_subscription() + await super().async_unload() + + async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: + """Switch group request from Switch.""" + if message.power_state.value == 0: + if self._switch is None or self._switch: + self._switch = False + await self.publish_event(NodeFeature.SWITCH, False) + elif message.power_state.value == 1: + if self._switch_state is None or not self._switch: self._switch_state = True - self.do_callback(FEATURE_SWITCH["id"]) + await self.publish_event(NodeFeature.SWITCH, True) else: - _LOGGER.debug( - "Unknown power_state (%s) received from %s", - str(message.power_state), - self.mac, + raise MessageError( + f"Unknown power_state '{message.power_state.value}' " + + f"received from {self.mac}" ) diff --git a/plugwise_usb/parser.py b/plugwise_usb/parser.py deleted file mode 100644 index 2e0fb6e2a..000000000 --- a/plugwise_usb/parser.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Data parser for USB-Stick - -The parser will: -- buffer receiving data -- filter out received zigbee routing data -- collect message data by detecting header and footer -- detect message type based on message ID or fixed sequence ID -- validate received data on checksum -- decode collected data into a response message instance -- pass over received messages to message_processor (controller.py) - -""" - -import logging - -from .constants import MESSAGE_FOOTER, MESSAGE_HEADER -from .exceptions import ( - InvalidMessageChecksum, - InvalidMessageFooter, - InvalidMessageHeader, - InvalidMessageLength, -) -from .messages.responses import get_message_response - -_LOGGER = logging.getLogger(__name__) - - -class PlugwiseParser: - """Transform Plugwise message from wire format to response message object.""" - - def __init__(self, message_processor): - self.message_processor = message_processor - self._buffer = bytes([]) - self._parsing = False - self._message = None - - def feed(self, data): - """Add new incoming data to buffer and try to process""" - _LOGGER.debug("Feed data: %s", str(data)) - self._buffer += data - if len(self._buffer) >= 8: - if not self._parsing: - self.parse_data() - - def next_message(self, message): - """Process next packet if present""" - try: - self.message_processor(message) - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _LOGGER.error( - "Error while processing %s : %s", - self._message.__class__.__name__, - err, - ) - _LOGGER.error(err, exc_info=True) - - def parse_data(self): - """Process next set of packet data""" - _LOGGER.debug("Parse data: %s ", str(self._buffer)) - if not self._parsing: - self._parsing = True - - # Lookup header of message in buffer - _LOGGER.debug( - "Lookup message header (%s) in (%s)", - str(MESSAGE_HEADER), - str(self._buffer), - ) - if (header_index := self._buffer.find(MESSAGE_HEADER)) == -1: - _LOGGER.debug("No valid message header found yet") - else: - _LOGGER.debug( - "Valid message header found at index %s", str(header_index) - ) - self._buffer = self._buffer[header_index:] - - # Header available, lookup footer of message in buffer - _LOGGER.debug( - "Lookup message footer (%s) in (%s)", - str(MESSAGE_FOOTER), - str(self._buffer), - ) - if (footer_index := self._buffer.find(MESSAGE_FOOTER)) == -1: - _LOGGER.debug("No valid message footer found yet") - else: - _LOGGER.debug( - "Valid message footer found at index %s", str(footer_index) - ) - self._message = get_message_response( - self._buffer[4:8], footer_index, self._buffer[8:12] - ) - if self._message: - try: - self._message.deserialize(self._buffer[: footer_index + 2]) - except ( - InvalidMessageChecksum, - InvalidMessageFooter, - InvalidMessageHeader, - InvalidMessageLength, - ) as err: - _LOGGER.warning(err) - # TODO: narrow exception - except Exception as err: # pylint: disable=broad-except - _LOGGER.error( - "Failed to parse %s message (%s)", - self._message.__class__.__name__, - str(self._buffer[: footer_index + 2]), - ) - _LOGGER.error(err) - else: - # Submit message - self.next_message(self._message) - # Parse remaining buffer - self.reset_parser(self._buffer[footer_index + 2 :]) - else: - # skip this message, so remove header from buffer - _LOGGER.error( - "Skip unknown message %s", - str(self._buffer[: footer_index + 2]), - ) - self.reset_parser(self._buffer[6:]) - self._parsing = False - else: - _LOGGER.debug("Skip parsing session") - - def reset_parser(self, new_buffer=bytes([])): - _LOGGER.debug("Reset parser : %s", new_buffer) - if new_buffer == b"\x83": - # Skip additional byte sometimes appended after footer - self._buffer = bytes([]) - else: - self._buffer = new_buffer - self._message = None - self._parsing = False - if len(self._buffer) > 0: - self.parse_data() diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 194875be3..44236b9a1 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -1,4 +1,6 @@ -"""Use of this source code is governed by the MIT license found in the LICENSE file. +""" +Use of this source code is governed by the MIT license found +in the LICENSE file. Plugwise protocol helpers """ @@ -6,27 +8,32 @@ import binascii import datetime +import os import re import struct +from typing import Any import crcmod -from .constants import HW_MODELS, LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8_DECODE +from .constants import ( + CACHE_DIR, + HW_MODELS, + LOGADDR_OFFSET, + PLUGWISE_EPOCH, + UTF8, +) -crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) +def get_writable_cache_dir(root_directory: str = "") -> str: + """Put together the default caching directory based on the OS.""" + if root_directory != "": + return root_directory + if os.name == "nt" and (data_dir := os.getenv("APPDATA")) is not None: + return os.path.join(data_dir, CACHE_DIR) + return os.path.join(os.path.expanduser("~"), CACHE_DIR) -# NOTE: this function version_to_model is shared between Smile and USB -def version_to_model(version: str) -> str: - """Translate hardware_version to device type.""" - model = HW_MODELS.get(version) - if model is None: - model = HW_MODELS.get(version[4:10]) - if model is None: - # Try again with reversed order - model = HW_MODELS.get(version[-2:] + version[-4:-2] + version[-6:-4]) - return model if model is not None else "Unknown" +crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) def validate_mac(mac: str) -> bool: @@ -39,27 +46,23 @@ def validate_mac(mac: str) -> bool: return True -def inc_seq_id(seq_id: str | None, value: int = 1) -> bytearray | bytes: - """Increment sequence id by value +def version_to_model(version: str | None) -> str | None: + """Translate hardware_version to device type.""" + if version is None: + return None - :return: 4 bytes - """ - if seq_id is None: - return b"0000" - # Max seq_id = b'FFFB' - # b'FFFC' reserved for message - # b'FFFD' reserved for 'NodeJoinAckResponse' message - # b'FFFE' reserved for 'NodeSwitchGroupResponse' message - # b'FFFF' reserved for 'NodeAwakeResponse' message - if (temp_int := int(seq_id, 16) + value) >= 65532: - temp_int = 0 - temp_str = str(hex(temp_int)).lstrip("0x").upper() - while len(temp_str) < 4: - temp_str = "0" + temp_str - return temp_str.encode() - - -# octals (and hex) type as int according to https://docs.python.org/3/library/stdtypes.html + model = HW_MODELS.get(version) + if model is None: + model = HW_MODELS.get(version[4:10]) + if model is None: + # Try again with reversed order + model = HW_MODELS.get(version[-2:] + version[-4:-2] + version[-6:-4]) + + return model if model is not None else "Unknown" + + +# octals (and hex) type as int according to +# https://docs.python.org/3/library/stdtypes.html def uint_to_int(val: int, octals: int) -> int: """Compute the 2's compliment of int value val for negative values""" bits = octals << 2 @@ -68,7 +71,8 @@ def uint_to_int(val: int, octals: int) -> int: return val -# octals (and hex) type as int according to https://docs.python.org/3/library/stdtypes.html +# octals (and hex) type as int according to +# https://docs.python.org/3/library/stdtypes.html def int_to_uint(val: int, octals: int) -> int: """Compute the 2's compliment of int value val for negative values""" bits = octals << 2 @@ -78,37 +82,34 @@ def int_to_uint(val: int, octals: int) -> int: class BaseType: - def __init__(self, value, length) -> None: # type: ignore[no-untyped-def] + def __init__(self, value: Any, length: int) -> None: self.value = value self.length = length - def serialize(self): # type: ignore[no-untyped-def] - return bytes(self.value, UTF8_DECODE) + def serialize(self) -> bytes: + return bytes(self.value, UTF8) - def deserialize(self, val): # type: ignore[no-untyped-def] + def deserialize(self, val: bytes) -> None: self.value = val - def __len__(self): # type: ignore[no-untyped-def] + def __len__(self) -> int: return self.length class CompositeType: def __init__(self) -> None: self.contents: list = [] - # datetime because of DateTime and Time and RealClockDate - self.value: datetime.datetime | datetime.time | datetime.date | None = None - def serialize(self): # type: ignore[no-untyped-def] + def serialize(self) -> bytes: return b"".join(a.serialize() for a in self.contents) - def deserialize(self, val): # type: ignore[no-untyped-def] + def deserialize(self, val: bytes) -> None: for content in self.contents: myval = val[: len(content)] content.deserialize(myval) - val = val[len(myval) :] - return val + val = val[len(myval):] - def __len__(self): # type: ignore[no-untyped-def] + def __len__(self) -> int: return sum(len(x) for x in self.contents) @@ -117,15 +118,17 @@ class String(BaseType): class Int(BaseType): - def __init__(self, value, length=2, negative: bool = True) -> None: # type: ignore[no-untyped-def] + def __init__( + self, value: int, length: int = 2, negative: bool = True + ) -> None: super().__init__(value, length) self.negative = negative - def serialize(self): # type: ignore[no-untyped-def] + def serialize(self) -> bytes: fmt = "%%0%dX" % self.length - return bytes(fmt % self.value, UTF8_DECODE) + return bytes(fmt % self.value, UTF8) - def deserialize(self, val): # type: ignore[no-untyped-def] + def deserialize(self, val: bytes) -> None: self.value = int(val, 16) if self.negative: mask = 1 << (self.length * 4 - 1) @@ -133,42 +136,41 @@ def deserialize(self, val): # type: ignore[no-untyped-def] class SInt(BaseType): - def __init__(self, value, length=2) -> None: # type: ignore[no-untyped-def] + def __init__(self, value: int, length: int = 2) -> None: super().__init__(value, length) @staticmethod - def negative(val, octals): # type: ignore[no-untyped-def] - """Compute the 2's compliment of int value val for negative values""" + def negative(val: int, octals: int) -> int: + """compute the 2's compliment of int value val for negative values""" bits = octals << 2 if (val & (1 << (bits - 1))) != 0: val = val - (1 << bits) return val - def serialize(self): # type: ignore[no-untyped-def] + def serialize(self) -> bytes: fmt = "%%0%dX" % self.length - return fmt % int_to_uint(self.value, self.length) + return bytes(fmt % int_to_uint(self.value, self.length), UTF8) - def deserialize(self, val): # type: ignore[no-untyped-def] + def deserialize(self, val: bytes) -> None: # TODO: negative is not initialized! 20220405 - self.value = self.negative(int(val, 16), self.length) # type: ignore [no-untyped-call] + self.value = self.negative(int(val, 16), self.length) class UnixTimestamp(Int): - def __init__(self, value, length=8) -> None: # type: ignore[no-untyped-def] - Int.__init__(self, value, length, False) + def __init__(self, value: float, length: int = 8) -> None: + Int.__init__(self, int(value), length, False) - def deserialize(self, val): # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - Int.deserialize(self, val) # type: ignore[no-untyped-call] - self.value = datetime.datetime.fromtimestamp(self.value) + def deserialize(self, val: bytes) -> None: + self.value = datetime.datetime.fromtimestamp( + int(val, 16), datetime.UTC + ) class Year2k(Int): """year value that is offset from the year 2000""" - def deserialize(self, val): # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - Int.deserialize(self, val) # type: ignore[no-untyped-call] + def deserialize(self, val: bytes) -> None: + Int.deserialize(self, val) self.value += PLUGWISE_EPOCH @@ -179,19 +181,21 @@ class DateTime(CompositeType): and last four bytes are offset from the beginning of the month in minutes """ - def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: + def __init__( + self, year: int = 0, month: int = 1, minutes: int = 0 + ) -> None: CompositeType.__init__(self) self.year = Year2k(year - PLUGWISE_EPOCH, 2) self.month = Int(month, 2, False) self.minutes = Int(minutes, 4, False) self.contents += [self.year, self.month, self.minutes] + self.value: datetime.datetime | None = None - def deserialize(self, val: int) -> None: - # TODO: Solution, fix Int 20220405 - CompositeType.deserialize(self, val) # type: ignore[no-untyped-call] - if self.minutes.value == 65535: + def deserialize(self, val: bytes) -> None: + if val == b"FFFFFFFF": self.value = None else: + CompositeType.deserialize(self, val) self.value = datetime.datetime( year=self.year.value, month=self.month.value, day=1 ) + datetime.timedelta(minutes=self.minutes.value) @@ -200,46 +204,50 @@ def deserialize(self, val: int) -> None: class Time(CompositeType): """time value as used in the clock info response""" - def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: + def __init__( + self, hour: int = 0, minute: int = 0, second: int = 0 + ) -> None: CompositeType.__init__(self) self.hour = Int(hour, 2, False) self.minute = Int(minute, 2, False) self.second = Int(second, 2, False) self.contents += [self.hour, self.minute, self.second] + self.value: datetime.time | None = None - def deserialize(self, val) -> None: # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - CompositeType.deserialize(self, val) # type: ignore[no-untyped-call] + def deserialize(self, val: bytes) -> None: + CompositeType.deserialize(self, val) self.value = datetime.time( self.hour.value, self.minute.value, self.second.value ) class IntDec(BaseType): - def __init__(self, value, length=2) -> None: # type: ignore[no-untyped-def] + def __init__(self, value: int, length: int = 2) -> None: super().__init__(value, length) - def serialize(self): # type: ignore[no-untyped-def] + def serialize(self) -> bytes: fmt = "%%0%dd" % self.length - return bytes(fmt % self.value, UTF8_DECODE) + return bytes(fmt % self.value, UTF8) - def deserialize(self, val): # type: ignore[no-untyped-def] - self.value = val.decode(UTF8_DECODE) + def deserialize(self, val: bytes) -> None: + self.value = val.decode(UTF8) class RealClockTime(CompositeType): """time value as used in the realtime clock info response""" - def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: + def __init__( + self, hour: int = 0, minute: int = 0, second: int = 0 + ) -> None: CompositeType.__init__(self) self.hour = IntDec(hour, 2) self.minute = IntDec(minute, 2) self.second = IntDec(second, 2) self.contents += [self.second, self.minute, self.hour] + self.value: datetime.time | None = None - def deserialize(self, val): # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - CompositeType.deserialize(self, val) # type: ignore[no-untyped-call] + def deserialize(self, val: bytes) -> None: + CompositeType.deserialize(self, val) self.value = datetime.time( int(self.hour.value), int(self.minute.value), @@ -256,10 +264,10 @@ def __init__(self, day: int = 0, month: int = 0, year: int = 0) -> None: self.month = IntDec(month, 2) self.year = IntDec(year - PLUGWISE_EPOCH, 2) self.contents += [self.day, self.month, self.year] + self.value: datetime.date | None = None - def deserialize(self, val): # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - CompositeType.deserialize(self, val) # type: ignore[no-untyped-call] + def deserialize(self, val: bytes) -> None: + CompositeType.deserialize(self, val) self.value = datetime.date( int(self.year.value) + PLUGWISE_EPOCH, int(self.month.value), @@ -268,19 +276,18 @@ def deserialize(self, val): # type: ignore[no-untyped-def] class Float(BaseType): - def __init__(self, value, length=4): # type: ignore[no-untyped-def] + def __init__(self, value: float, length: int = 4) -> None: super().__init__(value, length) - def deserialize(self, val): # type: ignore[no-untyped-def] + def deserialize(self, val: bytes) -> None: hexval = binascii.unhexlify(val) - self.value = struct.unpack("!f", hexval)[0] + self.value = float(struct.unpack("!f", hexval)[0]) class LogAddr(Int): - def serialize(self): # type: ignore[no-untyped-def] - return bytes("%08X" % ((self.value * 32) + LOGADDR_OFFSET), UTF8_DECODE) + def serialize(self) -> bytes: + return bytes("%08X" % ((self.value * 32) + LOGADDR_OFFSET), UTF8) - def deserialize(self, val): # type: ignore[no-untyped-def] - # TODO: Solution, fix Int 20220405 - Int.deserialize(self, val) # type: ignore[no-untyped-call] + def deserialize(self, val: bytes) -> None: + Int.deserialize(self, val) self.value = (self.value - LOGADDR_OFFSET) // 32 diff --git a/pyproject.toml b/pyproject.toml index aa7fe6311..d9352e7dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,21 +21,16 @@ authors = [ { name = "Plugwise device owners"} ] maintainers = [ - { name = "arnoutd_77" }, { name = "bouwew"}, { name = "brefra"}, - { name = "CoMPaTech" }, - { name = "dirixmjm" } + { name = "CoMPaTech" } ] requires-python = ">=3.13.0" dependencies = [ - "aiohttp", + "pyserial-asyncio", "async_timeout", + "aiofiles", "crcmod", - "defusedxml", - "munch", - "pyserial", - "python-dateutil", "semver", ] @@ -56,6 +51,7 @@ include = ["plugwise*"] [tool.black] target-version = ["py313"] exclude = 'generated' +line-length = 79 [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings @@ -193,7 +189,7 @@ norecursedirs = [ [tool.mypy] python_version = "3.13" show_error_codes = true -follow_imports = "silent" +follow_imports = "skip" ignore_missing_imports = true strict_equality = true warn_incomplete_stub = true @@ -211,7 +207,9 @@ no_implicit_optional = true strict = true warn_return_any = true warn_unreachable = true -exclude = [] +exclude = [ + "tests/test_usb.py" +] [tool.coverage.run] source = [ "plugwise" ] @@ -223,13 +221,12 @@ omit= [ [tool.ruff] target-version = "py313" -lint.select = [ +select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged - "B904", # Use raise from err or None to specify exception cause "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings @@ -290,7 +287,7 @@ lint.select = [ "W", # pycodestyle ] -lint.ignore = [ +ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line @@ -314,7 +311,7 @@ lint.ignore = [ exclude = [] -[tool.ruff.lint.flake8-import-conventions.extend-aliases] +[tool.ruff.flake8-import-conventions.extend-aliases] voluptuous = "vol" "homeassistant.helpers.area_registry" = "ar" "homeassistant.helpers.config_validation" = "cv" @@ -322,16 +319,16 @@ voluptuous = "vol" "homeassistant.helpers.entity_registry" = "er" "homeassistant.helpers.issue_registry" = "ir" -[tool.ruff.lint.flake8-pytest-style] +[tool.ruff.flake8-pytest-style] fixture-parentheses = false -[tool.ruff.lint.mccabe] +[tool.ruff.mccabe] max-complexity = 25 -[tool.ruff.lint.flake8-tidy-imports.banned-api] +[tool.ruff.flake8-tidy-imports.banned-api] "pytz".msg = "use zoneinfo instead" -[tool.ruff.lint.isort] +[tool.ruff.isort] force-sort-within-sections = true section-order = ["future", "standard-library", "first-party", "third-party", "local-folder"] known-third-party = [ From aff2672eee1af1c75b322940f00439fec6d31742 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 17:12:59 +0100 Subject: [PATCH 002/979] Remove check on exspected seq_id --- plugwise_usb/connection/sender.py | 33 ------------------------------- 1 file changed, 33 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 423839e2d..461223082 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -37,7 +37,6 @@ def __init__( self._loop = get_running_loop() self._receiver = stick_receiver self._transport = transport - self._expected_seq_id: bytes = b"FFFF" self._stick_response: Future[bytes] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None @@ -86,7 +85,6 @@ async def write_request_to_port( else: # Update request with session id request.seq_id = seq_id - self._expected_seq_id = self._next_seq_id(self._expected_seq_id) finally: self._stick_response = None self._stick_lock.release() @@ -95,20 +93,6 @@ async def write_request_to_port( async def _process_stick_response(self, response: StickResponse) -> None: """Process stick response.""" - if self._expected_seq_id == b"FFFF": - # First response, so accept current sequence id - self._expected_seq_id = response.seq_id - - if self._expected_seq_id != response.seq_id: - _LOGGER.warning( - "Stick response (ack_id=%s) received with invalid seq id, " + - "expected %s received %s", - str(response.ack_id), - str(self._expected_seq_id), - str(response.seq_id), - ) - return - if ( self._stick_response is None or self._stick_response.done() @@ -146,20 +130,3 @@ async def _process_stick_response(self, response: StickResponse) -> None: def stop(self) -> None: """Stop sender""" self._unsubscribe_stick_response() - - @staticmethod - def _next_seq_id(seq_id: bytes) -> bytes: - """Increment sequence id by one, return 4 bytes.""" - # Max seq_id = b'FFFB' - # b'FFFC' reserved for message - # b'FFFD' reserved for 'NodeJoinAckResponse' message - # b'FFFE' reserved for 'NodeAwakeResponse' message - # b'FFFF' reserved for 'NodeSwitchGroupResponse' message - if seq_id == b"FFFF": - return b"FFFF" - if (temp_int := int(seq_id, 16) + 1) >= 65532: - temp_int = 0 - temp_str = str(hex(temp_int)).lstrip("0x").upper() - while len(temp_str) < 4: - temp_str = "0" + temp_str - return temp_str.encode() From 15411abe4edbb10e7ace8324e9676c49dfd3d91b Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 17:50:16 +0100 Subject: [PATCH 003/979] Resolve some pylint issues --- plugwise_usb/messages/responses.py | 12 +++--- plugwise_usb/network/__init__.py | 8 ++-- plugwise_usb/network/cache.py | 60 +++++++++++++++--------------- plugwise_usb/nodes/__init__.py | 27 +++++++------- 4 files changed, 52 insertions(+), 55 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index b7edb72f4..bb0b05f07 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -145,12 +145,11 @@ def deserialize(self, response: bytes) -> None: response = response[:-2] # Checksum - calculated_checksum = self.calculate_checksum(response[:-4]) - if calculated_checksum != response[-4:]: + if (check := self.calculate_checksum(response[:-4])) != response[-4:]: raise MessageError( - f"Invalid checksum for {self.__class__.__name__}, " - + "expected {calculated_checksum} got " - + str(response[-4:]), + f"Invalid checksum for {self.__class__.__name__}, " + + f"expected {check} got " + + str(response[-4:]), ) response = response[:-4] @@ -411,7 +410,7 @@ def network_id(self) -> int: @property def network_online(self) -> bool: """Return state of network.""" - return True if self._network_online.value == 1 else False + return self._network_online.value == 1 class CirclePowerUsageResponse(PlugwiseResponse): @@ -549,7 +548,6 @@ def __init__(self, protocol_version: str = "2.0") -> None: self.last_logaddress = LogAddr(0, length=8) if protocol_version == "1.0": - pass # FIXME: Define "absoluteHour" variable self.datetime = DateTime() self.relay_state = Int(0, length=2) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index b03ab3104..c9da38254 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -197,17 +197,17 @@ async def _handle_stick_event(self, event: StickEvent) -> None: async def node_awake_message(self, response: NodeAwakeResponse) -> None: """Handle NodeAwakeResponse message.""" - mac = response.mac_decoded - if mac in self._nodes: + if response.mac_decoded in self._nodes: return - address: int | None = self._register.network_address(mac) - if address is None: + mac = response.mac_decoded + if self._register.network_address(mac) is None: _LOGGER.warning( "Skip node awake message for %s because network " + "registry address is unknown", mac ) return + address: int | None = self._register.network_address(mac) if self._awake_discovery.get(mac) is None: _LOGGER.info( "Node Awake Response from undiscovered node with mac %s" + diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 846876f4b..f655bcf49 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -109,41 +109,41 @@ async def async_restore_cache(self) -> bool: "Failed to read cache file %s", str(self._cache_file) ) return False - else: - self._registrations = {} - for line in lines: - data = line.strip().split(CACHE_SEPARATOR) - if len(data) != 3: + + self._registrations = {} + for line in lines: + data = line.strip().split(CACHE_SEPARATOR) + if len(data) != 3: + _LOGGER.warning( + "Skip invalid line '%s' in cache file %s", + line, + self._cache_file.name, + ) + break + address = int(data[0]) + mac = data[1] + node_type: NodeType | None = None + if data[2] != "": + try: + node_type = NodeType[data[2][9:]] + except KeyError: _LOGGER.warning( - "Skip invalid line '%s' in cache file %s", + "Skip invalid NodeType '%s' " + + "in data '%s' in cache file '%s'", + data[2][9:], line, self._cache_file.name, ) break - address = int(data[0]) - mac = data[1] - node_type: NodeType | None = None - if data[2] != "": - try: - node_type = NodeType[data[2][9:]] - except KeyError: - _LOGGER.warning( - "Skip invalid NodeType '%s' " + - "in data '%s' in cache file '%s'", - data[2][9:], - line, - self._cache_file.name, - ) - break - self._registrations[address] = (mac, node_type) - _LOGGER.debug( - "Restore registry address %s with mac %s " + - "with node type %s", - address, - mac if mac != "" else "", - str(node_type), - ) - return True + self._registrations[address] = (mac, node_type) + _LOGGER.debug( + "Restore registry address %s with mac %s " + + "with node type %s", + address, + mac if mac != "" else "", + str(node_type), + ) + return True async def async_delete_cache_file(self) -> None: """Delete cache file""" diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index e3d749457..b2d4b3380 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -493,15 +493,14 @@ def _node_info_update_state( if self._node_info.version != hardware: self._node_info.version = hardware # Generate modelname based on hardware version - hardware_model = version_to_model(hardware) - if hardware_model == "Unknown": + self._node_info.model = version_to_model(hardware) + if self._node_info.model == "Unknown": _LOGGER.warning( "Failed to detect hardware model for %s based on '%s'", self.mac, hardware, ) - self._node_info.model = hardware_model - if hardware_model is not None: + if self._node_info.model is not None: self._node_info.name = str(self._node_info.mac[-5:]) self._set_cache("hardware", hardware) if timestamp is None: @@ -540,16 +539,16 @@ async def async_is_online(self) -> bool: ) self._available_update_state(False) return False - else: - if ping_response is None: - _LOGGER.info( - "No response to ping for %s", - self.mac - ) - self._available_update_state(False) - return False - await self.async_ping_update(ping_response) - return True + + if ping_response is None: + _LOGGER.info( + "No response to ping for %s", + self.mac + ) + self._available_update_state(False) + return False + await self.async_ping_update(ping_response) + return True async def async_ping_update( self, ping_response: NodePingResponse | None = None, retries: int = 0 From fcf639cc23f23ebe5f122d43c0a03b599ed3c9e5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 18:00:10 +0100 Subject: [PATCH 004/979] Correct Stick class --- plugwise_usb/__init__.py | 105 +++++++++++++++------------------------ 1 file changed, 39 insertions(+), 66 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 939eaf798..de2eb38e4 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -8,31 +8,19 @@ from __future__ import annotations from asyncio import get_running_loop -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable from functools import wraps import logging from typing import Any, TypeVar, cast -from .api import StickEvent +from .api import NodeEvent, StickEvent from .connection import StickController -from .network import NETWORK_EVENTS, StickNetwork -from .network.subscription import StickSubscription -from .exceptions import StickError, SubscriptionError +from .network import StickNetwork +from .exceptions import StickError from .nodes import PlugwiseNode FuncT = TypeVar("FuncT", bound=Callable[..., Any]) -STICK_EVENTS = [ - StickEvent.CONNECTED, - StickEvent.DISCONNECTED, - StickEvent.MESSAGE_RECEIVED, - StickEvent.NODE_AWAKE, - StickEvent.NODE_LOADED, - StickEvent.NODE_DISCOVERED, - StickEvent.NODE_JOIN, - StickEvent.NETWORK_OFFLINE, - StickEvent.NETWORK_ONLINE, -] _LOGGER = logging.getLogger(__name__) @@ -83,7 +71,6 @@ def __init__( self._network: StickNetwork | None = None self._cache_enabled = cache_enabled self._port = port - self._events_supported = STICK_EVENTS self._cache_folder: str = "" @property @@ -232,52 +219,38 @@ def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - async def async_clear_cache(self) -> None: + async def clear_cache(self) -> None: """Clear current cache.""" if self._network is not None: await self._network.clear_cache() - def subscribe_to_event( + def subscribe_to_stick_events( self, - event: StickEvent, - callback: Callable[[Any], Coroutine[Any, Any, None]] - | Callable[[], Coroutine[Any, Any, None]], - ) -> int: - """Add subscription and returns the id to unsubscribe later.""" - - # Forward subscriptions for controller - if event in CONTROLLER_EVENTS: - return self._controller.subscribe_to_stick_events( - StickSubscription(event, callback) - ) - - # Forward subscriptions for network - if event in NETWORK_EVENTS: - if ( - not self._controller.is_connected - or self._network is None - ): - raise SubscriptionError( - "Unable to subscribe for stick event." - + " Connect to USB-stick first." - ) - return self._network.subscribe( - StickSubscription(event, callback) - ) - - raise SubscriptionError( - f"Unable to subscribe to unsupported {event} stick event." + stick_event_callback: Callable[[StickEvent], Awaitable[None]], + events: tuple[StickEvent], + ) -> Callable[[], None]: + """ + Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. + """ + return self._controller.subscribe_to_stick_events( + stick_event_callback, + events, ) - def unsubscribe(self, subscribe_id: int) -> bool: - """Remove subscription.""" - if self._controller.unsubscribe(subscribe_id): - return True - if self._network is not None and self._network.unsubscribe( - subscribe_id - ): - return True - return False + def subscribe_to_network_events( + self, + node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], + events: tuple[NodeEvent], + ) -> Callable[[], None]: + """ + Subscribe callback when specified NodeEvent occurs. + Returns the function to be called to unsubscribe later. + """ + return self._network.subscribe_to_network_events( + node_event_callback, + events, + ) def _validate_node_discovery(self) -> None: """ @@ -287,16 +260,16 @@ def _validate_node_discovery(self) -> None: if self._network is None or not self._network.is_running: raise StickError("Plugwise network node discovery is not active.") - async def async_setup( + async def setup( self, discover: bool = True, load: bool = True ) -> None: """Setup connection to USB-Stick.""" - await self.async_connect() - await self.async_initialize() + await self.connect_to_stick() + await self.initialize_stick() if discover: - await self.async_start() + await self.start_network() if load: - await self.async_load_nodes() + await self.load_nodes() async def connect_to_stick(self, port: str | None = None) -> None: """ @@ -335,7 +308,7 @@ async def initialize_stick(self) -> None: @raise_not_connected @raise_not_initialized - async def async_start(self) -> None: + async def start_network(self) -> None: """Start zigbee network.""" if self._network is None: self._network = StickNetwork(self._controller) @@ -345,7 +318,7 @@ async def async_start(self) -> None: @raise_not_connected @raise_not_initialized - async def async_load_nodes(self) -> bool: + async def load_nodes(self) -> bool: """Load all discovered nodes.""" if self._network is None: raise StickError( @@ -359,7 +332,7 @@ async def async_load_nodes(self) -> bool: @raise_not_connected @raise_not_initialized - async def async_discover_coordinator(self, load: bool = False) -> None: + async def discover_coordinator(self, load: bool = False) -> None: """Setup connection to Zigbee network coordinator.""" if self._network is None: raise StickError( @@ -369,7 +342,7 @@ async def async_discover_coordinator(self, load: bool = False) -> None: @raise_not_connected @raise_not_initialized - async def async_register_node(self, mac: str) -> bool: + async def register_node(self, mac: str) -> bool: """Add node to plugwise network.""" if self._network is None: return False @@ -377,7 +350,7 @@ async def async_register_node(self, mac: str) -> bool: @raise_not_connected @raise_not_initialized - async def async_unregister_node(self, mac: str) -> None: + async def unregister_node(self, mac: str) -> None: """Remove node to plugwise network.""" if self._network is None: return From 0cfe5d2a5809a5799845fc755668cf5672028b36 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 21:23:57 +0100 Subject: [PATCH 005/979] Correct async_load function --- plugwise_usb/nodes/celsius.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 862d15d4f..4882e3d9f 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -57,22 +57,19 @@ class PlugwiseCelsius(NodeSED): """provides interface to the Plugwise Celsius nodes""" - async def async_load( - self, lazy_load: bool = False, from_cache: bool = False - ) -> bool: + async def async_load(self) -> bool: """Load and activate node features.""" if self._loaded: return True - if lazy_load: + self._node_info.battery_powered = True + if self._cache_enabled: _LOGGER.debug( - "Lazy loading Celsius node %s...", - self._node_info.mac + "Load Celsius node %s from cache", self._node_info.mac ) - else: - _LOGGER.debug("Loading Celsius node %s...", self._node_info.mac) + if await self._async_load_from_cache(): + self._loaded = True + self._load_features() + return await self.async_initialize() - self._setup_protocol(FIRMWARE_CELSIUS) - self._features += CELSIUS_FEATURES - self._node_info.features = self._features - - return await super().async_load(lazy_load, from_cache) + _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) + return False From 7e793aa73ea406c9201d2bf954ea3f6dd554dc91 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 21:24:22 +0100 Subject: [PATCH 006/979] Apply formatting and fix pylint --- plugwise_usb/nodes/helpers/counter.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 4562a8a00..bab2e7598 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -104,7 +104,11 @@ def add_pulse_stats( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: """Add pulse statistics""" - _LOGGER.debug("add_pulse_stats | consumed=%s, for %s", str(pulses_consumed), self._mac) + _LOGGER.debug( + "add_pulse_stats | consumed=%s, for %s", + str(pulses_consumed), + self._mac, + ) self._pulse_collection.update_pulse_counter( pulses_consumed, pulses_produced, timestamp ) @@ -279,10 +283,8 @@ def energy(self) -> float | None: ) calc_value = corrected_pulses / PULSES_PER_KW_SECOND / HOUR_IN_SECONDS # Fix minor miscalculations? - # if -0.001 < calc_value < 0.001: - # calc_value = 0.0 - if calc_value < 0: - calc_value = calc_value * -1 + if -0.001 < calc_value < 0.001: + calc_value = 0.0 return calc_value @property @@ -318,7 +320,11 @@ def update( pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption ) - _LOGGER.debug("collected_pulses : pulses=%s | last_update=%s", pulses, last_update) + _LOGGER.debug( + "collected_pulses : pulses=%s | last_update=%s", + pulses, + last_update, + ) if pulses is None or last_update is None: return (None, None) self._last_update = last_update From 4357d63ef8eeea2cac6ee91f72837ff240e01d22 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 21:43:56 +0100 Subject: [PATCH 007/979] Remove async prefix --- plugwise_usb/network/__init__.py | 10 +- plugwise_usb/network/cache.py | 10 +- plugwise_usb/network/registry.py | 8 +- plugwise_usb/nodes/__init__.py | 48 +++---- plugwise_usb/nodes/celsius.py | 6 +- plugwise_usb/nodes/circle.py | 200 +++++++++++++--------------- plugwise_usb/nodes/circle_plus.py | 22 +-- plugwise_usb/nodes/helpers/cache.py | 6 +- plugwise_usb/nodes/scan.py | 22 +-- plugwise_usb/nodes/sed.py | 8 +- plugwise_usb/nodes/sense.py | 20 +-- plugwise_usb/nodes/switch.py | 12 +- 12 files changed, 180 insertions(+), 192 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index c9da38254..52bd16560 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -147,7 +147,7 @@ async def clear_cache(self) -> None: async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" await self._register.unregister_node(mac) - await self._nodes[mac].async_unload() + await self._nodes[mac].unload() self._nodes.pop(mac) # region - Handle stick connect/disconnect events @@ -431,9 +431,9 @@ async def _discover_node( self._create_node_object(mac, address, node_type) # Forward received NodeInfoResponse message to node object - await self._nodes[mac].async_node_info_update(node_info) + await self._nodes[mac].node_info_update(node_info) if node_ping is not None: - await self._nodes[mac].async_ping_update(node_ping) + await self._nodes[mac].ping_update(node_ping) _LOGGER.debug("Publish NODE_DISCOVERED for %s", mac) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) @@ -460,7 +460,7 @@ async def _load_node(self, mac: str) -> bool: return False if self._nodes[mac].loaded: return True - if await self._nodes[mac].async_load(): + if await self._nodes[mac].load(): await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) return True return False @@ -479,7 +479,7 @@ async def _unload_discovered_nodes(self) -> None: """Unload all nodes""" await gather( *[ - node.async_unload() + node.unload() for node in self._nodes.values() ] ) diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index f655bcf49..331334390 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -51,7 +51,7 @@ def registrations(self) -> dict[int, tuple[str, NodeType]]: """Cached network information""" return self._registrations - async def async_save_cache(self) -> None: + async def save_cache(self) -> None: """Save the node information to file.""" _LOGGER.debug("Save network cache %s", str(self._cache_file)) counter = 0 @@ -78,12 +78,12 @@ async def async_save_cache(self) -> None: str(self._cache_file) ) - async def async_clear_cache(self) -> None: + async def clear_cache(self) -> None: """Clear current cache.""" self._registrations = {} - await self.async_delete_cache_file() + await self.delete_cache_file() - async def async_restore_cache(self) -> bool: + async def restore_cache(self) -> bool: """Load the previously stored information.""" if self._cache_file is None: raise CacheError( @@ -145,7 +145,7 @@ async def async_restore_cache(self) -> bool: ) return True - async def async_delete_cache_file(self) -> None: + async def delete_cache_file(self) -> None: """Delete cache file""" if self._cache_file is None: return diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 118dba38f..e6559f8d2 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -65,7 +65,7 @@ def cache_enabled(self, enable: bool = True) -> None: elif not enable and self._cache_enabled: if self._network_cache is not None: create_task( - self._network_cache.async_delete_cache_file() + self._network_cache.delete_cache_file() ) _LOGGER.debug("Cache is disabled") self._cache_enabled = enable @@ -114,7 +114,7 @@ async def restore_network_cache(self) -> None: ) return if not self._cache_restored: - await self._network_cache.async_restore_cache() + await self._network_cache.restore_cache() self._cache_restored = True async def load_registry_from_cache(self) -> None: @@ -256,7 +256,7 @@ async def save_registry_to_cache(self) -> None: for address, registration in self._registry.items(): mac, node_type = registration self._network_cache.update_registration(address, mac, node_type) - await self._network_cache.async_save_cache() + await self._network_cache.save_cache() _LOGGER.debug( "save_registry_to_cache finished" ) @@ -309,7 +309,7 @@ async def unregister_node(self, mac: str) -> None: async def clear_register_cache(self) -> None: """Clear current cache.""" if self._network_cache is not None: - await self._network_cache.async_clear_cache() + await self._network_cache.clear_cache() self._cache_restored = False async def stop(self) -> None: diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index b2d4b3380..ad4059ce2 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -289,7 +289,7 @@ def _setup_protocol( async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" - if await self.async_ping_update() is not None: + if await self.ping_update() is not None: self._connected = True async def disconnect(self) -> None: @@ -307,8 +307,8 @@ def maintenance_interval(self) -> int | None: """ raise NotImplementedError() - async def async_relay_init(self, state: bool) -> None: - """Request to configure relay states at startup/power-up.""" + async def relay_init_set(self, state: bool) -> bool | None: + """Configure relay init state.""" raise NotImplementedError() async def scan_calibrate_light(self) -> bool: @@ -327,11 +327,11 @@ async def scan_configure( """Configure Scan device settings. Returns True if successful.""" raise NotImplementedError() - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate node features.""" raise NotImplementedError() - async def _async_load_cache_file(self) -> bool: + async def _load_cache_file(self) -> bool: """Load states from previous cached information.""" if self._loaded: return True @@ -349,26 +349,26 @@ async def _async_load_cache_file(self) -> bool: self.mac, ) return False - return await self._node_cache.async_restore_cache() + return await self._node_cache.restore_cache() - async def async_clear_cache(self) -> None: + async def clear_cache(self) -> None: """Clear current cache.""" if self._node_cache is not None: - await self._node_cache.async_clear_cache() + await self._node_cache.clear_cache() - async def _async_load_from_cache(self) -> bool: + async def _load_from_cache(self) -> bool: """ Load states from previous cached information. Return True if successful. """ if self._loaded: return True - if not await self._async_load_cache_file(): + if not await self._load_cache_file(): _LOGGER.debug("Node %s failed to load cache file", self.mac) return False # Node Info - if not await self._async_node_info_load_from_cache(): + if not await self._node_info_load_from_cache(): _LOGGER.debug( "Node %s failed to load node_info from cache", self.mac @@ -377,7 +377,7 @@ async def _async_load_from_cache(self) -> bool: self._load_features() return True - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize node.""" raise NotImplementedError() @@ -398,7 +398,7 @@ def _available_update_state(self, available: bool) -> None: self._available = False create_task(self.publish_event(NodeFeature.AVAILABLE, False)) - async def async_node_info_update( + async def node_info_update( self, node_info: NodeInfoResponse | None = None ) -> bool: """Update Node hardware information.""" @@ -429,7 +429,7 @@ async def async_node_info_update( ) return True - async def _async_node_info_load_from_cache(self) -> bool: + async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" firmware: datetime | None = None node_type: int | None = None @@ -514,10 +514,10 @@ def _node_info_update_state( self._node_info.type = NodeType(node_type) self._set_cache("node_type", self._node_info.type.value) if self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) return complete - async def async_is_online(self) -> bool: + async def is_online(self) -> bool: """Check if node is currently online.""" try: ping_response: NodePingResponse | None = await self._send( @@ -547,10 +547,10 @@ async def async_is_online(self) -> bool: ) self._available_update_state(False) return False - await self.async_ping_update(ping_response) + await self.ping_update(ping_response) return True - async def async_ping_update( + async def ping_update( self, ping_response: NodePingResponse | None = None, retries: int = 0 ) -> NetworkStatistics | None: """Update ping statistics.""" @@ -573,11 +573,11 @@ async def async_ping_update( create_task(self.publish_event(NodeFeature.PING, self._ping)) return self._ping - async def async_relay(self, state: bool) -> bool | None: + async def switch_relay(self, state: bool) -> bool | None: """Switch relay state.""" raise NodeError(f"Relay control is not supported for node {self.mac}") - async def async_get_state( + async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """ @@ -597,7 +597,7 @@ async def async_get_state( elif feature == NodeFeature.AVAILABLE: states[NodeFeature.AVAILABLE] = self.available elif feature == NodeFeature.PING: - states[NodeFeature.PING] = await self.async_ping_update() + states[NodeFeature.PING] = await self.ping_update() else: raise NodeError( f"Update of feature '{feature.name}' is " @@ -605,7 +605,7 @@ async def async_get_state( ) return states - async def async_unload(self) -> None: + async def unload(self) -> None: """Deactivate and unload node features.""" raise NotImplementedError() @@ -637,7 +637,7 @@ def _set_cache(self, setting: str, value: Any) -> None: else: self._node_cache.add_state(setting, str(value)) - async def async_save_cache(self) -> None: + async def save_cache(self) -> None: """Save current cache to cache file.""" if not self._cache_enabled: return @@ -648,7 +648,7 @@ async def async_save_cache(self) -> None: ) return _LOGGER.debug("Save cache file for node %s", self.mac) - await self._node_cache.async_save_cache() + await self._node_cache.save_cache() @staticmethod def skip_update(data_class: Any, seconds: int) -> bool: diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 4882e3d9f..5d3ae104e 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -57,7 +57,7 @@ class PlugwiseCelsius(NodeSED): """provides interface to the Plugwise Celsius nodes""" - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate node features.""" if self._loaded: return True @@ -66,10 +66,10 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Celsius node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) return False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 414d8c3d9..c28fab3b2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -139,17 +139,17 @@ def relay(self) -> bool: @raise_not_loaded def relay(self, state: bool) -> None: """Request the relay to switch state.""" - create_task(self.async_relay(state)) + create_task(self.switch_relay(state)) @raise_not_loaded - async def async_relay_off(self) -> None: + async def relay_off(self) -> None: """Switch relay off""" - await self.async_relay(False) + await self.switch_relay(False) @raise_not_loaded - async def async_relay_on(self) -> None: + async def relay_on(self) -> None: """Switch relay on""" - await self.async_relay(True) + await self.switch_relay(True) @property def relay_init( @@ -171,9 +171,9 @@ def relay_init(self, state: bool) -> None: "Configuring initial state of relay" + f"is not supported for device {self.mac}" ) - create_task(self.async_relay_init_set(state)) + create_task(self.relay_init_set(state)) - async def async_calibration_update(self) -> bool: + async def calibration_update(self) -> bool: """ Retrieve and update calibration settings. Returns True if successful. @@ -194,7 +194,7 @@ async def async_calibration_update(self) -> bool: return False self._available_update_state(True) - self._async_calibration_update_state( + self._calibration_update_state( calibration_response.gain_a, calibration_response.gain_b, calibration_response.off_noise, @@ -206,7 +206,7 @@ async def async_calibration_update(self) -> bool: ) return True - async def _async_calibration_load_from_cache(self) -> bool: + async def _calibration_load_from_cache(self) -> bool: """Load calibration settings from cache.""" cal_gain_a: float | None = None cal_gain_b: float | None = None @@ -222,7 +222,7 @@ async def _async_calibration_load_from_cache(self) -> bool: cal_tot = float(tot) # Restore calibration - result = self._async_calibration_update_state( + result = self._calibration_update_state( cal_gain_a, cal_gain_b, cal_noise, @@ -240,7 +240,7 @@ async def _async_calibration_load_from_cache(self) -> bool: ) return False - def _async_calibration_update_state( + def _calibration_update_state( self, gain_a: float | None, gain_b: float | None, @@ -273,11 +273,11 @@ def _async_calibration_update_state( self._set_cache("calibration_noise", off_noise) self._set_cache("calibration_tot", off_tot) if self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) return True @raise_calibration_missing - async def async_power_update(self) -> PowerStatistics | None: + async def power_update(self) -> PowerStatistics | None: """ Update the current power usage statistics. @@ -332,7 +332,7 @@ async def async_power_update(self) -> PowerStatistics | None: @raise_not_loaded @raise_calibration_missing - async def async_energy_update( + async def energy_update( self ) -> EnergyStatistics | None: """Update energy usage statistics, returns True if successful.""" @@ -342,13 +342,13 @@ async def async_energy_update( + "because last_log_address is unknown.", self._node_info.mac, ) - if not await self.async_node_info_update(): + if not await self.node_info_update(): return None else: if self._node_info.timestamp < ( datetime.now(tz=UTC) - timedelta(hours=1) ): - if not await self.async_node_info_update(): + if not await self.node_info_update(): return None if self._energy_counters.log_rollover: @@ -356,13 +356,13 @@ async def async_energy_update( "async_energy_update | Log rollover for %s", self._node_info.mac, ) - if await self.async_node_info_update(): - await self.async_energy_log_update(self._last_log_address) + if await self.node_info_update(): + await self.energy_log_update(self._last_log_address) missing_addresses = self._energy_counters.log_addresses_missing if missing_addresses is not None: if len(missing_addresses) == 0: - await self.async_power_update() + await self.power_update() _LOGGER.debug( "async_energy_update for %s | .. == 0 | %s", self.mac, @@ -370,8 +370,8 @@ async def async_energy_update( ) return self._energy_counters.energy_statistics if len(missing_addresses) == 1: - if await self.async_energy_log_update(missing_addresses[0]): - await self.async_power_update() + if await self.energy_log_update(missing_addresses[0]): + await self.power_update() _LOGGER.debug( "async_energy_update for %s | .. == 1 | %s", self.mac, @@ -388,7 +388,7 @@ async def async_energy_update( "Create task to update energy logs for node %s", self._node_info.mac, ) - await self.async_get_missing_energy_logs() + await self.get_missing_energy_logs() else: _LOGGER.debug( "Skip creating task to update energy logs for node %s", @@ -396,7 +396,7 @@ async def async_energy_update( ) return None - async def async_get_missing_energy_logs(self) -> None: + async def get_missing_energy_logs(self) -> None: """Task to retrieve missing energy logs""" self._energy_counters.update() missing_addresses = self._energy_counters.log_addresses_missing @@ -411,7 +411,7 @@ async def async_get_missing_energy_logs(self) -> None: self._last_log_address - 11, -1, ): - if not await self.async_energy_log_update(address): + if not await self.energy_log_update(address): _LOGGER.warning( "Failed to update energy log %s for %s", str(address), @@ -419,7 +419,7 @@ async def async_get_missing_energy_logs(self) -> None: ) break if self._cache_enabled: - await self._async_energy_log_records_save_to_cache() + await self._energy_log_records_save_to_cache() return if len(missing_addresses) == 0: return @@ -438,14 +438,14 @@ async def async_get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True)[:10] await gather( *[ - self.async_energy_log_update(address) + self.energy_log_update(address) for address in missing_addresses ] ) if self._cache_enabled: - await self._async_energy_log_records_save_to_cache() + await self._energy_log_records_save_to_cache() - async def async_energy_log_update(self, address: int) -> bool: + async def energy_log_update(self, address: int) -> bool: """ Request energy log statistics from node. Return true if successful @@ -481,7 +481,7 @@ async def async_energy_log_update(self, address: int) -> bool: ).value _log_pulses: int = getattr(response, "pulses%d" % (_slot,)).value if _log_timestamp is not None: - await self._async_energy_log_record_update_state( + await self._energy_log_record_update_state( response.logaddr.value, _slot, _log_timestamp.replace(tzinfo=UTC), @@ -491,11 +491,11 @@ async def async_energy_log_update(self, address: int) -> bool: await sleep(0) self._energy_counters.update() if self._cache_enabled: - create_task(self.async_save_cache()) + create_task(self.save_cache()) response = None return True - async def _async_energy_log_records_load_from_cache(self) -> bool: + async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" cached_energy_log_data = self._get_cache("energy_collection") if cached_energy_log_data is None: @@ -549,11 +549,11 @@ async def _async_energy_log_records_load_from_cache(self) -> bool: address, self._mac_in_bytes ) - create_task(self.async_energy_log_update(address)) + create_task(self.energy_log_update(address)) return False return True - async def _async_energy_log_records_save_to_cache(self) -> None: + async def _energy_log_records_save_to_cache(self) -> None: """Save currently collected energy logs to cached file""" if not self._cache_enabled: return @@ -572,7 +572,7 @@ async def _async_energy_log_records_save_to_cache(self) -> None: cached_logs += f"-{log.timestamp.second}:{log.pulses}" self._set_cache("energy_collection", cached_logs) - async def _async_energy_log_record_update_state( + async def _energy_log_record_update_state( self, address: int, slot: int, @@ -612,12 +612,12 @@ async def _async_energy_log_record_update_state( "energy_collection", cached_logs + "|" + log_cache_record ) - async def async_relay(self, state: bool) -> bool | None: + async def switch_relay(self, state: bool) -> bool | None: """ Switch state of relay. Return new state of relay """ - _LOGGER.debug("async_relay() start") + _LOGGER.debug("switch_relay() start") response: NodeResponse | None = await self._send( CircleRelaySwitchRequest(self._mac_in_bytes, state), ) @@ -633,12 +633,12 @@ async def async_relay(self, state: bool) -> bool | None: return None if response.ack_id == NodeResponseType.RELAY_SWITCHED_OFF: - await self._async_relay_update_state( + await self._relay_update_state( state=False, timestamp=response.timestamp ) return False if response.ack_id == NodeResponseType.RELAY_SWITCHED_ON: - await self._async_relay_update_state( + await self._relay_update_state( state=True, timestamp=response.timestamp ) return True @@ -650,7 +650,7 @@ async def async_relay(self, state: bool) -> bool | None: ) return None - async def _async_relay_load_from_cache(self) -> bool: + async def _relay_load_from_cache(self) -> bool: """Load relay state from cache.""" if self._relay is not None: # State already known, no need to load from cache @@ -664,16 +664,16 @@ async def _async_relay_load_from_cache(self) -> bool: relay_state = False if cached_relay_data == "True": relay_state = True - await self._async_relay_update_state(relay_state) + await self._relay_update_state(relay_state) return True _LOGGER.info( "Failed to restore relay state from cache for node %s, " + "try to request node info", self.mac ) - return await self.async_node_info_update() + return await self.node_info_update() - async def _async_relay_update_state( + async def _relay_update_state( self, state: bool, timestamp: datetime | None = None ) -> None: """Process relay state update.""" @@ -697,9 +697,9 @@ async def _async_relay_update_state( ) ) if self.cache_enabled and self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) - async def async_clock_synchronize(self) -> bool: + async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful""" clock_response: CircleClockResponse | None = await self._send( CircleClockGetRequest(self._mac_in_bytes) @@ -738,7 +738,7 @@ async def async_clock_synchronize(self) -> bool: return False return True - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate Circle node features.""" if self._loaded: return True @@ -746,10 +746,10 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Circle node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() _LOGGER.warning( "Load Circle node %s from cache failed", self._node_info.mac, @@ -758,7 +758,7 @@ async def async_load(self) -> bool: _LOGGER.debug("Load Circle node %s", self._node_info.mac) # Check if node is online - if not self._available and not await self.async_is_online(): + if not self._available and not await self.is_online(): _LOGGER.warning( "Failed to load Circle node %s because it is not online", self._node_info.mac @@ -766,7 +766,7 @@ async def async_load(self) -> bool: return False # Get node info - if not await self.async_node_info_update(): + if not await self.node_info_update(): _LOGGER.warning( "Failed to load Circle node %s because it is not responding" + " to information request", @@ -775,31 +775,31 @@ async def async_load(self) -> bool: return False self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() - async def _async_load_from_cache(self) -> bool: + async def _load_from_cache(self) -> bool: """ Load states from previous cached information. Return True if successful. """ - if not await super()._async_load_from_cache(): + if not await super()._load_from_cache(): return False # Calibration settings - if not await self._async_calibration_load_from_cache(): + if not await self._calibration_load_from_cache(): _LOGGER.debug( "Node %s failed to load calibration from cache", self.mac ) return False # Energy collection - if await self._async_energy_log_records_load_from_cache(): + if await self._energy_log_records_load_from_cache(): _LOGGER.debug( "Node %s failed to load energy_log_records from cache", self.mac, ) # Relay - if await self._async_relay_load_from_cache(): + if await self._relay_load_from_cache(): _LOGGER.debug( "Node %s failed to load relay state from cache", self.mac, @@ -808,7 +808,7 @@ async def _async_load_from_cache(self) -> bool: if ( NodeFeature.RELAY_INIT in self._features ): - if await self._async_relay_init_load_from_cache(): + if await self._relay_init_load_from_cache(): _LOGGER.debug( "Node %s failed to load relay_init state from cache", self.mac, @@ -816,26 +816,26 @@ async def _async_load_from_cache(self) -> bool: return True @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize node.""" if self._initialized: _LOGGER.debug("Already initialized node %s", self.mac) return True self._initialized = True - if not self._calibration and not await self.async_calibration_update(): + if not self._calibration and not await self.calibration_update(): _LOGGER.debug( "Failed to initialized node %s, no calibration", self.mac ) self._initialized = False return False - if not await self.async_node_info_update(): + if not await self.node_info_update(): _LOGGER.debug( "Failed to retrieve node info for %s", self.mac ) - if not await self.async_clock_synchronize(): + if not await self.clock_synchronize(): _LOGGER.debug( "Failed to initialized node %s, failed clock sync", self.mac @@ -844,15 +844,18 @@ async def async_initialize(self) -> bool: return False if ( NodeFeature.RELAY_INIT in self._features and - self._relay_init_state is None and - not await self.async_relay_init_update() + self._relay_init_state is None ): - _LOGGER.debug( - "Failed to initialized node %s, relay init", - self.mac - ) - self._initialized = False - return False + state: bool | None = await self.relay_init_get() + if state is None: + _LOGGER.debug( + "Failed to initialized node %s, relay init", + self.mac + ) + self._initialized = False + return False + else: + self._relay_init_state = state return True def _load_features(self) -> None: @@ -866,7 +869,7 @@ def _load_features(self) -> None: self._features += (NodeFeature.RELAY_INIT,) self._node_info.features = self._features - async def async_node_info_update( + async def node_info_update( self, node_info: NodeInfoResponse | None = None ) -> bool: """ @@ -892,7 +895,7 @@ async def async_node_info_update( node_type=node_info.node_type.value, timestamp=node_info.timestamp, ) - await self._async_relay_update_state( + await self._relay_update_state( node_info.relay_state.value == 1, timestamp=node_info.timestamp ) if ( @@ -912,13 +915,13 @@ async def async_node_info_update( "last_log_address", node_info.last_logaddress.value ) if self.cache_enabled and self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) node_info = None return True - async def _async_node_info_load_from_cache(self) -> bool: + async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" - result = await super()._async_node_info_load_from_cache() + result = await super()._node_info_load_from_cache() if ( last_log_address := self._get_cache("last_log_address") ) is not None: @@ -926,29 +929,14 @@ async def _async_node_info_load_from_cache(self) -> bool: return result return False - async def async_unload(self) -> None: + async def unload(self) -> None: """Deactivate and unload node features.""" if self._cache_enabled: - await self._async_energy_log_records_save_to_cache() - await self.async_save_cache() + await self._energy_log_records_save_to_cache() + await self.save_cache() self._loaded = False - async def async_relay_init_update(self) -> bool: - """ - Update current configuration of the power-up state of the relay - - Returns True if retrieval of state was successful - """ - if NodeFeature.RELAY_INIT not in self._features: - raise NodeError( - "Update of initial state of relay is not " - + f"supported for device {self.mac}" - ) - if await self.async_relay_init_get() is None: - return False - return True - - async def async_relay_init_get(self) -> bool | None: + async def relay_init_get(self) -> bool | None: """ Get current configuration of the power-up state of the relay. @@ -964,11 +952,11 @@ async def async_relay_init_get(self) -> bool | None: ) if response is None: return None - await self._async_relay_init_update_state(response.relay.value == 1) + await self._relay_init_update_state(response.relay.value == 1) return self._relay_init_state - async def async_relay_init_set(self, state: bool) -> bool | None: - """Switch relay state.""" + async def relay_init_set(self, state: bool) -> bool | None: + """Configure relay init state.""" if NodeFeature.RELAY_INIT not in self._features: raise NodeError( "Configuring of initial state of relay is not" @@ -979,10 +967,10 @@ async def async_relay_init_set(self, state: bool) -> bool | None: ) if response is None: return None - await self._async_relay_init_update_state(response.relay.value == 1) + await self._relay_init_update_state(response.relay.value == 1) return self._relay_init_state - async def _async_relay_init_load_from_cache(self) -> bool: + async def _relay_init_load_from_cache(self) -> bool: """ Load relay init state from cache. Return True if retrieval was successful. @@ -991,11 +979,11 @@ async def _async_relay_init_load_from_cache(self) -> bool: relay_init_state = False if cached_relay_data == "True": relay_init_state = True - await self._async_relay_init_update_state(relay_init_state) + await self._relay_init_update_state(relay_init_state) return True return False - async def _async_relay_init_update_state(self, state: bool) -> None: + async def _relay_init_update_state(self, state: bool) -> None: """Process relay init state update.""" state_update = False if state: @@ -1014,7 +1002,7 @@ async def _async_relay_init_update_state(self, state: bool) -> None: ) ) if self.cache_enabled and self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) @raise_calibration_missing def _calc_watts( @@ -1075,19 +1063,19 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: return pulses return 0.0 - async def async_get_state( + async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" if not self._loaded: - if not await self.async_load(): + if not await self.load(): _LOGGER.warning( "Unable to update state because load node %s failed", self.mac ) states: dict[NodeFeature, Any] = {} if not self._available: - if not await self.async_is_online(): + if not await self.is_online(): _LOGGER.warning( "Node %s does not respond, unable to update state", self.mac @@ -1104,7 +1092,7 @@ async def async_get_state( + f"not supported for {self.mac}" ) if feature == NodeFeature.ENERGY: - states[feature] = await self.async_energy_update() + states[feature] = await self.energy_update() _LOGGER.debug( "async_get_state %s - energy: %s", self.mac, @@ -1120,13 +1108,13 @@ async def async_get_state( elif feature == NodeFeature.RELAY_INIT: states[feature] = self._relay_init_state elif feature == NodeFeature.POWER: - states[feature] = await self.async_power_update() + states[feature] = await self.power_update() _LOGGER.debug( "async_get_state %s - power: %s", self.mac, states[feature], ) else: - state_result = await super().async_get_state([feature]) + state_result = await super().get_state([feature]) states[feature] = state_result[feature] return states diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 0c571e5b3..5c7bdbd1e 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -57,7 +57,7 @@ class PlugwiseCirclePlus(PlugwiseCircle): """provides interface to the Plugwise Circle+ nodes""" - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate Circle+ node features.""" if self._loaded: return True @@ -65,10 +65,10 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Circle node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() _LOGGER.warning( "Load Circle+ node %s from cache failed", self._node_info.mac, @@ -77,7 +77,7 @@ async def async_load(self) -> bool: _LOGGER.debug("Load Circle+ node %s", self._node_info.mac) # Check if node is online - if not self._available and not await self.async_is_online(): + if not self._available and not await self.is_online(): _LOGGER.warning( "Failed to load Circle+ node %s because it is not online", self._node_info.mac @@ -85,7 +85,7 @@ async def async_load(self) -> bool: return False # Get node info - if not await self.async_node_info_update(): + if not await self.node_info_update(): _LOGGER.warning( "Failed to load Circle+ node %s because it is not responding" + " to information request", @@ -94,10 +94,10 @@ async def async_load(self) -> bool: return False self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize node.""" if self._initialized: return True @@ -105,16 +105,16 @@ async def async_initialize(self) -> bool: if not self._available: self._initialized = False return False - if not self._calibration and not await self.async_calibration_update(): + if not self._calibration and not await self.calibration_update(): self._initialized = False return False - if not await self.async_realtime_clock_synchronize(): + if not await self.realtime_clock_synchronize(): self._initialized = False return False if ( NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None and - not await self.async_relay_init_update() + not await self.relay_init_update() ): self._initialized = False return False @@ -132,7 +132,7 @@ def _load_features(self) -> None: self._features += (NodeFeature.RELAY_INIT,) self._node_info.features = self._features - async def async_realtime_clock_synchronize(self) -> bool: + async def realtime_clock_synchronize(self) -> bool: """Synchronize realtime clock.""" clock_response: CirclePlusRealTimeClockResponse | None = ( await self._send( diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 8c0b82268..86989aca8 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -60,7 +60,7 @@ def get_state(self, state: str) -> str | None: """Return current value for state""" return self._states.get(state, None) - async def async_save_cache(self) -> None: + async def save_cache(self) -> None: """Save the node configuration to file.""" async with aiofiles.open( file=self._cache_file, @@ -76,12 +76,12 @@ async def async_save_cache(self) -> None: str(self._cache_file), ) - async def async_clear_cache(self) -> None: + async def clear_cache(self) -> None: """Clear current cache.""" self._states = {} await self.async_delete_cache_file() - async def async_restore_cache(self) -> bool: + async def restore_cache(self) -> bool: """Load the previously store state information.""" try: async with aiofiles.open( diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 2c50d70ab..40eb71974 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -63,7 +63,7 @@ class PlugwiseScan(NodeSED): """provides interface to the Plugwise Scan nodes""" - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate Scan node features.""" if self._loaded: return True @@ -72,21 +72,21 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Scan node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() - return await self.async_initialize() + return await self.initialize() _LOGGER.debug("Load of Scan node %s failed", self._node_info.mac) return False @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Scan node.""" if self._initialized: return True self._initialized = True - if not await super().async_initialize(): + if not await super().initialize(): self._initialized = False return False self._scan_subscription = self._message_subscribe( @@ -97,11 +97,11 @@ async def async_initialize(self) -> bool: self._initialized = True return True - async def async_unload(self) -> None: + async def unload(self) -> None: """Unload node.""" if self._scan_subscription is not None: self._scan_subscription() - await super().async_unload() + await super().unload() async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: """Switch group request from Scan.""" @@ -142,7 +142,7 @@ async def async_motion_state_update( ) ) if self.cache_enabled and self._loaded and self._initialized: - create_task(self.async_save_cache()) + create_task(self.save_cache()) async def scan_configure( self, @@ -205,12 +205,12 @@ def _load_features(self) -> None: self._features += SCAN_FEATURES self._node_info.features = self._features - async def async_get_state( + async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" if not self._loaded: - if not await self.async_load(): + if not await self.load(): _LOGGER.warning( "Unable to update state because load node %s failed", self.mac @@ -230,6 +230,6 @@ async def async_get_state( if feature == NodeFeature.MOTION: states[NodeFeature.MOTION] = self._motion_state else: - state_result = await super().async_get_state([feature]) + state_result = await super().get_state([feature]) states[feature] = state_result[feature] return states diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index e838b15fb..31fd4b641 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -101,17 +101,17 @@ def unsubscribe(self, subscription_id: int) -> bool: return True return False - async def async_unload(self) -> None: + async def unload(self) -> None: """Deactivate and unload node features.""" if self._maintenance_future is not None: self._maintenance_future.cancel() if self._awake_subscription is not None: self._awake_subscription() - await self.async_save_cache() + await self.save_cache() self._loaded = False @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize SED node.""" if self._initialized: return True @@ -142,7 +142,7 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: ): if self._ping_at_awake: ping_response: NodePingResponse | None = ( - await self.async_ping_update() # type: ignore [assignment] + await self.ping_update() # type: ignore [assignment] ) if ping_response is not None: self._ping_at_awake = False diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 7ee5b5e1d..b8887ec27 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -71,7 +71,7 @@ class PlugwiseSense(NodeSED): _sense_subscription: Callable[[], None] | None = None - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate Sense node features.""" if self._loaded: return True @@ -80,7 +80,7 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Sense node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() return True @@ -89,11 +89,11 @@ async def async_load(self) -> bool: return False @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Sense node.""" if self._initialized: return True - if not await super().async_initialize(): + if not await super().initialize(): return False self._sense_subscription = self._message_subscribe( self._sense_report, @@ -109,11 +109,11 @@ def _load_features(self) -> None: self._features += SENSE_FEATURES self._node_info.features = self._features - async def async_unload(self) -> None: + async def unload(self) -> None: """Unload node.""" if self._sense_subscription is not None: self._sense_subscription() - await super().async_unload() + await super().unload() async def _sense_report(self, message: SenseReportResponse) -> None: """ @@ -141,12 +141,12 @@ async def _sense_report(self, message: SenseReportResponse) -> None: self.publish_event(NodeFeature.HUMIDITY, self._humidity) ) - async def async_get_state( + async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" if not self._loaded: - if not await self.async_load(): + if not await self.load(): _LOGGER.warning( "Unable to update state because load node %s failed", self.mac @@ -168,9 +168,9 @@ async def async_get_state( elif feature == NodeFeature.HUMIDITY: states[NodeFeature.HUMIDITY] = self._humidity elif feature == NodeFeature.PING: - states[NodeFeature.PING] = await self.async_ping_update() + states[NodeFeature.PING] = await self.ping_update() else: - state_result = await super().async_get_state([feature]) + state_result = await super().get_state([feature]) states[feature] = state_result[feature] return states diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 74d20761c..0907f6a94 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -55,7 +55,7 @@ class PlugwiseSwitch(NodeSED): _switch_subscription: Callable[[], None] | None = None _switch_state: bool | None = None - async def async_load(self) -> bool: + async def load(self) -> bool: """Load and activate Switch node features.""" if self._loaded: return True @@ -64,7 +64,7 @@ async def async_load(self) -> bool: _LOGGER.debug( "Load Switch node %s from cache", self._node_info.mac ) - if await self._async_load_from_cache(): + if await self._load_from_cache(): self._loaded = True self._load_features() return True @@ -73,11 +73,11 @@ async def async_load(self) -> bool: return False @raise_not_loaded - async def async_initialize(self) -> bool: + async def initialize(self) -> bool: """Initialize Switch node.""" if self._initialized: return True - if not await super().async_initialize(): + if not await super().initialize(): return False self._switch_subscription = self._message_subscribe( b"0056", @@ -94,11 +94,11 @@ def _load_features(self) -> None: self._features += SWITCH_FEATURES self._node_info.features = self._features - async def async_unload(self) -> None: + async def unload(self) -> None: """Unload node.""" if self._switch_subscription is not None: self._switch_subscription() - await super().async_unload() + await super().unload() async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: """Switch group request from Switch.""" From d9b09093aeaa4b2611081374647169b5e375b009 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 21:54:58 +0100 Subject: [PATCH 008/979] Fix pylint issues --- plugwise_usb/nodes/circle.py | 63 ++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c28fab3b2..c133a472c 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -359,8 +359,9 @@ async def energy_update( if await self.node_info_update(): await self.energy_log_update(self._last_log_address) - missing_addresses = self._energy_counters.log_addresses_missing - if missing_addresses is not None: + if ( + missing_addresses := self._energy_counters.log_addresses_missing + ) is not None: if len(missing_addresses) == 0: await self.power_update() _LOGGER.debug( @@ -399,8 +400,9 @@ async def energy_update( async def get_missing_energy_logs(self) -> None: """Task to retrieve missing energy logs""" self._energy_counters.update() - missing_addresses = self._energy_counters.log_addresses_missing - if missing_addresses is None: + if ( + missing_addresses := self._energy_counters.log_addresses_missing + ) is None: _LOGGER.debug( "Start with initial energy request for the last 10 log" + " addresses for node %s.", @@ -497,17 +499,14 @@ async def energy_log_update(self, address: int) -> bool: async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" - cached_energy_log_data = self._get_cache("energy_collection") - if cached_energy_log_data is None: + if self._get_cache("energy_collection") is None: _LOGGER.info( "Failed to restore energy log records from cache for node %s", self.mac ) return False - restored_logs: dict[int, list[int]] = {} - - log_data = cached_energy_log_data.split("|") + log_data = self._get_cache("energy_collection").split("|") for log_record in log_data: log_fields = log_record.split(":") if len(log_fields) == 4: @@ -594,23 +593,23 @@ async def _energy_log_record_update_state( log_cache_record += f"-{timestamp.month}-{timestamp.day}" log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" log_cache_record += f"-{timestamp.second}:{pulses}" - cached_logs = self._get_cache("energy_collection") - if cached_logs is None: + if (cached_logs := self._get_cache('energy_collection')) is not None: + if log_cache_record not in cached_logs: + _LOGGER.info( + "Add logrecord (%s, %s) to log cache of %s", + str(address), + str(slot), + self.mac + ) + self._set_cache( + "energy_collection", cached_logs + "|" + log_cache_record + ) + else: _LOGGER.debug( "No existing energy collection log cached for %s", self.mac ) self._set_cache("energy_collection", log_cache_record) - elif log_cache_record not in cached_logs: - _LOGGER.info( - "Add logrecord (%s, %s) to log cache of %s", - str(address), - str(slot), - self.mac - ) - self._set_cache( - "energy_collection", cached_logs + "|" + log_cache_record - ) async def switch_relay(self, state: bool) -> bool | None: """ @@ -1028,17 +1027,19 @@ def _calc_watts( ) + self._calibration.off_tot ) - calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000 - # Fix minor miscalculations - if calc_value < 0.0: - _LOGGER.debug( - "FIX negative power miscalc from %s to 0.0 for %s", - str(calc_value), - self.mac - ) - calc_value = 0.0 - return calc_value + if ( + calc_value := corrected_pulses / PULSES_PER_KW_SECOND / seconds * ( + 1000 + ) + ) >= 0.0: + return calc_value + # Fix minor miscalculations + _LOGGER.debug( + "FIX negative power miscalc from %s to 0.0 for %s", + str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), + self.mac + ) def _correct_power_pulses(self, pulses: int, offset: int) -> float: """Correct pulses based on given measurement time offset (ns)""" From b7f1757c359923e4730cb11d50b7d449b668c046 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 12 Jan 2024 21:59:58 +0100 Subject: [PATCH 009/979] remove async prefix --- plugwise_usb/nodes/helpers/cache.py | 4 ++-- plugwise_usb/nodes/scan.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 86989aca8..93362e00a 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -79,7 +79,7 @@ async def save_cache(self) -> None: async def clear_cache(self) -> None: """Clear current cache.""" self._states = {} - await self.async_delete_cache_file() + await self.delete_cache_file() async def restore_cache(self) -> bool: """Load the previously store state information.""" @@ -114,7 +114,7 @@ async def restore_cache(self) -> bool: ) return True - async def async_delete_cache_file(self) -> None: + async def delete_cache_file(self) -> None: """Delete cache file""" if self._cache_file is None: return diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 40eb71974..fe4fcfaa9 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -108,17 +108,17 @@ async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: self._available_update_state(True) if message.power_state.value == 0: # turn off => clear motion - await self.async_motion_state_update(False, message.timestamp) + await self.motion_state_update(False, message.timestamp) elif message.power_state.value == 1: # turn on => motion - await self.async_motion_state_update(True, message.timestamp) + await self.motion_state_update(True, message.timestamp) else: raise MessageError( f"Unknown power_state '{message.power_state.value}' " + f"received from {self.mac}" ) - async def async_motion_state_update( + async def motion_state_update( self, motion_state: bool, timestamp: datetime | None = None ) -> None: """Process motion state update.""" From b0de180ad301f855ea9876b8897f232cb9c4e148 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 20:07:10 +0100 Subject: [PATCH 010/979] Make running state property explicit --- plugwise_usb/connection/__init__.py | 6 +++--- plugwise_usb/connection/queue.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 15f873388..86a1d3d73 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -134,11 +134,11 @@ def subscribe_to_node_responses( async def _handle_stick_event(self, event: StickEvent) -> None: """Handle stick events""" if event == StickEvent.CONNECTED: - if not self._queue.running: + if not self._queue.is_running: self._queue.start(self._manager) await self.initialize_stick() elif event == StickEvent.DISCONNECTED: - if self._queue.running: + if self._queue.is_running: await self._queue.stop() async def initialize_stick(self) -> None: @@ -150,7 +150,7 @@ async def initialize_stick(self) -> None: raise StickError( "Cannot initialize USB-stick, connected to USB-stick first" ) - if not self._queue.running: + if not self._queue.is_running: raise StickError("Cannot initialize, queue manager not running") try: diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index e1c7569f7..4adbe3853 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -41,7 +41,7 @@ def __init__(self) -> None: self._running = False @property - def running(self) -> bool: + def is_running(self) -> bool: """Return the state of the queue""" return self._running From 0e3c6a4e76047bdb356206e19d370242bd2f6480 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 20:08:25 +0100 Subject: [PATCH 011/979] Correct retrieval of current relay_init state --- plugwise_usb/nodes/circle.py | 5 +++-- plugwise_usb/nodes/circle_plus.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c133a472c..1a37877bd 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -845,8 +845,9 @@ async def initialize(self) -> bool: NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None ): - state: bool | None = await self.relay_init_get() - if state is None: + if (state := await self.relay_init_get()) is not None: + self._relay_init_state = state + else: _LOGGER.debug( "Failed to initialized node %s, relay init", self.mac diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 5c7bdbd1e..6b094bdd6 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -113,11 +113,16 @@ async def initialize(self) -> bool: return False if ( NodeFeature.RELAY_INIT in self._features and - self._relay_init_state is None and - not await self.relay_init_update() + self._relay_init_state is None ): - self._initialized = False - return False + if (state := await self.relay_init_get()) is not None: + self._relay_init_state = state + else: + _LOGGER.debug( + "Failed to initialized node %s, relay init", + self.mac + ) + return False self._initialized = True return True From 2cbcf6fc450e754f8c83e29ebd902b34c1f4ab52 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 20:23:44 +0100 Subject: [PATCH 012/979] Move firmware to helper --- plugwise_usb/nodes/__init__.py | 42 ++-- plugwise_usb/nodes/circle.py | 71 +------ plugwise_usb/nodes/circle_plus.py | 54 +---- plugwise_usb/nodes/helpers/firmware.py | 270 +++++++++++++++++++++++++ plugwise_usb/nodes/scan.py | 38 +--- plugwise_usb/nodes/sense.py | 54 +---- plugwise_usb/nodes/switch.py | 47 +---- 7 files changed, 334 insertions(+), 242 deletions(-) create mode 100644 plugwise_usb/nodes/helpers/firmware.py diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index ad4059ce2..43e1cd9ec 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -31,6 +31,7 @@ from .helpers.cache import NodeCache from .helpers.counter import EnergyCalibration, EnergyCounters from .helpers.subscription import NodePublisher +from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions _LOGGER = logging.getLogger(__name__) NODE_FEATURES = ( @@ -73,7 +74,7 @@ def __init__( self._connected: bool = False self._initialized: bool = False self._loaded: bool = False - self._node_protocols: tuple[str, str] | None = None + self._node_protocols: SupportedVersions | None = None self._node_last_online: datetime | None = None # Motion @@ -272,20 +273,33 @@ def relay_init(self, state: bool) -> None: raise NotImplementedError() def _setup_protocol( - self, firmware: dict[datetime, tuple[str, str]] + self, + firmware: dict[datetime, SupportedVersions], + node_features: tuple[NodeFeature], ) -> None: - """Extract protocol version from firmware version""" - if self._node_info.firmware is not None: - self._node_protocols = firmware.get(self._node_info.firmware, None) - if self._node_protocols is None: - _LOGGER.warning( - "Failed to determine the protocol version for node %s (%s)" - + " based on firmware version %s of list %s", - self._node_info.mac, - self.__class__.__name__, - self._node_info.firmware, - str(firmware.keys()), - ) + """ + Determine protocol version based on firmware version + and enable supported additional supported features + """ + if self._node_info.firmware is None: + return + self._node_protocols = firmware.get(self._node_info.firmware, None) + if self._node_protocols is None: + _LOGGER.warning( + "Failed to determine the protocol version for node %s (%s)" + + " based on firmware version %s of list %s", + self._node_info.mac, + self.__class__.__name__, + self._node_info.firmware, + str(firmware.keys()), + ) + return + for feature in node_features: + if ( + required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) + ) is not None: + if required_version <= self._node_protocols.min: + self._features += feature async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 1a37877bd..e1240d316 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -7,7 +7,7 @@ from datetime import datetime, UTC, timedelta from functools import wraps import logging -from typing import Any, Final, TypeVar, cast +from typing import Any, TypeVar, cast from ..api import NodeFeature from ..constants import ( @@ -18,6 +18,7 @@ UTF8, ) from .helpers import EnergyCalibration, raise_not_loaded +from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord from ..exceptions import NodeError from ..messages.requests import ( @@ -50,53 +51,6 @@ FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) -# Minimum and maximum supported (custom) zigbee protocol version based -# on utc timestamp of firmware -# Extracted from "Plugwise.IO.dll" file of Plugwise source installation -CIRCLE_FEATURES: Final = ( - NodeFeature.ENERGY, - NodeFeature.INFO, - NodeFeature.POWER, - NodeFeature.RELAY, -) -CIRCLE_FIRMWARE: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): ("1.0", "1.1"), - datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): ("2.0", "2.5"), - datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): ("2.0", "2.4"), - datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): ("2.0", "2.5"), - # Legrand - datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): ("2.0", "2.6"), - # Radio Test - datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): ("2.0", "2.5"), - # Beta release - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): ( - "2.0", - "2.6", - ), - # Proto release - datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): ( - "2.0", - "2.6", - ), - datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): ( - "2.0", - "2.6", - ), - # New Flash Update - datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): ( - "2.0", - "2.6", - ), -} - def raise_calibration_missing(func: FuncT) -> FuncT: """ @@ -747,7 +701,9 @@ async def load(self) -> bool: ) if await self._load_from_cache(): self._loaded = True - self._load_features() + self._setup_protocol( + CIRCLE_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + ) return await self.initialize() _LOGGER.warning( "Load Circle node %s from cache failed", @@ -773,7 +729,9 @@ async def load(self) -> bool: ) return False self._loaded = True - self._load_features() + self._setup_protocol( + CIRCLE_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + ) return await self.initialize() async def _load_from_cache(self) -> bool: @@ -854,21 +812,8 @@ async def initialize(self) -> bool: ) self._initialized = False return False - else: - self._relay_init_state = state return True - def _load_features(self) -> None: - """Enable additional supported feature(s)""" - self._setup_protocol(CIRCLE_FIRMWARE) - self._features += CIRCLE_FEATURES - if ( - self._node_protocols is not None and - "2.6" in self._node_protocols - ): - self._features += (NodeFeature.RELAY_INIT,) - self._node_info.features = self._features - async def node_info_update( self, node_info: NodeInfoResponse | None = None ) -> bool: diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 6b094bdd6..e27a3d761 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -4,9 +4,9 @@ from datetime import datetime, UTC import logging -from typing import Final from .helpers import raise_not_loaded +from .helpers.firmware import CIRCLE_PLUS_FIRMWARE_SUPPORT from ..api import NodeFeature from ..constants import MAX_TIME_DRIFT from ..messages.requests import ( @@ -18,41 +18,10 @@ NodeResponse, NodeResponseType, ) -from .circle import CIRCLE_FEATURES, PlugwiseCircle +from .circle import PlugwiseCircle _LOGGER = logging.getLogger(__name__) -# Minimum and maximum supported (custom) zigbee protocol version based -# on utc timestamp of firmware -# Extracted from "Plugwise.IO.dll" file of Plugwise source installation -CIRCLE_PLUS_FIRMWARE: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): ("1.0", "1.1"), - datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): ("2.0", "2.4"), - datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): ("2.0", "2.5"), - # Legrand - datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): ("2.0", "2.6"), - # Radio Test - datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): ("2.0", "2.5"), - # SMA firmware 2015-06-16 - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): ( - "2.0", - "2.6", - ), - # New Flash Update - datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): ( - "2.0", - "2.6", - ), -} - class PlugwiseCirclePlus(PlugwiseCircle): """provides interface to the Plugwise Circle+ nodes""" @@ -67,7 +36,9 @@ async def load(self) -> bool: ) if await self._load_from_cache(): self._loaded = True - self._load_features() + self._setup_protocol( + CIRCLE_PLUS_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + ) return await self.initialize() _LOGGER.warning( "Load Circle+ node %s from cache failed", @@ -93,7 +64,9 @@ async def load(self) -> bool: ) return False self._loaded = True - self._load_features() + self._setup_protocol( + CIRCLE_PLUS_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + ) return await self.initialize() @raise_not_loaded @@ -126,17 +99,6 @@ async def initialize(self) -> bool: self._initialized = True return True - def _load_features(self) -> None: - """Enable additional supported feature(s)""" - self._setup_protocol(CIRCLE_PLUS_FIRMWARE) - self._features += CIRCLE_FEATURES - if ( - self._node_protocols is not None and - "2.6" in self._node_protocols - ): - self._features += (NodeFeature.RELAY_INIT,) - self._node_info.features = self._features - async def realtime_clock_synchronize(self) -> bool: """Synchronize realtime clock.""" clock_response: CirclePlusRealTimeClockResponse | None = ( diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py new file mode 100644 index 000000000..78365e46a --- /dev/null +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -0,0 +1,270 @@ +""" +Firmware protocol support definitions + +The minimum and maximum supported (custom) zigbee protocol versions +are based on the utc timestamp of firmware. + +The data is extracted from analyzing the "Plugwise.IO.dll" file of +the Plugwise source installation. + +""" + +from __future__ import annotations + +from datetime import datetime, UTC + +from typing import Final, NamedTuple + +from plugwise_usb.api import NodeFeature + + +SupportedVersions = NamedTuple( + "SupportedVersions", [("min", float), ("max", float)] +) + +# region - node firmware versions +CIRCLE_FIRMWARE_SUPPORT: Final = { + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( + min=1.0, max=1.1, + ), + datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Legrand + datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Radio Test + datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Beta release + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Proto release + datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # New Flash Update + datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), +} + +CIRCLE_PLUS_FIRMWARE_SUPPORT: Final = { + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( + min=1.0, max=1.1 + ), + datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Legrand + datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Radio Test + datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # SMA firmware 2015-06-16 + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6, + ), + # New Flash Update + datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6, + ), +} + +SCAN_FIRMWARE_SUPPORT: Final = { + datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6, + ), + # Beta Scan Release + datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5, + ), + # Beta Scan Release + datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Scan RC1 + datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Legrand + datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # New Flash Update +} + +SENSE_FIRMWARE_SUPPORT: Final = { + # pre - internal test release - fixed version + datetime(2010, 12, 3, 10, 17, 7): ( + "2.0, max=2.5", + ), + # Proto release, with reset and join bug fixed + datetime(2011, 1, 11, 14, 19, 36): ( + "2.0, max=2.5", + ), + datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Legrand + datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Radio Test + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5, + ), + # New Flash Update + datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6, + ), +} + +SWITCH_FIRMWARE_SUPPORT: Final = { + datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.4 + ), + datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5 + ), + # Legrand + datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6 + ), + # Radio Test + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.5, + ), + # New Flash Update + datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): SupportedVersions( + min=2.0, max=2.6, + ), +} +# endregion + +# region - node firmware based features + +FEATURE_SUPPORTED_AT_FIRMWARE: Final = { + NodeFeature.INFO: 2.0, + NodeFeature.TEMPERATURE: 2.0, + NodeFeature.HUMIDITY: 2.0, + NodeFeature.ENERGY: 2.0, + NodeFeature.POWER: 2.0, + NodeFeature.RELAY: 2.0, + NodeFeature.RELAY_INIT: 2.6, + NodeFeature.MOTION: 2.0, + NodeFeature.SWITCH: 2.0, +} + +# endregion diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index fe4fcfaa9..6205b714f 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -3,11 +3,12 @@ from __future__ import annotations from asyncio import create_task -from datetime import datetime, UTC +from datetime import datetime import logging from typing import Any, Final from .helpers import raise_not_loaded +from .helpers.firmware import SCAN_FIRMWARE_SUPPORT from ..api import NodeFeature from ..constants import MotionSensitivity from ..exceptions import MessageError, NodeError, NodeTimeout @@ -35,30 +36,6 @@ # Light override SCAN_DAYLIGHT_MODE: Final = False -# Minimum and maximum supported (custom) zigbee protocol version based on -# utc timestamp of firmware extracted from "Plugwise.IO.dll" file of Plugwise -# source installation -SCAN_FIRMWARE: Final = { - datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): ( - "2.0", - "2.6", - ), # Beta Scan Release - datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): ( - "2.0", - "2.5", - ), # Beta Scan Release - datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): ("2.0", "2.5"), # Scan RC1 - datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): ("2.0", "2.6"), # Legrand - datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): ("2.0", "2.5"), - datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): ( - "2.0", - "2.6", - ), # New Flash Update -} -SCAN_FEATURES: Final = (NodeFeature.INFO, NodeFeature.MOTION) - class PlugwiseScan(NodeSED): """provides interface to the Plugwise Scan nodes""" @@ -74,7 +51,10 @@ async def load(self) -> bool: ) if await self._load_from_cache(): self._loaded = True - self._load_features() + self._setup_protocol( + SCAN_FIRMWARE_SUPPORT, + (NodeFeature.INFO, NodeFeature.MOTION), + ) return await self.initialize() _LOGGER.debug("Load of Scan node %s failed", self._node_info.mac) @@ -199,12 +179,6 @@ async def scan_calibrate_light(self) -> bool: return True return False - def _load_features(self) -> None: - """Enable additional supported feature(s)""" - self._setup_protocol(SCAN_FIRMWARE) - self._features += SCAN_FEATURES - self._node_info.features = self._features - async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index b8887ec27..bbd85ea86 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -3,11 +3,11 @@ from asyncio import create_task from collections.abc import Callable -from datetime import datetime, UTC import logging from typing import Any, Final from .helpers import raise_not_loaded +from .helpers.firmware import SENSE_FIRMWARE_SUPPORT from ..api import NodeFeature from ..exceptions import NodeError from ..messages.responses import SENSE_REPORT_ID, SenseReportResponse @@ -22,43 +22,6 @@ SENSE_TEMPERATURE_MULTIPLIER: Final = 175.72 SENSE_TEMPERATURE_OFFSET: Final = 46.85 -# Minimum and maximum supported (custom) zigbee protocol version based -# on utc timestamp of firmware -# Extracted from "Plugwise.IO.dll" file of Plugwise source installation -SENSE_FIRMWARE: Final = { - - # pre - internal test release - fixed version - datetime(2010, 12, 3, 10, 17, 7): ( - "2.0", - "2.5", - ), - - # Proto release, with reset and join bug fixed - datetime(2011, 1, 11, 14, 19, 36): ( - "2.0", - "2.5", - ), - - datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): ("2.0", "2.5"), - - # Legrand - datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): ("2.0", "2.6"), - - # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): ( - "2.0", - "2.5", - ), - - # New Flash Update - datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): ( - "2.0", - "2.6", - ), -} SENSE_FEATURES: Final = ( NodeFeature.INFO, NodeFeature.TEMPERATURE, @@ -82,7 +45,14 @@ async def load(self) -> bool: ) if await self._load_from_cache(): self._loaded = True - self._load_features() + self._setup_protocol( + SENSE_FIRMWARE_SUPPORT, + ( + NodeFeature.INFO, + NodeFeature.TEMPERATURE, + NodeFeature.HUMIDITY + ), + ) return True _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) @@ -103,12 +73,6 @@ async def initialize(self) -> bool: self._initialized = True return True - def _load_features(self) -> None: - """Enable additional supported feature(s)""" - self._setup_protocol(SENSE_FIRMWARE) - self._features += SENSE_FEATURES - self._node_info.features = self._features - async def unload(self) -> None: """Unload node.""" if self._sense_subscription is not None: diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 0907f6a94..23cce7c2a 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -3,11 +3,10 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, UTC import logging -from typing import Final from .helpers import raise_not_loaded +from .helpers.firmware import SWITCH_FIRMWARE_SUPPORT from ..api import NodeFeature from ..exceptions import MessageError from ..messages.responses import NODE_SWITCH_GROUP_ID, NodeSwitchGroupResponse @@ -15,39 +14,6 @@ _LOGGER = logging.getLogger(__name__) -# Minimum and maximum supported (custom) zigbee protocol version based -# on utc timestamp of firmware -# Extracted from "Plugwise.IO.dll" file of Plugwise source installation -SWITCH_FIRMWARE: Final = { - datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): ("2.0", "2.4"), - datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): ("2.0", "2.4"), - datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): ("2.0", "2.5"), - datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): ("2.0", "2.5"), - - # Legrand - datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): ("2.0", "2.6"), - - # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): ( - "2.0", - "2.5", - ), - - # New Flash Update - datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): ( - "2.0", - "2.6", - ), -} -SWITCH_FEATURES: Final = (NodeFeature.INFO, NodeFeature.SWITCH) - class PlugwiseSwitch(NodeSED): """provides interface to the Plugwise Switch nodes""" @@ -66,7 +32,10 @@ async def load(self) -> bool: ) if await self._load_from_cache(): self._loaded = True - self._load_features() + self._setup_protocol( + SWITCH_FIRMWARE_SUPPORT, + (NodeFeature.INFO, NodeFeature.SWITCH), + ) return True _LOGGER.debug("Load of Switch node %s failed", self._node_info.mac) @@ -88,12 +57,6 @@ async def initialize(self) -> bool: self._initialized = True return True - def _load_features(self) -> None: - """Enable additional supported feature(s)""" - self._setup_protocol(SWITCH_FIRMWARE) - self._features += SWITCH_FEATURES - self._node_info.features = self._features - async def unload(self) -> None: """Unload node.""" if self._switch_subscription is not None: From ef55a2ae990c6f7769a1c73dcd84b0f7f093d252 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:43:36 +0100 Subject: [PATCH 013/979] Move stick event subscription to manager --- plugwise_usb/connection/__init__.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 86a1d3d73..454e46c97 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -25,11 +25,7 @@ def __init__(self) -> None: """Initialize Stick controller.""" self._manager = StickConnectionManager() self._queue = StickQueue() - self._unsubscribe_stick_event = ( - self._manager.subscribe_to_stick_events( - self._handle_stick_event, None - ) - ) + self._unsubscribe_stick_event: Callable[[], None] | None = None self._init_sequence_id: bytes | None = None self._init_future: futures.Future | None = None @@ -103,7 +99,16 @@ def network_online(self) -> bool: async def connect_to_stick(self, serial_path: str) -> None: """Setup connection to USB stick.""" + if self._manager.is_connected: + raise StickError("Already connected") await self._manager.setup_connection_to_stick(serial_path) + if self._unsubscribe_stick_event is None: + self._unsubscribe_stick_event = ( + self._manager.subscribe_to_stick_events( + self._handle_stick_event, None + ) + ) + self._queue.start(self._manager) def subscribe_to_stick_events( self, @@ -114,6 +119,8 @@ def subscribe_to_stick_events( Subscribe callback when specified StickEvent occurs. Returns the function to be called to unsubscribe later. """ + if self._manager is None: + raise StickError("Connect to stick before subscribing to events") return self._manager.subscribe_to_stick_events( stick_event_callback, events, @@ -183,4 +190,7 @@ def _reset_states(self) -> None: async def disconnect_from_stick(self) -> None: """Disconnect from USB-Stick.""" + if self._unsubscribe_stick_event is not None: + self._unsubscribe_stick_event() + self._unsubscribe_stick_event = None await self._manager.disconnect_from_stick() From 3879190ea6eba64ff71c712f34b118a3b3275015 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:45:16 +0100 Subject: [PATCH 014/979] Make queue running state aware of stick events --- plugwise_usb/connection/queue.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 4adbe3853..83eafc589 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -11,10 +11,12 @@ get_running_loop, sleep, ) +from collections.abc import Callable from dataclasses import dataclass import logging from .manager import StickConnectionManager +from ..api import StickEvent from ..exceptions import StickError from ..messages.requests import PlugwiseRequest from ..messages.responses import PlugwiseResponse @@ -38,6 +40,7 @@ def __init__(self) -> None: self._loop = get_running_loop() self._queue: PriorityQueue[PlugwiseRequest] = PriorityQueue() self._submit_worker_task: Task | None = None + self._unsubscribe_connection_events: Callable[[], None] | None = None self._running = False @property @@ -53,10 +56,27 @@ def start( if self._running: raise StickError("Cannot start queue manager, already running") self._stick = stick_connection_manager + if self._stick.is_connected: + self._running = True + self._unsubscribe_connection_events = ( + self._stick.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED) + ) + ) + + async def _handle_stick_event(self, event: StickEvent) -> None: + """Handle events from stick""" + if event is StickEvent.CONNECTED: + self._running = True + elif event is StickEvent.DISCONNECTED: + self._running = False async def stop(self) -> None: """Stop sending from queue.""" _LOGGER.debug("Stop queue") + if self._unsubscribe_connection_events is not None: + self._unsubscribe_connection_events() self._running = False self._stick = None if ( From 6d73aec9f7fdfaae0af334c9198880693e449605 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:46:43 +0100 Subject: [PATCH 015/979] Correct typing transport --- plugwise_usb/connection/receiver.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index a14c92023..fcdf0b56f 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -22,13 +22,12 @@ gather, Lock, Protocol, - Transport, get_running_loop, ) +from serial_asyncio import SerialTransport from collections.abc import Awaitable, Callable from concurrent import futures import logging -from typing import Any from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER @@ -61,7 +60,7 @@ def __init__( super().__init__() self._loop = get_running_loop() self._connected_future = connected_future - self._transport: Transport | None = None + self._transport: SerialTransport | None = None self._buffer: bytes = bytes([]) self._connection_state = False @@ -110,7 +109,7 @@ def is_connected(self) -> bool: """Return current connection state of the USB-Stick.""" return self._connection_state - def connection_made(self, transport: Any) -> None: + def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.debug("Connection made") self._transport = transport From b010c02c076a29fa733eaab9d30b4c7d79249e89 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:46:57 +0100 Subject: [PATCH 016/979] Add missing await --- plugwise_usb/connection/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 454e46c97..8d98f81b1 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -161,7 +161,7 @@ async def initialize_stick(self) -> None: raise StickError("Cannot initialize, queue manager not running") try: - init_response: StickInitResponse = self._queue.submit( + init_response: StickInitResponse = await self._queue.submit( StickInitRequest() ) except StickError as err: From 5cae1568025a97c5cd9944e27aef94a0ab2dbffd Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:48:16 +0100 Subject: [PATCH 017/979] Remove redundant stick in function name --- plugwise_usb/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index de2eb38e4..06614fa1c 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -264,14 +264,14 @@ async def setup( self, discover: bool = True, load: bool = True ) -> None: """Setup connection to USB-Stick.""" - await self.connect_to_stick() - await self.initialize_stick() + await self.connect() + await self.initialize() if discover: await self.start_network() if load: await self.load_nodes() - async def connect_to_stick(self, port: str | None = None) -> None: + async def connect(self, port: str | None = None) -> None: """ Try to open connection. Does not initialize connection. Raises StickError if failed to create connection. @@ -281,7 +281,6 @@ async def connect_to_stick(self, port: str | None = None) -> None: f"Already connected to {self._port}, " + "Close existing connection before (re)connect." ) - if port is not None: self._port = port @@ -295,7 +294,7 @@ async def connect_to_stick(self, port: str | None = None) -> None: ) @raise_not_connected - async def initialize_stick(self) -> None: + async def initialize(self) -> None: """ Try to initialize existing connection to USB-Stick. Raises StickError if failed to communicate with USB-stick. @@ -356,7 +355,7 @@ async def unregister_node(self, mac: str) -> None: return await self._network.unregister_node(mac) - async def disconnect_from_stick(self) -> None: + async def disconnect(self) -> None: """Disconnect from USB-Stick.""" if self._network is not None: await self._network.stop() From e3c108f3358b66bdd54983bbef0fb56b85169857 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 14 Jan 2024 22:51:32 +0100 Subject: [PATCH 018/979] Handle stick event subscriptions at manager --- plugwise_usb/connection/manager.py | 44 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 9b5aa44f3..2abb90940 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from asyncio import Future, get_event_loop, wait_for, sleep +from asyncio import Future, gather, get_event_loop, wait_for from collections.abc import Awaitable, Callable import logging from typing import Any @@ -14,7 +14,7 @@ import serial_asyncio from .sender import StickSender -from .receiver import STICK_RECEIVER_EVENTS, StickReceiver +from .receiver import StickReceiver from ..api import StickEvent from ..exceptions import StickError from ..messages.requests import PlugwiseRequest @@ -37,6 +37,7 @@ def __init__(self) -> None: Callable[[], None], tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] ] = {} + self._unsubscribe_stick_events: Callable[[], None] | None = None @property def serial_path(self) -> str: @@ -52,10 +53,33 @@ def is_connected(self) -> bool: return False return self._receiver.is_connected + def _subscribe_to_stick_events(self) -> None: + """Subscribe to handle stick events by manager""" + if not self.is_connected: + raise StickError("Unable to subscribe to events") + if self._unsubscribe_stick_events is None: + self._unsubscribe_stick_events = ( + self._receiver.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED) + ) + ) + + async def _handle_stick_event( + self, + event: StickEvent, + ) -> None: + """Call callback for stick event subscribers""" + callback_list: list[Callable] = [] + for callback, filtered_event in self._stick_event_subscribers.values(): + if filtered_event is None or filtered_event == event: + callback_list.append(callback(event)) + await gather(*callback_list) + def subscribe_to_stick_events( self, stick_event_callback: Callable[[StickEvent], Awaitable[None]], - event: StickEvent | None, + events: tuple[StickEvent], ) -> Callable[[], None]: """ Subscribe callback when specified StickEvent occurs. @@ -65,13 +89,9 @@ def remove_subscription() -> None: """Remove stick event subscription.""" self._stick_event_subscribers.pop(remove_subscription) - if event in STICK_RECEIVER_EVENTS: - return self._receiver.subscribe_to_stick_events( - stick_event_callback, event - ) self._stick_event_subscribers[ remove_subscription - ] = (stick_event_callback, event) + ] = (stick_event_callback, events) return remove_subscription def subscribe_to_stick_replies( @@ -147,9 +167,12 @@ async def setup_connection_to_stick( connected_future.cancel() await sleep(0) await wait_for(connected_future, 5) - self._connected = True if self._receiver is None: raise StickError("Protocol is not loaded") + if await wait_for(connected_future, 5): + await self._handle_stick_event(StickEvent.CONNECTED) + self._connected = True + self._subscribe_to_stick_events() async def write_to_stick( self, request: PlugwiseRequest @@ -174,6 +197,9 @@ async def write_to_stick( async def disconnect_from_stick(self) -> None: """Disconnect from USB-Stick.""" _LOGGER.debug("Disconnecting manager") + if self._unsubscribe_stick_events is not None: + self._unsubscribe_stick_events() + self._unsubscribe_stick_events = None self._connected = False if self._receiver is not None: await self._receiver.close() From e8731f86860f571b256504479c38dd731c391218 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 19:55:54 +0100 Subject: [PATCH 019/979] Create task when needed only --- plugwise_usb/connection/receiver.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index fcdf0b56f..6986719af 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -98,9 +98,10 @@ def connection_lost(self, exc: Exception | None = None) -> None: self._connected_future.set_result(True) else: self._connected_future.set_exception(exc) - self._loop.create_task( - self._notify_stick_event_subscribers(StickEvent.DISCONNECTED) - ) + if len(self._stick_event_subscribers) > 0: + self._loop.create_task( + self._notify_stick_event_subscribers(StickEvent.DISCONNECTED) + ) self._transport = None self._connection_state = False @@ -119,9 +120,10 @@ def connection_made(self, transport: SerialTransport) -> None: ): self._connected_future.set_result(True) self._connection_state = True - self._loop.create_task( - self._notify_stick_event_subscribers(StickEvent.CONNECTED) - ) + if len(self._stick_event_subscribers) > 0: + self._loop.create_task( + self._notify_stick_event_subscribers(StickEvent.CONNECTED) + ) async def close(self) -> None: """Close connection.""" From 57b03b20ba9ae50c2ce2c89726ced8a68002a681 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:07:08 +0100 Subject: [PATCH 020/979] Rewrite feature subscription & make state upate async --- plugwise_usb/nodes/__init__.py | 42 ++++++---- plugwise_usb/nodes/circle.py | 35 ++++---- plugwise_usb/nodes/circle_plus.py | 4 +- plugwise_usb/nodes/helpers/subscription.py | 95 +++++++++------------- plugwise_usb/nodes/scan.py | 9 +- plugwise_usb/nodes/sed.py | 27 +----- plugwise_usb/nodes/sense.py | 12 ++- 7 files changed, 91 insertions(+), 133 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 43e1cd9ec..7e821b76e 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -30,7 +30,7 @@ from ..util import version_to_model from .helpers.cache import NodeCache from .helpers.counter import EnergyCalibration, EnergyCounters -from .helpers.subscription import NodePublisher +from .helpers.subscription import FeaturePublisher from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ ) -class PlugwiseNode(NodePublisher, ABC): +class PlugwiseNode(FeaturePublisher, ABC): """Abstract Base Class for a Plugwise node.""" def __init__( @@ -399,18 +399,22 @@ def _load_features(self) -> None: """Enable additional supported feature(s)""" raise NotImplementedError() - def _available_update_state(self, available: bool) -> None: + async def _available_update_state(self, available: bool) -> None: """Update the node availability state.""" if self._available == available: return if available: _LOGGER.info("Mark node %s to be available", self.mac) self._available = True - create_task(self.publish_event(NodeFeature.AVAILABLE, True)) + await self.publish_feature_update_to_subscribers( + NodeFeature.AVAILABLE, True + ) return _LOGGER.info("Mark node %s to be NOT available", self.mac) self._available = False - create_task(self.publish_event(NodeFeature.AVAILABLE, False)) + await self.publish_feature_update_to_subscribers( + NodeFeature.AVAILABLE, False + ) async def node_info_update( self, node_info: NodeInfoResponse | None = None @@ -422,10 +426,10 @@ async def node_info_update( ) if node_info is None: _LOGGER.debug( - "No response for async_node_info_update() for %s", + "No response for node_info_update() for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return False if node_info.mac_decoded != self.mac: raise NodeError( @@ -433,9 +437,9 @@ async def node_info_update( f"!= {self.mac}, id={node_info}" ) - self._available_update_state(True) + await self._available_update_state(True) - self._node_info_update_state( + await self._node_info_update_state( firmware=node_info.fw_ver.value, hardware=node_info.hw_ver.value.decode(UTF8), node_type=node_info.node_type.value, @@ -477,7 +481,7 @@ async def _node_info_load_from_cache(self) -> bool: second=int(data[5]), tzinfo=UTC ) - return self._node_info_update_state( + return await self._node_info_update_state( firmware=firmware, hardware=hardware, node_type=node_type, @@ -541,17 +545,17 @@ async def is_online(self) -> bool: ) except StickError: _LOGGER.warning( - "StickError for async_is_online() for %s", + "StickError for is_online() for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return False except NodeError: _LOGGER.warning( - "NodeError for async_is_online() for %s", + "NodeError for is_online() for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return False if ping_response is None: @@ -559,7 +563,7 @@ async def is_online(self) -> bool: "No response to ping for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return False await self.ping_update(ping_response) return True @@ -575,16 +579,18 @@ async def ping_update( ) ) if ping_response is None: - self._available_update_state(False) + await self._available_update_state(False) return None - self._available_update_state(True) + await self._available_update_state(True) self._ping.timestamp = ping_response.timestamp self._ping.rssi_in = ping_response.rssi_in self._ping.rssi_out = ping_response.rssi_out self._ping.rtt = ping_response.rtt - create_task(self.publish_event(NodeFeature.PING, self._ping)) + await self.publish_feature_update_to_subscribers( + NodeFeature.PING, self._ping + ) return self._ping async def switch_relay(self, state: bool) -> bool | None: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e1240d316..f75a8dedc 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -29,7 +29,6 @@ CircleRelayInitStateRequest, CircleRelaySwitchRequest, EnergyCalibrationRequest, - NodeInfoRequest, ) from ..messages.responses import ( CircleClockResponse, @@ -144,9 +143,9 @@ async def calibration_update(self) -> bool: "Updating energy calibration for node %s failed", self._node_info.mac, ) - self._available_update_state(False) + await self._available_update_state(False) return False - self._available_update_state(True) + await self._available_update_state(True) self._calibration_update_state( calibration_response.gain_a, @@ -250,14 +249,14 @@ async def power_update(self) -> PowerStatistics | None: "No response for async_power_update() for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return None if response.mac_decoded != self.mac: raise NodeError( f"Incorrect power response for {response.mac_decoded} " + f"!= {self.mac} = {self._mac_in_str} | {request.mac_decoded}" ) - self._available_update_state(True) + await self._available_update_state(True) # Update power stats self._power.last_second = self._calc_watts( @@ -267,7 +266,9 @@ async def power_update(self) -> PowerStatistics | None: response.pulse_8s.value, 8, response.nanosecond_offset ) self._power.timestamp = response.timestamp - create_task(self.publish_event(NodeFeature.POWER, self._power)) + await self.publish_feature_update_to_subscribers( + NodeFeature.POWER, self._power + ) # Forward pulse interval counters to pulse Collection self._energy_counters.add_pulse_stats( @@ -275,11 +276,8 @@ async def power_update(self) -> PowerStatistics | None: response.produced_counter, response.timestamp, ) - create_task( - self.publish_event( - NodeFeature.ENERGY, - self._energy_counters.energy_statistics - ) + await self.publish_feature_update_to_subscribers( + NodeFeature.ENERGY, self._energy_counters.energy_statistics ) response = None return self._power @@ -426,7 +424,7 @@ async def energy_log_update(self, address: int) -> bool: ) return False - self._available_update_state(True) + await self._available_update_state(True) # Forward historical energy log information to energy counters # Each response message contains 4 log counters (slots) of the @@ -643,11 +641,8 @@ async def _relay_update_state( state_update = False self._relay = state if state_update: - create_task( - self.publish_event( - NodeFeature.RELAY, - self._relay_state - ) + await self.publish_feature_update_to_subscribers( + NodeFeature.RELAY, self._relay_state ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) @@ -941,10 +936,8 @@ async def _relay_init_update_state(self, state: bool) -> None: state_update = True if state_update: self._relay_init_state = state - create_task( - self.publish_event( - NodeFeature.RELAY_INIT, self._relay_init_state - ) + await self.publish_feature_update_to_subscribers( + NodeFeature.RELAY_INIT, self._relay_init_state ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index e27a3d761..4f22cef9d 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -111,9 +111,9 @@ async def realtime_clock_synchronize(self) -> bool: "No response for async_realtime_clock_synchronize() for %s", self.mac ) - self._available_update_state(False) + await self._available_update_state(False) return False - self._available_update_state(True) + await self._available_update_state(True) _dt_of_circle: datetime = datetime.utcnow().replace( hour=clock_response.time.value.hour, diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index 499a030a6..5d8accd44 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -1,67 +1,52 @@ """Base class for plugwise node publisher.""" from __future__ import annotations -from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from asyncio import gather +from collections.abc import Awaitable, Callable from typing import Any -from ...api import NodeFeature -from ...exceptions import SubscriptionError +from ...api import NodeEvent, NodeFeature -@dataclass -class NodeSubscription: - """Class to subscribe a callback to node events.""" - - event: NodeFeature - callback: Callable[[Any], Coroutine[Any, Any, None]] | Callable[ - [], Coroutine[Any, Any, None] - ] - - -class NodePublisher(): +class FeaturePublisher(): """Base Class to call awaitable of subscription when event happens.""" - _subscribers: dict[int, NodeSubscription] = {} - _features: tuple[NodeFeature, ...] = () - - def subscribe(self, subscription: NodeSubscription) -> int: - """Add subscription and returns the id to unsubscribe later.""" - if subscription.event not in self._features: - raise SubscriptionError( - f"Subscription event {subscription.event} is not supported" - ) - if id(subscription) in self._subscribers: - raise SubscriptionError("Subscription already exists") - self._subscribers[id(subscription)] = subscription - return id(subscription) + _feature_update_subscribers: dict[ + Callable[[], None], + tuple[Callable[[NodeEvent], Awaitable[None]], NodeEvent | None] + ] = {} - def subscribe_to_event( + def subscribe_to_feature_update( self, - event: NodeFeature, - callback: Callable[[Any], Coroutine[Any, Any, None]] - | Callable[[], Coroutine[Any, Any, None]], - ) -> int: - """Subscribe callback to events.""" - return self.subscribe( - NodeSubscription( - event=event, - callback=callback, - ) - ) - - def unsubscribe(self, subscription_id: int) -> bool: - """Remove subscription. Returns True if unsubscribe was successful.""" - if subscription_id in self._subscribers: - del self._subscribers[subscription_id] - return True - return False - - async def publish_event(self, event: NodeFeature, value: Any) -> None: + node_feature_callback: Callable[ + [NodeFeature, Any], Awaitable[None] + ], + features: tuple[NodeFeature], + ) -> Callable[[], None]: + """ + Subscribe callback when specified NodeFeature state updates. + Returns the function to be called to unsubscribe later. + """ + def remove_subscription() -> None: + """Remove stick event subscription.""" + self._feature_update_subscribers.pop(remove_subscription) + + self._feature_update_subscribers[ + remove_subscription + ] = (node_feature_callback, features) + return remove_subscription + + async def publish_feature_update_to_subscribers( + self, + feature: NodeFeature, + state: Any, + ) -> None: """Publish feature to applicable subscribers.""" - if event not in self._features: - return - for subscription in list(self._subscribers.values()): - if subscription.event != event: - continue - await subscription.callback(event, value) + callback_list: list[Callable] = [] + for callback, filtered_features in ( + self._feature_update_subscribers.values() + ): + if feature in filtered_features: + callback_list.append(callback(feature, state)) + if len(callback_list) > 0: + await gather(*callback_list) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 6205b714f..36b33dc80 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -85,7 +85,7 @@ async def unload(self) -> None: async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: """Switch group request from Scan.""" - self._available_update_state(True) + await self._available_update_state(True) if message.power_state.value == 0: # turn off => clear motion await self.motion_state_update(False, message.timestamp) @@ -115,11 +115,8 @@ async def motion_state_update( state_update = True if state_update: self._motion = motion_state - create_task( - self.publish_event( - NodeFeature.MOTION, - self._motion_state, - ) + await self.publish_feature_update_to_subscribers( + NodeFeature.MOTION, self._motion_state, ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 31fd4b641..e49ba8db7 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -4,7 +4,6 @@ from asyncio import ( CancelledError, - create_task, Future, get_event_loop, wait_for, @@ -18,8 +17,6 @@ from plugwise_usb.connection import StickController from .helpers import raise_not_loaded -from .helpers.subscription import NodeSubscription -from ..api import NodeFeature from ..exceptions import NodeError, NodeTimeout from ..messages.requests import NodeSleepConfigRequest from ..messages.responses import ( @@ -85,22 +82,6 @@ def __init__( super().__init__(mac, address, controller) self._message_subscribe = controller.subscribe_to_node_responses - def subscribe(self, subscription: NodeSubscription) -> int: - if subscription.event == NodeFeature.PING: - self._ping_at_awake = True - return super().subscribe(subscription) - - def unsubscribe(self, subscription_id: int) -> bool: - if super().unsubscribe(subscription_id): - keep_ping = False - for subscription in self._subscribers.values(): - if subscription.event == NodeFeature.PING: - keep_ping = True - break - self._ping_at_awake = keep_ping - return True - return False - async def unload(self) -> None: """Deactivate and unload node features.""" if self._maintenance_future is not None: @@ -133,7 +114,7 @@ def maintenance_interval(self) -> int | None: async def _awake_response(self, message: NodeAwakeResponse) -> None: """Process awake message.""" self._node_last_online = message.timestamp - self._available_update_state(True) + await self._available_update_state(True) if message.timestamp is None: return if ( @@ -146,9 +127,7 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: ) if ping_response is not None: self._ping_at_awake = False - create_task( - self.reset_maintenance_awake(message.timestamp) - ) + await self.reset_maintenance_awake(message.timestamp) async def reset_maintenance_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" @@ -182,7 +161,7 @@ async def reset_maintenance_awake(self, last_alive: datetime) -> None: self.mac, str(self._maintenance_interval * 1.05), ) - self._available_update_state(False) + await self._available_update_state(False) except CancelledError: pass diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index bbd85ea86..df90ffa02 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -1,7 +1,6 @@ """Plugwise Sense node object.""" from __future__ import annotations -from asyncio import create_task from collections.abc import Callable import logging from typing import Any, Final @@ -84,7 +83,7 @@ async def _sense_report(self, message: SenseReportResponse) -> None: process sense report message to extract current temperature and humidity values. """ - self._available_update_state(True) + await self._available_update_state(True) if message.temperature.value != 65535: self._temperature = int( SENSE_TEMPERATURE_MULTIPLIER * ( @@ -92,17 +91,16 @@ async def _sense_report(self, message: SenseReportResponse) -> None: ) - SENSE_TEMPERATURE_OFFSET ) - create_task( - self.publish_event(NodeFeature.TEMPERATURE, self._temperature) + await self.publish_feature_update_to_subscribers( + NodeFeature.TEMPERATURE, self._temperature ) - if message.humidity.value != 65535: self._humidity = int( SENSE_HUMIDITY_MULTIPLIER * (message.humidity.value / 65536) - SENSE_HUMIDITY_OFFSET ) - create_task( - self.publish_event(NodeFeature.HUMIDITY, self._humidity) + await self.publish_feature_update_to_subscribers( + NodeFeature.HUMIDITY, self._humidity ) async def get_state( From f77c8db4ca17af37bc522aa5595a449d713e0f04 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:08:01 +0100 Subject: [PATCH 021/979] Correct ping attempts --- plugwise_usb/nodes/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 7e821b76e..1be8706f8 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -540,7 +540,7 @@ async def is_online(self) -> bool: try: ping_response: NodePingResponse | None = await self._send( NodePingRequest( - self._mac_in_bytes, retries=0 + self._mac_in_bytes, retries=1 ) ) except StickError: @@ -569,7 +569,7 @@ async def is_online(self) -> bool: return True async def ping_update( - self, ping_response: NodePingResponse | None = None, retries: int = 0 + self, ping_response: NodePingResponse | None = None, retries: int = 1 ) -> NetworkStatistics | None: """Update ping statistics.""" if ping_response is None: From 87811b9d0a45c35a93da7a51d5e62fdaef981d2d Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:11:12 +0100 Subject: [PATCH 022/979] Bubble up any error to future response --- plugwise_usb/connection/sender.py | 21 +++++++++++++++------ plugwise_usb/messages/requests.py | 10 +++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 461223082..f0472206b 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -15,7 +15,7 @@ """ from __future__ import annotations -from asyncio import Future, Lock, Transport, get_running_loop, wait_for +from asyncio import Future, Lock, Transport, get_running_loop, sleep, wait_for import logging from .receiver import StickReceiver @@ -71,17 +71,25 @@ async def write_request_to_port( # Write message to serial port buffer self._transport.write(serialized_data) request.add_send_attempt() + request.start_response_timeout() # Wait for USB stick to accept request try: seq_id: bytes = await wait_for( self._stick_response, timeout=STICK_TIME_OUT ) - except TimeoutError as exc: - raise StickError( - f"Failed to send {request.__class__.__name__} because " + - f"USB-Stick did not respond within {STICK_TIME_OUT} seconds." - ) from exc + except TimeoutError: + request.assign_error( + BaseException( + StickError( + f"Failed to send {request.__class__.__name__} " + + "because USB-Stick did not respond " + + f"within {STICK_TIME_OUT} seconds." + ) + ) + ) + except BaseException as exception: # [broad-exception-caught] + request.assign_error(exception) else: # Update request with session id request.seq_id = seq_id @@ -126,6 +134,7 @@ async def _process_stick_response(self, response: StickResponse) -> None: ) ) ) + await sleep(0.1) def stop(self) -> None: """Stop sender""" diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 8b6ea402d..93eba195c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -17,7 +17,7 @@ NODE_TIME_OUT, ) from ..messages.responses import PlugwiseResponse -from ..exceptions import NodeError +from ..exceptions import NodeError, StickError from ..util import ( DateTime, Int, @@ -110,6 +110,14 @@ def _response_timeout_expired(self) -> None: ) ) + def assign_error(self, error: StickError) -> None: + """Assign error for this request""" + if self._response_timeout is not None: + self._response_timeout.cancel() + if self._response_future.done(): + return + self._response_future.set_exception(error) + def _update_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node""" if self.seq_id is None: From e99782f385de94f571518341836022939fe5e093 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:14:37 +0100 Subject: [PATCH 023/979] Correct subscription to StickEvents --- plugwise_usb/connection/__init__.py | 3 ++- plugwise_usb/connection/manager.py | 11 ++++++++--- plugwise_usb/connection/receiver.py | 9 ++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 8d98f81b1..62159a546 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -105,7 +105,8 @@ async def connect_to_stick(self, serial_path: str) -> None: if self._unsubscribe_stick_event is None: self._unsubscribe_stick_event = ( self._manager.subscribe_to_stick_events( - self._handle_stick_event, None + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED), ) ) self._queue.start(self._manager) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 2abb90940..74f58d030 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -70,11 +70,16 @@ async def _handle_stick_event( event: StickEvent, ) -> None: """Call callback for stick event subscribers""" + if len(self._stick_event_subscribers) == 0: + return callback_list: list[Callable] = [] - for callback, filtered_event in self._stick_event_subscribers.values(): - if filtered_event is None or filtered_event == event: + for callback, filtered_events in ( + self._stick_event_subscribers.values() + ): + if event in filtered_events: callback_list.append(callback(event)) - await gather(*callback_list) + if len(callback_list) > 0: + await gather(*callback_list) def subscribe_to_stick_events( self, diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 6986719af..beae62a74 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -238,10 +238,13 @@ async def _notify_stick_event_subscribers( ) -> None: """Call callback for stick event subscribers""" callback_list: list[Callable] = [] - for callback, filtered_event in self._stick_event_subscribers.values(): - if filtered_event is None or filtered_event == event: + for callback, filtered_events in ( + self._stick_event_subscribers.values() + ): + if event in filtered_events: callback_list.append(callback(event)) - await gather(*callback_list) + if len(callback_list) > 0: + await gather(*callback_list) def subscribe_to_stick_responses( self, From 3713bb2e9acfa9818f970e160c267c125bffb8ac Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:15:42 +0100 Subject: [PATCH 024/979] Correct subscription to NodeEvents --- plugwise_usb/__init__.py | 2 +- plugwise_usb/network/__init__.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 06614fa1c..460112999 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -238,7 +238,7 @@ def subscribe_to_stick_events( events, ) - def subscribe_to_network_events( + def subscribe_to_node_events( self, node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], events: tuple[NodeEvent], diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 52bd16560..887a1697b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -571,9 +571,10 @@ async def _notify_node_event_subscribers( ) -> None: """Call callback for node event subscribers""" callback_list: list[Callable] = [] - for callback, filtered_event in ( + for callback, filtered_events in ( self._node_event_subscribers.values() ): - if filtered_event is None or filtered_event == event: + if event in filtered_events: callback_list.append(callback(event, mac)) - await gather(*callback_list) + if len(callback_list) > 0: + await gather(*callback_list) From bb74eee0d576e0983e24dc751b3d03dc8f9da0aa Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:16:40 +0100 Subject: [PATCH 025/979] Simplify node update --- plugwise_usb/nodes/circle.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f75a8dedc..8f90c492d 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -814,19 +814,8 @@ async def node_info_update( ) -> bool: """ Update Node hardware information. - Returns true if successful. """ - if node_info is None: - node_info = await self._send( - NodeInfoRequest(self._mac_in_bytes) - ) - else: - if node_info.mac_decoded != self.mac: - raise NodeError( - f"Incorrect node_info {node_info.mac_decoded} " + - f"!= {self.mac}={self._mac_in_str}" - ) - if node_info is None: + if not super().node_info_update(node_info): return False self._node_info_update_state( @@ -856,7 +845,6 @@ async def node_info_update( ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) - node_info = None return True async def _node_info_load_from_cache(self) -> bool: From 0f9f42899747b3bf03cd3f4898f5280395161166 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:17:10 +0100 Subject: [PATCH 026/979] Add missing state update --- plugwise_usb/connection/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 62159a546..f64cf37c0 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -177,6 +177,7 @@ async def initialize_stick(self) -> None: # Replace first 2 characters by 00 for mac of circle+ node self._mac_nc = init_response.mac_network_controller self._network_id = init_response.network_id + self._is_initialized = True async def send(self, request: PlugwiseRequest) -> PlugwiseResponse: """Submit request to queue and return response""" From eaa522809cf05aa1933d1bcb13d9382af53c260c Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:19:09 +0100 Subject: [PATCH 027/979] Correct setting-up serial connection --- plugwise_usb/connection/manager.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 74f58d030..fc03979fe 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -11,7 +11,7 @@ from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE from serial import SerialException -import serial_asyncio +from serial_asyncio import create_serial_connection, SerialTransport from .sender import StickSender from .receiver import StickReceiver @@ -30,9 +30,9 @@ def __init__(self) -> None: """Initialize Stick controller.""" self._sender: StickSender | None = None self._receiver: StickReceiver | None = None + self._serial_transport: SerialTransport | None = None self._port = "" self._connected: bool = False - self._stick_event_subscribers: dict[ Callable[[], None], tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] @@ -145,10 +145,10 @@ async def setup_connection_to_stick( try: ( - self._sender, + self._serial_transport, self._receiver, ) = await wait_for( - serial_asyncio.create_serial_connection( + create_serial_connection( loop, lambda: self._receiver, url=serial_path, @@ -170,11 +170,11 @@ async def setup_connection_to_stick( ) from err finally: connected_future.cancel() - await sleep(0) - await wait_for(connected_future, 5) + if self._receiver is None: raise StickError("Protocol is not loaded") - if await wait_for(connected_future, 5): + self._sender = StickSender(self._receiver, self._serial_transport) + if connected_future.result(): await self._handle_stick_event(StickEvent.CONNECTED) self._connected = True self._subscribe_to_stick_events() From da6ef1ee770b0fe1a79dddf3f64c9606d9df2482 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:19:43 +0100 Subject: [PATCH 028/979] Remove unused lock --- plugwise_usb/connection/receiver.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index beae62a74..05891a1d3 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -20,7 +20,6 @@ from asyncio import ( Future, gather, - Lock, Protocol, get_running_loop, ) @@ -64,7 +63,6 @@ def __init__( self._buffer: bytes = bytes([]) self._connection_state = False - self._stick_lock = Lock() self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} From d980d7eb74f10fa2395a191b32d021d023d46c42 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:21:17 +0100 Subject: [PATCH 029/979] Supply correct mac type to create NetworkRegister --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 887a1697b..33768249f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -57,7 +57,7 @@ def __init__( """Initialize the USB-Stick zigbee network class.""" self._controller = controller self._register = StickNetworkRegister( - controller.mac_coordinator, + bytes(controller.mac_coordinator, encoding=UTF8), controller.send, ) self._is_running: bool = False From 1f373b0154eff85dd88c5aec9aa2fb841b8cf0b2 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:22:40 +0100 Subject: [PATCH 030/979] Use correct function name to send request to controller --- plugwise_usb/network/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 33768249f..0b0dcea6c 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -385,7 +385,7 @@ async def get_node_details( ) # type: ignore [assignment] if ping_response is None: return (None, None) - info_response: NodeInfoResponse | None = await self._controller.submit( + info_response: NodeInfoResponse | None = await self._controller.send( NodeInfoRequest(bytes(mac, UTF8), retries=1) ) # type: ignore [assignment] return (info_response, ping_response) @@ -534,7 +534,7 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" - response: NodeAckResponse | None = await self._controller.submit( + response: NodeAckResponse | None = await self._controller.send( CirclePlusAllowJoiningRequest(state) ) # type: ignore [assignment] if response is None: From 6eeba7a6438fd8219ba377665980797bd6878cfc Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:23:27 +0100 Subject: [PATCH 031/979] Remove useless sleep --- plugwise_usb/connection/queue.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 83eafc589..af5513d38 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -9,7 +9,6 @@ PriorityQueue, Task, get_running_loop, - sleep, ) from collections.abc import Callable from dataclasses import dataclass @@ -133,4 +132,3 @@ async def _submit_worker(self) -> None: break await self._stick.write_to_stick(request) - await sleep(0.0) From fc5b3b25ed5d4c5e0f17b952dd05e15d1f9adcc7 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:25:12 +0100 Subject: [PATCH 032/979] Let BaseException to bubble up as expected error class --- plugwise_usb/connection/queue.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index af5513d38..9ab58c50b 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -103,7 +103,11 @@ async def submit( ) await self._add_request_to_queue(request) - return await request.response_future() + try: + response: PlugwiseResponse = await request.response_future() + except BaseException as exception: # [broad-exception-caught] + raise exception.args[0] + return response async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue and return the session id.""" From e73e677af989f55ebf973de61a221038f7de1027 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:25:37 +0100 Subject: [PATCH 033/979] Correct typing --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 0b0dcea6c..cec006e74 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -71,7 +71,7 @@ def __init__( self._node_event_subscribers: dict[ Callable[[], None], - tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + tuple[Callable[[NodeEvent], Awaitable[None]], NodeEvent | None] ] = {} self._unsubscribe_stick_event: Callable[[], None] | None = None From 4b921e0c1e270b703e5491c41d75ff616d743882 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:30:38 +0100 Subject: [PATCH 034/979] Correct ping attempts and swallow expected StickTimeout errors --- plugwise_usb/network/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index cec006e74..9dd4dffe3 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -270,7 +270,7 @@ async def discover_network_coordinator( ping_response = await self._controller.send( NodePingRequest( bytes(self._controller.mac_coordinator, UTF8), - retries=0 + retries=1 ), ) # type: ignore [assignment] except StickTimeout as err: @@ -377,14 +377,16 @@ async def get_node_details( ping_response: NodePingResponse | None = None if ping_first: # Define ping request with custom timeout - ping_request = NodePingRequest(bytes(mac, UTF8), retries=0) + ping_request = NodePingRequest(bytes(mac, UTF8), retries=1) # ping_request.timeout = 3 - ping_response = await self._controller.submit( - ping_request - ) # type: ignore [assignment] - if ping_response is None: + try: + ping_response = await self._controller.send( + ping_request + ) # type: ignore [assignment] + except StickTimeout: return (None, None) + info_response: NodeInfoResponse | None = await self._controller.send( NodeInfoRequest(bytes(mac, UTF8), retries=1) ) # type: ignore [assignment] From fa62c8e586f16fa88e1c7865637759046e3dec37 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:31:04 +0100 Subject: [PATCH 035/979] Fix starting network --- plugwise_usb/network/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 9dd4dffe3..978346b51 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -500,8 +500,7 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> None: """Discover nodes""" if not self._is_running: - await self._register.start() - self._subscribe_to_protocol_events() + await self.start() await self._discover_registered_nodes() await sleep(0) if load: From 5caf1ad1552aef5a211600bb3917782046d6bd5e Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:34:28 +0100 Subject: [PATCH 036/979] Change seq_id property --- plugwise_usb/messages/__init__.py | 11 +++++++++++ plugwise_usb/messages/requests.py | 5 ++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 1bdec9a1a..9b81387c6 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -16,6 +16,17 @@ def __init__(self, identifier: bytes) -> None: self._mac: bytes | None = None self._checksum: bytes | None = None self._args: list[Any] = [] + self._seq_id: bytes | None = None + + @property + def seq_id(self) -> bytes | None: + """Return sequence id assigned to this request""" + return self._seq_id + + @seq_id.setter + def seq_id(self, seq_id: bytes) -> None: + """Assign sequence id""" + self._seq_id = seq_id @property def identifier(self) -> bytes: diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 93eba195c..25ebb87b0 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -45,7 +45,6 @@ class PlugwiseRequest(PlugwiseMessage): arguments: list = [] priority: Priority = Priority.MEDIUM - seq_id: bytes | None = None def __init__( self, @@ -120,9 +119,9 @@ def assign_error(self, error: StickError) -> None: def _update_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node""" - if self.seq_id is None: + if self._seq_id is None: pass - if self.seq_id == response.seq_id: + if self._seq_id == response.seq_id: self._response = response self._response_timeout.cancel() self._response_future.set_result(response) From a50dcdc720aacab66430833ca4f040160af345cb Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:34:49 +0100 Subject: [PATCH 037/979] Add discover_nodes function --- plugwise_usb/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 460112999..260b6f49e 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -339,6 +339,16 @@ async def discover_coordinator(self, load: bool = False) -> None: ) await self._network.discover_network_coordinator(load=load) + @raise_not_connected + @raise_not_initialized + async def discover_nodes(self, load: bool = False) -> None: + """Setup connection to Zigbee network coordinator.""" + if self._network is None: + raise StickError( + "Cannot load nodes when network is not initialized" + ) + await self._network.discover_nodes(load=load) + @raise_not_connected @raise_not_initialized async def register_node(self, mac: str) -> bool: From 249d008cc05944b245002da8f3abe47e652d3075 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:41:01 +0100 Subject: [PATCH 038/979] Add initial tests --- testdata/stick.py | 1349 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_usb.py | 437 ++++++++++++++- 2 files changed, 1781 insertions(+), 5 deletions(-) create mode 100644 testdata/stick.py diff --git a/testdata/stick.py b/testdata/stick.py new file mode 100644 index 000000000..63e05cc9f --- /dev/null +++ b/testdata/stick.py @@ -0,0 +1,1349 @@ +from datetime import datetime, timedelta, UTC +import importlib + +pw_constants = importlib.import_module("plugwise_usb.constants") + +# test using utc timezone +utc_now = datetime.utcnow().replace(tzinfo=UTC) + + +# generate energy log timestamps with fixed hour timestamp used in tests +hour_timestamp = utc_now.replace(hour=23, minute=0, second=0, microsecond=0) + +LOG_TIMESTAMPS = {} +_one_hour = timedelta(hours=1) +for x in range(168): + delta_month = hour_timestamp - hour_timestamp.replace(day=1, hour=0) + LOG_TIMESTAMPS[x] = ( + bytes(("%%0%dX" % 2) % (hour_timestamp.year - 2000), pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % hour_timestamp.month, pw_constants.UTF8) + + bytes( + ("%%0%dX" % 4) + % int((delta_month.days * 1440) + (delta_month.seconds / 60)), + pw_constants.UTF8, + ) + ) + hour_timestamp -= _one_hour + + +RESPONSE_MESSAGES = { + b"\x05\x05\x03\x03000AB43C\r\n": ( + "STICK INIT", + b"000000C1", # Success ack + b"0011" # msg_id + + b"0123456789012345" # stick mac + + b"00" # unknown1 + + b"01" # network_is_online + + b"0098765432101234" # circle_plus_mac + + b"4321" # network_id + + b"00", # unknown2 + ), + b"\x05\x05\x03\x03002300987654321012341AE2\r\n": ( + "Node Info of network controller 0098765432101234", + b"000000C1", # Success ack + b"0024" # msg_id + + b"0098765432101234" # mac + + b"22026A68" # datetime + + b"00044280" # log address 20 + + b"01" # relay + + b"01" # hz + + b"000000070073" # hw_ver + + b"4E0843A9" # fw_ver + + b"01", # node_type (Circle+) + ), + b"\x05\x05\x03\x03000D0098765432101234C208\r\n": ( + "ping reply for 0098765432101234", + b"000000C1", # Success ack + b"000E" + + b"0098765432101234" + + b"45" # rssi in + + b"46" # rssi out + + b"0432", # roundtrip + ), + b"\x05\x05\x03\x030018009876543210123400BEF9\r\n": ( + "SCAN 00", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"1111111111111111" + b"00", + ), + b"\x05\x05\x03\x030018009876543210123401AED8\r\n": ( + "SCAN 01", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"2222222222222222" + b"01", + ), + b"\x05\x05\x03\x0300180098765432101234029EBB\r\n": ( + "SCAN 02", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"3333333333333333" + b"02", + ), + b"\x05\x05\x03\x0300180098765432101234038E9A\r\n": ( + "SCAN 03", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"4444444444444444" + b"03", + ), + b"\x05\x05\x03\x030018009876543210123404FE7D\r\n": ( + "SCAN 04", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"5555555555555555" + b"04", + ), + b"\x05\x05\x03\x030018009876543210123405EE5C\r\n": ( + "SCAN 05", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"6666666666666666" + b"05", + ), + b"\x05\x05\x03\x030018009876543210123406DE3F\r\n": ( + "SCAN 06", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"7777777777777777" + b"06", + ), + b"\x05\x05\x03\x030018009876543210123407CE1E\r\n": ( + "SCAN 07", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"8888888888888888" + b"07", + ), + b"\x05\x05\x03\x0300180098765432101234083FF1\r\n": ( + "SCAN 08", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"08", + ), + b"\x05\x05\x03\x0300180098765432101234092FD0\r\n": ( + "SCAN 09", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"09", + ), + b"\x05\x05\x03\x03001800987654321012340AD04F\r\n": ( + "SCAN 10", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0A", + ), + b"\x05\x05\x03\x03001800987654321012340BE02C\r\n": ( + "SCAN 11", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0B", + ), + b"\x05\x05\x03\x03001800987654321012340CF00D\r\n": ( + "SCAN 12", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0C", + ), + b"\x05\x05\x03\x03001800987654321012340D80EA\r\n": ( + "SCAN 13", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0D", + ), + b"\x05\x05\x03\x03001800987654321012340E90CB\r\n": ( + "SCAN 14", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0E", + ), + b"\x05\x05\x03\x03001800987654321012340FA0A8\r\n": ( + "SCAN 15", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"0F", + ), + b"\x05\x05\x03\x0300180098765432101234108DC8\r\n": ( + "SCAN 16", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"10", + ), + b"\x05\x05\x03\x0300180098765432101234119DE9\r\n": ( + "SCAN 17", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"11", + ), + b"\x05\x05\x03\x030018009876543210123412AD8A\r\n": ( + "SCAN 18", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"12", + ), + b"\x05\x05\x03\x030018009876543210123413BDAB\r\n": ( + "SCAN 19", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"13", + ), + b"\x05\x05\x03\x030018009876543210123414CD4C\r\n": ( + "SCAN 20", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"14", + ), + b"\x05\x05\x03\x030018009876543210123415DD6D\r\n": ( + "SCAN 21", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"15", + ), + b"\x05\x05\x03\x030018009876543210123416ED0E\r\n": ( + "SCAN 22", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"16", + ), + b"\x05\x05\x03\x030018009876543210123417FD2F\r\n": ( + "SCAN 23", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"17", + ), + b"\x05\x05\x03\x0300180098765432101234180CC0\r\n": ( + "SCAN 24", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"18", + ), + b"\x05\x05\x03\x0300180098765432101234191CE1\r\n": ( + "SCAN 25", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"19", + ), + b"\x05\x05\x03\x03001800987654321012341AE37E\r\n": ( + "SCAN 26", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1A", + ), + b"\x05\x05\x03\x03001800987654321012341BD31D\r\n": ( + "SCAN 27", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1B", + ), + b"\x05\x05\x03\x03001800987654321012341CC33C\r\n": ( + "SCAN 28", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1C", + ), + b"\x05\x05\x03\x03001800987654321012341DB3DB\r\n": ( + "SCAN 29", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1D", + ), + b"\x05\x05\x03\x03001800987654321012341EA3FA\r\n": ( + "SCAN 30", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1E", + ), + b"\x05\x05\x03\x03001800987654321012341F9399\r\n": ( + "SCAN 31", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"1F", + ), + b"\x05\x05\x03\x030018009876543210123420D89B\r\n": ( + "SCAN 32", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"20", + ), + b"\x05\x05\x03\x030018009876543210123421C8BA\r\n": ( + "SCAN 33", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"21", + ), + b"\x05\x05\x03\x030018009876543210123422F8D9\r\n": ( + "SCAN 34", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"22", + ), + b"\x05\x05\x03\x030018009876543210123423E8F8\r\n": ( + "SCAN 35", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"23", + ), + b"\x05\x05\x03\x030018009876543210123424981F\r\n": ( + "SCAN 36", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"24", + ), + b"\x05\x05\x03\x030018009876543210123425883E\r\n": ( + "SCAN 37", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"25", + ), + b"\x05\x05\x03\x030018009876543210123426B85D\r\n": ( + "SCAN 38", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"26", + ), + b"\x05\x05\x03\x030018009876543210123427A87C\r\n": ( + "SCAN 39", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"27", + ), + b"\x05\x05\x03\x0300180098765432101234285993\r\n": ( + "SCAN 40", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"28", + ), + b"\x05\x05\x03\x03001800987654321012342949B2\r\n": ( + "SCAN 41", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"29", + ), + b"\x05\x05\x03\x03001800987654321012342AB62D\r\n": ( + "SCAN 42", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2A", + ), + b"\x05\x05\x03\x03001800987654321012342B864E\r\n": ( + "SCAN 43", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2B", + ), + b"\x05\x05\x03\x03001800987654321012342C966F\r\n": ( + "SCAN 44", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2C", + ), + b"\x05\x05\x03\x03001800987654321012342DE688\r\n": ( + "SCAN 45", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2D", + ), + b"\x05\x05\x03\x03001800987654321012342EF6A9\r\n": ( + "SCAN 46", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2E", + ), + b"\x05\x05\x03\x03001800987654321012342FC6CA\r\n": ( + "SCAN 47", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"2F", + ), + b"\x05\x05\x03\x030018009876543210123430EBAA\r\n": ( + "SCAN 48", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"30", + ), + b"\x05\x05\x03\x030018009876543210123431FB8B\r\n": ( + "SCAN 49", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"31", + ), + b"\x05\x05\x03\x030018009876543210123432CBE8\r\n": ( + "SCAN 50", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"32", + ), + b"\x05\x05\x03\x030018009876543210123433DBC9\r\n": ( + "SCAN 51", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"33", + ), + b"\x05\x05\x03\x030018009876543210123434AB2E\r\n": ( + "SCAN 52", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"34", + ), + b"\x05\x05\x03\x030018009876543210123435BB0F\r\n": ( + "SCAN 53", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"35", + ), + b"\x05\x05\x03\x0300180098765432101234368B6C\r\n": ( + "SCAN 54", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"36", + ), + b"\x05\x05\x03\x0300180098765432101234379B4D\r\n": ( + "SCAN 55", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"37", + ), + b"\x05\x05\x03\x0300180098765432101234386AA2\r\n": ( + "SCAN 56", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"38", + ), + b"\x05\x05\x03\x0300180098765432101234397A83\r\n": ( + "SCAN 57", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"39", + ), + b"\x05\x05\x03\x03001800987654321012343A851C\r\n": ( + "SCAN 58", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3A", + ), + b"\x05\x05\x03\x03001800987654321012343BB57F\r\n": ( + "SCAN 59", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3B", + ), + b"\x05\x05\x03\x03001800987654321012343CA55E\r\n": ( + "SCAN 60", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3C", + ), + b"\x05\x05\x03\x03001800987654321012343DD5B9\r\n": ( + "SCAN 61", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3D", + ), + b"\x05\x05\x03\x03001800987654321012343EC598\r\n": ( + "SCAN 62", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3E", + ), + b"\x05\x05\x03\x03001800987654321012343FF5FB\r\n": ( + "SCAN 63", + b"000000C1", # Success ack + b"0019" + b"0098765432101234" + b"FFFFFFFFFFFFFFFF" + b"3F", + ), + b"\x05\x05\x03\x03000D11111111111111110B1A\r\n": ( + "ping reply for 1111111111111111", + b"000000C1", # Success ack + b"000E" + + b"1111111111111111" + + b"42" # rssi in 66 + + b"45" # rssi out 69 + + b"0237", # roundtrip 567 + ), + b"\x05\x05\x03\x03000D222222222222222234E3\r\n": ( + "ping reply for 2222222222222222", + b"000000C1", # Success ack + b"000E" + + b"2222222222222222" # mac + + b"44" # rssi in + + b"55" # rssi out + + b"4321", # roundtrip + ), + b"\x05\x05\x03\x03000D333333333333333321B4\r\n": ( + "ping reply for 3333333333333333", + b"000000C1", # Success ack + b"000E" + + b"3333333333333333" # mac + + b"44" # rssi in + + b"55" # rssi out + + b"4321", # roundtrip + ), + b"\x05\x05\x03\x03000D44444444444444444B11\r\n": ( + "ping reply for 4444444444444444", + b"000000C1", # Success ack + b"000E" # msg_id + + b"4444444444444444" # mac + + b"33" # rssi in + + b"44" # rssi out + + b"1234", # roundtrip + ), + b"\x05\x05\x03\x03000D55555555555555555E46\r\n": ( + "ping timeout for 5555555555555555", + b"000000E1", # Timeout + None, + ), + b"\x05\x05\x03\x03000D666666666666666661BF\r\n": ( + "ping timeout for 6666666666666666", + b"000000E1", # Timeout + None, + ), + b"\x05\x05\x03\x03000D777777777777777774E8\r\n": ( + "ping timeout for 7777777777777777", + b"000000E1", # Timeout + None, + ), + b"\x05\x05\x03\x03000D8888888888888888B4F5\r\n": ( + "ping timeout for 8888888888888888", + b"000000E1", # Timeout + None, + ), + b"\x05\x05\x03\x0300231111111111111111D3F0\r\n": ( + "Node info for 1111111111111111", + b"000000C1", # Success ack + b"0024" # msg_id + + b"1111111111111111" # mac + + b"22026A68" # datetime + + b"000442C0" # log address 44000 + + b"01" # relay + + b"01" # hz + + b"000000070140" # hw_ver + + b"4E0844C2" # fw_ver + + b"02", # node_type (Circle) + ), + b"\x05\x05\x03\x0300232222222222222222EC09\r\n": ( + "Node info for 2222222222222222", + b"000000C1", # Success ack + b"0024" # msg_id + + b"2222222222222222" # mac + + b"22026A68" # datetime + + b"00044300" # log address + + b"01" # relay + + b"01" # hz + + b"000000090011" # hw_ver + + b"4EB28FD5" # fw_ver + + b"09", # node_type (Stealth - Legrand) + ), + b"\x05\x05\x03\x03013822222222222222220000265D\r\n": ( + "Get Node relay init state for 2222222222222222", + b"000000C1", # Success ack + b"0139" # msg_id + + b"2222222222222222" # mac + + b"00" # is_get + + b"01", # relay config + ), + b"\x05\x05\x03\x03013822222222222222220100116D\r\n": ( + "Set Node relay init state off for 2222222222222222", + b"000000C1", # Success ack + b"0139" # msg_id + + b"2222222222222222" # mac + + b"01" # is_get + + b"00", # relay config + ), + b"\x05\x05\x03\x03013822222222222222220101014C\r\n": ( + "Set Node relay init state on for 2222222222222222", + b"000000C1", # Success ack + b"0139" # msg_id + + b"2222222222222222" # mac + + b"01" # is_get + + b"01", # relay config + ), + b"\x05\x05\x03\x0300233333333333333333F95E\r\n": ( + "Node info for 3333333333333333", + b"000000C1", # Success ack + b"0024" # msg_id + + b"3333333333333333" # mac + + b"22026A68" # datetime + + b"00044340" # log address + + b"01" # relay + + b"01" # hz + + b"000000070073" # hw_ver + + b"4DCCDB7B" # fw_ver + + b"02", # node_type (Circle) + ), + b"\x05\x05\x03\x030023444444444444444493FB\r\n": ( + "Node info for 4444444444444444", + b"000000C1", # Success ack + b"0024" # msg_id + + b"4444444444444444" # mac + + b"22026A68" # datetime + + b"000443C0" # log address + + b"01" # relay + + b"01" # hz + + b"000000070073" # hw_ver + + b"4E0844C2" # fw_ver + + b"02", # node_type (Circle) + ), + b"\x05\x05\x03\x03002600987654321012344988\r\n": ( + "Calibration for 0098765432101234", + b"000000C1", # Success ack + b"0027" # msg_id + + b"0098765432101234" # mac + + b"3F80308E" # gain_a + + b"B66CF94F" # gain_b + + b"00000000" # off_tot + + b"BD14BFEC", # off_noise + ), + b"\x05\x05\x03\x0300261111111111111111809A\r\n": ( + "Calibration for 1111111111111111", + b"000000C1", # Success ack + b"0027" # msg_id + + b"1111111111111111" # mac + + b"3F7AE254" # gain_a + + b"B638FFB4" # gain_b + + b"00000000" # off_tot + + b"BC726F67", # off_noise + ), + b"\x05\x05\x03\x0300262222222222222222BF63\r\n": ( + "Calibration for 2222222222222222", + b"000000C1", # Success ack + b"0027" # msg_id + + b"2222222222222222" # mac + + b"3F806192" # gain_a + + b"B56D8019" # gain_b + + b"00000000" # off_tot + + b"BB4FA127", # off_noise + ), + b"\x05\x05\x03\x0300263333333333333333AA34\r\n": ( + "Calibration for 3333333333333333", + b"000000C1", # Success ack + b"0027" # msg_id + + b"3333333333333333" # mac + + b"3F7D8AC6" # gain_a + + b"B5F45E13" # gain_b + + b"00000000" # off_tot + + b"3CC3A53F", # off_noise + ), + b"\x05\x05\x03\x0300264444444444444444C091\r\n": ( + "Calibration for 4444444444444444", + b"000000C1", # Success ack + b"0027" # msg_id + + b"4444444444444444" # mac + + b"3F7D8AC6" # gain_a + + b"B5F45E13" # gain_b + + b"00000000" # off_tot + + b"3CC3A53F", # off_noise + ), + b"\x05\x05\x03\x03013844444444444444440000265D\r\n": ( + "Get Node relay init state for 4444444444444444", + b"000000C1", # Success ack + b"0139" # msg_id + + b"4444444444444444" # mac + + b"00" # is_get + + b"01", # relay config + ), + b"\x05\x05\x03\x0300290098765432101234BC36\r\n": ( + "Realtime clock for 0098765432101234", + b"000000C1", # Success ack + b"003A" # msg_id + + b"0098765432101234" # mac + + bytes(("%%0%dd" % 2) % utc_now.second, pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % utc_now.minute, pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % utc_now.hour, pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % utc_now.weekday(), pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % utc_now.day, pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % utc_now.month, pw_constants.UTF8) + + bytes(("%%0%dd" % 2) % (utc_now.year - 2000), pw_constants.UTF8), + ), + b"\x05\x05\x03\x03003E11111111111111111B8A\r\n": ( + "clock for 0011111111111111", + b"000000C1", # Success ack + b"003F" # msg_id + + b"1111111111111111" # mac + + bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) + + b"00" # unknown + + b"0000", # unknown2 + ), + b"\x05\x05\x03\x03003E22222222222222222473\r\n": ( + "clock for 2222222222222222", + b"000000C1", # Success ack + b"003F" # msg_id + + b"2222222222222222" # mac + + bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) + + b"00" # unknown + + b"0000", # unknown2 + ), + b"\x05\x05\x03\x03003E33333333333333333124\r\n": ( + "clock for 3333333333333333", + b"000000C1", # Success ack + b"003F" # msg_id + + b"3333333333333333" # mac + + bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) + + b"00" # unknown + + b"0000", # unknown2 + ), + b"\x05\x05\x03\x03003E44444444444444445B81\r\n": ( + "clock for 4444444444444444", + b"000000C1", # Success ack + b"003F" # msg_id + + b"4444444444444444" # mac + + bytes(("%%0%dX" % 2) % utc_now.hour, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.minute, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.second, pw_constants.UTF8) + + bytes(("%%0%dX" % 2) % utc_now.weekday(), pw_constants.UTF8) + + b"00" # unknown + + b"0000", # unknown2 + ), + b"\x05\x05\x03\x03001700987654321012340104F9\r\n": ( + "Relay on for 0098765432101234", + b"000000C1", # Success ack + b"0000" # msg_id + + b"00D8" # ack id for RelaySwitchedOn + + b"0098765432101234", # mac + ), + b"\x05\x05\x03\x03001700987654321012340014D8\r\n": ( + "Relay off for 0098765432101234", + b"000000C1", # Success ack + b"0000" # msg_id + + b"00DE" # ack id for RelaySwitchedOff + + b"0098765432101234", # mac + ), + b"\x05\x05\x03\x030023555555555555555586AC\r\n": ( + "Node info for 5555555555555555", + b"000000C1", # Success ack + b"0024" # msg_id + + b"5555555555555555" # mac + + b"22026A68" # datetime + + b"00000000" # log address + + b"00" # relay + + b"01" # hz + + b"000000080007" # hw_ver + + b"4E084590" # fw_ver + + b"06", # node_type (Scan) + ), + b"\x05\x05\x03\x03001200987654321012340A72\r\n": ( + "Power usage for 0098765432101234", + b"000000C1", # Success ack + b"0013" # msg_id + + b"0098765432101234" # mac + + b"000A" # pulses 1s + + b"0066" # pulses 8s + + b"00001234" + + b"00000000" + + b"0004", + ), + b"\x05\x05\x03\x0300480098765432101234000442808C54\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 20", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[2] # datetime + + b"00000000" + + LOG_TIMESTAMPS[1] # datetime + + b"00111111" + + LOG_TIMESTAMPS[0] # datetime + + b"00111111" + + b"FFFFFFFF" # datetime + + b"00000000" + + b"00044280", # log address + ), + b"\x05\x05\x03\x030048009876543210123400044260AF5B\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 19", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[6] # datetime + + b"00000000" + + LOG_TIMESTAMPS[5] # datetime + + b"00000000" + + LOG_TIMESTAMPS[4] # datetime + + b"00000000" + + LOG_TIMESTAMPS[3] # datetime + + b"00000000" + + b"00044260", + ), + b"\x05\x05\x03\x030048009876543210123400044240C939\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 18", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[10] # datetime + + b"00000000" + + LOG_TIMESTAMPS[9] # datetime + + b"00000000" + + LOG_TIMESTAMPS[8] # datetime + + b"00000000" + + LOG_TIMESTAMPS[7] # datetime + + b"00000000" + + b"00044240", + ), + b"\x05\x05\x03\x030048009876543210123400044220639F\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 17", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[14] # datetime + + b"00000000" + + LOG_TIMESTAMPS[13] # datetime + + b"00000000" + + LOG_TIMESTAMPS[12] # datetime + + b"00000000" + + LOG_TIMESTAMPS[11] # datetime + + b"00000000" + + b"00044220", + ), + b"\x05\x05\x03\x03004800987654321012340004420005FD\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 16", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[18] # datetime + + b"00000000" + + LOG_TIMESTAMPS[17] # datetime + + b"00000000" + + LOG_TIMESTAMPS[16] # datetime + + b"00000000" + + LOG_TIMESTAMPS[15] # datetime + + b"00000000" + + b"00044200", + ), + b"\x05\x05\x03\x0300480098765432101234000441E0AB01\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 15", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[22] # datetime + + b"00000000" + + LOG_TIMESTAMPS[21] # datetime + + b"00000000" + + LOG_TIMESTAMPS[20] # datetime + + b"00000000" + + LOG_TIMESTAMPS[19] # datetime + + b"00000000" + + b"000441E0", + ), + b"\x05\x05\x03\x0300480098765432101234000441C001A7\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 14", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[26] # datetime + + b"00001234" + + LOG_TIMESTAMPS[25] # datetime + + b"00000080" + + LOG_TIMESTAMPS[24] # datetime + + b"00000050" + + LOG_TIMESTAMPS[23] # datetime + + b"00000000" + + b"000441C0", + ), + b"\x05\x05\x03\x0300480098765432101234000441A067C5\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 13", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[30] # datetime + + b"00000512" + + LOG_TIMESTAMPS[29] # datetime + + b"00001224" + + LOG_TIMESTAMPS[28] # datetime + + b"00000888" + + LOG_TIMESTAMPS[27] # datetime + + b"00009999" + + b"000441A0", + ), + b"\x05\x05\x03\x030048009876543210123400044180D504\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 12", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[34] # datetime + + b"00000212" + + LOG_TIMESTAMPS[33] # datetime + + b"00001664" + + LOG_TIMESTAMPS[32] # datetime + + b"00000338" + + LOG_TIMESTAMPS[31] # datetime + + b"00001299" + + b"00044180", + ), + b"\x05\x05\x03\x030048009876543210123400044160F60B\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 11", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[38] # datetime + + b"00001512" + + LOG_TIMESTAMPS[37] # datetime + + b"00004324" + + LOG_TIMESTAMPS[36] # datetime + + b"00000338" + + LOG_TIMESTAMPS[35] # datetime + + b"00006666" + + b"00044160", + ), + b"\x05\x05\x03\x0300480098765432101234000441409069\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 10", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[42] # datetime + + b"00001542" + + LOG_TIMESTAMPS[41] # datetime + + b"00004366" + + LOG_TIMESTAMPS[40] # datetime + + b"00000638" + + LOG_TIMESTAMPS[39] # datetime + + b"00005231" + + b"00044140", + ), + b"\x05\x05\x03\x0300480098765432101234000441203ACF\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 9", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[46] # datetime + + b"00001542" + + LOG_TIMESTAMPS[45] # datetime + + b"00004366" + + LOG_TIMESTAMPS[44] # datetime + + b"00000638" + + LOG_TIMESTAMPS[43] # datetime + + b"00005231" + + b"00044120", + ), + b"\x05\x05\x03\x0300480098765432101234000440E09C31\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 8", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[50] # datetime + + b"00001542" + + LOG_TIMESTAMPS[49] # datetime + + b"00004366" + + LOG_TIMESTAMPS[48] # datetime + + b"00000638" + + LOG_TIMESTAMPS[47] # datetime + + b"00005231" + + b"000440E0", + ), + b"\x05\x05\x03\x0300480098765432101234000440C03697\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 7", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[54] # datetime + + b"00001542" + + LOG_TIMESTAMPS[53] # datetime + + b"00004366" + + LOG_TIMESTAMPS[52] # datetime + + b"00000638" + + LOG_TIMESTAMPS[51] # datetime + + b"00005231" + + b"000440C0", + ), + b"\x05\x05\x03\x0300480098765432101234000440A050F5\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 6", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[58] # datetime + + b"00001542" + + LOG_TIMESTAMPS[57] # datetime + + b"00004366" + + LOG_TIMESTAMPS[56] # datetime + + b"00000638" + + LOG_TIMESTAMPS[55] # datetime + + b"00005231" + + b"000440A0", + ), + b"\x05\x05\x03\x030048009876543210123400044080E234\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 5", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[62] # datetime + + b"00001542" + + LOG_TIMESTAMPS[61] # datetime + + b"00004366" + + LOG_TIMESTAMPS[60] # datetime + + b"00000638" + + LOG_TIMESTAMPS[59] # datetime + + b"00005231" + + b"00044080", + ), + b"\x05\x05\x03\x030048009876543210123400044060C13B\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 4", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[66] # datetime + + b"00001542" + + LOG_TIMESTAMPS[65] # datetime + + b"00004366" + + LOG_TIMESTAMPS[64] # datetime + + b"00000638" + + LOG_TIMESTAMPS[63] # datetime + + b"00005231" + + b"00044060", + ), + b"\x05\x05\x03\x030048009876543210123400044040A759\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 3", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[70] # datetime + + b"00001542" + + LOG_TIMESTAMPS[69] # datetime + + b"00004366" + + LOG_TIMESTAMPS[68] # datetime + + b"00000638" + + LOG_TIMESTAMPS[67] # datetime + + b"00005231" + + b"00044040", + ), + b"\x05\x05\x03\x0300480098765432101234000440200DFF\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 2", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[74] # datetime + + b"00001542" + + LOG_TIMESTAMPS[73] # datetime + + b"00004366" + + LOG_TIMESTAMPS[72] # datetime + + b"00000638" + + LOG_TIMESTAMPS[71] # datetime + + b"00005231" + + b"00044020", + ), + b"\x05\x05\x03\x0300480098765432101234000441005CAD\r\n": ( + "Energy log for 0098765432101234 @ log ADDRESS 1", + b"000000C1", # Success ack + b"0049" # msg_id + + b"0098765432101234" # mac + + LOG_TIMESTAMPS[78] # datetime + + b"00001542" + + LOG_TIMESTAMPS[77] # datetime + + b"00004366" + + LOG_TIMESTAMPS[76] # datetime + + b"00000638" + + LOG_TIMESTAMPS[75] # datetime + + b"00005231" + + b"00044100", + ), + b"\x05\x05\x03\x0300121111111111111111C360\r\n": ( + "Power usage for 1111111111111111", + b"000000C1", # Success ack + b"0013" # msg_id + + b"1111111111111111" # mac + + b"005A" # pulses 1s + + b"0098" # pulses 8s + + b"00008787" + + b"00008123" + + b"0004", + ), + b"\x05\x05\x03\x0300481111111111111111000442C05D37\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 20", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[0] # datetime + + b"00222222" + + LOG_TIMESTAMPS[0] # datetime + + b"00111111" + + b"FFFFFFFF" # datetime + + b"00000000" + + b"FFFFFFFF" # datetime + + b"00000000" + + b"000442C0", # log address + ), + b"\x05\x05\x03\x0300481111111111111111000442A03B55\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 19", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[2] # datetime + + b"00002000" + + LOG_TIMESTAMPS[2] # datetime + + b"00001000" + + LOG_TIMESTAMPS[1] # datetime + + b"00000500" + + LOG_TIMESTAMPS[1] # datetime + + b"00000250" + + b"000442A0", + ), + b"\x05\x05\x03\x0300481111111111111111000442808994\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 18", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[4] # datetime + + b"00000000" + + LOG_TIMESTAMPS[4] # datetime + + b"00000000" + + LOG_TIMESTAMPS[3] # datetime + + b"00008000" + + LOG_TIMESTAMPS[3] # datetime + + b"00004000" + + b"00044280", + ), + b"\x05\x05\x03\x030048111111111111111100044260AA9B\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 17", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[6] # datetime + + b"00000800" + + LOG_TIMESTAMPS[6] # datetime + + b"00000400" + + LOG_TIMESTAMPS[5] # datetime + + b"00040000" + + LOG_TIMESTAMPS[5] # datetime + + b"00020000" + + b"00044260", + ), + b"\x05\x05\x03\x030048111111111111111100044240CCF9\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 16", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[8] # datetime + + b"00000000" + + LOG_TIMESTAMPS[8] # datetime + + b"00000000" + + LOG_TIMESTAMPS[7] # datetime + + b"00000000" + + LOG_TIMESTAMPS[7] # datetime + + b"00000000" + + b"00044240", + ), + b"\x05\x05\x03\x030048111111111111111100044220665F\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 14", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[10] # datetime + + b"00004444" + + LOG_TIMESTAMPS[10] # datetime + + b"00002222" + + LOG_TIMESTAMPS[9] # datetime + + b"00011111" + + LOG_TIMESTAMPS[9] # datetime + + b"00022222" + + b"00044220", + ), + b"\x05\x05\x03\x030048111111111111111100044200003D\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 13", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[12] # datetime + + b"00000660" + + LOG_TIMESTAMPS[12] # datetime + + b"00000330" + + LOG_TIMESTAMPS[11] # datetime + + b"00006400" + + LOG_TIMESTAMPS[11] # datetime + + b"00003200" + + b"00044200", # log address + ), + b"\x05\x05\x03\x0300481111111111111111000441E0AEC1\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 12", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[12] # datetime + + b"00000660" + + LOG_TIMESTAMPS[12] # datetime + + b"00000330" + + LOG_TIMESTAMPS[11] # datetime + + b"00006400" + + LOG_TIMESTAMPS[11] # datetime + + b"00003200" + + b"000441E0", # log address + ), + b"\x05\x05\x03\x0300481111111111111111000441C00467\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 11", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[14] # datetime + + b"00000512" + + LOG_TIMESTAMPS[14] # datetime + + b"00000254" + + LOG_TIMESTAMPS[13] # datetime + + b"00000888" + + LOG_TIMESTAMPS[13] # datetime + + b"00000444" + + b"000441C0", + ), + b"\x05\x05\x03\x0300481111111111111111000441A06205\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 10", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[16] # datetime + + b"00000512" + + LOG_TIMESTAMPS[16] # datetime + + b"00001224" + + LOG_TIMESTAMPS[15] # datetime + + b"00000888" + + LOG_TIMESTAMPS[15] # datetime + + b"00009999" + + b"000441A0", + ), + b"\x05\x05\x03\x030048111111111111111100044180D0C4\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 9", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[18] # datetime + + b"00000512" + + LOG_TIMESTAMPS[18] # datetime + + b"00001224" + + LOG_TIMESTAMPS[17] # datetime + + b"00000888" + + LOG_TIMESTAMPS[17] # datetime + + b"00000444" + + b"00044180", + ), + b"\x05\x05\x03\x030048111111111111111100044160F3CB\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 8", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[20] # datetime + + b"00006666" + + LOG_TIMESTAMPS[20] # datetime + + b"00003333" + + LOG_TIMESTAMPS[19] # datetime + + b"00004848" + + LOG_TIMESTAMPS[19] # datetime + + b"00002424" + + b"00044160", + ), + b"\x05\x05\x03\x03004811111111111111110004414095A9\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 7", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[22] # datetime + + b"00000512" + + LOG_TIMESTAMPS[22] # datetime + + b"00001224" + + LOG_TIMESTAMPS[21] # datetime + + b"00000888" + + LOG_TIMESTAMPS[21] # datetime + + b"00009999" + + b"00044140", + ), + b"\x05\x05\x03\x0300481111111111111111000441203F0F\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 6", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[25] # datetime + + b"00001024" + + LOG_TIMESTAMPS[25] # datetime + + b"00000512" + + LOG_TIMESTAMPS[24] # datetime + + b"00004646" + + LOG_TIMESTAMPS[24] # datetime + + b"00002323" + + b"00044120", + ), + b"\x05\x05\x03\x030048111111111111111100044100596D\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 5", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[27] # datetime + + b"00001024" + + LOG_TIMESTAMPS[27] # datetime + + b"00000512" + + LOG_TIMESTAMPS[26] # datetime + + b"00004646" + + LOG_TIMESTAMPS[26] # datetime + + b"00002323" + + b"00044100", + ), + b"\x05\x05\x03\x0300481111111111111111000440E099F1\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 4", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[29] # datetime + + b"00001024" + + LOG_TIMESTAMPS[29] # datetime + + b"00000512" + + LOG_TIMESTAMPS[28] # datetime + + b"00004646" + + LOG_TIMESTAMPS[28] # datetime + + b"00002323" + + b"000440E0", + ), + b"\x05\x05\x03\x0300481111111111111111000440C03357\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 3", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[31] # datetime + + b"00001024" + + LOG_TIMESTAMPS[31] # datetime + + b"00000512" + + LOG_TIMESTAMPS[30] # datetime + + b"00004646" + + LOG_TIMESTAMPS[30] # datetime + + b"00002323" + + b"000440C0", + ), + b"\x05\x05\x03\x0300481111111111111111000440A05535\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 2", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[33] # datetime + + b"00001024" + + LOG_TIMESTAMPS[33] # datetime + + b"00000512" + + LOG_TIMESTAMPS[32] # datetime + + b"00004646" + + LOG_TIMESTAMPS[32] # datetime + + b"00002323" + + b"000440A0", + ), + b"\x05\x05\x03\x030048111111111111111100044080E7F4\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 1", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[35] # datetime + + b"00001024" + + LOG_TIMESTAMPS[35] # datetime + + b"00000512" + + LOG_TIMESTAMPS[34] # datetime + + b"00004646" + + LOG_TIMESTAMPS[34] # datetime + + b"00002323" + + b"00044080", + ), + b"\x05\x05\x03\x030048111111111111111100044060C4FB\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 1", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[38] # datetime + + b"00001024" + + LOG_TIMESTAMPS[38] # datetime + + b"00000512" + + LOG_TIMESTAMPS[36] # datetime + + b"00004646" + + LOG_TIMESTAMPS[36] # datetime + + b"00002323" + + b"00044060", + ), + b"\x05\x05\x03\x030048111111111111111100044040A299\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 1", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[41] # datetime + + b"00001024" + + LOG_TIMESTAMPS[41] # datetime + + b"00000512" + + LOG_TIMESTAMPS[40] # datetime + + b"00004646" + + LOG_TIMESTAMPS[40] # datetime + + b"00002323" + + b"00044040", + ), + b"\x05\x05\x03\x030048111111111111111100044020083F\r\n": ( + "Energy log for 1111111111111111 @ log ADDRESS 6", + b"000000C1", # Success ack + b"0049" # msg_id + + b"1111111111111111" # mac + + LOG_TIMESTAMPS[43] # datetime + + b"00000512" + + LOG_TIMESTAMPS[43] # datetime + + b"00000254" + + LOG_TIMESTAMPS[42] # datetime + + b"00000888" + + LOG_TIMESTAMPS[42] # datetime + + b"00000444" + + b"00044020", + ), +} + + +PARTLY_RESPONSE_MESSAGES = { + b"\x05\x05\x03\x0300161111111111111111": ( + "Clock set 1111111111111111", + b"000000C1", # Success ack + b"0000" + b"00D7" + b"1111111111111111", # msg_id, ClockAccepted, mac + ), + b"\x05\x05\x03\x0300162222222222222222": ( + "Clock set 2222222222222222", + b"000000C1", # Success ack + b"0000" + b"00D7" + b"2222222222222222", # msg_id, ClockAccepted, mac + ), + b"\x05\x05\x03\x0300163333333333333333": ( + "Clock set 3333333333333333", + b"000000C1", # Success ack + b"0000" + b"00D7" + b"3333333333333333", # msg_id, ClockAccepted, mac + ), + b"\x05\x05\x03\x0300164444444444444444": ( + "Clock set 4444444444444444", + b"000000C1", # Success ack + b"0000" + b"00D7" + b"4444444444444444", # msg_id, ClockAccepted, mac + ), +} + +SECOND_RESPONSE_MESSAGES = { + b"\x05\x05\x03\x03000D55555555555555555E46\r\n": ( + "ping reply for 5555555555555555", + b"000000C1", # Success ack + b"000E" + + b"5555555555555555" # mac + + b"44" # rssi in + + b"33" # rssi out + + b"0055", # roundtrip + ) +} diff --git a/tests/test_usb.py b/tests/test_usb.py index bcffb953b..389383f3c 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,11 +1,438 @@ -# pylint: disable=protected-access -"""Test Plugwise Stick features.""" - +import aiofiles +import asyncio +from concurrent import futures +from datetime import datetime as dt, timedelta as td, timezone as tz import importlib +import logging +from unittest import mock +from unittest.mock import Mock + +import crcmod +from freezegun import freeze_time +import pytest + + +crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) -pw_constants = importlib.import_module("plugwise_usb.constants") -pw_exceptions = importlib.import_module("plugwise_usb.exceptions") pw_stick = importlib.import_module("plugwise_usb") +pw_api = importlib.import_module("plugwise_usb.api") +pw_exceptions = importlib.import_module("plugwise_usb.exceptions") +pw_connection = importlib.import_module("plugwise_usb.connection") +pw_connection_manager = importlib.import_module( + "plugwise_usb.connection.manager" +) +pw_network = importlib.import_module("plugwise_usb.network") +pw_receiver = importlib.import_module("plugwise_usb.connection.receiver") +pw_sender = importlib.import_module("plugwise_usb.connection.sender") +pw_constants = importlib.import_module("plugwise_usb.constants") +pw_requests = importlib.import_module("plugwise_usb.messages.requests") +pw_responses = importlib.import_module("plugwise_usb.messages.responses") +pw_userdata = importlib.import_module("testdata.stick") +pw_energy_counter = importlib.import_module( + "plugwise_usb.nodes.helpers.counter" +) +pw_energy_calibration = importlib.import_module( + "plugwise_usb.nodes.helpers" +) +pw_energy_pulses = importlib.import_module("plugwise_usb.nodes.helpers.pulses") + +_LOGGER = logging.getLogger(__name__) +_LOGGER.setLevel(logging.DEBUG) + + +def inc_seq_id(seq_id: bytes) -> bytes: + """Increment sequence id.""" + if seq_id is None: + return b"0000" + temp_int = int(seq_id, 16) + 1 + if temp_int >= 65532: + temp_int = 0 + temp_str = str(hex(temp_int)).lstrip("0x").upper() + while len(temp_str) < 4: + temp_str = "0" + temp_str + return temp_str.encode() + + +def construct_message(data: bytes, seq_id: bytes = b"0000") -> bytes: + """construct plugwise message.""" + body = data[:4] + seq_id + data[4:] + return ( + pw_constants.MESSAGE_HEADER + + body + + bytes("%04X" % crc_fun(body), pw_constants.UTF8) + + pw_constants.MESSAGE_FOOTER + ) + + +class DummyTransport: + def __init__(self, loop, test_data=None) -> None: + self._loop = loop + self._msg = 0 + self._seq_id = b"1233" + self.protocol_data_received = None + self._processed = [] + self._first_response = test_data + self._second_response = test_data + if test_data is None: + self._first_response = pw_userdata.RESPONSE_MESSAGES + self._second_response = pw_userdata.SECOND_RESPONSE_MESSAGES + self.random_extra_byte = 0 + + def is_closing(self) -> bool: + return False + + def write(self, data: bytes) -> None: + log = None + if data in self._processed: + log, ack, response = self._second_response.get( + data, (None, None, None) + ) + if log is None: + log, ack, response = self._first_response.get( + data, (None, None, None) + ) + if log is None: + resp = pw_userdata.PARTLY_RESPONSE_MESSAGES.get( + data[:24], (None, None, None) + ) + if resp is None: + _LOGGER.debug("No msg response for %s", str(data)) + return + log, ack, response = resp + if ack is None: + _LOGGER.debug("No ack response for %s", str(data)) + return + + self._seq_id = inc_seq_id(self._seq_id) + self.message_response(ack, self._seq_id) + self._processed.append(data) + if response is None: + return + self._loop.create_task( + # 0.5, + self._delayed_response(response, self._seq_id) + ) + + async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: + await asyncio.sleep(0.5) + self.message_response(data, seq_id) + + def message_response(self, data: bytes, seq_id: bytes) -> None: + self.random_extra_byte += 1 + if self.random_extra_byte > 25: + self.protocol_data_received(b"\x83") + self.random_extra_byte = 0 + self.protocol_data_received( + construct_message(data, seq_id) + b"\x83" + ) + else: + self.protocol_data_received(construct_message(data, seq_id)) + + def close(self) -> None: + pass + + +class MockSerial: + def __init__(self, custom_response) -> None: + self.custom_response = custom_response + self._protocol = None + self._transport = None + + async def mock_connection(self, loop, protocol_factory, **kwargs): + """Mock connection with dummy connection.""" + self._protocol = protocol_factory() + self._transport = DummyTransport(loop, self.custom_response) + self._transport.protocol_data_received = self._protocol.data_received + loop.call_soon_threadsafe( + self._protocol.connection_made, self._transport + ) + return self._transport, self._protocol + + +class TestStick: + + @pytest.mark.asyncio + async def test_sorting_request_messages(self): + """Test request message priority sorting""" + + node_add_request = pw_requests.NodeAddRequest( + b"1111222233334444", True + ) + await asyncio.sleep(0.001) + relay_switch_request = pw_requests.CircleRelaySwitchRequest( + b"1234ABCD12341234", True + ) + await asyncio.sleep(0.001) + circle_plus_allow_joining_request = pw_requests.CirclePlusAllowJoiningRequest( + True + ) + + # validate sorting based on timestamp with same priority level + assert node_add_request < circle_plus_allow_joining_request + assert circle_plus_allow_joining_request > node_add_request + assert circle_plus_allow_joining_request >= node_add_request + assert node_add_request <= circle_plus_allow_joining_request + + # validate sorting based on priority + assert relay_switch_request > node_add_request + assert relay_switch_request >= node_add_request + assert node_add_request < relay_switch_request + assert node_add_request <= relay_switch_request + assert relay_switch_request > circle_plus_allow_joining_request + assert relay_switch_request >= circle_plus_allow_joining_request + assert circle_plus_allow_joining_request < relay_switch_request + assert circle_plus_allow_joining_request <= relay_switch_request + + # Change priority + node_add_request.priority = pw_requests.Priority.LOW + # Validate node_add_request is less than other requests + assert node_add_request < relay_switch_request + assert node_add_request <= relay_switch_request + assert node_add_request < circle_plus_allow_joining_request + assert node_add_request <= circle_plus_allow_joining_request + assert relay_switch_request > node_add_request + assert relay_switch_request >= node_add_request + assert circle_plus_allow_joining_request > node_add_request + assert circle_plus_allow_joining_request >= node_add_request + + @pytest.mark.asyncio + async def test_stick_connect_without_port(self): + """Test connecting to stick without port config""" + stick = pw_stick.Stick() + assert stick.accept_join_request is None + assert stick.nodes == {} + assert stick.joined_nodes is None + with pytest.raises(pw_exceptions.StickError): + assert stick.mac_stick + assert stick.mac_coordinator + assert stick.network_id + assert not stick.network_discovered + assert not stick.network_state + unsub_connect = stick.subscribe_to_stick_events( + stick_event_callback=lambda x: print(x), + events=(pw_api.StickEvent.CONNECTED,), + ) + unsub_nw_online = stick.subscribe_to_stick_events( + stick_event_callback=lambda x: print(x), + events=(pw_api.StickEvent.NETWORK_ONLINE,), + ) + with pytest.raises(pw_exceptions.StickError): + await stick.connect() + stick.port = "null" + await stick.connect() + + @pytest.mark.asyncio + async def test_stick_reconnect(self, monkeypatch): + """Test connecting to stick while already connected""" + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + MockSerial(None).mock_connection, + ) + stick = pw_stick.Stick() + stick.port = "test_port" + assert stick.port == "test_port" + await stick.connect() + # second time should raise + with pytest.raises(pw_exceptions.StickError): + await stick.connect() + await stick.disconnect() + + @pytest.mark.asyncio + async def test_stick_connect_without_response(self, monkeypatch): + """Test connecting to stick without response""" + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + MockSerial( + { + b"dummy": ( + "no response", + b"0000", + None, + ), + } + ).mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + stick = pw_stick.Stick() + stick.port = "test_port" + with pytest.raises(pw_exceptions.StickError): + await stick.initialize() + # Connect + await stick.connect() + # Still raise StickError connected but without response + with pytest.raises(pw_exceptions.StickError): + await stick.initialize() + + @pytest.mark.asyncio + async def test_stick_connect_timeout(self, monkeypatch): + """Test connecting to stick""" + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + MockSerial( + { + b"\x05\x05\x03\x03000AB43C\r\n": ( + "STICK INIT timeout", + b"000000E1", # Timeout ack + None, # + ), + } + ).mock_connection, + ) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 5) + stick = pw_stick.Stick() + await stick.connect("test_port") + with pytest.raises(pw_exceptions.StickTimeout): + await stick.initialize() + await stick.disconnect() + + @pytest.mark.asyncio + async def test_stick_connect(self, monkeypatch): + """Test connecting to stick""" + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + MockSerial(None).mock_connection, + ) + stick = pw_stick.Stick(port="test_port", cache_enabled=False) + await stick.connect("test_port") + await stick.initialize() + assert stick.mac_stick == "0123456789012345" + assert stick.mac_coordinator == "0098765432101234" + assert not stick.network_discovered + assert stick.network_state + assert stick.network_id == 17185 + assert stick.accept_join_request is None + # test failing of join requests without active discovery + with pytest.raises(pw_exceptions.StickError): + stick.accept_join_request = True + await stick.disconnect() + assert not stick.network_state + with pytest.raises(pw_exceptions.StickError): + assert stick.mac_stick + + async def disconnected(self, event): + """Callback helper for stick disconnect event""" + if event is pw_api.StickEvent.DISCONNECTED: + self.test_disconnected.set_result(True) + else: + self.test_disconnected.set_exception(BaseException("Incorrect event")) + + @pytest.mark.asyncio + async def test_stick_connection_lost(self, monkeypatch): + """Test connecting to stick""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + stick = pw_stick.Stick() + await stick.connect("test_port") + await stick.initialize() + assert stick.network_state + self.test_disconnected = asyncio.Future() + unsub_connect = stick.subscribe_to_stick_events( + stick_event_callback=self.disconnected, + events=(pw_api.StickEvent.DISCONNECTED,), + ) + # Trigger disconnect + mock_serial._protocol.connection_lost() + assert await self.test_disconnected + assert not stick.network_state + unsub_connect() + await stick.disconnect() + + async def node_discovered(self, event: pw_api.NodeEvent, mac: str): + """Callback helper for node discovery""" + if event == pw_api.NodeEvent.DISCOVERED: + self.test_node_discovered.set_result(mac) + else: + self.test_node_discovered.set_exception( + BaseException( + f"Invalid {event} event, expected " + + f"{pw_api.NodeEvent.DISCOVERED}" + ) + ) + + async def node_awake(self, event: pw_api.NodeEvent, mac: str): + """Callback helper for node discovery""" + if event == pw_api.NodeEvent.AWAKE: + self.test_node_awake.set_result(mac) + else: + self.test_node_awake.set_exception( + BaseException( + f"Invalid {event} event, expected " + + f"{pw_api.NodeEvent.AWAKE}" + ) + ) + + async def node_motion_state( + self, + feature: pw_api.NodeFeature, + state: bool, + ): + """Callback helper for node_motion event""" + if feature == pw_api.NodeFeature.MOTION: + self.motion_on.set_result(state) + else: + self.motion_off.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" + ) + ) + + async def node_ping( + self, + feature: pw_api.NodeFeature, + ping_collection, + ): + """Callback helper for node ping collection""" + if feature == pw_api.NodeFeature.PING: + self.node_ping_result.set_result(ping_collection) + else: + self.node_ping_result.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.PING}" + ) + ) + + @pytest.mark.asyncio + async def test_stick_node_discovered_subscription(self, monkeypatch): + """Testing "new_node" subscription for Scan""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + stick.accept_join_request = True + self.test_node_awake = asyncio.Future() + unsub_awake = stick.subscribe_to_node_events( + node_event_callback=self.node_awake, + events=(pw_api.NodeEvent.AWAKE,), + ) + self.test_node_discovered = asyncio.Future() + unsub_discovered = stick.subscribe_to_node_events( + node_event_callback=self.node_discovered, + events=(pw_api.NodeEvent.DISCOVERED,), + ) + # Inject NodeAwakeResponse message to trigger a 'node discovered' event + mock_serial._transport.message_response(b"004F555555555555555500", b"FFFE") + mac_awake_node = await self.test_node_awake + assert mac_awake_node == "5555555555555555" + unsub_awake() + # No tests available From c1d7a5e3d679a90510ff646b889f06bb56c1d94d Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 20:46:36 +0100 Subject: [PATCH 039/979] Add missing await --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 8f90c492d..00a3224fa 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -815,7 +815,7 @@ async def node_info_update( """ Update Node hardware information. """ - if not super().node_info_update(node_info): + if not await super().node_info_update(node_info): return False self._node_info_update_state( From 9ba4e3448053231594cf9d2218bed9e30e7a2932 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 21:13:01 +0100 Subject: [PATCH 040/979] Guard against changed dict during runtime due to change subscription --- plugwise_usb/connection/manager.py | 2 +- plugwise_usb/connection/receiver.py | 6 ++++-- plugwise_usb/network/__init__.py | 2 +- plugwise_usb/nodes/helpers/subscription.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index fc03979fe..b77068549 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -73,7 +73,7 @@ async def _handle_stick_event( if len(self._stick_event_subscribers) == 0: return callback_list: list[Callable] = [] - for callback, filtered_events in ( + for callback, filtered_events in list( self._stick_event_subscribers.values() ): if event in filtered_events: diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 05891a1d3..d4b90610e 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -236,7 +236,7 @@ async def _notify_stick_event_subscribers( ) -> None: """Call callback for stick event subscribers""" callback_list: list[Callable] = [] - for callback, filtered_events in ( + for callback, filtered_events in list( self._stick_event_subscribers.values() ): if event in filtered_events: @@ -290,7 +290,9 @@ async def _notify_node_response_subscribers( self, node_response: PlugwiseResponse ) -> None: """Call callback for all node response message subscribers""" - for callback, mac, ids in self._node_response_subscribers.values(): + for callback, mac, ids in list( + self._node_response_subscribers.values() + ): if mac is not None: if mac != node_response.mac: continue diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 978346b51..07a4aad3b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -572,7 +572,7 @@ async def _notify_node_event_subscribers( ) -> None: """Call callback for node event subscribers""" callback_list: list[Callable] = [] - for callback, filtered_events in ( + for callback, filtered_events in list( self._node_event_subscribers.values() ): if event in filtered_events: diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index 5d8accd44..b53b332e4 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -43,7 +43,7 @@ async def publish_feature_update_to_subscribers( ) -> None: """Publish feature to applicable subscribers.""" callback_list: list[Callable] = [] - for callback, filtered_features in ( + for callback, filtered_features in list( self._feature_update_subscribers.values() ): if feature in filtered_features: From f5d295cc5e132622e9b7f67b46583c1ecda5793d Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 21:13:23 +0100 Subject: [PATCH 041/979] Make function async --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 25ebb87b0..b54efbb7f 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -117,7 +117,7 @@ def assign_error(self, error: StickError) -> None: return self._response_future.set_exception(error) - def _update_response(self, response: PlugwiseResponse) -> None: + async def _update_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node""" if self._seq_id is None: pass From 68210b18d1474d6f8af37582655163569076069b Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 21:13:50 +0100 Subject: [PATCH 042/979] Function is not async --- plugwise_usb/nodes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 1be8706f8..a64a96971 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -439,7 +439,7 @@ async def node_info_update( await self._available_update_state(True) - await self._node_info_update_state( + self._node_info_update_state( firmware=node_info.fw_ver.value, hardware=node_info.hw_ver.value.decode(UTF8), node_type=node_info.node_type.value, From 296cef08f6dec498c7aadbf423403f838df35239 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 22:28:18 +0100 Subject: [PATCH 043/979] Remove necessary else --- plugwise_usb/nodes/helpers/pulses.py | 54 ++++++++++++++-------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 98c3f3151..35a466843 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -786,33 +786,33 @@ def _missing_addresses_after( if address not in addresses: addresses.append(address) return addresses - else: - # Production logging active - calc_interval_prod = timedelta(hours=1) - if ( - self._log_interval_production is not None - and self._log_interval_production > 0 - ): - calc_interval_prod = timedelta( - minutes=self._log_interval_production - ) - expected_timestamp_cons = ( - self._logs[address][slot].timestamp + calc_interval_cons - ) - expected_timestamp_prod = ( - self._logs[address][slot].timestamp + calc_interval_prod + # Production logging active + calc_interval_prod = timedelta(hours=1) + if ( + self._log_interval_production is not None + and self._log_interval_production > 0 + ): + calc_interval_prod = timedelta( + minutes=self._log_interval_production ) + + expected_timestamp_cons = ( + self._logs[address][slot].timestamp + calc_interval_cons + ) + expected_timestamp_prod = ( + self._logs[address][slot].timestamp + calc_interval_prod + ) + address, slot = calc_log_address(address, slot, 1) + while ( + expected_timestamp_cons < target + or expected_timestamp_prod < target + ): + if address not in addresses: + addresses.append(address) + if expected_timestamp_prod < expected_timestamp_cons: + expected_timestamp_prod += calc_interval_prod + else: + expected_timestamp_cons += calc_interval_cons address, slot = calc_log_address(address, slot, 1) - while ( - expected_timestamp_cons < target - or expected_timestamp_prod < target - ): - if address not in addresses: - addresses.append(address) - if expected_timestamp_prod < expected_timestamp_cons: - expected_timestamp_prod += calc_interval_prod - else: - expected_timestamp_cons += calc_interval_cons - address, slot = calc_log_address(address, slot, 1) - return addresses + return addresses From f7d8ae2eba02a113cb6128273224593877ee0646 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 22:28:52 +0100 Subject: [PATCH 044/979] Simplify handling awake messages --- plugwise_usb/network/__init__.py | 55 +++++++++----------------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 07a4aad3b..eb828b633 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -2,13 +2,9 @@ # region - Imports from __future__ import annotations -from asyncio import ( - create_task, - gather, - sleep, -) +from asyncio import gather, sleep from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta import logging from .registry import StickNetworkRegister @@ -197,9 +193,18 @@ async def _handle_stick_event(self, event: StickEvent) -> None: async def node_awake_message(self, response: NodeAwakeResponse) -> None: """Handle NodeAwakeResponse message.""" - if response.mac_decoded in self._nodes: - return mac = response.mac_decoded + if self._awake_discovery.get(mac) is None: + self._awake_discovery[mac] = ( + response.timestamp - timedelta(seconds=15) + ) + if mac in self._nodes: + if self._awake_discovery[mac] < ( + response.timestamp - timedelta(seconds=10) + ): + await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + self._awake_discovery[mac] = response.timestamp + return if self._register.network_address(mac) is None: _LOGGER.warning( "Skip node awake message for %s because network " + @@ -208,39 +213,9 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: ) return address: int | None = self._register.network_address(mac) - if self._awake_discovery.get(mac) is None: - _LOGGER.info( - "Node Awake Response from undiscovered node with mac %s" + - ", start discovery", - mac - ) - self._awake_discovery[mac] = datetime.now(UTC) - if self._nodes.get(mac) is None: - await self._discover_and_load_node(address, mac, None) + if self._nodes.get(mac) is None: + await self._discover_and_load_node(address, mac, None) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) - else: - # Skip multiple node awake messages for same node within 10 sec. - - if self._awake_discovery[mac] < ( - datetime.now(UTC) - timedelta(seconds=10) - ): - _LOGGER.info( - "Node Awake Response from previously undiscovered node " + - "with mac %s, start discovery", - mac - ) - self._awake_discovery[mac] = datetime.now(UTC) - if self._nodes.get(mac) is None: - create_task( - self._discover_and_load_node(address, mac, None) - ) - await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) - else: - _LOGGER.debug( - "Skip second Node Awake Response within 10 seconds for " + - "undiscovered node with mac %s", - mac - ) def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" From aa459dcecaf664ac46877e33f972c18977b5d19c Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 22:29:44 +0100 Subject: [PATCH 045/979] Fix updating features based on protocol version --- plugwise_usb/nodes/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index a64a96971..c5c430b98 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -294,12 +294,17 @@ def _setup_protocol( str(firmware.keys()), ) return + new_feature_list = list(self._features) for feature in node_features: if ( required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) ) is not None: - if required_version <= self._node_protocols.min: - self._features += feature + if ( + required_version <= self._node_protocols.min and + feature not in new_feature_list + ): + new_feature_list.append(feature) + self._features = tuple(new_feature_list) async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" From 658b86ecb3f9ddbd029a064ec7d76fee7314d131 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 22:30:00 +0100 Subject: [PATCH 046/979] Fix loading scan nodes --- plugwise_usb/nodes/scan.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 36b33dc80..b3b6965d1 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -50,13 +50,14 @@ async def load(self) -> bool: "Load Scan node %s from cache", self._node_info.mac ) if await self._load_from_cache(): - self._loaded = True - self._setup_protocol( - SCAN_FIRMWARE_SUPPORT, - (NodeFeature.INFO, NodeFeature.MOTION), - ) - return await self.initialize() - + pass + self._loaded = True + self._setup_protocol( + SCAN_FIRMWARE_SUPPORT, + (NodeFeature.INFO, NodeFeature.MOTION), + ) + if await self.initialize(): + return True _LOGGER.debug("Load of Scan node %s failed", self._node_info.mac) return False From 462a3c6d0632c2508a0c6895af3dd194390b6a6b Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 18 Jan 2024 22:30:31 +0100 Subject: [PATCH 047/979] Fix detection of hardware model --- plugwise_usb/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 44236b9a1..c46c68e3d 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -56,7 +56,7 @@ def version_to_model(version: str | None) -> str | None: model = HW_MODELS.get(version[4:10]) if model is None: # Try again with reversed order - model = HW_MODELS.get(version[-2:] + version[-4:-2] + version[-6:-4]) + model = HW_MODELS.get(version[-6:-4] + version[-4:-2] + version[-2:]) return model if model is not None else "Unknown" From c79bc6953bb22444d8d2681dc01d6a9166116f46 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 19 Jan 2024 17:41:51 +0100 Subject: [PATCH 048/979] Move firmware definition Celsius node --- plugwise_usb/nodes/celsius.py | 48 +++++----------------- plugwise_usb/nodes/helpers/firmware.py | 57 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 5d3ae104e..f3ff83e45 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -5,48 +5,15 @@ """ from __future__ import annotations -from datetime import datetime import logging from typing import Final from ..api import NodeFeature +from .helpers.firmware import CELSIUS_FIRMWARE_SUPPORT from ..nodes.sed import NodeSED _LOGGER = logging.getLogger(__name__) -# Minimum and maximum supported (custom) zigbee protocol version based -# on utc timestamp of firmware -# Extracted from "Plugwise.IO.dll" file of Plugwise source installation -FIRMWARE_CELSIUS: Final = { - # Celsius Proto - datetime(2013, 9, 25, 15, 9, 44): ("2.0", "2.6"), - - datetime(2013, 10, 11, 15, 15, 58): ("2.0", "2.6"), - datetime(2013, 10, 17, 10, 13, 12): ("2.0", "2.6"), - datetime(2013, 11, 19, 17, 35, 48): ("2.0", "2.6"), - datetime(2013, 12, 5, 16, 25, 33): ("2.0", "2.6"), - datetime(2013, 12, 11, 10, 53, 55): ("2.0", "2.6"), - datetime(2014, 1, 30, 8, 56, 21): ("2.0", "2.6"), - datetime(2014, 2, 3, 10, 9, 27): ("2.0", "2.6"), - datetime(2014, 3, 7, 16, 7, 42): ("2.0", "2.6"), - datetime(2014, 3, 24, 11, 12, 23): ("2.0", "2.6"), - - # MSPBootloader Image - Required to allow - # a MSPBootload image for OTA update - datetime(2014, 4, 14, 15, 45, 26): ( - "2.0", - "2.6", - ), - - # CelsiusV Image - datetime(2014, 7, 23, 19, 24, 18): ("2.0", "2.6"), - - # CelsiusV Image - datetime(2014, 9, 12, 11, 36, 40): ("2.0", "2.6"), - - # New Flash Update - datetime(2017, 7, 11, 16, 2, 50): ("2.0", "2.6"), -} CELSIUS_FEATURES: Final = ( NodeFeature.INFO, NodeFeature.TEMPERATURE, @@ -67,9 +34,14 @@ async def load(self) -> bool: "Load Celsius node %s from cache", self._node_info.mac ) if await self._load_from_cache(): - self._loaded = True - self._load_features() - return await self.initialize() - + pass + + self._loaded = True + self._setup_protocol( + CELSIUS_FIRMWARE_SUPPORT, + (NodeFeature.INFO, NodeFeature.TEMPERATURE), + ) + if await self.initialize(): + return True _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) return False diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 78365e46a..6d2ff1178 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -251,6 +251,63 @@ min=2.0, max=2.6, ), } + +CELSIUS_FIRMWARE_SUPPORT: Final = { + # Celsius Proto + datetime(2013, 9, 25, 15, 9, 44): SupportedVersions( + min=2.0, max=2.6, + ), + + datetime(2013, 10, 11, 15, 15, 58): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2013, 10, 17, 10, 13, 12): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2013, 11, 19, 17, 35, 48): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2013, 12, 5, 16, 25, 33): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2013, 12, 11, 10, 53, 55): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2014, 1, 30, 8, 56, 21): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2014, 2, 3, 10, 9, 27): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2014, 3, 7, 16, 7, 42): SupportedVersions( + min=2.0, max=2.6, + ), + datetime(2014, 3, 24, 11, 12, 23): SupportedVersions( + min=2.0, max=2.6, + ), + + # MSPBootloader Image - Required to allow + # a MSPBootload image for OTA update + datetime(2014, 4, 14, 15, 45, 26): SupportedVersions( + min=2.0, max=2.6, + ), + + # CelsiusV Image + datetime(2014, 7, 23, 19, 24, 18): SupportedVersions( + min=2.0, max=2.6, + ), + + # CelsiusV Image + datetime(2014, 9, 12, 11, 36, 40): SupportedVersions( + min=2.0, max=2.6, + ), + + # New Flash Update + datetime(2017, 7, 11, 16, 2, 50): SupportedVersions( + min=2.0, max=2.6, + ), +} + # endregion # region - node firmware based features From 08a24c08455496bef5ac5c85fb9e27e1139b248d Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 19 Jan 2024 17:42:09 +0100 Subject: [PATCH 049/979] Add test requirements --- requirements_test.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements_test.txt b/requirements_test.txt index db933ea38..f1ffa09c3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,3 +4,6 @@ pytest-asyncio radon==6.0.1 types-python-dateutil +pyserial-asyncio +aiofiles +freezegun \ No newline at end of file From bf9bfd7898097744f20a071e9d20a22b18599026 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 11:38:24 +0100 Subject: [PATCH 050/979] Improve description and parameter name --- plugwise_usb/connection/__init__.py | 10 ++++++++-- plugwise_usb/connection/manager.py | 9 +++++---- plugwise_usb/connection/receiver.py | 15 ++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index f64cf37c0..4804e0900 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -131,12 +131,18 @@ def subscribe_to_node_responses( self, node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], mac: bytes | None = None, - identifiers: tuple[bytes] | None = None, + message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: + """ + Subscribe a awaitable callback to be called when a specific + message is received. + Returns function to unsubscribe. + """ + return self._manager.subscribe_to_node_responses( node_response_callback, mac, - identifiers, + message_ids, ) async def _handle_stick_event(self, event: StickEvent) -> None: diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index b77068549..fd6c4f2ad 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -117,11 +117,12 @@ def subscribe_to_node_responses( self, node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], mac: bytes | None = None, - identifiers: tuple[bytes] | None = None, + message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: """ - Subscribe to response messages from node(s). - Returns callable function to unsubscribe + Subscribe a awaitable callback to be called when a specific + message is received. + Returns function to unsubscribe. """ if self._receiver is None or not self._receiver.is_connected: raise StickError( @@ -129,7 +130,7 @@ def subscribe_to_node_responses( "is not loaded" ) return self._receiver.subscribe_to_node_responses( - node_response_callback, mac, identifiers + node_response_callback, mac, message_ids ) async def setup_connection_to_stick( diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index d4b90610e..38e105243 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -271,11 +271,12 @@ def subscribe_to_node_responses( self, node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], mac: bytes | None = None, - identifiers: tuple[bytes] | None = None, + message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: """ - Subscribe to response messages from node(s). - Returns callable function to unsubscribe + Subscribe a awaitable callback to be called when a specific + message is received. + Returns function to unsubscribe. """ def remove_listener() -> None: """Remove update listener.""" @@ -283,20 +284,20 @@ def remove_listener() -> None: self._node_response_subscribers[ remove_listener - ] = (node_response_callback, mac, identifiers) + ] = (node_response_callback, mac, message_ids) return remove_listener async def _notify_node_response_subscribers( self, node_response: PlugwiseResponse ) -> None: """Call callback for all node response message subscribers""" - for callback, mac, ids in list( + for callback, mac, message_ids in list( self._node_response_subscribers.values() ): if mac is not None: if mac != node_response.mac: continue - if ids is not None: - if node_response.identifier not in ids: + if message_ids is not None: + if node_response.identifier not in message_ids: continue await callback(node_response) From 21fa7810d6890b27a0e6b9405c2977d817e24957 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 12:10:05 +0100 Subject: [PATCH 051/979] Fix subscription parameter --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index b54efbb7f..970f696d7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -86,7 +86,7 @@ def subscribe_to_responses( subscription_fn( self._update_response, mac=self._mac, - identifiers=(self._reply_identifier,), + message_ids=(self._reply_identifier,), ) ) From f8858a8aacff1b31e7fcc024db67a5314d38fe40 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 12:12:16 +0100 Subject: [PATCH 052/979] Remove duplicate attribute --- plugwise_usb/messages/requests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 970f696d7..b250c7df5 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -63,7 +63,6 @@ def __init__( self._reply_identifier: bytes = b"0000" self._unsubscribe_response: Callable[[], None] | None = None - self._response: PlugwiseResponse | None = None self._response_timeout: TimerHandle | None = None self._response_future: Future[PlugwiseResponse] = ( self._loop.create_future() @@ -74,9 +73,11 @@ def response_future(self) -> Future[PlugwiseResponse]: return self._response_future @property - def response(self) -> PlugwiseResponse | None: + def response(self) -> PlugwiseResponse: """Return response message""" - return self._response + if not self._response_future.done(): + raise StickError("No response available") + return self._response_future.result() def subscribe_to_responses( self, subscription_fn: Callable[[], None] @@ -122,7 +123,6 @@ async def _update_response(self, response: PlugwiseResponse) -> None: if self._seq_id is None: pass if self._seq_id == response.seq_id: - self._response = response self._response_timeout.cancel() self._response_future.set_result(response) self._unsubscribe_response() From 817e45a9728f687591d956cde25f7b50d7b96ad3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 12:12:34 +0100 Subject: [PATCH 053/979] Remove guard --- plugwise_usb/connection/queue.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 9ab58c50b..5a16899f1 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -126,13 +126,4 @@ async def _submit_worker(self) -> None: while self._queue.qsize() > 0: # Get item with highest priority from queue first request = await self._queue.get() - - # Guard for incorrect futures - if request.response is not None: - _LOGGER.error( - "%s has already a response", - request.__class__.__name__, - ) - break - await self._stick.write_to_stick(request) From e90161c8079a3220f978e5ece7988c836e321d84 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 13:39:23 +0100 Subject: [PATCH 054/979] Handle value conversion in NodeInfoResponse class --- plugwise_usb/messages/responses.py | 63 ++++++++++++++++++++++-------- plugwise_usb/network/__init__.py | 3 +- plugwise_usb/nodes/__init__.py | 12 +++--- plugwise_usb/nodes/circle.py | 24 ++++++------ 4 files changed, 65 insertions(+), 37 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index bb0b05f07..26b8c0715 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -6,6 +6,7 @@ from typing import Any, Final from . import PlugwiseMessage +from ..api import NodeType from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..exceptions import MessageError from ..util import ( @@ -546,43 +547,73 @@ def __init__(self, protocol_version: str = "2.0") -> None: """Initialize NodeInfoResponse message object""" super().__init__(b"0024") - self.last_logaddress = LogAddr(0, length=8) + self._last_logaddress = LogAddr(0, length=8) if protocol_version == "1.0": # FIXME: Define "absoluteHour" variable self.datetime = DateTime() - self.relay_state = Int(0, length=2) + self._relay_state = Int(0, length=2) self._params += [ self.datetime, - self.last_logaddress, - self.relay_state, + self._last_logaddress, + self._relay_state, ] elif protocol_version == "2.0": self.datetime = DateTime() - self.relay_state = Int(0, length=2) + self._relay_state = Int(0, length=2) self._params += [ self.datetime, - self.last_logaddress, - self.relay_state, + self._last_logaddress, + self._relay_state, ] elif protocol_version == "2.3": # FIXME: Define "State_mask" variable self.state_mask = Int(0, length=2) self._params += [ self.datetime, - self.last_logaddress, + self._last_logaddress, self.state_mask, ] - self.frequency = Int(0, length=2) - self.hw_ver = String(None, length=12) - self.fw_ver = UnixTimestamp(0) - self.node_type = Int(0, length=2) + self._frequency = Int(0, length=2) + self._hw_ver = String(None, length=12) + self._fw_ver = UnixTimestamp(0) + self._node_type = Int(0, length=2) self._params += [ - self.frequency, - self.hw_ver, - self.fw_ver, - self.node_type, + self._frequency, + self._hw_ver, + self._fw_ver, + self._node_type, ] + @property + def hardware(self) -> str: + """Return hardware id""" + return self._hw_ver.value.decode(UTF8) + + @property + def firmware(self) -> datetime: + """Return timestamp of firmware""" + return self._fw_ver.value + + @property + def node_type(self) -> NodeType: + """Return the type of node""" + return NodeType(self._node_type.value) + + @property + def last_logaddress(self) -> int: + """Return the current energy log address""" + return self._last_logaddress.value + + @property + def relay_state(self) -> bool: + """Return state of relay""" + return self._relay_state.value == 1 + + @property + def frequency(self) -> int: + """Return frequency config of node""" + return self._frequency + class EnergyCalibrationResponse(PlugwiseResponse): """ diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index eb828b633..f8ef1f257 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -404,8 +404,7 @@ async def _discover_node( node_info, node_ping = await self.get_node_details(mac, True) if node_info is None: return False - node_type = NodeType(node_info.node_type.value) - self._create_node_object(mac, address, node_type) + self._create_node_object(mac, address, node_info.node_type) # Forward received NodeInfoResponse message to node object await self._nodes[mac].node_info_update(node_info) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index c5c430b98..fd29471fa 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -445,9 +445,9 @@ async def node_info_update( await self._available_update_state(True) self._node_info_update_state( - firmware=node_info.fw_ver.value, - hardware=node_info.hw_ver.value.decode(UTF8), - node_type=node_info.node_type.value, + firmware=node_info.firmware, + node_type=node_info.node_type, + hardware=node_info.hardware, timestamp=node_info.timestamp, ) return True @@ -455,7 +455,7 @@ async def node_info_update( async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" firmware: datetime | None = None - node_type: int | None = None + node_type: NodeType | None = None hardware: str | None = self._get_cache("hardware") timestamp: datetime | None = None if (firmware_str := self._get_cache("firmware")) is not None: @@ -471,7 +471,7 @@ async def _node_info_load_from_cache(self) -> bool: tzinfo=UTC ) if (node_type_str := self._get_cache("node_type")) is not None: - node_type = int(node_type_str) + node_type = NodeType(int(node_type_str)) if ( timestamp_str := self._get_cache("node_info_timestamp") ) is not None: @@ -497,7 +497,7 @@ def _node_info_update_state( self, firmware: datetime | None, hardware: str | None, - node_type: int | None, + node_type: NodeType | None, timestamp: datetime | None, ) -> bool: """ diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 00a3224fa..712f655ef 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -15,7 +15,6 @@ MINIMAL_POWER_UPDATE, PULSES_PER_KW_SECOND, SECOND_IN_NANOSECONDS, - UTF8, ) from .helpers import EnergyCalibration, raise_not_loaded from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT @@ -29,6 +28,7 @@ CircleRelayInitStateRequest, CircleRelaySwitchRequest, EnergyCalibrationRequest, + NodeInfoRequest, ) from ..messages.responses import ( CircleClockResponse, @@ -815,33 +815,31 @@ async def node_info_update( """ Update Node hardware information. """ + if node_info is None: + node_info = await self._send( + NodeInfoRequest(self._mac_in_bytes) + ) if not await super().node_info_update(node_info): return False - self._node_info_update_state( - firmware=node_info.fw_ver.value, - hardware=node_info.hw_ver.value.decode(UTF8), - node_type=node_info.node_type.value, - timestamp=node_info.timestamp, - ) await self._relay_update_state( - node_info.relay_state.value == 1, timestamp=node_info.timestamp + node_info.relay_state, timestamp=node_info.timestamp ) if ( self._last_log_address is not None and - self._last_log_address > node_info.last_logaddress.value + self._last_log_address > node_info.last_logaddress ): # Rollover of log address _LOGGER.warning( "Rollover log address from %s into %s for node %s", self._last_log_address, - node_info.last_logaddress.value, + node_info.last_logaddress, self.mac ) - if self._last_log_address != node_info.last_logaddress.value: - self._last_log_address = node_info.last_logaddress.value + if self._last_log_address != node_info.last_logaddress: + self._last_log_address = node_info.last_logaddress self._set_cache( - "last_log_address", node_info.last_logaddress.value + "last_log_address", node_info.last_logaddress ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) From 59e03cf3538effb5711ee8a88c1856212f8a5d30 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 13:40:59 +0100 Subject: [PATCH 055/979] Fix notification of new nodes --- plugwise_usb/messages/responses.py | 5 ++-- plugwise_usb/network/__init__.py | 38 +++++++++++++----------------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 26b8c0715..7cdb4a34a 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -21,6 +21,7 @@ UnixTimestamp, ) +NODE_JOIN_ID: Final = b"0006" NODE_AWAKE_RESPONSE_ID: Final = b"004F" NODE_SWITCH_GROUP_ID: Final = b"0056" SENSE_REPORT_ID: Final = b"0105" @@ -310,7 +311,7 @@ class NodeJoinAvailableResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeJoinAvailableResponse message object""" - super().__init__(b"0006") + super().__init__(NODE_JOIN_ID) class NodePingResponse(PlugwiseResponse): @@ -860,7 +861,7 @@ def __init__(self) -> None: b"0002": StickNetworkInfoResponse(), b"0003": NodeSpecificResponse(), b"0005": CirclePlusConnectResponse(), - b"0006": NodeJoinAvailableResponse(), + NODE_JOIN_ID: NodeJoinAvailableResponse(), b"000E": NodePingResponse(), b"0010": NodeImageValidationResponse(), b"0011": StickInitResponse(), diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f8ef1f257..7d4e2db18 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -19,9 +19,11 @@ ) from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, + NODE_JOIN_ID, NodeAckResponse, NodeAwakeResponse, NodeInfoResponse, + NodeJoinAvailableResponse, # NodeJoinAvailableResponse, NodePingResponse, NodeResponseType, @@ -72,6 +74,7 @@ def __init__( self._unsubscribe_stick_event: Callable[[], None] | None = None self._unsubscribe_node_awake: Callable[[], None] | None = None + self._unsubscribe_node_join: Callable[[], None] | None = None # region - Properties @@ -162,13 +165,13 @@ def _subscribe_to_protocol_events(self) -> None: (NODE_AWAKE_RESPONSE_ID,), ) ) - # self._unsubscribe_node_join = ( - # self._controller.subscribe_to_node_responses( - # self.node_join_available_message, - # None, - # (b"0006",), - # ) - # ) + self._unsubscribe_node_join = ( + self._controller.subscribe_to_node_responses( + self.node_join_available_message, + None, + (NODE_JOIN_ID,), + ) + ) async def _handle_stick_event(self, event: StickEvent) -> None: """Handle stick events""" @@ -217,6 +220,13 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: await self._discover_and_load_node(address, mac, None) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + async def node_join_available_message( + self, response: NodeJoinAvailableResponse + ) -> None: + """Handle NodeJoinAvailableResponse messages.""" + mac = response.mac_decoded + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" if self._unsubscribe_node_awake is not None: @@ -492,20 +502,6 @@ async def stop(self) -> None: _LOGGER.debug("Stopping finished") # endregion - # async def node_join_available_message( - # self, response: NodeJoinAvailableResponse - # ) -> None: - # """Receive NodeJoinAvailableResponse messages.""" - # mac = response.mac_decoded - # await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) - # if self.join_available is not None: - # self.join_available(response.mac_decoded) - # if not self.accept_join_request: - # # TODO: Add debug logging - # return - # if not await self.register_network_node(response.mac_decoded): - # # TODO: Add warning logging - # pass async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" From c475abf10829f360974f8c153efbcd6ced0ca88b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 13:41:59 +0100 Subject: [PATCH 056/979] At node discovery discover network coordinator too --- plugwise_usb/network/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7d4e2db18..f672edf35 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -485,6 +485,7 @@ async def discover_nodes(self, load: bool = True) -> None: """Discover nodes""" if not self._is_running: await self.start() + await self.discover_network_coordinator() await self._discover_registered_nodes() await sleep(0) if load: From 69ac8623b55beb0d872dedf155c137879d94d35e Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 13:43:53 +0100 Subject: [PATCH 057/979] Add test for motion --- tests/test_usb.py | 70 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 389383f3c..3ac2cc223 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -372,18 +372,29 @@ async def node_awake(self, event: pw_api.NodeEvent, mac: str): async def node_motion_state( self, feature: pw_api.NodeFeature, - state: bool, + state: pw_api.MotionState, ): """Callback helper for node_motion event""" if feature == pw_api.NodeFeature.MOTION: - self.motion_on.set_result(state) + if state.motion: + self.motion_on.set_result(state.motion) + else: + self.motion_off.set_result(state.motion) else: - self.motion_off.set_exception( - BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.MOTION}" + if state.motion: + self.motion_on.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" + ) + ) + else: + self.motion_off.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" + ) ) - ) async def node_ping( self, @@ -422,17 +433,52 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): node_event_callback=self.node_awake, events=(pw_api.NodeEvent.AWAKE,), ) - self.test_node_discovered = asyncio.Future() - unsub_discovered = stick.subscribe_to_node_events( - node_event_callback=self.node_discovered, - events=(pw_api.NodeEvent.DISCOVERED,), - ) + # Inject NodeAwakeResponse message to trigger a 'node discovered' event mock_serial._transport.message_response(b"004F555555555555555500", b"FFFE") mac_awake_node = await self.test_node_awake assert mac_awake_node == "5555555555555555" unsub_awake() + assert await stick.nodes["5555555555555555"].load() + assert stick.nodes["5555555555555555"].node_info.firmware == dt( + 2011, 6, 27, 8, 55, 44, tzinfo=tz.utc + ) + assert stick.nodes["5555555555555555"].node_info.version == "000000080007" + assert stick.nodes["5555555555555555"].node_info.model == "Scan" + assert stick.nodes["5555555555555555"].available + assert stick.nodes["5555555555555555"].node_info.battery_powered + assert sorted(stick.nodes["5555555555555555"].features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.MOTION, + ) + ) + + # Motion + self.motion_on = asyncio.Future() + self.motion_off = asyncio.Future() + unsub_motion = stick.nodes[ + "5555555555555555" + ].subscribe_to_feature_update( + node_feature_callback=self.node_motion_state, + features=(pw_api.NodeFeature.MOTION,), + ) + # Inject motion message to trigger a 'motion on' event + mock_serial._transport.message_response(b"005655555555555555550001", b"FFFF") + motion_on = await self.motion_on + assert motion_on + + # Inject motion message to trigger a 'motion off' event + mock_serial._transport.message_response(b"005655555555555555550000", b"FFFF") + motion_off = await self.motion_off + assert not motion_off + unsub_motion() + + + await stick.disconnect() # No tests available From 0d51b3db63f0c369382a1311414ce04e1c8ebd42 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 13:44:25 +0100 Subject: [PATCH 058/979] Add test for join notification --- tests/test_usb.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 3ac2cc223..82278fe0f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -480,6 +480,46 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): await stick.disconnect() + async def node_join(self, event: pw_api.NodeEvent, mac: str): + """Callback helper for node_join event""" + if event == pw_api.NodeEvent.JOIN: + self.test_node_join.set_result(mac) + else: + self.test_node_join.set_exception( + BaseException( + f"Invalid {event} event, expected " + + f"{pw_api.NodeEvent.JOIN}" + ) + ) + + @pytest.mark.asyncio + async def test_stick_node_join_subscription(self, monkeypatch): + """Testing "new_node" subscription""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + self.test_node_join = asyncio.Future() + unusb_join = stick.subscribe_to_node_events( + node_event_callback=self.node_join, + events=(pw_api.NodeEvent.JOIN,), + ) + + # Inject node join request message + mock_serial._transport.message_response(b"00069999999999999999", b"FFFC") + mac_join_node = await self.test_node_join + assert mac_join_node == "9999999999999999" + unusb_join() + await stick.disconnect() + # No tests available class TestPlugwise: # pylint: disable=attribute-defined-outside-init From 83a2e91faf83c218d352c5f64f24091ff941d458 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:35:46 +0100 Subject: [PATCH 059/979] Correct protocol version check --- plugwise_usb/nodes/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index fd29471fa..cb57e324b 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -300,8 +300,10 @@ def _setup_protocol( required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) ) is not None: if ( - required_version <= self._node_protocols.min and - feature not in new_feature_list + self._node_protocols.min + <= required_version + <= self._node_protocols.max + and feature not in new_feature_list ): new_feature_list.append(feature) self._features = tuple(new_feature_list) From 69ce803e15fe4aa25e022042642844136b2f01a6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:36:13 +0100 Subject: [PATCH 060/979] Fix updating relay state --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 712f655ef..02dc210bb 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -638,7 +638,7 @@ async def _relay_update_state( if not state: self._set_cache("relay", "False") if (self._relay is None or self._relay): - state_update = False + state_update = True self._relay = state if state_update: await self.publish_feature_update_to_subscribers( From 76dba6e30bbdf8d25055984c61b4a35b625709e0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:39:45 +0100 Subject: [PATCH 061/979] Make relay_init control functions private --- plugwise_usb/nodes/__init__.py | 4 ---- plugwise_usb/nodes/circle.py | 16 ++++++++++++---- plugwise_usb/nodes/circle_plus.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index cb57e324b..a80bc6856 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -328,10 +328,6 @@ def maintenance_interval(self) -> int | None: """ raise NotImplementedError() - async def relay_init_set(self, state: bool) -> bool | None: - """Configure relay init state.""" - raise NotImplementedError() - async def scan_calibrate_light(self) -> bool: """ Request to calibration light sensitivity of Scan device. diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 02dc210bb..9c4a35712 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -124,7 +124,7 @@ def relay_init(self, state: bool) -> None: "Configuring initial state of relay" + f"is not supported for device {self.mac}" ) - create_task(self.relay_init_set(state)) + create_task(self._relay_init_set(state)) async def calibration_update(self) -> bool: """ @@ -798,7 +798,7 @@ async def initialize(self) -> bool: NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None ): - if (state := await self.relay_init_get()) is not None: + if (state := await self._relay_init_get()) is not None: self._relay_init_state = state else: _LOGGER.debug( @@ -862,7 +862,15 @@ async def unload(self) -> None: await self.save_cache() self._loaded = False - async def relay_init_get(self) -> bool | None: + async def switch_init_relay(self, state: bool) -> bool: + """ + Switch state of initial power-up relay state. + Return new state of relay + """ + await self._relay_init_set(state) + return self._relay_init_state + + async def _relay_init_get(self) -> bool | None: """ Get current configuration of the power-up state of the relay. @@ -881,7 +889,7 @@ async def relay_init_get(self) -> bool | None: await self._relay_init_update_state(response.relay.value == 1) return self._relay_init_state - async def relay_init_set(self, state: bool) -> bool | None: + async def _relay_init_set(self, state: bool) -> bool | None: """Configure relay init state.""" if NodeFeature.RELAY_INIT not in self._features: raise NodeError( diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 4f22cef9d..f1b657e8c 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -88,7 +88,7 @@ async def initialize(self) -> bool: NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None ): - if (state := await self.relay_init_get()) is not None: + if (state := await self._relay_init_get()) is not None: self._relay_init_state = state else: _LOGGER.debug( From 0ab9e9e7e6557f3d669f1c29fdb33f4b57edf6ca Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:40:24 +0100 Subject: [PATCH 062/979] Remove unnecessary sleep --- plugwise_usb/nodes/circle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 9c4a35712..3838c5b59 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -572,7 +572,6 @@ async def switch_relay(self, state: bool) -> bool | None: response: NodeResponse | None = await self._send( CircleRelaySwitchRequest(self._mac_in_bytes, state), ) - await sleep(0) if ( response is None or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED From 38b87e9518ff3f0b206d9a0de72c6667a2a1266e Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:40:58 +0100 Subject: [PATCH 063/979] Add node discovery test --- tests/test_usb.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 82278fe0f..dac0f9cae 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -520,6 +520,25 @@ async def test_stick_node_join_subscription(self, monkeypatch): unusb_join() await stick.disconnect() + @pytest.mark.asyncio + async def test_node_discovery(self, monkeypatch): + """Testing discovery of nodes""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + assert stick.joined_nodes == 11 + assert len(stick.nodes) == 6 # Discovered nodes + await stick.disconnect() + # No tests available class TestPlugwise: # pylint: disable=attribute-defined-outside-init From 1830929bae6c98f6d7ede2edc4c6b8cbff29383e Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 15:41:33 +0100 Subject: [PATCH 064/979] Add node relay tests --- tests/test_usb.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index dac0f9cae..b55a761b2 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -539,6 +539,153 @@ async def test_node_discovery(self, monkeypatch): assert len(stick.nodes) == 6 # Discovered nodes await stick.disconnect() + async def node_relay_state( + self, + feature: pw_api.NodeFeature, + state: pw_api.RelayState, + ): + """Callback helper for relay event""" + if feature == pw_api.NodeFeature.RELAY: + if state.relay_state: + self.test_relay_state_on.set_result(state.relay_state) + else: + self.test_relay_state_off.set_result(state.relay_state) + else: + self.test_relay_state_on.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY}" + ) + ) + self.test_relay_state_off.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY}" + ) + ) + + async def node_init_relay_state( + self, + feature: pw_api.NodeFeature, + state: bool, + ): + """Callback helper for relay event""" + if feature == pw_api.NodeFeature.RELAY_INIT: + if state: + self.test_init_relay_state_on.set_result(state) + else: + self.test_init_relay_state_off.set_result(state) + else: + self.test_init_relay_state_on.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY_INIT}" + ) + ) + self.test_init_relay_state_off.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY_INIT}" + ) + ) + + @pytest.mark.asyncio + async def test_node_relay(self, monkeypatch): + """Testing discovery of nodes""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + + # Manually load node + assert await stick.nodes["0098765432101234"].load() + + self.test_relay_state_on = asyncio.Future() + self.test_relay_state_off = asyncio.Future() + unsub_relay = stick.nodes[ + "0098765432101234" + ].subscribe_to_feature_update( + node_feature_callback=self.node_relay_state, + features=(pw_api.NodeFeature.RELAY,), + ) + # Test sync switching from on to off + assert stick.nodes["0098765432101234"].relay + stick.nodes["0098765432101234"].relay = False + assert not await self.test_relay_state_off + assert not stick.nodes["0098765432101234"].relay + + # Test sync switching back from off to on + stick.nodes["0098765432101234"].relay = True + assert await self.test_relay_state_on + assert stick.nodes["0098765432101234"].relay + + # Test async switching back from on to off + self.test_relay_state_off = asyncio.Future() + assert not await stick.nodes["0098765432101234"].switch_relay(False) + assert not await self.test_relay_state_off + assert not stick.nodes["0098765432101234"].relay + + # Test async switching back from off to on + self.test_relay_state_on = asyncio.Future() + assert await stick.nodes["0098765432101234"].switch_relay(True) + assert await self.test_relay_state_on + assert stick.nodes["0098765432101234"].relay + + unsub_relay() + + # Test non-support init relay state + with pytest.raises(pw_exceptions.NodeError): + assert stick.nodes["0098765432101234"].relay_init + with pytest.raises(pw_exceptions.NodeError): + await stick.nodes["0098765432101234"].switch_init_relay(True) + await stick.nodes["0098765432101234"].switch_init_relay(False) + + # Test relay init + # load node 2222222222222222 which has + # the firmware with init relay feature + assert await stick.nodes["2222222222222222"].load() + self.test_init_relay_state_on = asyncio.Future() + self.test_init_relay_state_off = asyncio.Future() + unsub_inti_relay = stick.nodes[ + "0098765432101234" + ].subscribe_to_feature_update( + node_feature_callback=self.node_init_relay_state, + features=(pw_api.NodeFeature.RELAY_INIT,), + ) + # Test sync switching init_state from on to off + assert stick.nodes["2222222222222222"].relay_init + stick.nodes["2222222222222222"].relay_init = False + assert not await self.test_init_relay_state_off + assert not stick.nodes["2222222222222222"].relay_init + + # Test sync switching back init_state from off to on + stick.nodes["2222222222222222"].relay_init = True + assert await self.test_init_relay_state_on + assert stick.nodes["2222222222222222"].relay_init + + # Test async switching back init_state from on to off + self.test_init_relay_state_off = asyncio.Future() + assert not await stick.nodes["2222222222222222"].switch_init_relay(False) + assert not await self.test_init_relay_state_off + assert not stick.nodes["2222222222222222"].relay_init + + # Test async switching back from off to on + self.test_init_relay_state_on = asyncio.Future() + assert await stick.nodes["2222222222222222"].switch_init_relay(True) + assert await self.test_init_relay_state_on + assert stick.nodes["2222222222222222"].relay_init + + unsub_inti_relay() + + await stick.disconnect() # No tests available class TestPlugwise: # pylint: disable=attribute-defined-outside-init From e394d67f09e40ae0c1a5ac9917181c9d5a3226b7 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:00:16 +0100 Subject: [PATCH 065/979] Raise StickError when zigbee connection is down --- plugwise_usb/connection/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 4804e0900..e3f665421 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -185,6 +185,11 @@ async def initialize_stick(self) -> None: self._network_id = init_response.network_id self._is_initialized = True + if not self._network_online: + raise StickError( + "Zigbee network connection to Circle+ is down." + ) + async def send(self, request: PlugwiseRequest) -> PlugwiseResponse: """Submit request to queue and return response""" return await self._queue.submit(request) From 5693204969940d2915687e8b2f88be84aa7829e3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:02:30 +0100 Subject: [PATCH 066/979] Fix timestamp check --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 35a466843..6aefc8d27 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -648,7 +648,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if not self._log_exists(address, slot): missing.append(address) break - if self.logs[address][slot].timestamp < from_timestamp: + if self.logs[address][slot].timestamp <= from_timestamp: finished = True break if finished: From 5049452074d5b0fecf75adf4593e93c269374792 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:03:23 +0100 Subject: [PATCH 067/979] Store initial set of pulse values --- plugwise_usb/nodes/helpers/pulses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 6aefc8d27..07bfb0702 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -236,6 +236,8 @@ def update_pulse_counter( self._last_update = timestamp if self._next_log_consumption_timestamp is None: + self._pulses_consumption = pulses_consumed + self._pulses_production = pulses_produced return if ( self._log_production @@ -636,7 +638,6 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: _LOGGER.debug("_logs_missing | %s | first_address=%s, last_address=%s", self._mac, first_address, last_address) if last_address <= first_address: - _LOGGER.warning("_logs_missing | %s | first_address=%s >= last_address=%s", self._mac, first_address, last_address) return [] finished = False From df86e9bf3aadf5364f061e4464ecd3792d8de940 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:06:14 +0100 Subject: [PATCH 068/979] Add test for energy counter --- tests/test_usb.py | 96 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index b55a761b2..a797151f0 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -687,10 +687,94 @@ async def test_node_relay(self, monkeypatch): await stick.disconnect() -# No tests available -class TestPlugwise: # pylint: disable=attribute-defined-outside-init - """Tests for Plugwise USB.""" + @freeze_time(dt.now()) + def test_pulse_collection(self): + """Testing pulse collection class""" + + _pulse_update = 0 + + def pulse_update(self, timestamp: dt, is_consumption: bool): + """Callback helper for pulse updates for energy counter""" + self._pulse_update += 1 + if self._pulse_update == 1: + return (None, None) + if self._pulse_update == 2: + return (None, timestamp + td(minutes=5)) + if self._pulse_update == 3: + return (2222, None) + if self._pulse_update == 4: + return (2222, timestamp + td(minutes=10)) + return (3333, timestamp + td(minutes=15, seconds=10)) + + @freeze_time(dt.now()) + def test_energy_counter(self): + """Testing energy counter class""" + pulse_col_mock = Mock() + pulse_col_mock.collected_pulses.side_effect = self.pulse_update + + fixed_timestamp_utc = dt.now(tz.utc) + fixed_timestamp_local = dt.now(dt.now(tz.utc).astimezone().tzinfo) + + _LOGGER.debug( + "test_energy_counter | fixed_timestamp-utc = %s", str(fixed_timestamp_utc) + ) + + calibration_config = pw_energy_calibration.EnergyCalibration(1, 2, 3, 4) + + # Initialize hour counter + energy_counter_init = pw_energy_counter.EnergyCounter( + pw_energy_counter.EnergyType.CONSUMPTION_HOUR, + ) + assert energy_counter_init.calibration is None + energy_counter_init.calibration = calibration_config + + assert energy_counter_init.energy is None + assert energy_counter_init.is_consumption + assert energy_counter_init.last_reset is None + assert energy_counter_init.last_update is None + + # First update (None, None) + assert energy_counter_init.update(pulse_col_mock) == (None, None) + assert energy_counter_init.energy is None + assert energy_counter_init.last_reset is None + assert energy_counter_init.last_update is None + # Second update (None, timestamp) + assert energy_counter_init.update(pulse_col_mock) == (None, None) + assert energy_counter_init.energy is None + assert energy_counter_init.last_reset is None + assert energy_counter_init.last_update is None + # Third update (2222, None) + assert energy_counter_init.update(pulse_col_mock) == (None, None) + assert energy_counter_init.energy is None + assert energy_counter_init.last_reset is None + assert energy_counter_init.last_update is None + + # forth update (2222, timestamp + 00:10:00) + reset_timestamp = fixed_timestamp_local.replace( + minute=0, second=0, microsecond=0 + ) + assert energy_counter_init.update(pulse_col_mock) == ( + 0.07204743061527973, + reset_timestamp, + ) + assert energy_counter_init.energy == 0.07204743061527973 + assert energy_counter_init.last_reset == reset_timestamp + assert energy_counter_init.last_update == reset_timestamp + td(minutes=10) + + # fifth update (3333, timestamp + 00:15:10) + assert energy_counter_init.update(pulse_col_mock) == ( + 0.08263379198066137, + reset_timestamp, + ) + assert energy_counter_init.energy == 0.08263379198066137 + assert energy_counter_init.last_reset == reset_timestamp + assert energy_counter_init.last_update == reset_timestamp + td( + minutes=15, seconds=10 + ) + + # Production hour + energy_counter_p_h = pw_energy_counter.EnergyCounter( + pw_energy_counter.EnergyType.PRODUCTION_HOUR, + ) + assert not energy_counter_p_h.is_consumption - async def test_connect_legacy_anna(self): - """No tests available.""" - assert True From 635706d558bdcab80087005cf4191afb2be46cde Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:07:31 +0100 Subject: [PATCH 069/979] Add tests for pulse collection --- tests/test_usb.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index a797151f0..88c220e9f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -691,6 +691,178 @@ async def test_node_relay(self, monkeypatch): def test_pulse_collection(self): """Testing pulse collection class""" + fixed_timestamp_utc = dt.now(tz.utc) + fixed_this_hour = fixed_timestamp_utc.replace( + minute=0, second=0, microsecond=0 + ) + missing_check = [] + + # Test consumption logs + tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") + assert tst_consumption.log_addresses_missing is None + assert tst_consumption.production_logging is None + + # Test consumption - Log import #1 + # No missing addresses yet + test_timestamp = fixed_this_hour - td(hours=1) + tst_consumption.add_log(100, 1, test_timestamp, 1000) + assert tst_consumption.log_interval_consumption is None + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is None + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( + None, + None, + ) + assert tst_consumption.log_addresses_missing == missing_check + + # Test consumption - Log import #2, random log + # return intermediate missing addresses + test_timestamp = fixed_this_hour - td(hours=18) + tst_consumption.add_log(95, 4, test_timestamp, 1000) + missing_check += [99, 98, 97, 96] + assert tst_consumption.log_interval_consumption is None + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is None + assert tst_consumption.log_addresses_missing == missing_check + + # Test consumption - Log import #3 + # log next to existing with different timestamp + # so 'production logging' should be marked as False now + test_timestamp = fixed_this_hour - td(hours=19) + tst_consumption.add_log(95, 3, test_timestamp, 1000) + assert tst_consumption.log_interval_consumption is None + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is False + assert tst_consumption.log_addresses_missing == missing_check + + # Test consumption - Log import #4, no change + test_timestamp = fixed_this_hour - td(hours=20) + tst_consumption.add_log(95, 2, test_timestamp, 1000) + assert tst_consumption.log_interval_consumption is None + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is False + assert tst_consumption.log_addresses_missing == missing_check + + # Test consumption - Log import #5 + # Complete log import for address 95 so it must drop from missing list + test_timestamp = fixed_this_hour - td(hours=21) + tst_consumption.add_log(95, 1, test_timestamp, 1000) + + assert tst_consumption.log_interval_consumption is None + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is False + assert tst_consumption.log_addresses_missing == missing_check + + # Test consumption - Log import #6 + # Add before last log so interval of consumption must be determined + test_timestamp = fixed_this_hour - td(hours=2) + tst_consumption.add_log(99, 4, test_timestamp, 750) + assert tst_consumption.log_interval_consumption == 60 + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is False + assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == ( + None, + None, + ) + + # Test consumption - pulse update #1 + pulse_update_1 = fixed_this_hour + td(minutes=5) + tst_consumption.update_pulse_counter(1234, 0, pulse_update_1) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == ( + 1234, + pulse_update_1, + ) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=False) == ( + None, + None, + ) + # Test consumption - pulse update #2 + pulse_update_2 = fixed_this_hour + td(minutes=7) + test_timestamp = fixed_this_hour + tst_consumption.update_pulse_counter(2345, 0, pulse_update_2) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( + 2345, + pulse_update_2, + ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( + None, + None, + ) + # Test consumption - pulses + log (address=100, slot=1) + test_timestamp = fixed_this_hour - td(hours=1) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( + 2345 + 1000, + pulse_update_2, + ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( + None, + None, + ) + # Test consumption - pulses + logs (address=100, slot=1 & address=99, slot=4) + test_timestamp = fixed_this_hour - td(hours=2) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( + 2345 + 1000 + 750, + pulse_update_2, + ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( + None, + None, + ) + + # Test consumption - pulses + missing logs + test_timestamp = fixed_this_hour - td(hours=3) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( + None, + None, + ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( + None, + None, + ) + + # Test consumption and production logs + tst_production = pw_energy_pulses.PulseCollection(mac="0098765432101234") + assert tst_production.log_addresses_missing is None + assert tst_production.production_logging is None + + # Test consumption & production - Log import #1 + # Missing addresses must be populated + test_timestamp = fixed_this_hour - td(hours=1) + tst_production.add_log(200, 2, test_timestamp, 2000) + missing_check = [] + assert tst_production.log_addresses_missing == missing_check + assert tst_production.production_logging is None + + # Test consumption & production - Log import #2 + # production must be enabled & intervals are unknown + # Log at address 200 is known and expect production logs too + test_timestamp = fixed_this_hour - td(hours=1) + tst_production.add_log(200, 1, test_timestamp, 1000) + assert tst_production.log_addresses_missing == missing_check + assert tst_production.log_interval_consumption == 0 + assert tst_production.log_interval_production is None + assert tst_production.production_logging + + # Test consumption & production - Log import #3 + # Interval of production is not yet available + test_timestamp = fixed_this_hour - td(hours=2) + tst_production.add_log(199, 4, test_timestamp, 4000) + missing_check = list(range(199, 157, -1)) + assert tst_production.log_addresses_missing == missing_check + assert tst_production.log_interval_consumption == 0 # FIXME + assert tst_production.log_interval_production is None + assert tst_production.production_logging + + # Test consumption & production - Log import #4 + # Interval of consumption is available + test_timestamp = fixed_this_hour - td(hours=2) + tst_production.add_log(199, 3, test_timestamp, 3000) + assert tst_production.log_addresses_missing == missing_check + assert tst_production.log_interval_consumption == 0 # FIXME + assert tst_production.log_interval_production == 60 + assert tst_production.production_logging + _pulse_update = 0 def pulse_update(self, timestamp: dt, is_consumption: bool): From 080b232c297c95d602c4d0e0ffb1ac402e269c77 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:08:25 +0100 Subject: [PATCH 070/979] Add testing creating messages --- tests/test_usb.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 88c220e9f..048dabeb9 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -950,3 +950,83 @@ def test_energy_counter(self): ) assert not energy_counter_p_h.is_consumption + @pytest.mark.asyncio + async def test_creating_request_messages(self): + + node_network_info_request = pw_requests.StickNetworkInfoRequest() + assert node_network_info_request.serialize() == b"\x05\x05\x03\x030001CAAB\r\n" + circle_plus_connect_request = pw_requests.CirclePlusConnectRequest( + b"1111222233334444" + ) + assert ( + circle_plus_connect_request.serialize() + == b"\x05\x05\x03\x030004000000000000000000001111222233334444BDEC\r\n" + ) + node_add_request = pw_requests.NodeAddRequest(b"1111222233334444", True) + assert ( + node_add_request.serialize() + == b"\x05\x05\x03\x0300070111112222333344445578\r\n" + ) + node_reset_request = pw_requests.NodeResetRequest(b"1111222233334444", 2, 5) + assert ( + node_reset_request.serialize() + == b"\x05\x05\x03\x030009111122223333444402053D5C\r\n" + ) + node_image_activate_request = pw_requests.NodeImageActivateRequest( + b"1111222233334444", 2, 5 + ) + assert ( + node_image_activate_request.serialize() + == b"\x05\x05\x03\x03000F1111222233334444020563AA\r\n" + ) + circle_log_data_request = pw_requests.CircleLogDataRequest( + b"1111222233334444", + dt(2022, 5, 3, 0, 0, 0), + dt(2022, 5, 10, 23, 0, 0), + ) + assert ( + circle_log_data_request.serialize() + == b"\x05\x05\x03\x030014111122223333444416050B4016053804AD3A\r\n" + ) + node_remove_request = pw_requests.NodeRemoveRequest( + b"1111222233334444", "5555666677778888" + ) + assert ( + node_remove_request.serialize() + == b"\x05\x05\x03\x03001C11112222333344445555666677778888D89C\r\n" + ) + + circle_plus_realtimeclock_request = ( + pw_requests.CirclePlusRealTimeClockSetRequest( + b"1111222233334444", dt(2022, 5, 4, 3, 1, 0) + ) + ) + assert ( + circle_plus_realtimeclock_request.serialize() + == b"\x05\x05\x03\x030028111122223333444400010302040522ADE2\r\n" + ) + + node_sleep_config_request = pw_requests.NodeSleepConfigRequest( + b"1111222233334444", + 5, # Duration in seconds the SED will be awake for receiving commands + 360, # Duration in minutes the SED will be in sleeping mode and not able to respond any command + 1440, # Interval in minutes the node will wake up and able to receive commands + False, # Enable/disable clock sync + 0, # Duration in minutes the node synchronize its clock + ) + assert ( + node_sleep_config_request.serialize() + == b"\x05\x05\x03\x030050111122223333444405016805A00000008C9D\r\n" + ) + + scan_configure_request = pw_requests.ScanConfigureRequest( + b"1111222233334444", + 5, # Delay in minutes when signal is send when no motion is detected + 30, # Sensitivity of Motion sensor (High, Medium, Off) + False, # Daylight override to only report motion when lightlevel is below calibrated level + ) + assert ( + scan_configure_request.serialize() + == b"\x05\x05\x03\x03010111112222333344441E0005025E\r\n" + ) + From ba94b262e9b93a8df3d490892efaabb9227c7249 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:09:46 +0100 Subject: [PATCH 071/979] Add testing stick network down --- tests/test_usb.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 048dabeb9..99771187b 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1030,3 +1030,33 @@ async def test_creating_request_messages(self): == b"\x05\x05\x03\x03010111112222333344441E0005025E\r\n" ) + @pytest.mark.asyncio + async def test_stick_network_down(self, monkeypatch): + """Testing timeout circle+ discovery""" + mock_serial = MockSerial( + { + b"\x05\x05\x03\x03000AB43C\r\n": ( + "STICK INIT", + b"000000C1", # Success ack + b"0011" # msg_id + + b"0123456789012345" # stick mac + + b"00" # unknown1 + + b"00" # network_is_online + + b"0098765432101234" # circle_plus_mac + + b"4321" # network_id + + b"00", # unknown2 + ), + } + ) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick(port="test_port", cache_enabled=False) + await stick.connect() + with pytest.raises(pw_exceptions.StickError): + await stick.initialize() + From caf78515531c74990a80a22bc1bd02d17279d799 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:20:44 +0100 Subject: [PATCH 072/979] Simplify code using walrus --- plugwise_usb/nodes/circle.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 3838c5b59..e323df77a 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -605,8 +605,7 @@ async def _relay_load_from_cache(self) -> bool: if self._relay is not None: # State already known, no need to load from cache return True - cached_relay_data = self._get_cache("relay") - if cached_relay_data is not None: + if (cached_relay_data := self._get_cache("relay")) is not None: _LOGGER.debug( "Restore relay state cache for node %s", self.mac From efa1cdaba5ff11500f587575b23115d8a5d03057 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:32:31 +0100 Subject: [PATCH 073/979] Fix publishing feature updates --- plugwise_usb/nodes/switch.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 23cce7c2a..fec4dd59d 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -68,11 +68,15 @@ async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: if message.power_state.value == 0: if self._switch is None or self._switch: self._switch = False - await self.publish_event(NodeFeature.SWITCH, False) + await self.publish_feature_update_to_subscribers( + NodeFeature.SWITCH, False + ) elif message.power_state.value == 1: if self._switch_state is None or not self._switch: self._switch_state = True - await self.publish_event(NodeFeature.SWITCH, True) + await self.publish_feature_update_to_subscribers( + NodeFeature.SWITCH, True + ) else: raise MessageError( f"Unknown power_state '{message.power_state.value}' " + From a0d3e2c711242d20224f9e8b1f408e9166e41a3e Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:33:17 +0100 Subject: [PATCH 074/979] Remove unnecessary else block --- plugwise_usb/nodes/helpers/cache.py | 33 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 93362e00a..4142ea724 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -95,23 +95,22 @@ async def restore_cache(self) -> bool: "Failed to read cache file %s", str(self._cache_file) ) return False - else: - self._states.clear() - for line in lines: - data = line.strip().split(CACHE_SEPARATOR) - if len(data) != 2: - _LOGGER.warning( - "Skip invalid line '%s' in cache file %s", - line, - str(self._cache_file) - ) - break - self._states[data[0]] = data[1] - _LOGGER.debug( - "Cached settings restored %s lines from cache file %s", - str(len(self._states)), - str(self._cache_file), - ) + self._states.clear() + for line in lines: + data = line.strip().split(CACHE_SEPARATOR) + if len(data) != 2: + _LOGGER.warning( + "Skip invalid line '%s' in cache file %s", + line, + str(self._cache_file) + ) + break + self._states[data[0]] = data[1] + _LOGGER.debug( + "Cached settings restored %s lines from cache file %s", + str(len(self._states)), + str(self._cache_file), + ) return True async def delete_cache_file(self) -> None: From 83084da1d74a9fcaadd1d50bbded87e53851fdb2 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:35:28 +0100 Subject: [PATCH 075/979] Add energy log feature to CircleClockSetRequest --- plugwise_usb/messages/requests.py | 15 ++++++++++----- plugwise_usb/nodes/circle.py | 6 +++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index b250c7df5..0931b50df 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -422,6 +422,8 @@ class CircleClockSetRequest(PlugwiseRequest): """ Set internal clock of node and flash address + reset=True, will reset all locally stored energy logs + Supported protocols : 1.0, 2.0 Response message : NodeResponse """ @@ -430,17 +432,17 @@ def __init__( self, mac: bytes, dt: datetime, - flash_address: str = "FFFFFFFF", - protocol_version: str = "2.0", + protocol_version: float, + reset: bool = False, ) -> None: """Initialize CircleLogDataRequest message object""" super().__init__(b"0016", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH - if protocol_version == "1.0": + if protocol_version == 1.0: pass # FIXME: Define "absoluteHour" variable - elif protocol_version == "2.0": + elif protocol_version >= 2.0: passed_days = dt.day - 1 month_minutes = ( (passed_days * DAY_IN_MINUTES) @@ -450,7 +452,10 @@ def __init__( this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) - log_buf_addr = String(flash_address, 8) + if reset: + log_buf_addr = String("00044000", 8) + else: + log_buf_addr = String("FFFFFFFF", 8) self._args += [this_date, log_buf_addr, this_time, day_of_week] diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e323df77a..cf9d744ee 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -671,7 +671,11 @@ async def clock_synchronize(self) -> bool: str(clock_offset.seconds), ) node_response: NodeResponse | None = await self._send( - CircleClockSetRequest(self._mac_in_bytes, datetime.utcnow()), + CircleClockSetRequest( + self._mac_in_bytes, + datetime.utcnow(), + self._node_protocols.max + ) ) if ( node_response is None From 91ed811f57f14c01125019f2efc027fc2cbb6327 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:43:13 +0100 Subject: [PATCH 076/979] Return None for NodeErrors by default --- plugwise_usb/connection/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index e3f665421..e7d64e722 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -9,7 +9,7 @@ import logging from ..api import StickEvent -from ..exceptions import StickError +from ..exceptions import NodeError, StickError from ..messages.requests import PlugwiseRequest, StickInitRequest from ..messages.responses import PlugwiseResponse, StickInitResponse from .manager import StickConnectionManager @@ -190,9 +190,16 @@ async def initialize_stick(self) -> None: "Zigbee network connection to Circle+ is down." ) - async def send(self, request: PlugwiseRequest) -> PlugwiseResponse: + async def send( + self, request: PlugwiseRequest, suppress_node_errors: bool = True + ) -> PlugwiseResponse | None: """Submit request to queue and return response""" - return await self._queue.submit(request) + if not suppress_node_errors: + return await self._queue.submit(request) + try: + return await self._queue.submit(request) + except NodeError: + return None def _reset_states(self) -> None: """Reset internal connection information.""" From b8266ec79a353a35c79d596871f48acb46aeb1d8 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 17:51:36 +0100 Subject: [PATCH 077/979] Disable broad exception as we pass it over to request class --- plugwise_usb/connection/sender.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index f0472206b..edebbc1d9 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -88,8 +88,8 @@ async def write_request_to_port( ) ) ) - except BaseException as exception: # [broad-exception-caught] - request.assign_error(exception) + except BaseException as exc: # pylint: disable=broad-exception-caught + request.assign_error(exc) else: # Update request with session id request.seq_id = seq_id From f32fd3396a661ba38ddbb1e55e7903b909740c4b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 20:15:20 +0100 Subject: [PATCH 078/979] Handle stick timeout response at request --- plugwise_usb/messages/requests.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 0931b50df..1108584f2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -11,12 +11,13 @@ from ..constants import ( DAY_IN_MINUTES, HOUR_IN_MINUTES, + LOGADDR_OFFSET, MAX_RETRIES, MESSAGE_FOOTER, MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..messages.responses import PlugwiseResponse +from ..messages.responses import PlugwiseResponse, StickResponse from ..exceptions import NodeError, StickError from ..util import ( DateTime, @@ -87,7 +88,7 @@ def subscribe_to_responses( subscription_fn( self._update_response, mac=self._mac, - message_ids=(self._reply_identifier,), + message_ids=(b"0000", self._reply_identifier), ) ) @@ -99,10 +100,19 @@ def start_response_timeout(self) -> None: NODE_TIME_OUT, self._response_timeout_expired ) - def _response_timeout_expired(self) -> None: + def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout""" - if not self._response_future.done(): - self._unsubscribe_response() + if self._response_future.done(): + return + self._unsubscribe_response() + if stick_timeout: + self._response_future.set_exception( + NodeError( + f"Timeout by stick to " + + f"{self.mac_decoded}" + ) + ) + else: self._response_future.set_exception( NodeError( f"No response within {NODE_TIME_OUT} from node " + @@ -121,8 +131,13 @@ def assign_error(self, error: StickError) -> None: async def _update_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node""" if self._seq_id is None: - pass - if self._seq_id == response.seq_id: + return + if self._seq_id != response.seq_id: + return + if isinstance(response, StickResponse): + self._response_timeout.cancel() + self._response_timeout_expired() + else: self._response_timeout.cancel() self._response_future.set_result(response) self._unsubscribe_response() From 393b3f9e8bd4ab86873931623c1e880099024f84 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 20:21:54 +0100 Subject: [PATCH 079/979] Fix error text generation --- plugwise_usb/messages/requests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 1108584f2..fe1d635fc 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -108,8 +108,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: if stick_timeout: self._response_future.set_exception( NodeError( - f"Timeout by stick to " + - f"{self.mac_decoded}" + "Timeout by stick to {self.mac_decoded}" ) ) else: From 98dbf6f6d00535d67ad32a26e02799720b336f84 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 20:23:51 +0100 Subject: [PATCH 080/979] Use the LogAddr class and default offset constant --- plugwise_usb/constants.py | 2 +- plugwise_usb/messages/requests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 5ef2aa529..f1d0d89a2 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -67,7 +67,7 @@ PULSES_PER_KW_SECOND: Final = 468.9385193 # Energy log memory addresses -LOGADDR_OFFSET: Final = 278528 +LOGADDR_OFFSET: Final = 278528 # = b"00044000" LOGADDR_MAX: Final = 65535 # TODO: Determine last log address, not used yet # Max seconds the internal clock of plugwise nodes diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fe1d635fc..e05f38cd0 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -437,7 +437,7 @@ class CircleClockSetRequest(PlugwiseRequest): Set internal clock of node and flash address reset=True, will reset all locally stored energy logs - + Supported protocols : 1.0, 2.0 Response message : NodeResponse """ From d33ca0a7443b4b2e460c5aa502de06919e9cdb41 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 20:58:47 +0100 Subject: [PATCH 081/979] Use the LogAddr class and default offset constant --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index e05f38cd0..cb505d10c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -467,7 +467,7 @@ def __init__( this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) if reset: - log_buf_addr = String("00044000", 8) + log_buf_addr = LogAddr(LOGADDR_OFFSET, 8, False) else: log_buf_addr = String("FFFFFFFF", 8) self._args += [this_date, log_buf_addr, this_time, day_of_week] From 8f822e3e1c3c9ff7ac09b84a59ed809e5eda4ff0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 21:00:51 +0100 Subject: [PATCH 082/979] Retry ressponse callbacks when subscription is missing --- plugwise_usb/connection/receiver.py | 33 ++++++++++++++++++++++++++++- plugwise_usb/messages/responses.py | 11 ++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 38e105243..72bb34f68 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -19,9 +19,11 @@ from __future__ import annotations from asyncio import ( Future, + create_task, gather, Protocol, get_running_loop, + sleep, ) from serial_asyncio import SerialTransport from collections.abc import Awaitable, Callable @@ -45,6 +47,12 @@ ) +async def delayed_run(coroutine: Callable, seconds: float): + """Postpone a coroutine to be executed after given delay""" + await sleep(seconds) + await coroutine + + class StickReceiver(Protocol): """ Receive data from USB Stick connection and @@ -291,6 +299,7 @@ async def _notify_node_response_subscribers( self, node_response: PlugwiseResponse ) -> None: """Call callback for all node response message subscribers""" + callback_list: list[Callable] = [] for callback, mac, message_ids in list( self._node_response_subscribers.values() ): @@ -300,4 +309,26 @@ async def _notify_node_response_subscribers( if message_ids is not None: if node_response.identifier not in message_ids: continue - await callback(node_response) + callback_list.append(callback(node_response)) + + if len(callback_list) > 0: + await gather(*callback_list) + return + + # No subscription for response, retry in 0.5 sec. + node_response.notify_retries += 1 + if node_response.notify_retries > 10: + _LOGGER.warning( + "No subscriber to handle %s from %s", + node_response.__class__.__name__, + node_response.mac_decoded, + ) + return + create_task( + delayed_run( + self._notify_node_response_subscribers( + node_response + ), + 0.5, + ) + ) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 7cdb4a34a..95a09dcf4 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -112,6 +112,7 @@ def __init__( self._decode_mac = decode_mac self._params: list[Any] = [] self._seq_id: bytes = b"FFFF" + self._notify_retries: int = 0 @property def ack_id(self) -> bytes | None: @@ -123,6 +124,16 @@ def seq_id(self) -> bytes: """Sequence ID""" return self._seq_id + @property + def notify_retries(self) -> int: + """Return number of notifies""" + return self._notify_retries + + @notify_retries.setter + def notify_retries(self, retries: int) -> None: + """Set number of notification retries""" + self._notify_retries = retries + def deserialize(self, response: bytes) -> None: """Deserialize bytes to actual message properties.""" self.timestamp = datetime.now(UTC) From bc49a8fa4021c8a303b4ee7800b7dd624e38ccc1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 21:16:33 +0100 Subject: [PATCH 083/979] Fix imports --- plugwise_usb/nodes/helpers/firmware.py | 4 +--- plugwise_usb/nodes/sed.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 6d2ff1178..56446e7a6 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -8,14 +8,12 @@ the Plugwise source installation. """ - from __future__ import annotations from datetime import datetime, UTC - from typing import Final, NamedTuple -from plugwise_usb.api import NodeFeature +from ...api import NodeFeature SupportedVersions = NamedTuple( diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index e49ba8db7..ff57f93d7 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -14,9 +14,8 @@ import logging from typing import Final -from plugwise_usb.connection import StickController - from .helpers import raise_not_loaded +from ..connection import StickController from ..exceptions import NodeError, NodeTimeout from ..messages.requests import NodeSleepConfigRequest from ..messages.responses import ( From 2a6f4b85248241d02b684cf5646062fc83befff9 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 22:31:37 +0100 Subject: [PATCH 084/979] Proper re-raise of error --- plugwise_usb/connection/queue.py | 5 ++++- tests/test_usb.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 5a16899f1..b15ef0304 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -106,7 +106,10 @@ async def submit( try: response: PlugwiseResponse = await request.response_future() except BaseException as exception: # [broad-exception-caught] - raise exception.args[0] + raise StickError( + f"No response received for {request.__class__.__name__} " + + f"to {request.mac_decoded}" + ) from exception return response async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: diff --git a/tests/test_usb.py b/tests/test_usb.py index 99771187b..0aa5f7511 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -284,7 +284,7 @@ async def test_stick_connect_timeout(self, monkeypatch): monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 5) stick = pw_stick.Stick() await stick.connect("test_port") - with pytest.raises(pw_exceptions.StickTimeout): + with pytest.raises(pw_exceptions.StickError): await stick.initialize() await stick.disconnect() From 76263dc743f2d792d57e021854448f7061ead2dc Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 22:32:09 +0100 Subject: [PATCH 085/979] Update message --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index cb505d10c..be34cd254 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -114,7 +114,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: else: self._response_future.set_exception( NodeError( - f"No response within {NODE_TIME_OUT} from node " + + f"No response within {NODE_TIME_OUT} seconds from node " + f"{self.mac_decoded}" ) ) From ec4ae38510eec0866dc2c466a4551918c8f5ac57 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 21 Jan 2024 22:32:46 +0100 Subject: [PATCH 086/979] Guard for multiple duplicate responses --- plugwise_usb/messages/requests.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index be34cd254..6a64811e9 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -136,10 +136,15 @@ async def _update_response(self, response: PlugwiseResponse) -> None: if isinstance(response, StickResponse): self._response_timeout.cancel() self._response_timeout_expired() - else: - self._response_timeout.cancel() + return + + self._response_timeout.cancel() + # Guard for multiple duplicate response message + if not self._response_future.done(): self._response_future.set_result(response) + if self._unsubscribe_response is not None: self._unsubscribe_response() + self._unsubscribe_response = None @property def object_id(self) -> int: From eed7b374c7be31f37970d2d6c1038fb2ac021f77 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Mon, 22 Jan 2024 20:51:47 +0000 Subject: [PATCH 087/979] correct response offset property name take missing addresses out of loop for usage later send can throw timeout exceptions.. async functions lose them give CIRCLE(PLUS ENERGY and POWER features --- plugwise_usb/nodes/circle.py | 108 +++++++++++++++++------------- plugwise_usb/nodes/circle_plus.py | 14 +++- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index cf9d744ee..7c7686fc5 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -4,11 +4,11 @@ from asyncio import create_task, gather, sleep from collections.abc import Awaitable, Callable -from datetime import datetime, UTC, timedelta +from datetime import UTC, datetime, timedelta from functools import wraps import logging from typing import Any, TypeVar, cast - +from ..exceptions import PlugwiseException from ..api import NodeFeature from ..constants import ( MAX_TIME_DRIFT, @@ -16,9 +16,6 @@ PULSES_PER_KW_SECOND, SECOND_IN_NANOSECONDS, ) -from .helpers import EnergyCalibration, raise_not_loaded -from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT -from .helpers.pulses import PulseLogRecord from ..exceptions import NodeError from ..messages.requests import ( CircleClockGetRequest, @@ -40,12 +37,10 @@ NodeResponse, NodeResponseType, ) -from ..nodes import ( - EnergyStatistics, - PlugwiseNode, - PowerStatistics, -) - +from ..nodes import EnergyStatistics, PlugwiseNode, PowerStatistics +from .helpers import EnergyCalibration, raise_not_loaded +from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT +from .helpers.pulses import PulseLogRecord FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -55,12 +50,13 @@ def raise_calibration_missing(func: FuncT) -> FuncT: """ Decorator function to make sure energy calibration settings are available. """ + @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: - if args[0].calibrated is None: raise NodeError("Energy calibration settings are missing") return func(*args, **kwargs) + return cast(FuncT, decorated) @@ -241,9 +237,7 @@ async def power_update(self) -> PowerStatistics | None: return self._power request = CirclePowerUsageRequest(self._mac_in_bytes) - response: CirclePowerUsageResponse | None = await self._send( - request - ) + response: CirclePowerUsageResponse | None = await self._send(request) if response is None or response.timestamp is None: _LOGGER.debug( "No response for async_power_update() for %s", @@ -260,10 +254,10 @@ async def power_update(self) -> PowerStatistics | None: # Update power stats self._power.last_second = self._calc_watts( - response.pulse_1s, 1, response.nanosecond_offset + response.pulse_1s, 1, response.offset ) self._power.last_8_seconds = self._calc_watts( - response.pulse_8s.value, 8, response.nanosecond_offset + response.pulse_8s, 8, response.offset ) self._power.timestamp = response.timestamp await self.publish_feature_update_to_subscribers( @@ -369,35 +363,38 @@ async def get_missing_energy_logs(self) -> None: _LOGGER.warning( "Failed to update energy log %s for %s", str(address), - self._mac_in_str + self._mac_in_str, ) break if self._cache_enabled: await self._energy_log_records_save_to_cache() return - if len(missing_addresses) == 0: - return - _LOGGER.debug( - "Request %s missing energy logs for node %s | %s", - str(len(missing_addresses)), - self._node_info.mac, - str(missing_addresses), - ) - if len(missing_addresses) > 10: - _LOGGER.warning( - "Limit requesting max 10 energy logs %s for node %s", + if ( + missing_addresses := self._energy_counters.log_addresses_missing + ) is not None: + if len(missing_addresses) == 0: + return + _LOGGER.debug( + "Request %s missing energy logs for node %s | %s", str(len(missing_addresses)), self._node_info.mac, + str(missing_addresses), ) - missing_addresses = sorted(missing_addresses, reverse=True)[:10] - await gather( - *[ - self.energy_log_update(address) - for address in missing_addresses - ] - ) - if self._cache_enabled: - await self._energy_log_records_save_to_cache() + if len(missing_addresses) > 10: + _LOGGER.warning( + "Limit requesting max 10 energy logs %s for node %s", + str(len(missing_addresses)), + self._node_info.mac, + ) + missing_addresses = sorted(missing_addresses, reverse=True)[:10] + await gather( + *[ + self.energy_log_update(address) + for address in missing_addresses + ] + ) + if self._cache_enabled: + await self._energy_log_records_save_to_cache() async def energy_log_update(self, address: int) -> bool: """ @@ -412,9 +409,10 @@ async def energy_log_update(self, address: int) -> bool: str(address), self._mac_in_str, ) - response: CircleEnergyLogsResponse | None = await self._send( - request - ) + try: + response: CircleEnergyLogsResponse | None = await self._send(request) + except PlugwiseException: + response = None await sleep(0) if response is None: _LOGGER.warning( @@ -699,7 +697,12 @@ async def load(self) -> bool: if await self._load_from_cache(): self._loaded = True self._setup_protocol( - CIRCLE_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + CIRCLE_FIRMWARE_SUPPORT, + ( + NodeFeature.RELAY_INIT, + NodeFeature.ENERGY, + NodeFeature.POWER, + ), ) return await self.initialize() _LOGGER.warning( @@ -843,8 +846,22 @@ async def node_info_update( self._set_cache( "last_log_address", node_info.last_logaddress ) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + if ( + self._last_log_address is not None + and self._last_log_address > node_info.last_logaddress.value + ): + # Rollover of log address + _LOGGER.warning( + "Rollover log address from %s into %s for node %s", + self._last_log_address, + node_info.last_logaddress.value, + self.mac + ) + if self._last_log_address != node_info.last_logaddress.value: + self._last_log_address = node_info.last_logaddress.value + self._set_cache("last_log_address", node_info.last_logaddress.value) + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.save_cache()) return True async def _node_info_load_from_cache(self) -> bool: @@ -1006,8 +1023,7 @@ async def get_state( if not self._loaded: if not await self.load(): _LOGGER.warning( - "Unable to update state because load node %s failed", - self.mac + "Unable to update state because load node %s failed", self.mac ) states: dict[NodeFeature, Any] = {} if not self._available: diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index f1b657e8c..643cb9fa2 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -37,7 +37,12 @@ async def load(self) -> bool: if await self._load_from_cache(): self._loaded = True self._setup_protocol( - CIRCLE_PLUS_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + CIRCLE_PLUS_FIRMWARE_SUPPORT, + ( + NodeFeature.RELAY_INIT, + NodeFeature.ENERGY, + NodeFeature.POWER, + ), ) return await self.initialize() _LOGGER.warning( @@ -65,7 +70,12 @@ async def load(self) -> bool: return False self._loaded = True self._setup_protocol( - CIRCLE_PLUS_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + CIRCLE_PLUS_FIRMWARE_SUPPORT, + ( + NodeFeature.RELAY_INIT, + NodeFeature.ENERGY, + NodeFeature.POWER, + ), ) return await self.initialize() From 4d3775106ac2d15d00b95fe2fede150ea96208f0 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:03:52 +0000 Subject: [PATCH 088/979] Use queue in receiver... USB can hold multiple msg (especially the StickResponse and the StickInitResponse to start with) see queue size up to 3. Also use a queue instead of async function as order is important for the Stick and Node messages --- plugwise_usb/connection/receiver.py | 67 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 72bb34f68..abffe19ad 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -22,6 +22,7 @@ create_task, gather, Protocol, + Queue, get_running_loop, sleep, ) @@ -29,13 +30,11 @@ from collections.abc import Awaitable, Callable from concurrent import futures import logging - from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError from ..messages.responses import ( PlugwiseResponse, - StickInitResponse, StickResponse, get_message_object, ) @@ -70,10 +69,11 @@ def __init__( self._transport: SerialTransport | None = None self._buffer: bytes = bytes([]) self._connection_state = False - + self._request_queue = Queue() self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} - + self._stick_response_future: futures.Future | None = None + #self._msg_processing_task: Task | None = None # Subscribers self._stick_event_subscribers: dict[ Callable[[], None], @@ -82,7 +82,7 @@ def __init__( self._stick_response_subscribers: dict[ Callable[[], None], - Callable[[StickResponse | StickInitResponse], Awaitable[None]] + Callable[[StickResponse], Awaitable[None]] ] = {} self._node_response_subscribers: dict[ @@ -108,8 +108,10 @@ def connection_lost(self, exc: Exception | None = None) -> None: self._loop.create_task( self._notify_stick_event_subscribers(StickEvent.DISCONNECTED) ) + self._transport = None self._connection_state = False + #self._msg_processing_task.cancel() @property def is_connected(self) -> bool: @@ -120,6 +122,8 @@ def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.debug("Connection made") self._transport = transport + #self._msg_processing_task = + if ( self._connected_future is not None and not self._connected_future.done() @@ -137,22 +141,24 @@ async def close(self) -> None: return if self._stick_future is not None and not self._stick_future.done(): self._stick_future.cancel() + self._transport.close() def data_received(self, data: bytes) -> None: - """ - Receive data from USB-Stick connection. + """Receive data from USB-Stick connection. + This function is called by inherited asyncio.Protocol class """ + _LOGGER.debug("USB stick received [%s]", data) self._buffer += data if len(self._buffer) < 8: return while self.extract_message_from_buffer(): pass - def extract_message_from_buffer(self) -> bool: - """ - Parse data in buffer and extract any message. + def extract_message_from_buffer(self, queue=None) -> bool: + """Parse data in buffer and extract any message. + When buffer does not contain any message return False. """ # Lookup header of message @@ -179,15 +185,19 @@ def extract_message_from_buffer(self) -> bool: response = self._populate_message( _empty_message, self._buffer[: _footer_index + 2] ) - + _LOGGER.debug('USB Got %s', response) # Parse remaining buffer self._reset_buffer(self._buffer[_footer_index:]) if response is not None: - self._forward_response(response) + self._request_queue.put_nowait(response) - if len(self._buffer) > 0: + if len(self._buffer) >= 8: self.extract_message_from_buffer() + else: + self._loop.create_task( + self._msg_queue_processing_function() + ) return False def _populate_message( @@ -201,16 +211,15 @@ def _populate_message( return None return message - def _forward_response(self, response: PlugwiseResponse) -> None: - """Receive and handle response messages.""" - if isinstance(response, StickResponse): - self._loop.create_task( - self._notify_stick_response_subscribers(response) - ) - else: - self._loop.create_task( - self._notify_node_response_subscribers(response) - ) + async def _msg_queue_processing_function(self): + while self._request_queue.qsize() > 0: + response: PlugwiseResponse | None = await self._request_queue.get() + _LOGGER.debug("Processing %s", response) + if isinstance(response, StickResponse): + await self._notify_stick_response_subscribers(response) + else: + await self._notify_node_response_subscribers(response) + self._request_queue.task_done() def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -225,8 +234,8 @@ def subscribe_to_stick_events( stick_event_callback: Callable[[StickEvent], Awaitable[None]], events: tuple[StickEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified StickEvent occurs. + """Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. """ def remove_subscription() -> None: @@ -244,7 +253,7 @@ async def _notify_stick_event_subscribers( ) -> None: """Call callback for stick event subscribers""" callback_list: list[Callable] = [] - for callback, filtered_events in list( + for callback, filtered_events in ( self._stick_event_subscribers.values() ): if event in filtered_events: @@ -254,9 +263,7 @@ async def _notify_stick_event_subscribers( def subscribe_to_stick_responses( self, - callback: Callable[ - [StickResponse | StickInitResponse], Awaitable[None] - ], + callback: Callable[[StickResponse] , Awaitable[None]], ) -> Callable[[], None]: """Subscribe to response messages from stick.""" def remove_subscription() -> None: @@ -271,7 +278,7 @@ def remove_subscription() -> None: async def _notify_stick_response_subscribers( self, stick_response: StickResponse ) -> None: - """Call callback for all stick response message subscribers""" + """Call callback for all stick response message subscribers.""" for callback in self._stick_response_subscribers.values(): await callback(stick_response) From 63ec2ac095e50810d7ea15a5c990576578d20a9c Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:13:45 +0000 Subject: [PATCH 089/979] Reformatting add __repr__ functions for logging --- plugwise_usb/messages/__init__.py | 14 +++++----- plugwise_usb/messages/requests.py | 43 +++++++++++++----------------- plugwise_usb/messages/responses.py | 35 +++++++++++------------- 3 files changed, 40 insertions(+), 52 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 9b81387c6..b6e29ef83 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..util import crc_fun @@ -11,7 +10,7 @@ class PlugwiseMessage: """Plugwise message base class.""" def __init__(self, identifier: bytes) -> None: - """Initialize a plugwise message""" + """Initialize a plugwise message.""" self._identifier = identifier self._mac: bytes | None = None self._checksum: bytes | None = None @@ -20,22 +19,22 @@ def __init__(self, identifier: bytes) -> None: @property def seq_id(self) -> bytes | None: - """Return sequence id assigned to this request""" + """Return sequence id assigned to this request.""" return self._seq_id @seq_id.setter def seq_id(self, seq_id: bytes) -> None: - """Assign sequence id""" + """Assign sequence id.""" self._seq_id = seq_id @property def identifier(self) -> bytes: - """Return the message ID""" + """Return the message ID.""" return self._identifier @property def mac(self) -> bytes: - """Return mac in bytes""" + """Return mac in bytes.""" return self._mac @property @@ -47,8 +46,7 @@ def mac_decoded(self) -> str: def serialize(self) -> bytes: """Return message in a serialized format that can be sent out.""" - data = bytes() - data += self._identifier + data = self._identifier if self._mac is not None: data += self._mac data += b"".join(a.serialize() for a in self._args) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6a64811e9..6da557a75 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -69,6 +69,9 @@ def __init__( self._loop.create_future() ) + def __repr__(self) -> str: + return f"{self.__class__.__name__} for {self.mac_decoded}" + def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message""" return self._response_future @@ -120,7 +123,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) def assign_error(self, error: StickError) -> None: - """Assign error for this request""" + """Assign error for this request.""" if self._response_timeout is not None: self._response_timeout.cancel() if self._response_future.done(): @@ -128,42 +131,35 @@ def assign_error(self, error: StickError) -> None: self._response_future.set_exception(error) async def _update_response(self, response: PlugwiseResponse) -> None: - """Process incoming message from node""" + """Process incoming message from node.""" if self._seq_id is None: - return - if self._seq_id != response.seq_id: - return - if isinstance(response, StickResponse): + pass + if self._seq_id == response.seq_id: + _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) + self._response = response self._response_timeout.cancel() - self._response_timeout_expired() - return - self._response_timeout.cancel() - # Guard for multiple duplicate response message - if not self._response_future.done(): - self._response_future.set_result(response) - if self._unsubscribe_response is not None: - self._unsubscribe_response() - self._unsubscribe_response = None + + @property def object_id(self) -> int: - """return the object id""" + """return the object id.""" return self._id @property def max_retries(self) -> int: - """Return the maximum retries""" + """Return the maximum retries.""" return self._max_retries @max_retries.setter def max_retries(self, max_retries: int) -> None: - """Set maximum retries""" + """Set maximum retries.""" self._max_retries = max_retries @property def retries_left(self) -> int: - """Return number of retries left""" + """Return number of retries left.""" return self._max_retries - self._send_counter @property @@ -172,7 +168,7 @@ def resend(self) -> bool: return self._max_retries > self._send_counter def add_send_attempt(self): - """Decrease the number of retries""" + """Increase the number of retries""" self._send_counter += 1 def __gt__(self, other: PlugwiseRequest) -> bool: @@ -209,8 +205,7 @@ def __le__(self, other: PlugwiseRequest) -> bool: class StickNetworkInfoRequest(PlugwiseRequest): - """ - Request network information + """Request network information. Supported protocols : 1.0, 2.0 Response message : NodeNetworkInfoResponse @@ -223,8 +218,7 @@ def __init__(self) -> None: class CirclePlusConnectRequest(PlugwiseRequest): - """ - Request to connect a Circle+ to the Stick + """Request to connect a Circle+ to the Stick. Supported protocols : 1.0, 2.0 Response message : CirclePlusConnectResponse @@ -514,7 +508,6 @@ def __init__(self, mac: bytes, network_address: int) -> None: self._args.append(Int(network_address, length=2)) self.network_address = network_address - class NodeRemoveRequest(PlugwiseRequest): """ Request node to be removed from Plugwise network by diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 95a09dcf4..0a3f670d1 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -114,6 +114,9 @@ def __init__( self._seq_id: bytes = b"FFFF" self._notify_retries: int = 0 + def __repr__(self) -> str: + return f"{self.__class__.__name__} from {self.mac_decoded} seq_id {self.seq_id}" + @property def ack_id(self) -> bytes | None: """Return the acknowledge id""" @@ -160,9 +163,9 @@ def deserialize(self, response: bytes) -> None: # Checksum if (check := self.calculate_checksum(response[:-4])) != response[-4:]: raise MessageError( - f"Invalid checksum for {self.__class__.__name__}, " + - f"expected {check} got " + - str(response[-4:]), + f"Invalid checksum for {self.__class__.__name__}, " + + f"expected {check} got " + + str(response[-4:]), ) response = response[:-4] @@ -204,7 +207,7 @@ def _parse_params(self, response: bytes) -> bytes: for param in self._params: my_val = response[: len(param)] param.deserialize(my_val) - response = response[len(my_val):] + response = response[len(my_val) :] return response def __len__(self) -> int: @@ -225,6 +228,9 @@ def __init__(self) -> None: """Initialize StickResponse message object""" super().__init__(b"0000", decode_ack=True, decode_mac=False) + def __repr__(self) -> str: + return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) + class NodeResponse(PlugwiseResponse): """ @@ -642,12 +648,7 @@ def __init__(self) -> None: self._gain_b = Float(0, 8) self._off_tot = Float(0, 8) self._off_noise = Float(0, 8) - self._params += [ - self._gain_a, - self._gain_b, - self._off_tot, - self._off_noise - ] + self._params += [self._gain_a, self._gain_b, self._off_tot, self._off_noise] @property def gain_a(self) -> float: @@ -823,8 +824,7 @@ def __init__(self) -> None: class NodeAckResponse(PlugwiseResponse): - """ - Acknowledge message in regular format + """Acknowledge message in regular format Sent by nodes supporting plugwise 2.4 protocol version Response to: ? @@ -836,8 +836,8 @@ def __init__(self) -> None: class SenseReportResponse(PlugwiseResponse): - """ - Returns the current temperature and humidity of a Sense node. + """Returns the current temperature and humidity of a Sense node. + The interval this report is sent is configured by the 'SenseReportIntervalRequest' request @@ -853,8 +853,7 @@ def __init__(self) -> None: class CircleRelayInitStateResponse(PlugwiseResponse): - """ - Returns the configured relay state after power-up of Circle + """Returns the configured relay state after power-up of Circle. Supported protocols : 2.6 Response to request : CircleRelayInitStateRequest @@ -896,9 +895,7 @@ def __init__(self) -> None: def get_message_object( identifier: bytes, length: int, seq_id: bytes ) -> PlugwiseResponse | None: - """ - Return message class based on sequence ID, Length of message or message ID. - """ + """Return message class based on sequence ID, Length of message or message ID.""" # First check for known sequence ID's if seq_id == REJOIN_RESPONSE_SEQ_ID: From f8ec754396cdbb4a8d154122778102134d466407 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:16:48 +0000 Subject: [PATCH 090/979] correct use_cache variable do not connect if connect do not initialise if initialised discover_coordinator and discover_nodeson discover pseudo random delay (0.05-0.25) testing limit for 15 seconds else hangs on failrue --- plugwise_usb/__init__.py | 12 ++++++++---- tests/test_usb.py | 13 +++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 260b6f49e..93149e8aa 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -62,14 +62,14 @@ class Stick: """Plugwise connection stick.""" def __init__( - self, port: str | None = None, cache_enabled: bool = True + self, port: str | None = None, use_cache: bool = True ) -> None: """Initialize Stick.""" self._loop = get_running_loop() self._loop.set_debug(True) self._controller = StickController() self._network: StickNetwork | None = None - self._cache_enabled = cache_enabled + self._cache_enabled = use_cache self._port = port self._cache_folder: str = "" @@ -264,10 +264,14 @@ async def setup( self, discover: bool = True, load: bool = True ) -> None: """Setup connection to USB-Stick.""" - await self.connect() - await self.initialize() + if not self.is_connected: + await self.connect() + if not self.is_initialized: + await self.initialize() if discover: await self.start_network() + await self.discover_coordinator() + await self.discover_nodes() if load: await self.load_nodes() diff --git a/tests/test_usb.py b/tests/test_usb.py index 0aa5f7511..de0aa1a62 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,6 +1,4 @@ -import aiofiles import asyncio -from concurrent import futures from datetime import datetime as dt, timedelta as td, timezone as tz import importlib import logging @@ -114,7 +112,9 @@ def write(self, data: bytes) -> None: ) async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: - await asyncio.sleep(0.5) + import random + delay = random.uniform(0.05, 0.25) + await asyncio.sleep(delay) self.message_response(data, seq_id) def message_response(self, data: bytes, seq_id: bytes) -> None: @@ -296,7 +296,7 @@ async def test_stick_connect(self, monkeypatch): "create_serial_connection", MockSerial(None).mock_connection, ) - stick = pw_stick.Stick(port="test_port", cache_enabled=False) + stick = pw_stick.Stick(port="test_port", use_cache=False) await stick.connect("test_port") await stick.initialize() assert stick.mac_stick == "0123456789012345" @@ -423,10 +423,11 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): ) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) - stick = pw_stick.Stick("test_port", cache_enabled=False) + stick = pw_stick.Stick("test_port", use_cache=False) await stick.connect() await stick.initialize() - await stick.discover_nodes(load=False) + async with asyncio.timeout(15.0): + await stick.discover_nodes(load=False) stick.accept_join_request = True self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( From 7305e4b9b1c4dd8792444ae3ad070d35adfc7afd Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:32:02 +0000 Subject: [PATCH 091/979] if submit queue is getting bigger wait for some sending (CircleLogs can be 10 at a time...) Keep track on open_request and timeout if so Do not wait 100ms, but wait for the stick_lock to come back and remove the open request --- plugwise_usb/connection/queue.py | 7 ++++++- plugwise_usb/connection/sender.py | 34 +++++++++++++++++++++++++++---- pyproject.toml | 3 +++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index b15ef0304..a9d2b766b 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -8,6 +8,7 @@ InvalidStateError, PriorityQueue, Task, + sleep, get_running_loop, ) from collections.abc import Callable @@ -96,6 +97,7 @@ async def submit( Add request to queue and return the response of node Raises an error when something fails """ + _LOGGER.debug("Queueing %s", request) if not self._running or self._stick is None: raise StickError( f"Cannot send message {request.__class__.__name__} for" + @@ -126,7 +128,10 @@ def _start_submit_worker(self) -> None: async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" - while self._queue.qsize() > 0: + while (size := self._queue.qsize()) > 0: # Get item with highest priority from queue first request = await self._queue.get() + sleeptime = (2**size) * 0.0001 + sleeptime = min(sleeptime, 0.05) + await sleep(sleeptime) await self._stick.write_to_stick(request) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index edebbc1d9..4ca9dde93 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -15,14 +15,14 @@ """ from __future__ import annotations -from asyncio import Future, Lock, Transport, get_running_loop, sleep, wait_for +from asyncio import Future, Lock, Transport, get_running_loop, wait_for import logging -from .receiver import StickReceiver from ..constants import STICK_TIME_OUT from ..exceptions import StickError, StickFailed, StickTimeout -from ..messages.responses import StickResponse, StickResponseType from ..messages.requests import PlugwiseRequest +from ..messages.responses import StickResponse, StickResponseType +from .receiver import StickReceiver _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,9 @@ async def write_request_to_port( self._receiver.subscribe_to_node_responses ) + _LOGGER.debug("Sending %s", request) # Write message to serial port buffer + _LOGGER.debug("USB write [%s]", str(serialized_data)) self._transport.write(serialized_data) request.add_send_attempt() request.start_response_timeout() @@ -92,7 +94,9 @@ async def write_request_to_port( request.assign_error(exc) else: # Update request with session id + _LOGGER.debug("Request %s assigned seq_id %s", request, str(seq_id)) request.seq_id = seq_id + self._open_requests[seq_id] = request finally: self._stick_response = None self._stick_lock.release() @@ -105,12 +109,28 @@ async def _process_stick_response(self, response: StickResponse) -> None: self._stick_response is None or self._stick_response.done() ): + + if response.ack_id == StickResponseType.TIMEOUT: + _LOGGER.warning("%s TIMEOUT", response) + if (request := self._open_requests.get(response.seq_id, None)): + _LOGGER.error("Failed to send %s because USB-Stick could not send the request to the node.", request) + request.assign_error( + BaseException( + StickTimeout( + f"Failed to send {request.__class__.__name__} because USB-Stick could not send the {request} to the {request.mac}." + ) + ) + ) + del self._open_requests[response.seq_id] + return + _LOGGER.warning( "Unexpected stick response (ack_id=%s, seq_id=%s) received", str(response.ack_id), str(response.seq_id), ) return + _LOGGER.debug("Received stick %s", response) if response.ack_id == StickResponseType.ACCEPT: self._stick_response.set_result(response.seq_id) @@ -134,7 +154,13 @@ async def _process_stick_response(self, response: StickResponse) -> None: ) ) ) - await sleep(0.1) + return + await self._stick_lock.acquire() + if response.seq_id in self._open_requests: + del self._open_requests[response.seq_id] + else: + return + self._stick_lock.release() def stop(self) -> None: """Stop sender""" diff --git a/pyproject.toml b/pyproject.toml index d9352e7dc..eda2a755e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,6 +172,8 @@ overgeneral-exceptions = [ ] [tool.pytest.ini_options] +log_cli_level="DEBUG" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" asyncio_mode = "strict" markers = [ # mark a test as a asynchronous io test. @@ -218,6 +220,7 @@ omit= [ "setup.py", ] + [tool.ruff] target-version = "py313" From 9b5f563bd5a9226f00ce8f95efce9f1daf82ed81 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:32:33 +0000 Subject: [PATCH 092/979] recaculate missing pulse counters --- plugwise_usb/nodes/helpers/counter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index bab2e7598..453098842 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -192,6 +192,7 @@ def update(self) -> None: ) = self._counters[EnergyType.PRODUCTION_WEEK].update( self._pulse_collection ) + self._pulse_collection.recalculate_missing_log_addresses() @property def timestamp(self) -> datetime | None: From fda33d05236690a0f5f3d9c942fac6cccb031926 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 23 Jan 2024 22:34:00 +0000 Subject: [PATCH 093/979] no await on non async function safegaurd logs --- plugwise_usb/nodes/__init__.py | 17 ++++++++--------- plugwise_usb/nodes/helpers/pulses.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index a80bc6856..841c4554a 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -5,7 +5,7 @@ from abc import ABC from asyncio import create_task, sleep from collections.abc import Callable -from datetime import datetime, timedelta, UTC +from datetime import UTC, datetime, timedelta import logging from typing import Any @@ -22,16 +22,13 @@ from ..connection import StickController from ..constants import UTF8, MotionSensitivity from ..exceptions import NodeError, StickError -from ..messages.requests import ( - NodeInfoRequest, - NodePingRequest, -) +from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse from ..util import version_to_model from .helpers.cache import NodeCache from .helpers.counter import EnergyCalibration, EnergyCounters -from .helpers.subscription import FeaturePublisher from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions +from .helpers.subscription import FeaturePublisher _LOGGER = logging.getLogger(__name__) NODE_FEATURES = ( @@ -307,6 +304,7 @@ def _setup_protocol( ): new_feature_list.append(feature) self._features = tuple(new_feature_list) + self._node_info.features = self._features async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" @@ -391,7 +389,7 @@ async def _load_from_cache(self) -> bool: self.mac ) return False - self._load_features() + #self._load_features() return True async def initialize(self) -> bool: @@ -399,7 +397,7 @@ async def initialize(self) -> bool: raise NotImplementedError() def _load_features(self) -> None: - """Enable additional supported feature(s)""" + """Enable additional supported feature(s).""" raise NotImplementedError() async def _available_update_state(self, available: bool) -> None: @@ -484,7 +482,7 @@ async def _node_info_load_from_cache(self) -> bool: second=int(data[5]), tzinfo=UTC ) - return await self._node_info_update_state( + return self._node_info_update_state( firmware=firmware, hardware=hardware, node_type=node_type, @@ -616,6 +614,7 @@ async def get_state( + f"not supported for {self.mac}" ) if feature == NodeFeature.INFO: + await self.node_info_update(None) states[NodeFeature.INFO] = self._node_info elif feature == NodeFeature.AVAILABLE: states[NodeFeature.AVAILABLE] = self.available diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 07bfb0702..2ffaf760f 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -624,14 +624,14 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: return None last_address, last_slot = self._last_log_reference() if last_address is None or last_slot is None: - _LOGGER.debug("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) + _LOGGER.warning("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) return None if self._logs[last_address][last_slot].timestamp <= from_timestamp: return [] first_address, first_slot = self._first_log_reference() if first_address is None or first_slot is None: - _LOGGER.debug("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) + _LOGGER.warning("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) return None missing = [] @@ -662,6 +662,12 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: _LOGGER.debug("_logs_missing | %s | missing in range=%s", self._mac, missing) return missing + if first_address not in self.logs: + return missing + + if first_slot not in self.logs[first_address]: + return missing + if self.logs[first_address][first_slot].timestamp < from_timestamp: return missing From 2f314e0192c351d5dbf523cdba2cd15f8acd80f1 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Wed, 24 Jan 2024 14:46:50 +0000 Subject: [PATCH 094/979] Fix issues due to merge create separate task to retrieve missing energy logs but 1 message at a time not to flush the queue. Make StickInit message for testing in one go as real stick does that too. --- plugwise_usb/__init__.py | 4 +- plugwise_usb/connection/__init__.py | 2 + plugwise_usb/messages/requests.py | 8 ++-- plugwise_usb/nodes/circle.py | 71 ++++++++++++----------------- tests/test_usb.py | 67 +++++++++++++++++---------- 5 files changed, 80 insertions(+), 72 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 93149e8aa..7571839f4 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -62,14 +62,14 @@ class Stick: """Plugwise connection stick.""" def __init__( - self, port: str | None = None, use_cache: bool = True + self, port: str | None = None, cache_enabled: bool = True ) -> None: """Initialize Stick.""" self._loop = get_running_loop() self._loop.set_debug(True) self._controller = StickController() self._network: StickNetwork | None = None - self._cache_enabled = use_cache + self._cache_enabled = cache_enabled self._port = port self._cache_folder: str = "" diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index e7d64e722..17b6802d7 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -198,6 +198,8 @@ async def send( return await self._queue.submit(request) try: return await self._queue.submit(request) + except StickError: + return None except NodeError: return None diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6da557a75..f9d6b186c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -17,7 +17,7 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..messages.responses import PlugwiseResponse, StickResponse +from ..messages.responses import PlugwiseResponse from ..exceptions import NodeError, StickError from ..util import ( DateTime, @@ -62,7 +62,7 @@ def __init__( self._loop = get_running_loop() self._id = id(self) self._reply_identifier: bytes = b"0000" - + self._response: PlugwiseResponse | None = None self._unsubscribe_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None self._response_future: Future[PlugwiseResponse] = ( @@ -138,8 +138,8 @@ async def _update_response(self, response: PlugwiseResponse) -> None: _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) self._response = response self._response_timeout.cancel() - - + self._response_future.set_result(response) + self._unsubscribe_response() @property diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 7c7686fc5..d68d75a53 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import create_task, gather, sleep +from asyncio import create_task, sleep from collections.abc import Awaitable, Callable from datetime import UTC, datetime, timedelta from functools import wraps @@ -335,7 +335,7 @@ async def energy_update( "Create task to update energy logs for node %s", self._node_info.mac, ) - await self.get_missing_energy_logs() + self._retrieve_energy_logs_task = create_task(self.get_missing_energy_logs()) else: _LOGGER.debug( "Skip creating task to update energy logs for node %s", @@ -345,6 +345,7 @@ async def energy_update( async def get_missing_energy_logs(self) -> None: """Task to retrieve missing energy logs""" + self._energy_counters.update() if ( missing_addresses := self._energy_counters.log_addresses_missing @@ -371,30 +372,29 @@ async def get_missing_energy_logs(self) -> None: return if ( missing_addresses := self._energy_counters.log_addresses_missing + ) is not None: + _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) + last_loop = 0 + while ( + missing_addresses := self._energy_counters.log_addresses_missing ) is not None: - if len(missing_addresses) == 0: - return - _LOGGER.debug( - "Request %s missing energy logs for node %s | %s", - str(len(missing_addresses)), - self._node_info.mac, - str(missing_addresses), - ) - if len(missing_addresses) > 10: - _LOGGER.warning( - "Limit requesting max 10 energy logs %s for node %s", - str(len(missing_addresses)), + if (missing_address_count := len(missing_addresses)) != 0: + if last_loop == missing_address_count: + return + last_loop = missing_address_count + _LOGGER.debug( + "Task Request %s missing energy logs for node %s | %s", + str(missing_address_count), self._node_info.mac, + str(missing_addresses), ) - missing_addresses = sorted(missing_addresses, reverse=True)[:10] - await gather( - *[ - self.energy_log_update(address) - for address in missing_addresses - ] - ) - if self._cache_enabled: - await self._energy_log_records_save_to_cache() + + missing_addresses = sorted(missing_addresses, reverse=True) + for address in missing_addresses: + await self.energy_log_update(address) + + if self._cache_enabled: + await self._energy_log_records_save_to_cache() async def energy_log_update(self, address: int) -> bool: """ @@ -821,12 +821,15 @@ async def node_info_update( Update Node hardware information. """ if node_info is None: - node_info = await self._send( + node_info: NodeInfoResponse = await self._send( NodeInfoRequest(self._mac_in_bytes) ) if not await super().node_info_update(node_info): return False - + + if node_info is None: + return False + await self._relay_update_state( node_info.relay_state, timestamp=node_info.timestamp ) @@ -846,22 +849,8 @@ async def node_info_update( self._set_cache( "last_log_address", node_info.last_logaddress ) - if ( - self._last_log_address is not None - and self._last_log_address > node_info.last_logaddress.value - ): - # Rollover of log address - _LOGGER.warning( - "Rollover log address from %s into %s for node %s", - self._last_log_address, - node_info.last_logaddress.value, - self.mac - ) - if self._last_log_address != node_info.last_logaddress.value: - self._last_log_address = node_info.last_logaddress.value - self._set_cache("last_log_address", node_info.last_logaddress.value) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + if self.cache_enabled and self._loaded and self._initialized: + create_task(self.save_cache()) return True async def _node_info_load_from_cache(self) -> bool: diff --git a/tests/test_usb.py b/tests/test_usb.py index de0aa1a62..92f2b7f07 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -102,14 +102,19 @@ def write(self, data: bytes) -> None: return self._seq_id = inc_seq_id(self._seq_id) - self.message_response(ack, self._seq_id) - self._processed.append(data) - if response is None: - return - self._loop.create_task( - # 0.5, - self._delayed_response(response, self._seq_id) - ) + if response and self._msg == 0: + self.message_response_at_once(ack, response, self._seq_id) + self._processed.append(data) + else: + self.message_response(ack, self._seq_id) + self._processed.append(data) + if response is None: + return + self._loop.create_task( + # 0.5, + self._delayed_response(response, self._seq_id) + ) + self._msg += 1 async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: import random @@ -128,6 +133,17 @@ def message_response(self, data: bytes, seq_id: bytes) -> None: else: self.protocol_data_received(construct_message(data, seq_id)) + def message_response_at_once(self, ack: bytes, data: bytes, seq_id: bytes) -> None: + self.random_extra_byte += 1 + if self.random_extra_byte > 25: + self.protocol_data_received(b"\x83") + self.random_extra_byte = 0 + self.protocol_data_received( + construct_message(ack, seq_id) + construct_message(data, seq_id) + b"\x83" + ) + else: + self.protocol_data_received(construct_message(ack, seq_id) + construct_message(data, seq_id)) + def close(self) -> None: pass @@ -296,22 +312,23 @@ async def test_stick_connect(self, monkeypatch): "create_serial_connection", MockSerial(None).mock_connection, ) - stick = pw_stick.Stick(port="test_port", use_cache=False) - await stick.connect("test_port") - await stick.initialize() - assert stick.mac_stick == "0123456789012345" - assert stick.mac_coordinator == "0098765432101234" - assert not stick.network_discovered - assert stick.network_state - assert stick.network_id == 17185 - assert stick.accept_join_request is None - # test failing of join requests without active discovery - with pytest.raises(pw_exceptions.StickError): - stick.accept_join_request = True - await stick.disconnect() - assert not stick.network_state - with pytest.raises(pw_exceptions.StickError): - assert stick.mac_stick + stick = pw_stick.Stick(port="test_port", cache_enabled=False) + async with asyncio.timeout(10.0): + await stick.connect("test_port") + await stick.initialize() + assert stick.mac_stick == "0123456789012345" + assert stick.mac_coordinator == "0098765432101234" + assert not stick.network_discovered + assert stick.network_state + assert stick.network_id == 17185 + assert stick.accept_join_request is None + # test failing of join requests without active discovery + with pytest.raises(pw_exceptions.StickError): + stick.accept_join_request = True + await stick.disconnect() + assert not stick.network_state + with pytest.raises(pw_exceptions.StickError): + assert stick.mac_stick async def disconnected(self, event): """Callback helper for stick disconnect event""" @@ -423,7 +440,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): ) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) - stick = pw_stick.Stick("test_port", use_cache=False) + stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() async with asyncio.timeout(15.0): From 43d3ccab0646294a3aaf22c89bd852ef4b7d98e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jan 2024 14:49:19 +0000 Subject: [PATCH 095/979] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- plugwise_usb/nodes/helpers/firmware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 56446e7a6..09bece070 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -16,9 +16,9 @@ from ...api import NodeFeature -SupportedVersions = NamedTuple( - "SupportedVersions", [("min", float), ("max", float)] -) +class SupportedVersions(NamedTuple): + min: float + max: float # region - node firmware versions CIRCLE_FIRMWARE_SUPPORT: Final = { From bff12da9e51702e207e171448edb588d84b0bd94 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Wed, 24 Jan 2024 14:55:37 +0000 Subject: [PATCH 096/979] Sonarqube fixes --- plugwise_usb/connection/receiver.py | 2 +- plugwise_usb/messages/requests.py | 4 +--- plugwise_usb/nodes/circle.py | 8 ++------ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index abffe19ad..2521755af 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -156,7 +156,7 @@ def data_received(self, data: bytes) -> None: while self.extract_message_from_buffer(): pass - def extract_message_from_buffer(self, queue=None) -> bool: + def extract_message_from_buffer(self) -> bool: """Parse data in buffer and extract any message. When buffer does not contain any message return False. diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index f9d6b186c..c0ad074d2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -132,9 +132,7 @@ def assign_error(self, error: StickError) -> None: async def _update_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node.""" - if self._seq_id is None: - pass - if self._seq_id == response.seq_id: + if self._seq_id is not None and self._seq_id == response.seq_id: _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) self._response = response self._response_timeout.cancel() diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d68d75a53..a1b34bfb0 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -347,9 +347,7 @@ async def get_missing_energy_logs(self) -> None: """Task to retrieve missing energy logs""" self._energy_counters.update() - if ( - missing_addresses := self._energy_counters.log_addresses_missing - ) is None: + if self._energy_counters.log_addresses_missing is None: _LOGGER.debug( "Start with initial energy request for the last 10 log" + " addresses for node %s.", @@ -370,9 +368,7 @@ async def get_missing_energy_logs(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() return - if ( - missing_addresses := self._energy_counters.log_addresses_missing - ) is not None: + if self._energy_counters.log_addresses_missing is not None: _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) last_loop = 0 while ( From 491876fe5a79e4ef781cdab8e1c57431b3878fc6 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Wed, 24 Jan 2024 15:23:11 +0000 Subject: [PATCH 097/979] Fix while loop --- plugwise_usb/nodes/circle.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index a1b34bfb0..204d2eeb7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -370,24 +370,20 @@ async def get_missing_energy_logs(self) -> None: return if self._energy_counters.log_addresses_missing is not None: _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) - last_loop = 0 - while ( + if ( missing_addresses := self._energy_counters.log_addresses_missing ) is not None: - if (missing_address_count := len(missing_addresses)) != 0: - if last_loop == missing_address_count: - return - last_loop = missing_address_count - _LOGGER.debug( - "Task Request %s missing energy logs for node %s | %s", - str(missing_address_count), - self._node_info.mac, - str(missing_addresses), - ) + _LOGGER.info( + "Task Request %s missing energy logs for node %s | %s", + str(len(missing_addresses)), + self._node_info.mac, + str(missing_addresses), + ) - missing_addresses = sorted(missing_addresses, reverse=True) - for address in missing_addresses: - await self.energy_log_update(address) + missing_addresses = sorted(missing_addresses, reverse=True) + for address in missing_addresses: + await self.energy_log_update(address) + await sleep(0.3) if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -541,7 +537,7 @@ async def _energy_log_record_update_state( log_cache_record += f"-{timestamp.second}:{pulses}" if (cached_logs := self._get_cache('energy_collection')) is not None: if log_cache_record not in cached_logs: - _LOGGER.info( + _LOGGER.debug( "Add logrecord (%s, %s) to log cache of %s", str(address), str(slot), From 29590b094aa0c419125f3b9bcb48a0c9ce354340 Mon Sep 17 00:00:00 2001 From: Arnout Dieterman Date: Thu, 25 Jan 2024 20:27:08 +0100 Subject: [PATCH 098/979] Update pyproject.toml update to v0.40.0a0 remove pytest log cli settings --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eda2a755e..75dfc9730 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.31.4a0" +version = "v0.40.0a0" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" @@ -172,8 +172,6 @@ overgeneral-exceptions = [ ] [tool.pytest.ini_options] -log_cli_level="DEBUG" -log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" asyncio_mode = "strict" markers = [ # mark a test as a asynchronous io test. From afc06d8e83c642dc2b64fe1c6c4f3d0270e902a1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 28 Jan 2024 19:54:57 +0100 Subject: [PATCH 099/979] Combine errors --- plugwise_usb/connection/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 17b6802d7..3e471dad7 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -198,9 +198,7 @@ async def send( return await self._queue.submit(request) try: return await self._queue.submit(request) - except StickError: - return None - except NodeError: + except (NodeError, StickError): return None def _reset_states(self) -> None: From 76cd79746c853612ab505a2b08946ba91e3c81b6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 28 Jan 2024 19:56:30 +0100 Subject: [PATCH 100/979] Remove uncommented code --- plugwise_usb/connection/receiver.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2521755af..61579e96d 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -73,7 +73,6 @@ def __init__( self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None - #self._msg_processing_task: Task | None = None # Subscribers self._stick_event_subscribers: dict[ Callable[[], None], @@ -111,7 +110,6 @@ def connection_lost(self, exc: Exception | None = None) -> None: self._transport = None self._connection_state = False - #self._msg_processing_task.cancel() @property def is_connected(self) -> bool: @@ -122,8 +120,6 @@ def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.debug("Connection made") self._transport = transport - #self._msg_processing_task = - if ( self._connected_future is not None and not self._connected_future.done() @@ -141,7 +137,7 @@ async def close(self) -> None: return if self._stick_future is not None and not self._stick_future.done(): self._stick_future.cancel() - + self._transport.close() def data_received(self, data: bytes) -> None: @@ -263,7 +259,7 @@ async def _notify_stick_event_subscribers( def subscribe_to_stick_responses( self, - callback: Callable[[StickResponse] , Awaitable[None]], + callback: Callable[[StickResponse], Awaitable[None]], ) -> Callable[[], None]: """Subscribe to response messages from stick.""" def remove_subscription() -> None: From dd9467b7d903d12bc34c7eb8ef007e72a872fa2e Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 22 Jan 2024 21:34:35 +0100 Subject: [PATCH 101/979] No need to catch error --- plugwise_usb/network/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f672edf35..eec295716 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -365,11 +365,12 @@ async def get_node_details( ping_request = NodePingRequest(bytes(mac, UTF8), retries=1) # ping_request.timeout = 3 - try: - ping_response = await self._controller.send( + ping_response: NodePingResponse | None = ( + await self._controller.send( ping_request - ) # type: ignore [assignment] - except StickTimeout: + ) + ) + if ping_response is None: return (None, None) info_response: NodeInfoResponse | None = await self._controller.send( From 52f83f935325f85bdd9e1aed42638d700a68340b Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 22 Jan 2024 22:38:42 +0100 Subject: [PATCH 102/979] Remove obsolete function --- plugwise_usb/nodes/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 841c4554a..48931aa0a 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -389,17 +389,12 @@ async def _load_from_cache(self) -> bool: self.mac ) return False - #self._load_features() return True async def initialize(self) -> bool: """Initialize node.""" raise NotImplementedError() - def _load_features(self) -> None: - """Enable additional supported feature(s).""" - raise NotImplementedError() - async def _available_update_state(self, available: bool) -> None: """Update the node availability state.""" if self._available == available: From 1187e7ec88365707433731cbcc05e9abdb994ffc Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 28 Jan 2024 22:09:05 +0100 Subject: [PATCH 103/979] Handle StickRespone (timeouts) in requests --- plugwise_usb/connection/receiver.py | 13 ++++-- plugwise_usb/connection/sender.py | 3 +- plugwise_usb/messages/requests.py | 65 +++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 61579e96d..a2545d383 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -81,7 +81,10 @@ def __init__( self._stick_response_subscribers: dict[ Callable[[], None], - Callable[[StickResponse], Awaitable[None]] + tuple[ + Callable[[StickResponse], Awaitable[None]], + bytes | None + ] ] = {} self._node_response_subscribers: dict[ @@ -260,6 +263,7 @@ async def _notify_stick_event_subscribers( def subscribe_to_stick_responses( self, callback: Callable[[StickResponse], Awaitable[None]], + seq_id: bytes | None = None ) -> Callable[[], None]: """Subscribe to response messages from stick.""" def remove_subscription() -> None: @@ -268,14 +272,17 @@ def remove_subscription() -> None: self._stick_response_subscribers[ remove_subscription - ] = callback + ] = callback, seq_id return remove_subscription async def _notify_stick_response_subscribers( self, stick_response: StickResponse ) -> None: """Call callback for all stick response message subscribers.""" - for callback in self._stick_response_subscribers.values(): + for callback, seq_id in list(self._stick_response_subscribers.values()): + if seq_id is not None: + if seq_id != stick_response.seq_id: + continue await callback(stick_response) def subscribe_to_node_responses( diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 4ca9dde93..19d647619 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -65,7 +65,8 @@ async def write_request_to_port( serialized_data = request.serialize() request.subscribe_to_responses( - self._receiver.subscribe_to_node_responses + self._receiver.subscribe_to_stick_responses, + self._receiver.subscribe_to_node_responses, ) _LOGGER.debug("Sending %s", request) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index c0ad074d2..fd311430f 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -17,7 +17,7 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..messages.responses import PlugwiseResponse +from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from ..exceptions import NodeError, StickError from ..util import ( DateTime, @@ -63,7 +63,9 @@ def __init__( self._id = id(self) self._reply_identifier: bytes = b"0000" self._response: PlugwiseResponse | None = None - self._unsubscribe_response: Callable[[], None] | None = None + self._stick_subscription_fn: Callable[[], None] | None = None + self._unsubscribe_stick_response: Callable[[], None] | None = None + self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None self._response_future: Future[PlugwiseResponse] = ( self._loop.create_future() @@ -83,17 +85,36 @@ def response(self) -> PlugwiseResponse: raise StickError("No response available") return self._response_future.result() + @property + def seq_id(self) -> bytes | None: + """Return sequence id assigned to this request.""" + return self._seq_id + + @seq_id.setter + def seq_id(self, seq_id: bytes) -> None: + """Assign sequence id.""" + self._seq_id = seq_id + if self._unsubscribe_stick_response is not None: + return + self._unsubscribe_stick_response = self._stick_subscription_fn( + self._process_stick_response, + seq_id=seq_id + ) + def subscribe_to_responses( - self, subscription_fn: Callable[[], None] + self, + stick_subscription_fn: Callable[[], None], + node_subscription_fn: Callable[[], None] ) -> None: """Register for response messages""" - self._unsubscribe_response = ( - subscription_fn( - self._update_response, + self._unsubscribe_node_response = ( + node_subscription_fn( + self._process_node_response, mac=self._mac, message_ids=(b"0000", self._reply_identifier), ) ) + self._stick_subscription_fn = stick_subscription_fn def start_response_timeout(self) -> None: """Start timeout for node response""" @@ -107,7 +128,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout""" if self._response_future.done(): return - self._unsubscribe_response() + self._unsubscribe_node_response() if stick_timeout: self._response_future.set_exception( NodeError( @@ -130,15 +151,41 @@ def assign_error(self, error: StickError) -> None: return self._response_future.set_exception(error) - async def _update_response(self, response: PlugwiseResponse) -> None: + async def _process_node_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node.""" if self._seq_id is not None and self._seq_id == response.seq_id: _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) + self._unsubscribe_stick_response() self._response = response self._response_timeout.cancel() self._response_future.set_result(response) - self._unsubscribe_response() + self._unsubscribe_node_response() + async def _process_stick_response(self, stick_response: StickResponse) -> None: + """Process incoming stick response""" + if self._response_future.done(): + return + if self._seq_id is not None and self._seq_id == stick_response.seq_id: + _LOGGER.debug('%s for request %s id %d', stick_response, self, self._id) + if stick_response.ack_id == StickResponseType.TIMEOUT: + self._response_timeout_expired(stick_timeout=True) + elif stick_response.ack_id == StickResponseType.FAILED: + self._unsubscribe_node_response() + self._response_future.set_exception( + NodeError( + f"Stick failed request {self._seq_id}" + ) + ) + elif stick_response.ack_id == StickResponseType.ACCEPT: + pass + else: + _LOGGER.debug( + 'Unknown StickResponseType %s at %s for request %s id %d', + str(stick_response.ack_id), + stick_response, + self, + self._id + ) @property def object_id(self) -> int: From 4817ac1b0acf589a7daf4a3f57a158bfba129137 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 17:34:34 +0100 Subject: [PATCH 104/979] Correct spelling --- plugwise_usb/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index dc2667cec..50737e3d1 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -30,8 +30,8 @@ class NodeType(Enum): SWITCH = 3 # AME_SEDSwitch SENSE = 5 # AME_SEDSense SCAN = 6 # AME_SEDScan - CELSUIS_SED = 7 # AME_CelsiusSED - CELSUIS_NR = 8 # AME_CelsiusNR + CELSIUS_SED = 7 # AME_CelsiusSED + CELSIUS_NR = 8 # AME_CelsiusNR STEALTH = 9 # AME_STEALTH_ZE From 45af4d1f40bc8d273323e105bf1e4f657bd713db Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 17:34:51 +0100 Subject: [PATCH 105/979] Remove spaces --- plugwise_usb/nodes/circle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 204d2eeb7..42c593913 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -369,7 +369,7 @@ async def get_missing_energy_logs(self) -> None: await self._energy_log_records_save_to_cache() return if self._energy_counters.log_addresses_missing is not None: - _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) + _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) if ( missing_addresses := self._energy_counters.log_addresses_missing ) is not None: @@ -818,10 +818,10 @@ async def node_info_update( ) if not await super().node_info_update(node_info): return False - + if node_info is None: return False - + await self._relay_update_state( node_info.relay_state, timestamp=node_info.timestamp ) From 134d35717b585d50c2c83a4af63049e1218465ed Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 19:56:18 +0100 Subject: [PATCH 106/979] Add missing line --- plugwise_usb/nodes/helpers/firmware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 09bece070..b38c8f4fb 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -20,6 +20,7 @@ class SupportedVersions(NamedTuple): min: float max: float + # region - node firmware versions CIRCLE_FIRMWARE_SUPPORT: Final = { datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( From f0e4c2632cf59326f6377736161c4ef6d012034a Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 20:08:43 +0100 Subject: [PATCH 107/979] Remove spaces --- plugwise_usb/nodes/helpers/pulses.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 2ffaf760f..44b3f9830 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -273,7 +273,7 @@ def update_pulse_counter( self._next_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) ): - _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) + _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) self._rollover_pulses_consumption = True if self._log_production: @@ -667,7 +667,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if first_slot not in self.logs[first_address]: return missing - + if self.logs[first_address][first_slot].timestamp < from_timestamp: return missing @@ -692,7 +692,7 @@ def _last_known_duration(self) -> timedelta: last_known_timestamp = self.logs[address][slot].timestamp address, slot = calc_log_address(address, slot, -1) while ( - self._log_exists(address, slot) or + self._log_exists(address, slot) or self.logs[address][slot].timestamp == last_known_timestamp ): address, slot = calc_log_address(address, slot, -1) From d86e887ceb0e44206eca5c112daeb85742757d4a Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 20:54:55 +0100 Subject: [PATCH 108/979] Fix detecting hardware model based on firmware --- plugwise_usb/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index c46c68e3d..44236b9a1 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -56,7 +56,7 @@ def version_to_model(version: str | None) -> str | None: model = HW_MODELS.get(version[4:10]) if model is None: # Try again with reversed order - model = HW_MODELS.get(version[-6:-4] + version[-4:-2] + version[-2:]) + model = HW_MODELS.get(version[-2:] + version[-4:-2] + version[-6:-4]) return model if model is not None else "Unknown" From 8bffa967ab7c73e22a275a33710f556493350792 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 20:57:36 +0100 Subject: [PATCH 109/979] Return 0.0 even when negative calculation happens --- plugwise_usb/nodes/circle.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 42c593913..32ac903fe 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -961,18 +961,19 @@ def _calc_watts( + self._calibration.off_tot ) + # Fix minor miscalculations if ( calc_value := corrected_pulses / PULSES_PER_KW_SECOND / seconds * ( 1000 ) ) >= 0.0: return calc_value - # Fix minor miscalculations _LOGGER.debug( - "FIX negative power miscalc from %s to 0.0 for %s", + "Correct negative power %s to 0.0 for %s", str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), self.mac ) + return 0.0 def _correct_power_pulses(self, pulses: int, offset: int) -> float: """Correct pulses based on given measurement time offset (ns)""" From b0a55a8f07ad0b8b24c184e604087ca851b22782 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 20:58:35 +0100 Subject: [PATCH 110/979] Add relay feature for Circle and Circle+ --- plugwise_usb/nodes/circle.py | 1 + plugwise_usb/nodes/circle_plus.py | 1 + 2 files changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 32ac903fe..c84f7319e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -691,6 +691,7 @@ async def load(self) -> bool: self._setup_protocol( CIRCLE_FIRMWARE_SUPPORT, ( + NodeFeature.RELAY, NodeFeature.RELAY_INIT, NodeFeature.ENERGY, NodeFeature.POWER, diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 643cb9fa2..e65eaf825 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -39,6 +39,7 @@ async def load(self) -> bool: self._setup_protocol( CIRCLE_PLUS_FIRMWARE_SUPPORT, ( + NodeFeature.RELAY, NodeFeature.RELAY_INIT, NodeFeature.ENERGY, NodeFeature.POWER, From b82a697dd962e8600976b619453eaa8fd09cdb00 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 22:25:03 +0100 Subject: [PATCH 111/979] Correct hardware detection test --- testdata/stick.py | 2 +- tests/test_usb.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testdata/stick.py b/testdata/stick.py index 63e05cc9f..780207e9a 100644 --- a/testdata/stick.py +++ b/testdata/stick.py @@ -654,7 +654,7 @@ + b"00000000" # log address + b"00" # relay + b"01" # hz - + b"000000080007" # hw_ver + + b"000000070008" # hw_ver + b"4E084590" # fw_ver + b"06", # node_type (Scan) ), diff --git a/tests/test_usb.py b/tests/test_usb.py index 92f2b7f07..e13528836 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -462,7 +462,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): assert stick.nodes["5555555555555555"].node_info.firmware == dt( 2011, 6, 27, 8, 55, 44, tzinfo=tz.utc ) - assert stick.nodes["5555555555555555"].node_info.version == "000000080007" + assert stick.nodes["5555555555555555"].node_info.version == "000000070008" assert stick.nodes["5555555555555555"].node_info.model == "Scan" assert stick.nodes["5555555555555555"].available assert stick.nodes["5555555555555555"].node_info.battery_powered From e1a001dc71119a0b10e90a9164ddbb25bfe56017 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 29 Jan 2024 22:26:11 +0100 Subject: [PATCH 112/979] Correct log record states and add missing typing --- plugwise_usb/nodes/helpers/pulses.py | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 44b3f9830..e70879c9b 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -457,9 +457,9 @@ def _log_exists(self, address: int, slot: int) -> bool: return False if self._logs.get(address) is None: return False - if self._logs[address].get(slot) is not None: - return True - return False + if self._logs[address].get(slot) is None: + return False + return True def _update_last_log_reference( self, address: int, slot: int, timestamp @@ -474,7 +474,7 @@ def _update_last_log_reference( self._last_log_timestamp = timestamp def _update_last_consumption_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log consumption record.""" if ( @@ -492,7 +492,7 @@ def _update_last_consumption_log_reference( ) def _update_last_production_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log production record""" if ( @@ -508,7 +508,7 @@ def _update_last_production_log_reference( ) def _update_first_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log record""" if ( @@ -520,7 +520,7 @@ def _update_first_log_reference( self._first_log_timestamp = timestamp def _update_first_consumption_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log consumption record.""" if ( @@ -532,7 +532,7 @@ def _update_first_consumption_log_reference( self._first_log_consumption_slot = slot def _update_first_production_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log production record.""" if ( @@ -549,27 +549,27 @@ def _update_log_references(self, address: int, slot: int) -> None: return if not self._log_exists(address, slot): return - log_record = self.logs[address][slot] + log_time_stamp = self.logs[address][slot].timestamp # Update log references - self._update_first_log_reference(address, slot, log_record.timestamp) - self._update_last_log_reference(address, slot, log_record.timestamp) + self._update_first_log_reference(address, slot, log_time_stamp) + self._update_last_log_reference(address, slot, log_time_stamp) - if log_record.is_consumption: + if self.logs[address][slot].is_consumption: # Consumption self._update_first_consumption_log_reference( - address, slot, log_record.timestamp + address, slot, log_time_stamp ) self._update_last_consumption_log_reference( - address, slot, log_record.timestamp + address, slot, log_time_stamp ) else: # production self._update_first_production_log_reference( - address, slot, log_record.timestamp + address, slot, log_time_stamp ) self._update_last_production_log_reference( - address, slot, log_record.timestamp + address, slot, log_time_stamp ) @property From 94d5ae6bebbd8b094b581a30f150861a7a1527be Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 30 Jan 2024 20:18:30 +0100 Subject: [PATCH 113/979] Cleanup --- plugwise_usb/messages/requests.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fd311430f..43b2e04fc 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -525,8 +525,6 @@ class CircleRelaySwitchRequest(PlugwiseRequest): Response message : NodeResponse """ - ID = b"0017" - def __init__(self, mac: bytes, on: bool) -> None: """Initialize CircleRelaySwitchRequest message object""" super().__init__(b"0017", mac) @@ -553,6 +551,7 @@ def __init__(self, mac: bytes, network_address: int) -> None: self._args.append(Int(network_address, length=2)) self.network_address = network_address + class NodeRemoveRequest(PlugwiseRequest): """ Request node to be removed from Plugwise network by @@ -924,8 +923,6 @@ class SenseReportIntervalRequest(PlugwiseRequest): Response message: NodeAckResponse """ - ID = b"0103" - def __init__(self, mac: bytes, interval: int): """Initialize ScanLightCalibrateRequest message object""" super().__init__(b"0103", mac) From 601084d7f682c5bbd6401a4f11a199a133d37579 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:14:27 +0100 Subject: [PATCH 114/979] Apply formatting --- plugwise_usb/messages/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 0a3f670d1..9e7d95cd1 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -207,7 +207,7 @@ def _parse_params(self, response: bytes) -> bytes: for param in self._params: my_val = response[: len(param)] param.deserialize(my_val) - response = response[len(my_val) :] + response = response[len(my_val):] return response def __len__(self) -> int: @@ -230,7 +230,7 @@ def __init__(self) -> None: def __repr__(self) -> str: return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) - + class NodeResponse(PlugwiseResponse): """ From b8ed9a4ca36358c0a310a4d27237cd7c59d13e12 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:16:41 +0100 Subject: [PATCH 115/979] Take rollover detection of pulse counter too --- plugwise_usb/nodes/helpers/pulses.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e70879c9b..b3669d6cf 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -137,7 +137,10 @@ def log_interval_production(self) -> int | None: def log_rollover(self) -> bool: """Indicate if new log is required""" return ( - self._rollover_log_consumption or self._rollover_log_production + self._rollover_log_consumption + or self._rollover_log_production + or self._rollover_pulses_consumption + or self._rollover_pulses_production ) @property From 24a5c382bad8d05b79c664f12830725f3324c9a6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:17:16 +0100 Subject: [PATCH 116/979] No need to check for last log record --- plugwise_usb/nodes/helpers/pulses.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b3669d6cf..4036bcbca 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -629,8 +629,6 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if last_address is None or last_slot is None: _LOGGER.warning("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) return None - if self._logs[last_address][last_slot].timestamp <= from_timestamp: - return [] first_address, first_slot = self._first_log_reference() if first_address is None or first_slot is None: From b6ae0debdbd8ba2818ab7eb373bf856e6e9861f5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:18:45 +0100 Subject: [PATCH 117/979] No need to request node info if it's recently updated --- plugwise_usb/nodes/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 48931aa0a..132610af2 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -609,7 +609,9 @@ async def get_state( + f"not supported for {self.mac}" ) if feature == NodeFeature.INFO: - await self.node_info_update(None) + # Only request node info when information is > 5 minutes old + if not self.skip_update(self._node_info, 300): + await self.node_info_update(None) states[NodeFeature.INFO] = self._node_info elif feature == NodeFeature.AVAILABLE: states[NodeFeature.AVAILABLE] = self.available From e5ea72df792f600e21c8a3dd1acd7c4584c8e49d Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:20:20 +0100 Subject: [PATCH 118/979] Only request node info if current data is 30min old --- plugwise_usb/nodes/circle.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c84f7319e..d2b8929b2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -291,9 +291,8 @@ async def energy_update( if not await self.node_info_update(): return None else: - if self._node_info.timestamp < ( - datetime.now(tz=UTC) - timedelta(hours=1) - ): + # request node info update every 30 minutes. + if not self.skip_update(self._node_info, 1800): if not await self.node_info_update(): return None From 57d7ca758cbfe8c5aeec2295624cd09a238eb2cf Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:20:50 +0100 Subject: [PATCH 119/979] Cancel energy log task at unload --- plugwise_usb/nodes/circle.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d2b8929b2..d2589339e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -857,6 +857,8 @@ async def _node_info_load_from_cache(self) -> bool: async def unload(self) -> None: """Deactivate and unload node features.""" + if self._retrieve_energy_logs_task is not None and not self._retrieve_energy_logs_task.done(): + self._retrieve_energy_logs_task.cancel() if self._cache_enabled: await self._energy_log_records_save_to_cache() await self.save_cache() From d0acb30c7553d007a1a475ea74aae3838a90e7d5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:27:21 +0100 Subject: [PATCH 120/979] Add py.typed --- plugwise_usb/connection/py.typed | 0 plugwise_usb/messages/py.typed | 0 plugwise_usb/network/py.typed | 0 plugwise_usb/nodes/helpers/py.typed | 0 plugwise_usb/nodes/py.typed | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 plugwise_usb/connection/py.typed create mode 100644 plugwise_usb/messages/py.typed create mode 100644 plugwise_usb/network/py.typed create mode 100644 plugwise_usb/nodes/helpers/py.typed create mode 100644 plugwise_usb/nodes/py.typed diff --git a/plugwise_usb/connection/py.typed b/plugwise_usb/connection/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/plugwise_usb/messages/py.typed b/plugwise_usb/messages/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/plugwise_usb/network/py.typed b/plugwise_usb/network/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/plugwise_usb/nodes/helpers/py.typed b/plugwise_usb/nodes/helpers/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/plugwise_usb/nodes/py.typed b/plugwise_usb/nodes/py.typed new file mode 100644 index 000000000..e69de29bb From 3642d20cc54bd60123919c2fc4b97efad9c0bb5b Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:28:58 +0100 Subject: [PATCH 121/979] Add mac tor EnergyCounter to allow debug logging --- plugwise_usb/nodes/helpers/counter.py | 7 +++++-- tests/test_usb.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 453098842..4e3cb79f4 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -68,7 +68,7 @@ def __init__(self, mac: str) -> None: self._calibration: EnergyCalibration | None = None self._counters: dict[EnergyType, EnergyCounter] = {} for energy_type in ENERGY_COUNTERS: - self._counters[energy_type] = EnergyCounter(energy_type) + self._counters[energy_type] = EnergyCounter(energy_type, mac) self._pulse_collection = PulseCollection(mac) self._energy_statistics = EnergyStatistics() @@ -214,8 +214,10 @@ class EnergyCounter: def __init__( self, energy_id: EnergyType, + mac: str, ) -> None: """Initialize energy counter based on energy id.""" + self._mac = mac if energy_id not in ENERGY_COUNTERS: raise EnergyError( f"Invalid energy id '{energy_id}' for Energy counter" @@ -322,7 +324,8 @@ def update( last_reset, self._is_consumption ) _LOGGER.debug( - "collected_pulses : pulses=%s | last_update=%s", + "Counter Update | %s | pulses=%s | last_update=%s", + self._mac, pulses, last_update, ) diff --git a/tests/test_usb.py b/tests/test_usb.py index e13528836..9c7770ee2 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -913,7 +913,7 @@ def test_energy_counter(self): # Initialize hour counter energy_counter_init = pw_energy_counter.EnergyCounter( - pw_energy_counter.EnergyType.CONSUMPTION_HOUR, + pw_energy_counter.EnergyType.CONSUMPTION_HOUR, "fake mac" ) assert energy_counter_init.calibration is None energy_counter_init.calibration = calibration_config @@ -964,7 +964,7 @@ def test_energy_counter(self): # Production hour energy_counter_p_h = pw_energy_counter.EnergyCounter( - pw_energy_counter.EnergyType.PRODUCTION_HOUR, + pw_energy_counter.EnergyType.PRODUCTION_HOUR, "fake mac" ) assert not energy_counter_p_h.is_consumption From 5807486f0f2a376f7c2a28dd9ce81a728fe4b40d Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:29:27 +0100 Subject: [PATCH 122/979] Add seq_id to debug log message --- plugwise_usb/connection/receiver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index a2545d383..310a39acd 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -329,8 +329,9 @@ async def _notify_node_response_subscribers( node_response.notify_retries += 1 if node_response.notify_retries > 10: _LOGGER.warning( - "No subscriber to handle %s from %s", + "No subscriber to handle %s, seq_id=%s from %s", node_response.__class__.__name__, + node_response.seq_id, node_response.mac_decoded, ) return From 7633b88213453935f2b6161e636869b4ccfcfad1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:30:00 +0100 Subject: [PATCH 123/979] Remove unused import --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d2589339e..8168ac941 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -4,7 +4,7 @@ from asyncio import create_task, sleep from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from functools import wraps import logging from typing import Any, TypeVar, cast From c202c8690b2c0c1fa464bac5eecab79d144ed5ce Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:31:23 +0100 Subject: [PATCH 124/979] Always request last energy log records at initial startup --- plugwise_usb/nodes/circle.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 8168ac941..843c868e7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -66,6 +66,7 @@ class PlugwiseCircle(PlugwiseNode): and base class for Circle+ nodes """ _retrieve_energy_logs_task: None | Awaitable = None + _last_energy_log_requested: bool = False @property def calibrated(self) -> bool: @@ -296,6 +297,10 @@ async def energy_update( if not await self.node_info_update(): return None + # Always request last energy log records at initial startup + if not self._last_energy_log_requested: + self._last_energy_log_requested = await self.energy_log_update(self._last_log_address) + if self._energy_counters.log_rollover: _LOGGER.debug( "async_energy_update | Log rollover for %s", From bd126fc87db3a8585ee81be07522273461e50fee Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 31 Jan 2024 22:57:05 +0100 Subject: [PATCH 125/979] Add some more test --- tests/test_usb.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 9c7770ee2..01412069d 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -475,6 +475,16 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): ) ) + # Check Scan is raising NodeError for unsupported features + with pytest.raises(pw_exceptions.NodeError): + assert stick.nodes["5555555555555555"].relay + assert stick.nodes["5555555555555555"].relay_state + assert stick.nodes["5555555555555555"].switch + assert stick.nodes["5555555555555555"].power + assert stick.nodes["5555555555555555"].humidity + assert stick.nodes["5555555555555555"].temperature + assert stick.nodes["5555555555555555"].energy + # Motion self.motion_on = asyncio.Future() self.motion_off = asyncio.Future() @@ -659,13 +669,22 @@ async def test_node_relay(self, monkeypatch): unsub_relay() + # Check if node is online + assert await stick.nodes["0098765432101234"].is_online() + # Test non-support init relay state with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].relay_init - with pytest.raises(pw_exceptions.NodeError): await stick.nodes["0098765432101234"].switch_init_relay(True) await stick.nodes["0098765432101234"].switch_init_relay(False) + # Check Circle is raising NodeError for unsupported features + with pytest.raises(pw_exceptions.NodeError): + assert stick.nodes["0098765432101234"].motion + assert stick.nodes["0098765432101234"].switch + assert stick.nodes["0098765432101234"].humidity + assert stick.nodes["0098765432101234"].temperature + # Test relay init # load node 2222222222222222 which has # the firmware with init relay feature From e8f51f8be8fe2c24c50f662fe154c9f5f1f34668 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 12:59:46 +0100 Subject: [PATCH 126/979] Raise NodeError when energy is not supported --- plugwise_usb/nodes/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 132610af2..b50ead728 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -149,7 +149,10 @@ def available(self) -> bool: @property def energy(self) -> EnergyStatistics | None: """"Return energy statistics.""" - raise NotImplementedError() + if NodeFeature.POWER not in self._features: + raise NodeError( + f"Energy state is not supported for node {self.mac}" + ) @property def features(self) -> tuple[NodeFeature, ...]: From b8529279f1e5e35b0f35668b1afd2d2178383d35 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 13:01:26 +0100 Subject: [PATCH 127/979] Assert error raising is done for all actions --- tests/test_usb.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 01412069d..5403c25af 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -220,7 +220,9 @@ async def test_stick_connect_without_port(self): assert stick.joined_nodes is None with pytest.raises(pw_exceptions.StickError): assert stick.mac_stick + with pytest.raises(pw_exceptions.StickError): assert stick.mac_coordinator + with pytest.raises(pw_exceptions.StickError): assert stick.network_id assert not stick.network_discovered assert not stick.network_state @@ -234,7 +236,8 @@ async def test_stick_connect_without_port(self): ) with pytest.raises(pw_exceptions.StickError): await stick.connect() - stick.port = "null" + stick.port = "null" + with pytest.raises(pw_exceptions.StickError): await stick.connect() @pytest.mark.asyncio @@ -478,11 +481,17 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): # Check Scan is raising NodeError for unsupported features with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].relay + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].relay_state + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].switch + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].power + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].humidity + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].temperature + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["5555555555555555"].energy # Motion @@ -675,14 +684,19 @@ async def test_node_relay(self, monkeypatch): # Test non-support init relay state with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].relay_init + with pytest.raises(pw_exceptions.NodeError): await stick.nodes["0098765432101234"].switch_init_relay(True) + with pytest.raises(pw_exceptions.NodeError): await stick.nodes["0098765432101234"].switch_init_relay(False) # Check Circle is raising NodeError for unsupported features with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].motion + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].switch + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].humidity + with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].temperature # Test relay init From 61e243ef69f98c875534ba6c073ba257a21a915d Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 13:28:55 +0100 Subject: [PATCH 128/979] Do not execute scan callbacks at registration, but when actual scan is finished --- plugwise_usb/network/__init__.py | 4 ++-- plugwise_usb/network/registry.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index eec295716..7c3ca310b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -476,8 +476,8 @@ async def _unload_discovered_nodes(self) -> None: # region - Network instance async def start(self) -> 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.quick_scan_finished(self._discover_registered_nodes) + self._register.full_scan_finished(self._discover_registered_nodes) await self._register.start() self._subscribe_to_protocol_events() self._is_running = True diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index e6559f8d2..d58dc13f1 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -223,7 +223,8 @@ async def update_missing_registrations( self.update_missing_registrations(quick=False) ) if self._quick_scan_finished is not None: - await self._quick_scan_finished + await self._quick_scan_finished() + self._quick_scan_finished = None _LOGGER.info("Quick network registration discovery finished") else: _LOGGER.debug("Full network registration finished, save to cache") @@ -233,7 +234,8 @@ async def update_missing_registrations( _LOGGER.debug("Full network registration finished, post") _LOGGER.info("Full network registration discovery completed") if self._full_scan_finished is not None: - await self._full_scan_finished + await self._full_scan_finished() + self._full_scan_finished = None def _stop_registration_task(self) -> None: """Stop the background registration task""" From 4a8d6fd21f0203cc0289c1d638e2169315f01375 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 21:27:54 +0100 Subject: [PATCH 129/979] Use the python 3.10 compatible UTC constant --- plugwise_usb/constants.py | 2 +- plugwise_usb/messages/requests.py | 4 +- plugwise_usb/messages/responses.py | 4 +- plugwise_usb/nodes/__init__.py | 10 +- plugwise_usb/nodes/circle.py | 8 +- plugwise_usb/nodes/circle_plus.py | 4 +- plugwise_usb/nodes/helpers/firmware.py | 154 ++++++++++++------------- plugwise_usb/nodes/helpers/pulses.py | 8 +- plugwise_usb/util.py | 2 +- testdata/stick.py | 4 +- 10 files changed, 100 insertions(+), 100 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index f1d0d89a2..48369564d 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -33,7 +33,7 @@ VOLUME_CUBIC_METERS: Final = "m³" VOLUME_CUBIC_METERS_PER_HOUR: Final = "m³/h" -LOCAL_TIMEZONE = dt.datetime.now(dt.UTC).astimezone().tzinfo +LOCAL_TIMEZONE = dt.datetime.now(dt.timezone.utc).astimezone().tzinfo UTF8: Final = "utf-8" # Time diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 43b2e04fc..daba86c14 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -3,7 +3,7 @@ from asyncio import Future, TimerHandle, get_running_loop from collections.abc import Callable -from datetime import datetime, UTC +from datetime import datetime, timezone from enum import Enum import logging @@ -58,7 +58,7 @@ def __init__( self._mac = mac self._send_counter: int = 0 self._max_retries: int = MAX_RETRIES - self.timestamp = datetime.now(UTC) + self.timestamp = datetime.now(timezone.utc) self._loop = get_running_loop() self._id = id(self) self._reply_identifier: bytes = b"0000" diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 9e7d95cd1..44cea0726 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,7 +1,7 @@ """All known response messages to be received from plugwise devices.""" from __future__ import annotations -from datetime import datetime, UTC +from datetime import datetime, timezone from enum import Enum from typing import Any, Final @@ -139,7 +139,7 @@ def notify_retries(self, retries: int) -> None: def deserialize(self, response: bytes) -> None: """Deserialize bytes to actual message properties.""" - self.timestamp = datetime.now(UTC) + self.timestamp = datetime.now(timezone.utc) # Header if response[:4] != MESSAGE_HEADER: raise MessageError( diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index b50ead728..69b7cf9b7 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -5,7 +5,7 @@ from abc import ABC from asyncio import create_task, sleep from collections.abc import Callable -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import logging from typing import Any @@ -48,7 +48,7 @@ def __init__( controller: StickController, ): self._features = NODE_FEATURES - self._last_update = datetime.now(UTC) + self._last_update = datetime.now(timezone.utc) self._node_info = NodeInfo(mac, address) self._ping = NetworkStatistics() self._power = PowerStatistics() @@ -462,7 +462,7 @@ async def _node_info_load_from_cache(self) -> bool: hour=int(data[3]), minute=int(data[4]), second=int(data[5]), - tzinfo=UTC + tzinfo=timezone.utc ) if (node_type_str := self._get_cache("node_type")) is not None: node_type = NodeType(int(node_type_str)) @@ -478,7 +478,7 @@ async def _node_info_load_from_cache(self) -> bool: hour=int(data[3]), minute=int(data[4]), second=int(data[5]), - tzinfo=UTC + tzinfo=timezone.utc ) return self._node_info_update_state( firmware=firmware, @@ -686,6 +686,6 @@ def skip_update(data_class: Any, seconds: int) -> bool: return False if data_class.timestamp + timedelta( seconds=seconds - ) > datetime.now(UTC): + ) > datetime.now(timezone.utc): return True return False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 843c868e7..0d1a81466 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -4,7 +4,7 @@ from asyncio import create_task, sleep from collections.abc import Awaitable, Callable -from datetime import UTC, datetime +from datetime import datetime, timezone from functools import wraps import logging from typing import Any, TypeVar, cast @@ -432,7 +432,7 @@ async def energy_log_update(self, address: int) -> bool: await self._energy_log_record_update_state( response.logaddr.value, _slot, - _log_timestamp.replace(tzinfo=UTC), + _log_timestamp.replace(tzinfo=timezone.utc), _log_pulses, import_only=True ) @@ -470,7 +470,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: hour=int(timestamp_energy_log[3]), minute=int(timestamp_energy_log[4]), second=int(timestamp_energy_log[5]), - tzinfo=UTC + tzinfo=timezone.utc ), pulses=int(log_fields[3]), import_only=True, @@ -651,7 +651,7 @@ async def clock_synchronize(self) -> bool: minute=clock_response.time.minute.value, second=clock_response.time.second.value, microsecond=0, - tzinfo=UTC, + tzinfo=timezone.utc, ) clock_offset = ( clock_response.timestamp.replace(microsecond=0) - _dt_of_circle diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index e65eaf825..b698b738c 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, UTC +from datetime import datetime, timezone import logging from .helpers import raise_not_loaded @@ -131,7 +131,7 @@ async def realtime_clock_synchronize(self) -> bool: minute=clock_response.time.value.minute, second=clock_response.time.value.second, microsecond=0, - tzinfo=UTC, + tzinfo=timezone.utc, ) clock_offset = ( clock_response.timestamp.replace(microsecond=0) - _dt_of_circle diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index b38c8f4fb..c2e19c006 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from datetime import datetime, UTC +from datetime import datetime, timezone from typing import Final, NamedTuple from ...api import NodeFeature @@ -23,146 +23,146 @@ class SupportedVersions(NamedTuple): # region - node firmware versions CIRCLE_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( + datetime(2008, 8, 26, 15, 46, tzinfo=timezone.utc): SupportedVersions( min=1.0, max=1.1, ), - datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): SupportedVersions( + datetime(2009, 9, 8, 13, 50, 31, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): SupportedVersions( + datetime(2010, 4, 27, 11, 56, 23, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 4, 14, 9, 6, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 17, 7, 40, 37, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 31, 5, 55, 19, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 31, 10, 21, 2, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): SupportedVersions( + datetime(2010, 10, 7, 14, 46, 38, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): SupportedVersions( + datetime(2010, 11, 1, 13, 29, 38, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 25, 17, 40, 20, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): SupportedVersions( + datetime(2011, 5, 13, 7, 19, 23, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): SupportedVersions( + datetime(2011, 6, 27, 8, 52, 18, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): SupportedVersions( + datetime(2011, 11, 3, 12, 57, 57, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): SupportedVersions( + datetime(2012, 4, 19, 14, 0, 42, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Beta release - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( + datetime(2015, 6, 18, 14, 42, 54, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Proto release - datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions( + datetime(2015, 6, 16, 21, 9, 10, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), - datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions( + datetime(2015, 6, 18, 14, 0, 54, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # New Flash Update - datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions( + datetime(2017, 7, 11, 16, 6, 59, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), } CIRCLE_PLUS_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( + datetime(2008, 8, 26, 15, 46, tzinfo=timezone.utc): SupportedVersions( min=1.0, max=1.1 ), - datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): SupportedVersions( + datetime(2009, 9, 8, 14, 0, 32, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): SupportedVersions( + datetime(2010, 4, 27, 11, 54, 15, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 4, 12, 56, 59, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 17, 7, 37, 57, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 31, 10, 9, 18, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): SupportedVersions( + datetime(2010, 10, 7, 14, 49, 29, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): SupportedVersions( + datetime(2010, 11, 1, 13, 24, 49, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 25, 17, 37, 55, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): SupportedVersions( + datetime(2011, 5, 13, 7, 17, 7, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): SupportedVersions( + datetime(2011, 6, 27, 8, 47, 37, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): SupportedVersions( + datetime(2011, 11, 3, 12, 55, 23, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): SupportedVersions( + datetime(2012, 4, 19, 14, 3, 55, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # SMA firmware 2015-06-16 - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( + datetime(2015, 6, 18, 14, 42, 54, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # New Flash Update - datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): SupportedVersions( + datetime(2017, 7, 11, 16, 5, 57, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), } SCAN_FIRMWARE_SUPPORT: Final = { - datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): SupportedVersions( + datetime(2010, 11, 4, 16, 58, 46, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # Beta Scan Release - datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): SupportedVersions( + datetime(2011, 1, 12, 8, 32, 56, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5, ), # Beta Scan Release - datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 4, 14, 43, 31, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Scan RC1 - datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 28, 9, 0, 24, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): SupportedVersions( + datetime(2011, 5, 13, 7, 21, 55, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): SupportedVersions( + datetime(2011, 11, 3, 13, 0, 56, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Legrand - datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): SupportedVersions( + datetime(2011, 6, 27, 8, 55, 44, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): SupportedVersions( + datetime(2017, 7, 11, 16, 8, 3, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # New Flash Update @@ -177,132 +177,132 @@ class SupportedVersions(NamedTuple): datetime(2011, 1, 11, 14, 19, 36): ( "2.0, max=2.5", ), - datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 4, 14, 52, 30, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 25, 17, 43, 2, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): SupportedVersions( + datetime(2011, 5, 13, 7, 24, 26, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): SupportedVersions( + datetime(2011, 6, 27, 8, 58, 19, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): SupportedVersions( + datetime(2011, 11, 3, 13, 7, 33, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( + datetime(2012, 4, 19, 14, 10, 48, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5, ), # New Flash Update - datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): SupportedVersions( + datetime(2017, 7, 11, 16, 9, 5, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), } SWITCH_FIRMWARE_SUPPORT: Final = { - datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): SupportedVersions( + datetime(2009, 9, 8, 14, 7, 4, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): SupportedVersions( + datetime(2010, 1, 16, 14, 7, 13, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): SupportedVersions( + datetime(2010, 4, 27, 11, 59, 31, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 4, 14, 15, 25, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 17, 7, 44, 24, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): SupportedVersions( + datetime(2010, 8, 31, 10, 23, 32, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): SupportedVersions( + datetime(2010, 10, 7, 14, 29, 55, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): SupportedVersions( + datetime(2010, 11, 1, 13, 41, 30, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): SupportedVersions( + datetime(2011, 3, 25, 17, 46, 41, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): SupportedVersions( + datetime(2011, 5, 13, 7, 26, 54, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): SupportedVersions( + datetime(2011, 6, 27, 9, 4, 10, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): SupportedVersions( + datetime(2011, 11, 3, 13, 10, 18, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( + datetime(2012, 4, 19, 14, 10, 48, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.5, ), # New Flash Update - datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): SupportedVersions( + datetime(2017, 7, 11, 16, 11, 10, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), } CELSIUS_FIRMWARE_SUPPORT: Final = { # Celsius Proto - datetime(2013, 9, 25, 15, 9, 44): SupportedVersions( + datetime(2013, 9, 25, 15, 9, 44, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 10, 11, 15, 15, 58): SupportedVersions( + datetime(2013, 10, 11, 15, 15, 58, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 10, 17, 10, 13, 12): SupportedVersions( + datetime(2013, 10, 17, 10, 13, 12, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 11, 19, 17, 35, 48): SupportedVersions( + datetime(2013, 11, 19, 17, 35, 48, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 12, 5, 16, 25, 33): SupportedVersions( + datetime(2013, 12, 5, 16, 25, 33, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 12, 11, 10, 53, 55): SupportedVersions( + datetime(2013, 12, 11, 10, 53, 55, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 1, 30, 8, 56, 21): SupportedVersions( + datetime(2014, 1, 30, 8, 56, 21, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 2, 3, 10, 9, 27): SupportedVersions( + datetime(2014, 2, 3, 10, 9, 27, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 3, 7, 16, 7, 42): SupportedVersions( + datetime(2014, 3, 7, 16, 7, 42, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 3, 24, 11, 12, 23): SupportedVersions( + datetime(2014, 3, 24, 11, 12, 23, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # MSPBootloader Image - Required to allow # a MSPBootload image for OTA update - datetime(2014, 4, 14, 15, 45, 26): SupportedVersions( + datetime(2014, 4, 14, 15, 45, 26, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # CelsiusV Image - datetime(2014, 7, 23, 19, 24, 18): SupportedVersions( + datetime(2014, 7, 23, 19, 24, 18, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # CelsiusV Image - datetime(2014, 9, 12, 11, 36, 40): SupportedVersions( + datetime(2014, 9, 12, 11, 36, 40, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), # New Flash Update - datetime(2017, 7, 11, 16, 2, 50): SupportedVersions( + datetime(2017, 7, 11, 16, 2, 50, tzinfo=timezone.utc): SupportedVersions( min=2.0, max=2.6, ), } diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 4036bcbca..c118876c8 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, UTC +from datetime import datetime, timedelta, timezone import logging from typing import Final @@ -102,7 +102,7 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} - skip_before = datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + skip_before = datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) sorted_addresses = sorted(self._logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(self._logs[address].keys(), reverse=True) @@ -320,7 +320,7 @@ def add_log( def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses""" self._log_addresses_missing = self._logs_missing( - datetime.now(UTC) - timedelta( + datetime.now(timezone.utc) - timedelta( hours=MAX_LOG_HOURS ) ) @@ -336,7 +336,7 @@ def _add_log_record( return False # Drop unused log records if log_record.timestamp < ( - datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) ): return False if self._logs.get(address) is None: diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 44236b9a1..357903a10 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -162,7 +162,7 @@ def __init__(self, value: float, length: int = 8) -> None: def deserialize(self, val: bytes) -> None: self.value = datetime.datetime.fromtimestamp( - int(val, 16), datetime.UTC + int(val, 16), datetime.timezone.utc ) diff --git a/testdata/stick.py b/testdata/stick.py index 780207e9a..172cc7251 100644 --- a/testdata/stick.py +++ b/testdata/stick.py @@ -1,10 +1,10 @@ -from datetime import datetime, timedelta, UTC +from datetime import datetime, timedelta, timezone import importlib pw_constants = importlib.import_module("plugwise_usb.constants") # test using utc timezone -utc_now = datetime.utcnow().replace(tzinfo=UTC) +utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) # generate energy log timestamps with fixed hour timestamp used in tests From d490d7eaf6ed6e138402f5cb432ebb1e3907bd29 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 21:46:40 +0100 Subject: [PATCH 130/979] Move stick test data script to test folder just to make importlib happy to know where to import from --- testdata/stick.py => tests/stick_test_data.py | 0 tests/test_usb.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename testdata/stick.py => tests/stick_test_data.py (100%) diff --git a/testdata/stick.py b/tests/stick_test_data.py similarity index 100% rename from testdata/stick.py rename to tests/stick_test_data.py diff --git a/tests/test_usb.py b/tests/test_usb.py index 5403c25af..e034c981f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -25,7 +25,7 @@ pw_constants = importlib.import_module("plugwise_usb.constants") pw_requests = importlib.import_module("plugwise_usb.messages.requests") pw_responses = importlib.import_module("plugwise_usb.messages.responses") -pw_userdata = importlib.import_module("testdata.stick") +pw_userdata = importlib.import_module("stick_test_data") pw_energy_counter = importlib.import_module( "plugwise_usb.nodes.helpers.counter" ) From db6d82fa6e19e64f83a0dbb6129c0f66c16f50ed Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 1 Feb 2024 22:11:27 +0100 Subject: [PATCH 131/979] asyncio.timeout context manager is not supported at python 3.10 --- tests/test_usb.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index e034c981f..9577a8249 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -2,7 +2,6 @@ from datetime import datetime as dt, timedelta as td, timezone as tz import importlib import logging -from unittest import mock from unittest.mock import Mock import crcmod @@ -316,22 +315,22 @@ async def test_stick_connect(self, monkeypatch): MockSerial(None).mock_connection, ) stick = pw_stick.Stick(port="test_port", cache_enabled=False) - async with asyncio.timeout(10.0): - await stick.connect("test_port") - await stick.initialize() - assert stick.mac_stick == "0123456789012345" - assert stick.mac_coordinator == "0098765432101234" - assert not stick.network_discovered - assert stick.network_state - assert stick.network_id == 17185 - assert stick.accept_join_request is None - # test failing of join requests without active discovery - with pytest.raises(pw_exceptions.StickError): - stick.accept_join_request = True - await stick.disconnect() - assert not stick.network_state - with pytest.raises(pw_exceptions.StickError): - assert stick.mac_stick + + await stick.connect("test_port") + await stick.initialize() + assert stick.mac_stick == "0123456789012345" + assert stick.mac_coordinator == "0098765432101234" + assert not stick.network_discovered + assert stick.network_state + assert stick.network_id == 17185 + assert stick.accept_join_request is None + # test failing of join requests without active discovery + with pytest.raises(pw_exceptions.StickError): + stick.accept_join_request = True + await stick.disconnect() + assert not stick.network_state + with pytest.raises(pw_exceptions.StickError): + assert stick.mac_stick async def disconnected(self, event): """Callback helper for stick disconnect event""" @@ -446,8 +445,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() - async with asyncio.timeout(15.0): - await stick.discover_nodes(load=False) + await stick.discover_nodes(load=False) stick.accept_join_request = True self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( @@ -514,7 +512,6 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): assert not motion_off unsub_motion() - await stick.disconnect() async def node_join(self, event: pw_api.NodeEvent, mac: str): From fbd649b85213405e00518b5fbe398ad398b6fc7d Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 20:38:27 +0100 Subject: [PATCH 132/979] No need to cancel future --- plugwise_usb/connection/manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index fd6c4f2ad..157a42795 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -169,8 +169,6 @@ async def setup_connection_to_stick( raise StickError( f"Failed to open serial connection to {serial_path}" ) from err - finally: - connected_future.cancel() if self._receiver is None: raise StickError("Protocol is not loaded") From 497be965621f6e806147afb50a61d5af07def24f Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 20:39:22 +0100 Subject: [PATCH 133/979] Add test for connect events --- tests/test_usb.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 9577a8249..d0c365fe2 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -306,6 +306,13 @@ async def test_stick_connect_timeout(self, monkeypatch): await stick.initialize() await stick.disconnect() + async def connected(self, event): + """Callback helper for stick connected event""" + if event is pw_api.StickEvent.CONNECTED: + self.test_connected.set_result(True) + else: + self.test_connected.set_exception(BaseException("Incorrect event")) + @pytest.mark.asyncio async def test_stick_connect(self, monkeypatch): """Test connecting to stick""" @@ -316,7 +323,14 @@ async def test_stick_connect(self, monkeypatch): ) stick = pw_stick.Stick(port="test_port", cache_enabled=False) + self.test_connected = asyncio.Future() + unsub_connect = stick.subscribe_to_stick_events( + stick_event_callback=self.connected, + events=(pw_api.StickEvent.CONNECTED,), + ) + await stick.connect("test_port") + assert await self.test_connected await stick.initialize() assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" @@ -327,6 +341,7 @@ async def test_stick_connect(self, monkeypatch): # test failing of join requests without active discovery with pytest.raises(pw_exceptions.StickError): stick.accept_join_request = True + unsub_connect() await stick.disconnect() assert not stick.network_state with pytest.raises(pw_exceptions.StickError): From 6db3266a44020ea6939c502f65cd65d8896e5b6e Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 20:40:24 +0100 Subject: [PATCH 134/979] Proper variable name for unregister --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d0c365fe2..f6c473876 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -368,7 +368,7 @@ async def test_stick_connection_lost(self, monkeypatch): await stick.initialize() assert stick.network_state self.test_disconnected = asyncio.Future() - unsub_connect = stick.subscribe_to_stick_events( + unsub_disconnect = stick.subscribe_to_stick_events( stick_event_callback=self.disconnected, events=(pw_api.StickEvent.DISCONNECTED,), ) @@ -376,7 +376,7 @@ async def test_stick_connection_lost(self, monkeypatch): mock_serial._protocol.connection_lost() assert await self.test_disconnected assert not stick.network_state - unsub_connect() + unsub_disconnect() await stick.disconnect() async def node_discovered(self, event: pw_api.NodeEvent, mac: str): From 65e07e7a85089665c23485debbd736322030b463 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 20:45:58 +0100 Subject: [PATCH 135/979] Wait for future before retrieving result --- plugwise_usb/connection/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 157a42795..fca24aa12 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -173,6 +173,7 @@ async def setup_connection_to_stick( if self._receiver is None: raise StickError("Protocol is not loaded") self._sender = StickSender(self._receiver, self._serial_transport) + await connected_future if connected_future.result(): await self._handle_stick_event(StickEvent.CONNECTED) self._connected = True From d88cf8e8bc01c8e1ee04b263471a22c81399362a Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 21:51:59 +0100 Subject: [PATCH 136/979] Add tests for power state retrieval --- tests/test_usb.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index f6c473876..ae32c6411 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -639,7 +639,7 @@ async def node_init_relay_state( ) @pytest.mark.asyncio - async def test_node_relay(self, monkeypatch): + async def test_node_relay_and_power(self, monkeypatch): """Testing discovery of nodes""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -688,6 +688,11 @@ async def test_node_relay(self, monkeypatch): assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay + # Test power state without request + assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) + pu = await stick.nodes["0098765432101234"].power_update() + assert pu.last_second == 21.2780505980402 + assert pu.last_8_seconds == 27.150578775440106 unsub_relay() # Check if node is online From bea2c83f63f9e3f15cea229c261f01598953407c Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 21:52:20 +0100 Subject: [PATCH 137/979] Test motion state --- tests/test_usb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index ae32c6411..5c9898839 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -520,11 +520,13 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): mock_serial._transport.message_response(b"005655555555555555550001", b"FFFF") motion_on = await self.motion_on assert motion_on + assert stick.nodes["5555555555555555"].motion # Inject motion message to trigger a 'motion off' event mock_serial._transport.message_response(b"005655555555555555550000", b"FFFF") motion_off = await self.motion_off assert not motion_off + assert not stick.nodes["5555555555555555"].motion unsub_motion() await stick.disconnect() From 167c0b89fa2ec202f7a5f7af05e0b8fc403929dc Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 22:10:10 +0100 Subject: [PATCH 138/979] Add missing relay feature --- plugwise_usb/nodes/circle_plus.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index b698b738c..04aad801d 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -73,6 +73,7 @@ async def load(self) -> bool: self._setup_protocol( CIRCLE_PLUS_FIRMWARE_SUPPORT, ( + NodeFeature.RELAY, NodeFeature.RELAY_INIT, NodeFeature.ENERGY, NodeFeature.POWER, From 73bc59e3afa68b0f95eb2aa026857c7c9a8add75 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 22:10:27 +0100 Subject: [PATCH 139/979] Add testing relay_state --- tests/test_usb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 5c9898839..678d94885 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -672,11 +672,13 @@ async def test_node_relay_and_power(self, monkeypatch): stick.nodes["0098765432101234"].relay = False assert not await self.test_relay_state_off assert not stick.nodes["0098765432101234"].relay + assert not stick.nodes["0098765432101234"].relay_state.relay_state # Test sync switching back from off to on stick.nodes["0098765432101234"].relay = True assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay + assert stick.nodes["0098765432101234"].relay_state.relay_state # Test async switching back from on to off self.test_relay_state_off = asyncio.Future() From 1e5eb7632bb63b4288f4722fd44c91fd4c767f3e Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 2 Feb 2024 22:22:17 +0100 Subject: [PATCH 140/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 75dfc9730..6dbd4ac11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a0" +version = "v0.40.0a1" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 1dc240871bb727fd552c50bf350cb69ab3634b99 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 4 Feb 2024 16:50:41 +0100 Subject: [PATCH 141/979] Remove single use of function --- plugwise_usb/network/__init__.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7c3ca310b..14cfa9941 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -217,7 +217,8 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: return address: int | None = self._register.network_address(mac) if self._nodes.get(mac) is None: - await self._discover_and_load_node(address, mac, None) + await self._discover_node(address, mac, None) + await self._load_node(mac) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) async def node_join_available_message( @@ -378,16 +379,6 @@ async def get_node_details( ) # type: ignore [assignment] return (info_response, ping_response) - async def _discover_and_load_node( - self, - address: int, - mac: str, - node_type: NodeType | None - ) -> bool: - """Discover and load node""" - await self._discover_node(address, mac, node_type) - await self._load_node(mac) - async def _discover_node( self, address: int, From 0229813da8efff8fe298142c8ae4478ab2207148 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 4 Feb 2024 16:54:15 +0100 Subject: [PATCH 142/979] Convert network_address to property --- plugwise_usb/nodes/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 69b7cf9b7..9d32e7afb 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -101,9 +101,10 @@ def __init__( # Energy self._energy_counters = EnergyCounters(mac) - def update_registry_address(self, address: int) -> None: - """Update network registration address""" - self._node_info.zigbee_address = address + @property + def network_address(self) -> int: + """Network (zigbee based) registration address of this node.""" + return self._node_info.zigbee_address @property def cache_folder(self) -> str: From 688a186fe93df7af30908686e1010280e9b9f51d Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 4 Feb 2024 19:30:19 +0100 Subject: [PATCH 143/979] Guard node event subscription after initialization --- plugwise_usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 7571839f4..a16e3e813 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -238,6 +238,7 @@ def subscribe_to_stick_events( events, ) + @raise_not_initialized def subscribe_to_node_events( self, node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], From b6e36c62802fa932c185ea1392c20cae2c3ba83b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 4 Feb 2024 22:34:51 +0100 Subject: [PATCH 144/979] Test relay functions --- tests/test_usb.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 678d94885..9f3af4417 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -692,6 +692,20 @@ async def test_node_relay_and_power(self, monkeypatch): assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay + # Test sync switching back from on to off + self.test_relay_state_off = asyncio.Future() + await stick.nodes["0098765432101234"].relay_off() + assert not await self.test_relay_state_off + assert not stick.nodes["0098765432101234"].relay + assert not stick.nodes["0098765432101234"].relay_state.relay_state + + # Test sync switching back from off to on + self.test_relay_state_on = asyncio.Future() + await stick.nodes["0098765432101234"].relay_on() + assert await self.test_relay_state_on + assert stick.nodes["0098765432101234"].relay + assert stick.nodes["0098765432101234"].relay_state.relay_state + # Test power state without request assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) pu = await stick.nodes["0098765432101234"].power_update() From b4984aa728655179f7aa1ef1044a195f3826018a Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 20:05:06 +0100 Subject: [PATCH 145/979] Use local variable --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index c118876c8..e62cb0c45 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -650,7 +650,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if not self._log_exists(address, slot): missing.append(address) break - if self.logs[address][slot].timestamp <= from_timestamp: + if self._logs[address][slot].timestamp <= from_timestamp: finished = True break if finished: From e7a9e729824b5167254bfff3f3abf4d43010ccca Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 20:50:30 +0100 Subject: [PATCH 146/979] Sort imports --- plugwise_usb/__init__.py | 2 +- plugwise_usb/connection/manager.py | 9 ++++----- plugwise_usb/connection/queue.py | 5 +++-- plugwise_usb/connection/receiver.py | 16 +++++++--------- plugwise_usb/messages/requests.py | 3 +-- plugwise_usb/messages/responses.py | 2 +- plugwise_usb/network/__init__.py | 6 +++--- plugwise_usb/network/cache.py | 7 ++++--- plugwise_usb/network/registry.py | 9 +++------ plugwise_usb/nodes/__init__.py | 3 ++- plugwise_usb/nodes/celsius.py | 2 +- plugwise_usb/nodes/circle.py | 6 +++--- plugwise_usb/nodes/circle_plus.py | 6 +++--- plugwise_usb/nodes/helpers/counter.py | 4 ++-- plugwise_usb/nodes/helpers/subscription.py | 3 ++- plugwise_usb/nodes/scan.py | 8 ++++---- plugwise_usb/nodes/sed.py | 10 ++-------- plugwise_usb/nodes/sense.py | 6 +++--- plugwise_usb/nodes/switch.py | 8 ++++---- 19 files changed, 53 insertions(+), 62 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a16e3e813..deb77563f 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -15,8 +15,8 @@ from .api import NodeEvent, StickEvent from .connection import StickController -from .network import StickNetwork from .exceptions import StickError +from .network import StickNetwork from .nodes import PlugwiseNode FuncT = TypeVar("FuncT", bound=Callable[..., Any]) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index fca24aa12..71f14a05c 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -9,16 +9,15 @@ import logging from typing import Any -from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE -from serial import SerialException -from serial_asyncio import create_serial_connection, SerialTransport +from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE, SerialException +from serial_asyncio import SerialTransport, create_serial_connection -from .sender import StickSender -from .receiver import StickReceiver from ..api import StickEvent from ..exceptions import StickError from ..messages.requests import PlugwiseRequest from ..messages.responses import PlugwiseResponse, StickResponse +from .receiver import StickReceiver +from .sender import StickSender _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index a9d2b766b..8c2591046 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -8,18 +8,19 @@ InvalidStateError, PriorityQueue, Task, - sleep, get_running_loop, + sleep, ) from collections.abc import Callable +import contextlib from dataclasses import dataclass import logging -from .manager import StickConnectionManager from ..api import StickEvent from ..exceptions import StickError from ..messages.requests import PlugwiseRequest from ..messages.responses import PlugwiseResponse +from .manager import StickConnectionManager _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 310a39acd..ec39b2a9e 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -15,29 +15,27 @@ 1. Notify status subscribers to connection state changes """ - from __future__ import annotations + from asyncio import ( Future, - create_task, - gather, Protocol, Queue, + create_task, + gather, get_running_loop, sleep, ) -from serial_asyncio import SerialTransport from collections.abc import Awaitable, Callable from concurrent import futures import logging + +from serial_asyncio import SerialTransport + from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError -from ..messages.responses import ( - PlugwiseResponse, - StickResponse, - get_message_object, -) +from ..messages.responses import PlugwiseResponse, StickResponse, get_message_object _LOGGER = logging.getLogger(__name__) STICK_RECEIVER_EVENTS = ( diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index daba86c14..957bf22a2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -7,7 +7,6 @@ from enum import Enum import logging -from . import PlugwiseMessage from ..constants import ( DAY_IN_MINUTES, HOUR_IN_MINUTES, @@ -17,8 +16,8 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from ..exceptions import NodeError, StickError +from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from ..util import ( DateTime, Int, diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 44cea0726..47da828cb 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -5,7 +5,6 @@ from enum import Enum from typing import Any, Final -from . import PlugwiseMessage from ..api import NodeType from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..exceptions import MessageError @@ -20,6 +19,7 @@ Time, UnixTimestamp, ) +from . import PlugwiseMessage NODE_JOIN_ID: Final = b"0006" NODE_AWAKE_RESPONSE_ID: Final = b"004F" diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 14cfa9941..f6dea71fc 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -2,12 +2,12 @@ # region - Imports from __future__ import annotations -from asyncio import gather, sleep + +from asyncio import gather from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging -from .registry import StickNetworkRegister from ..api import NodeEvent, NodeType, StickEvent from ..connection import StickController from ..constants import UTF8 @@ -24,7 +24,6 @@ NodeAwakeResponse, NodeInfoResponse, NodeJoinAvailableResponse, - # NodeJoinAvailableResponse, NodePingResponse, NodeResponseType, ) @@ -36,6 +35,7 @@ from ..nodes.stealth import PlugwiseStealth from ..nodes.switch import PlugwiseSwitch from ..util import validate_mac +from .registry import StickNetworkRegister _LOGGER = logging.getLogger(__name__) # endregion diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 331334390..81604d53c 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -2,15 +2,16 @@ from __future__ import annotations -import aiofiles -import aiofiles.os import logging from pathlib import Path, PurePath -from ..util import get_writable_cache_dir +import aiofiles +import aiofiles.os + from ..api import NodeType from ..constants import CACHE_SEPARATOR, UTF8 from ..exceptions import CacheError +from ..util import get_writable_cache_dir _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index d58dc13f1..7261b4999 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -1,16 +1,17 @@ """Network register""" from __future__ import annotations + from asyncio import Task, create_task, sleep from collections.abc import Awaitable, Callable, Coroutine from copy import deepcopy import logging from typing import Any -from .cache import NetworkRegistrationCache from ..api import NodeType from ..constants import UTF8 from ..exceptions import NodeError +from ..messages.requests import CirclePlusScanRequest, NodeAddRequest, NodeRemoveRequest from ..messages.responses import ( CirclePlusScanResponse, NodeRemoveResponse, @@ -18,12 +19,8 @@ NodeResponseType, PlugwiseResponse, ) -from ..messages.requests import ( - CirclePlusScanRequest, - NodeRemoveRequest, - NodeAddRequest, -) from ..util import validate_mac +from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 9d32e7afb..772087914 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC -from asyncio import create_task, sleep +from asyncio import create_task from collections.abc import Callable from datetime import datetime, timedelta, timezone import logging @@ -25,6 +25,7 @@ from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse from ..util import version_to_model +from .helpers import raise_not_loaded from .helpers.cache import NodeCache from .helpers.counter import EnergyCalibration, EnergyCounters from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index f3ff83e45..71e62acd4 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -9,8 +9,8 @@ from typing import Final from ..api import NodeFeature -from .helpers.firmware import CELSIUS_FIRMWARE_SUPPORT from ..nodes.sed import NodeSED +from .helpers.firmware import CELSIUS_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0d1a81466..03d3d1a5b 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,15 +8,15 @@ from functools import wraps import logging from typing import Any, TypeVar, cast -from ..exceptions import PlugwiseException -from ..api import NodeFeature + +from ..api import NodeEvent, NodeFeature from ..constants import ( MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, PULSES_PER_KW_SECOND, SECOND_IN_NANOSECONDS, ) -from ..exceptions import NodeError +from ..exceptions import NodeError, PlugwiseException from ..messages.requests import ( CircleClockGetRequest, CircleClockSetRequest, diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 04aad801d..a5de321d2 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -5,9 +5,7 @@ from datetime import datetime, timezone import logging -from .helpers import raise_not_loaded -from .helpers.firmware import CIRCLE_PLUS_FIRMWARE_SUPPORT -from ..api import NodeFeature +from ..api import NodeEvent, NodeFeature from ..constants import MAX_TIME_DRIFT from ..messages.requests import ( CirclePlusRealTimeClockGetRequest, @@ -19,6 +17,8 @@ NodeResponseType, ) from .circle import PlugwiseCircle +from .helpers import raise_not_loaded +from .helpers.firmware import CIRCLE_PLUS_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 4e3cb79f4..4a4761430 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -5,11 +5,11 @@ import logging from typing import Final -from .pulses import PulseCollection, PulseLogRecord -from ..helpers import EnergyCalibration from ...api import EnergyStatistics from ...constants import HOUR_IN_SECONDS, LOCAL_TIMEZONE, PULSES_PER_KW_SECOND from ...exceptions import EnergyError +from ..helpers import EnergyCalibration +from .pulses import PulseCollection, PulseLogRecord class EnergyType(Enum): diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index b53b332e4..62dce4c74 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -1,11 +1,12 @@ """Base class for plugwise node publisher.""" from __future__ import annotations + from asyncio import gather from collections.abc import Awaitable, Callable from typing import Any -from ...api import NodeEvent, NodeFeature +from ...api import NodeFeature class FeaturePublisher(): diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index b3b6965d1..120c25434 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -1,15 +1,13 @@ """Plugwise Scan node object.""" from __future__ import annotations -from asyncio import create_task +from asyncio import create_task from datetime import datetime import logging from typing import Any, Final -from .helpers import raise_not_loaded -from .helpers.firmware import SCAN_FIRMWARE_SUPPORT -from ..api import NodeFeature +from ..api import NodeEvent, NodeFeature from ..constants import MotionSensitivity from ..exceptions import MessageError, NodeError, NodeTimeout from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest @@ -20,6 +18,8 @@ NodeSwitchGroupResponse, ) from ..nodes.sed import NodeSED +from .helpers import raise_not_loaded +from .helpers.firmware import SCAN_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index ff57f93d7..1422100a5 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -2,19 +2,12 @@ from __future__ import annotations -from asyncio import ( - CancelledError, - Future, - get_event_loop, - wait_for, -) -from asyncio import TimeoutError as AsyncTimeOutError +from asyncio import CancelledError, Future, get_event_loop, wait_for from collections.abc import Callable from datetime import datetime import logging from typing import Final -from .helpers import raise_not_loaded from ..connection import StickController from ..exceptions import NodeError, NodeTimeout from ..messages.requests import NodeSleepConfigRequest @@ -27,6 +20,7 @@ NodeResponseType, ) from ..nodes import PlugwiseNode +from .helpers import raise_not_loaded # Defaults for 'Sleeping End Devices' diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index df90ffa02..73fce3007 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -5,12 +5,12 @@ import logging from typing import Any, Final -from .helpers import raise_not_loaded -from .helpers.firmware import SENSE_FIRMWARE_SUPPORT -from ..api import NodeFeature +from ..api import NodeEvent, NodeFeature from ..exceptions import NodeError from ..messages.responses import SENSE_REPORT_ID, SenseReportResponse from ..nodes.sed import NodeSED +from .helpers import raise_not_loaded +from .helpers.firmware import SENSE_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index fec4dd59d..1533bc4af 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -1,16 +1,16 @@ """Plugwise switch node object.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable import logging -from .helpers import raise_not_loaded -from .helpers.firmware import SWITCH_FIRMWARE_SUPPORT -from ..api import NodeFeature +from ..api import NodeEvent, NodeFeature from ..exceptions import MessageError from ..messages.responses import NODE_SWITCH_GROUP_ID, NodeSwitchGroupResponse from ..nodes.sed import NodeSED +from .helpers import raise_not_loaded +from .helpers.firmware import SWITCH_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) From f8a6d7423c178257713dbd4604f1face17136415 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:03:20 +0100 Subject: [PATCH 147/979] Reformat docstrings --- plugwise_usb/__init__.py | 85 +++----- plugwise_usb/connection/__init__.py | 20 +- plugwise_usb/connection/manager.py | 28 +-- plugwise_usb/connection/queue.py | 18 +- plugwise_usb/connection/receiver.py | 19 +- plugwise_usb/connection/sender.py | 14 +- plugwise_usb/messages/requests.py | 226 +++++++++------------ plugwise_usb/messages/responses.py | 193 +++++++++--------- plugwise_usb/network/__init__.py | 47 ++--- plugwise_usb/network/cache.py | 10 +- plugwise_usb/network/registry.py | 43 ++-- plugwise_usb/nodes/__init__.py | 43 ++-- plugwise_usb/nodes/celsius.py | 5 +- plugwise_usb/nodes/circle.py | 36 ++-- plugwise_usb/nodes/circle_plus.py | 4 +- plugwise_usb/nodes/helpers/__init__.py | 6 +- plugwise_usb/nodes/helpers/cache.py | 10 +- plugwise_usb/nodes/helpers/counter.py | 32 ++- plugwise_usb/nodes/helpers/firmware.py | 5 +- plugwise_usb/nodes/helpers/pulses.py | 55 +++-- plugwise_usb/nodes/helpers/subscription.py | 6 +- plugwise_usb/nodes/scan.py | 2 +- plugwise_usb/nodes/sed.py | 12 +- plugwise_usb/nodes/sense.py | 7 +- plugwise_usb/nodes/stealth.py | 2 +- plugwise_usb/nodes/switch.py | 2 +- 26 files changed, 394 insertions(+), 536 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index deb77563f..32488801d 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -1,8 +1,7 @@ -""" +"""Main stick object to control associated plugwise plugs. + Use of this source code is governed by the MIT license found in the LICENSE file. - -Main stick object to control associated plugwise plugs """ from __future__ import annotations @@ -26,11 +25,7 @@ def raise_not_connected(func: FuncT) -> FuncT: - """ - Decorator function to validate existence of an active - connection to Stick. - Raise StickError when there is no active connection. - """ + """Validate existence of an active connection to Stick. Raise StickError when there is no active connection.""" @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: if not args[0].is_connected: @@ -42,11 +37,7 @@ def decorated(*args: Any, **kwargs: Any) -> Any: def raise_not_initialized(func: FuncT) -> FuncT: - """ - Decorator function to validate if active connection is - initialized. - Raise StickError when not initialized. - """ + """Validate if active connection is initialized. Raise StickError when not initialized.""" @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: if not args[0].is_initialized: @@ -90,7 +81,7 @@ def cache_folder(self, cache_folder: str) -> None: @property def cache_enabled(self) -> bool: - """Return usage of cache.""" + """Indicates if caching is active.""" return self._cache_enabled @cache_enabled.setter @@ -102,30 +93,24 @@ def cache_enabled(self, enable: bool = True) -> None: @property def nodes(self) -> dict[str, PlugwiseNode]: - """ - All discovered and supported plugwise devices - with the MAC address as their key - """ + """Dictionary with all discovered and supported plugwise devices with the MAC address as their key.""" if self._network is None: return {} return self._network.nodes @property def is_connected(self) -> bool: - """Return current connection state""" + """Current connection state to USB-Stick.""" return self._controller.is_connected @property def is_initialized(self) -> bool: - """Return current initialization state""" + """Current initialization state of USB-Stick connection.""" return self._controller.is_initialized @property def joined_nodes(self) -> int | None: - """ - Total number of nodes registered to Circle+ - including Circle+ itself. - """ + """Total number of nodes registered to Circle+ including Circle+ itself.""" if ( not self._controller.is_connected or self._network is None @@ -136,43 +121,31 @@ def joined_nodes(self) -> int | None: @property def mac_stick(self) -> str: - """ - Return mac address of USB-Stick. - Raises StickError is connection is missing. - """ + """MAC address of USB-Stick. Raises StickError is connection is missing.""" return self._controller.mac_stick @property def mac_coordinator(self) -> str: - """ - Return mac address of the network coordinator (Circle+). - Raises StickError is connection is missing. - """ + """MAC address of the network coordinator (Circle+). Raises StickError is connection is missing.""" return self._controller.mac_coordinator @property def network_discovered(self) -> bool: - """ - Return the discovery state of the Plugwise network. - Raises StickError is connection is missing. - """ + """Indicate if discovery of network is active. Raises StickError is connection is missing.""" if self._network is None: return False return self._network.is_running @property def network_state(self) -> bool: - """Return the state of the Plugwise network.""" + """Indicate state of the Plugwise network.""" if not self._controller.is_connected: return False return self._controller.network_online @property def network_id(self) -> int: - """ - Return the id of the Plugwise network. - Raises StickError is connection is missing. - """ + """Network id of the Plugwise network. Raises StickError is connection is missing.""" return self._controller.network_id @property @@ -206,7 +179,7 @@ def accept_join_request(self) -> bool | None: @accept_join_request.setter def accept_join_request(self, state: bool) -> None: - """Configure join requests""" + """Configure join request setting.""" if not self._controller.is_connected: raise StickError( "Cannot accept joining node" @@ -229,8 +202,8 @@ def subscribe_to_stick_events( stick_event_callback: Callable[[StickEvent], Awaitable[None]], events: tuple[StickEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified StickEvent occurs. + """Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. """ return self._controller.subscribe_to_stick_events( @@ -244,8 +217,8 @@ def subscribe_to_node_events( node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], events: tuple[NodeEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified NodeEvent occurs. + """Subscribe callback to be called when specific NodeEvent occurs. + Returns the function to be called to unsubscribe later. """ return self._network.subscribe_to_network_events( @@ -254,8 +227,8 @@ def subscribe_to_node_events( ) def _validate_node_discovery(self) -> None: - """ - Validate if network discovery is running + """Validate if network discovery is running. + Raises StickError if network is not active. """ if self._network is None or not self._network.is_running: @@ -264,7 +237,7 @@ def _validate_node_discovery(self) -> None: async def setup( self, discover: bool = True, load: bool = True ) -> None: - """Setup connection to USB-Stick.""" + """Fully connect, initialize USB-Stick and discover all connected nodes.""" if not self.is_connected: await self.connect() if not self.is_initialized: @@ -277,10 +250,7 @@ async def setup( await self.load_nodes() async def connect(self, port: str | None = None) -> None: - """ - Try to open connection. Does not initialize connection. - Raises StickError if failed to create connection. - """ + """Connect to USB-Stick. Raises StickError if connection fails.""" if self._controller.is_connected: raise StickError( f"Already connected to {self._port}, " + @@ -300,10 +270,7 @@ async def connect(self, port: str | None = None) -> None: @raise_not_connected async def initialize(self) -> None: - """ - Try to initialize existing connection to USB-Stick. - Raises StickError if failed to communicate with USB-stick. - """ + """Initialize connection to USB-Stick.""" await self._controller.initialize_stick() if self._network is None: self._network = StickNetwork(self._controller) @@ -337,7 +304,7 @@ async def load_nodes(self) -> bool: @raise_not_connected @raise_not_initialized async def discover_coordinator(self, load: bool = False) -> None: - """Setup connection to Zigbee network coordinator.""" + """Discover the network coordinator.""" if self._network is None: raise StickError( "Cannot load nodes when network is not initialized" @@ -347,7 +314,7 @@ async def discover_coordinator(self, load: bool = False) -> None: @raise_not_connected @raise_not_initialized async def discover_nodes(self, load: bool = False) -> None: - """Setup connection to Zigbee network coordinator.""" + """Discover all nodes.""" if self._network is None: raise StickError( "Cannot load nodes when network is not initialized" diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 3e471dad7..5bfff3cc2 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -1,7 +1,5 @@ -""" -The 'Connection ' manage the connection and communication -flow through the USB-Stick. -""" +"""Manage the connection and communication flow through the USB-Stick.""" + from __future__ import annotations from collections.abc import Awaitable, Callable @@ -45,15 +43,12 @@ def is_initialized(self) -> bool: @property def is_connected(self) -> bool: - """Return connection state from connection manager""" + """Return connection state from connection manager.""" return self._manager.is_connected @property def mac_stick(self) -> str: - """ - Returns the MAC address of USB-Stick. - Raises StickError when not connected. - """ + """MAC address of USB-Stick. Raises StickError when not connected.""" if not self._manager.is_connected or self._mac_stick is None: raise StickError( "No mac address available. " + @@ -146,7 +141,7 @@ def subscribe_to_node_responses( ) async def _handle_stick_event(self, event: StickEvent) -> None: - """Handle stick events""" + """Handle stick event.""" if event == StickEvent.CONNECTED: if not self._queue.is_running: self._queue.start(self._manager) @@ -156,10 +151,7 @@ async def _handle_stick_event(self, event: StickEvent) -> None: await self._queue.stop() async def initialize_stick(self) -> None: - """ - Initialize connection to the USB-stick. - Raises StickError if initialization fails. - """ + """Initialize connection to the USB-stick.""" if not self._manager.is_connected: raise StickError( "Cannot initialize USB-stick, connected to USB-stick first" diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 71f14a05c..6741a0e4c 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -1,7 +1,5 @@ -""" -The 'connection controller' manage the communication flow through the USB-Stick -towards the Plugwise (propriety) Zigbee like network. -""" +"""Manage the communication flow through the USB-Stick towards the Plugwise (propriety) Zigbee like network.""" + from __future__ import annotations from asyncio import Future, gather, get_event_loop, wait_for @@ -40,7 +38,7 @@ def __init__(self) -> None: @property def serial_path(self) -> str: - """Return current port""" + """Return current port.""" return self._port @property @@ -53,7 +51,7 @@ def is_connected(self) -> bool: return self._receiver.is_connected def _subscribe_to_stick_events(self) -> None: - """Subscribe to handle stick events by manager""" + """Subscribe to handle stick events by manager.""" if not self.is_connected: raise StickError("Unable to subscribe to events") if self._unsubscribe_stick_events is None: @@ -68,7 +66,7 @@ async def _handle_stick_event( self, event: StickEvent, ) -> None: - """Call callback for stick event subscribers""" + """Call callback for stick event subscribers.""" if len(self._stick_event_subscribers) == 0: return callback_list: list[Callable] = [] @@ -85,8 +83,8 @@ def subscribe_to_stick_events( stick_event_callback: Callable[[StickEvent], Awaitable[None]], events: tuple[StickEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified StickEvent occurs. + """Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. """ def remove_subscription() -> None: @@ -118,9 +116,8 @@ def subscribe_to_node_responses( mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: - """ - Subscribe a awaitable callback to be called when a specific - message is received. + """Subscribe a awaitable callback to be called when a specific message is received. + Returns function to unsubscribe. """ if self._receiver is None or not self._receiver.is_connected: @@ -135,7 +132,7 @@ def subscribe_to_node_responses( async def setup_connection_to_stick( self, serial_path: str ) -> None: - """Setup serial connection to USB-stick.""" + """Create serial connection to USB-stick.""" if self._connected: raise StickError("Cannot setup connection, already connected") loop = get_event_loop() @@ -181,10 +178,7 @@ async def setup_connection_to_stick( async def write_to_stick( self, request: PlugwiseRequest ) -> PlugwiseRequest: - """ - Write message to USB stick. - Returns the updated request object. - """ + """Write message to USB stick. Returns the updated request object.""" if not request.resend: raise StickError( f"Failed to send {request.__class__.__name__} " + diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 8c2591046..5a9de19dd 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,6 +1,4 @@ -""" -Manage the communication sessions towards the USB-Stick -""" +"""Manage the communication sessions towards the USB-Stick.""" from __future__ import annotations from asyncio import ( @@ -28,6 +26,7 @@ @dataclass class RequestState: """Node hardware information.""" + session: bytes zigbee_address: int @@ -46,14 +45,14 @@ def __init__(self) -> None: @property def is_running(self) -> bool: - """Return the state of the queue""" + """Return the state of the queue.""" return self._running def start( self, stick_connection_manager: StickConnectionManager ) -> None: - """Start sending request from queue""" + """Start sending request from queue.""" if self._running: raise StickError("Cannot start queue manager, already running") self._stick = stick_connection_manager @@ -67,7 +66,7 @@ def start( ) async def _handle_stick_event(self, event: StickEvent) -> None: - """Handle events from stick""" + """Handle events from stick.""" if event is StickEvent.CONNECTED: self._running = True elif event is StickEvent.DISCONNECTED: @@ -94,10 +93,7 @@ async def stop(self) -> None: async def submit( self, request: PlugwiseRequest ) -> PlugwiseResponse: - """ - Add request to queue and return the response of node - Raises an error when something fails - """ + """Add request to queue and return the response of node. Raises an error when something fails.""" _LOGGER.debug("Queueing %s", request) if not self._running or self._stick is None: raise StickError( @@ -121,7 +117,7 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: self._start_submit_worker() def _start_submit_worker(self) -> None: - """Start the submit worker if submit worker is not yet running""" + """Start the submit worker if submit worker is not yet running.""" if self._submit_worker_task is None or self._submit_worker_task.done(): self._submit_worker_task = self._loop.create_task( self._submit_worker() diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index ec39b2a9e..a769d90e4 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -1,5 +1,4 @@ -""" -Protocol receiver +"""Receive data from USB-Stick. Process incoming data stream from the Plugwise USB-Stick and convert it into response messages. @@ -45,16 +44,13 @@ async def delayed_run(coroutine: Callable, seconds: float): - """Postpone a coroutine to be executed after given delay""" + """Postpone a coroutine to be executed after given delay.""" await sleep(seconds) await coroutine class StickReceiver(Protocol): - """ - Receive data from USB Stick connection and - convert it into response messages. - """ + """Receive data from USB Stick connection and convert it into response messages.""" def __init__( self, @@ -248,7 +244,7 @@ async def _notify_stick_event_subscribers( self, event: StickEvent, ) -> None: - """Call callback for stick event subscribers""" + """Call callback for stick event subscribers.""" callback_list: list[Callable] = [] for callback, filtered_events in ( self._stick_event_subscribers.values() @@ -289,9 +285,8 @@ def subscribe_to_node_responses( mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: - """ - Subscribe a awaitable callback to be called when a specific - message is received. + """Subscribe a awaitable callback to be called when a specific message is received. + Returns function to unsubscribe. """ def remove_listener() -> None: @@ -306,7 +301,7 @@ def remove_listener() -> None: async def _notify_node_response_subscribers( self, node_response: PlugwiseResponse ) -> None: - """Call callback for all node response message subscribers""" + """Call callback for all node response message subscribers.""" callback_list: list[Callable] = [] for callback, mac, message_ids in list( self._node_response_subscribers.values() diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 19d647619..f85f72a41 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -1,4 +1,5 @@ -""" +"""Send data to USB-Stick. + Serialize request message and pass data stream to legacy Plugwise USB-Stick Wait for stick to respond. When request is accepted by USB-Stick, return the Sequence ID of the session. @@ -33,7 +34,7 @@ class StickSender(): def __init__( self, stick_receiver: StickReceiver, transport: Transport ) -> None: - """Initialize the Stick Sender class""" + """Initialize the Stick Sender class.""" self._loop = get_running_loop() self._receiver = stick_receiver self._transport = transport @@ -50,10 +51,9 @@ def __init__( async def write_request_to_port( self, request: PlugwiseRequest ) -> PlugwiseRequest: - """ - Send message to serial port of USB stick. - Returns the updated request object. - Raises StickError + """Send message to serial port of USB stick. + + Returns the updated request object. Raises StickError """ await self._stick_lock.acquire() self._current_request = request @@ -164,5 +164,5 @@ async def _process_stick_response(self, response: StickResponse) -> None: self._stick_lock.release() def stop(self) -> None: - """Stop sender""" + """Stop sender.""" self._unsubscribe_stick_response() diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 957bf22a2..fd4aed5f7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -28,6 +28,7 @@ String, Time, ) +from . import PlugwiseMessage _LOGGER = logging.getLogger(__name__) @@ -51,6 +52,7 @@ def __init__( identifier: bytes, mac: bytes | None, ) -> None: + """Initialize request message.""" super().__init__(identifier) self._args = [] @@ -71,15 +73,16 @@ def __init__( ) def __repr__(self) -> str: + """Convert request into writable str.""" return f"{self.__class__.__name__} for {self.mac_decoded}" def response_future(self) -> Future[PlugwiseResponse]: - """Return awaitable future with response message""" + """Return awaitable future with response message.""" return self._response_future @property def response(self) -> PlugwiseResponse: - """Return response message""" + """Return response message.""" if not self._response_future.done(): raise StickError("No response available") return self._response_future.result() @@ -105,7 +108,7 @@ def subscribe_to_responses( stick_subscription_fn: Callable[[], None], node_subscription_fn: Callable[[], None] ) -> None: - """Register for response messages""" + """Register for response messages.""" self._unsubscribe_node_response = ( node_subscription_fn( self._process_node_response, @@ -116,7 +119,7 @@ def subscribe_to_responses( self._stick_subscription_fn = stick_subscription_fn def start_response_timeout(self) -> None: - """Start timeout for node response""" + """Start timeout for node response.""" if self._response_timeout is not None: self._response_timeout.cancel() self._response_timeout = self._loop.call_later( @@ -124,7 +127,7 @@ def start_response_timeout(self) -> None: ) def _response_timeout_expired(self, stick_timeout: bool = False) -> None: - """Handle response timeout""" + """Handle response timeout.""" if self._response_future.done(): return self._unsubscribe_node_response() @@ -161,7 +164,7 @@ async def _process_node_response(self, response: PlugwiseResponse) -> None: self._unsubscribe_node_response() async def _process_stick_response(self, stick_response: StickResponse) -> None: - """Process incoming stick response""" + """Process incoming stick response.""" if self._response_future.done(): return if self._seq_id is not None and self._seq_id == stick_response.seq_id: @@ -188,7 +191,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: @property def object_id(self) -> int: - """return the object id.""" + """Return the object id.""" return self._id @property @@ -212,11 +215,11 @@ def resend(self) -> bool: return self._max_retries > self._send_counter def add_send_attempt(self): - """Increase the number of retries""" + """Increase the number of retries.""" self._send_counter += 1 def __gt__(self, other: PlugwiseRequest) -> bool: - """Greater than""" + """Greater than.""" if self.priority.value == other.priority.value: return self.timestamp > other.timestamp if self.priority.value < other.priority.value: @@ -224,7 +227,7 @@ def __gt__(self, other: PlugwiseRequest) -> bool: return False def __lt__(self, other: PlugwiseRequest) -> bool: - """Less than""" + """Less than.""" if self.priority.value == other.priority.value: return self.timestamp < other.timestamp if self.priority.value > other.priority.value: @@ -232,7 +235,7 @@ def __lt__(self, other: PlugwiseRequest) -> bool: return False def __ge__(self, other: PlugwiseRequest) -> bool: - """Greater than or equal""" + """Greater than or equal.""" if self.priority.value == other.priority.value: return self.timestamp >= other.timestamp if self.priority.value < other.priority.value: @@ -240,7 +243,7 @@ def __ge__(self, other: PlugwiseRequest) -> bool: return False def __le__(self, other: PlugwiseRequest) -> bool: - """Less than or equal""" + """Less than or equal.""" if self.priority.value == other.priority.value: return self.timestamp <= other.timestamp if self.priority.value > other.priority.value: @@ -256,7 +259,7 @@ class StickNetworkInfoRequest(PlugwiseRequest): """ def __init__(self) -> None: - """Initialize StickNetworkInfoRequest message object""" + """Initialize StickNetworkInfoRequest message object.""" self._reply_identifier = b"0002" super().__init__(b"0001", None) @@ -269,13 +272,14 @@ class CirclePlusConnectRequest(PlugwiseRequest): """ def __init__(self, mac: bytes) -> None: - """Initialize CirclePlusConnectRequest message object""" + """Initialize CirclePlusConnectRequest message object.""" self._reply_identifier = b"0005" super().__init__(b"0004", mac) # This message has an exceptional format and therefore # need to override the serialize method def serialize(self) -> bytes: + """Convert message to serialized list of bytes.""" # This command has # args: byte # key, byte @@ -290,15 +294,14 @@ def serialize(self) -> bytes: class NodeAddRequest(PlugwiseRequest): - """ - Add node to the Plugwise Network and add it to memory of Circle+ node + """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 Response message : TODO """ def __init__(self, mac: bytes, accept: bool) -> None: - """Initialize NodeAddRequest message object""" + """Initialize NodeAddRequest message object.""" super().__init__(b"0007", mac) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) @@ -306,6 +309,7 @@ def __init__(self, mac: bytes, accept: bool) -> None: # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method def serialize(self) -> bytes: + """Convert message to serialized list of bytes.""" args = b"".join(a.serialize() for a in self._args) msg: bytes = self._identifier + args if self._mac is not None: @@ -314,13 +318,13 @@ def serialize(self) -> bytes: return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER def validate_reply(self, node_response: PlugwiseResponse) -> bool: - """"Validate node response""" + """"Validate node response.""" return True class CirclePlusAllowJoiningRequest(PlugwiseRequest): - """ - Enable or disable receiving joining request of unjoined nodes. + """Enable or disable receiving joining request of unjoined nodes. + Circle+ node will respond Supported protocols : 1.0, 2.0, @@ -329,7 +333,7 @@ class CirclePlusAllowJoiningRequest(PlugwiseRequest): """ def __init__(self, enable: bool) -> None: - """Initialize NodeAddRequest message object""" + """Initialize NodeAddRequest message object.""" super().__init__(b"0008", None) self._reply_identifier = b"0003" val = 1 if enable else 0 @@ -337,15 +341,14 @@ def __init__(self, enable: bool) -> None: class NodeResetRequest(PlugwiseRequest): - """ - TODO: Some kind of reset request + """TODO:Some kind of reset request. Supported protocols : 1.0, 2.0, 2.1 Response message : """ def __init__(self, mac: bytes, moduletype: int, timeout: int) -> None: - """Initialize NodeResetRequest message object""" + """Initialize NodeResetRequest message object.""" super().__init__(b"0009", mac) self._args += [ Int(moduletype, length=2), @@ -354,65 +357,60 @@ def __init__(self, mac: bytes, moduletype: int, timeout: int) -> None: class StickInitRequest(PlugwiseRequest): - """ - Initialize USB-Stick. + """Initialize USB-Stick. Supported protocols : 1.0, 2.0 Response message : StickInitResponse """ def __init__(self) -> None: - """Initialize StickInitRequest message object""" + """Initialize StickInitRequest message object.""" super().__init__(b"000A", None) self._reply_identifier = b"0011" self._max_retries = 1 class NodeImagePrepareRequest(PlugwiseRequest): - """ - TODO: Some kind of request to prepare node for a firmware image. + """TODO: Some kind of request to prepare node for a firmware image. Supported protocols : 1.0, 2.0 Response message : """ def __init__(self) -> None: - """Initialize NodeImagePrepareRequest message object""" + """Initialize NodeImagePrepareRequest message object.""" super().__init__(b"000B", None) class NodeImageValidateRequest(PlugwiseRequest): - """ - TODO: Some kind of request to validate a firmware image for a node. + """TODO: Some kind of request to validate a firmware image for a node. Supported protocols : 1.0, 2.0 Response message : NodeImageValidationResponse """ def __init__(self) -> None: - """Initialize NodeImageValidateRequest message object""" + """Initialize NodeImageValidateRequest message object.""" super().__init__(b"000C", None) self._reply_identifier = b"0010" class NodePingRequest(PlugwiseRequest): - """ - Ping node + """Ping node. Supported protocols : 1.0, 2.0 Response message : NodePingResponse """ def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: - """Initialize NodePingRequest message object""" + """Initialize NodePingRequest message object.""" super().__init__(b"000D", mac) self._reply_identifier = b"000E" self._max_retries = retries class NodeImageActivateRequest(PlugwiseRequest): - """ - TODO: Some kind of request to activate a firmware image for a node. + """TODO: Some kind of request to activate a firmware image for a node. Supported protocols : 1.0, 2.0 Response message : @@ -421,7 +419,7 @@ class NodeImageActivateRequest(PlugwiseRequest): def __init__( self, mac: bytes, request_type: int, reset_delay: int ) -> None: - """Initialize NodeImageActivateRequest message object""" + """Initialize NodeImageActivateRequest message object.""" super().__init__(b"000F", mac) _type = Int(request_type, 2) _reset_delay = Int(reset_delay, 2) @@ -429,22 +427,21 @@ def __init__( class CirclePowerUsageRequest(PlugwiseRequest): - """ - Request current power usage. + """Request current power usage. Supported protocols : 1.0, 2.0, 2.1, 2.3 Response message : CirclePowerUsageResponse """ def __init__(self, mac: bytes) -> None: - """Initialize CirclePowerUsageRequest message object""" + """Initialize CirclePowerUsageRequest message object.""" super().__init__(b"0012", mac) self._reply_identifier = b"0013" class CircleLogDataRequest(PlugwiseRequest): - """ - TODO: Some kind of request to get log data from a node. + """TODO: Some kind of request to get log data from a node. + Only supported at protocol version 1.0 ! @@ -455,7 +452,7 @@ class CircleLogDataRequest(PlugwiseRequest): """ def __init__(self, mac: bytes, start: datetime, end: datetime) -> None: - """Initialize CircleLogDataRequest message object""" + """Initialize CircleLogDataRequest message object.""" super().__init__(b"0014", mac) self._reply_identifier = b"0015" passed_days_start = start.day - 1 @@ -476,8 +473,7 @@ def __init__(self, mac: bytes, start: datetime, end: datetime) -> None: class CircleClockSetRequest(PlugwiseRequest): - """ - Set internal clock of node and flash address + """Set internal clock of node and flash address. reset=True, will reset all locally stored energy logs @@ -492,7 +488,7 @@ def __init__( protocol_version: float, reset: bool = False, ) -> None: - """Initialize CircleLogDataRequest message object""" + """Initialize CircleLogDataRequest message object.""" super().__init__(b"0016", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH @@ -517,15 +513,14 @@ def __init__( class CircleRelaySwitchRequest(PlugwiseRequest): - """ - Request to switches relay on/off + """Request to switches relay on/off. Supported protocols : 1.0, 2.0 Response message : NodeResponse """ def __init__(self, mac: bytes, on: bool) -> None: - """Initialize CircleRelaySwitchRequest message object""" + """Initialize CircleRelaySwitchRequest message object.""" super().__init__(b"0017", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH @@ -534,9 +529,9 @@ def __init__(self, mac: bytes, on: bool) -> None: class CirclePlusScanRequest(PlugwiseRequest): - """ - Request all linked Circle plugs from Circle+ - a Plugwise network (Circle+) can have 64 devices the node ID value + """Request all linked Circle plugs from Circle+. + + A Plugwise network (Circle+) can have 64 devices the node ID value has a range from 0 to 63 Supported protocols : 1.0, 2.0 @@ -544,7 +539,7 @@ class CirclePlusScanRequest(PlugwiseRequest): """ def __init__(self, mac: bytes, network_address: int) -> None: - """Initialize CirclePlusScanRequest message object""" + """Initialize CirclePlusScanRequest message object.""" super().__init__(b"0018", mac) self._reply_identifier = b"0019" self._args.append(Int(network_address, length=2)) @@ -552,60 +547,55 @@ def __init__(self, mac: bytes, network_address: int) -> None: class NodeRemoveRequest(PlugwiseRequest): - """ - Request node to be removed from Plugwise network by - removing it from memory of Circle+ node. + """Request node to be removed from Plugwise network by removing it from memory of Circle+ node. Supported protocols : 1.0, 2.0 Response message : NodeRemoveResponse """ def __init__(self, mac_circle_plus: bytes, mac_to_unjoined: str) -> None: - """Initialize NodeRemoveRequest message object""" + """Initialize NodeRemoveRequest message object.""" super().__init__(b"001C", mac_circle_plus) self._reply_identifier = b"001D" self._args.append(String(mac_to_unjoined, length=16)) class NodeInfoRequest(PlugwiseRequest): - """ - Request status info of node + """Request status info of node. Supported protocols : 1.0, 2.0, 2.3 Response message : NodeInfoResponse """ def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: - """Initialize NodeInfoRequest message object""" + """Initialize NodeInfoRequest message object.""" super().__init__(b"0023", mac) self._reply_identifier = b"0024" self._max_retries = retries class EnergyCalibrationRequest(PlugwiseRequest): - """ - Request power calibration settings of node + """Request power calibration settings of node. Supported protocols : 1.0, 2.0 Response message : EnergyCalibrationResponse """ def __init__(self, mac: bytes) -> None: - """Initialize EnergyCalibrationRequest message object""" + """Initialize EnergyCalibrationRequest message object.""" super().__init__(b"0026", mac) self._reply_identifier = b"0027" class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): - """ - Set real time clock of Circle+ + """Set real time clock of Circle+. Supported protocols : 1.0, 2.0 Response message : NodeResponse """ def __init__(self, mac: bytes, dt: datetime): - """Initialize CirclePlusRealTimeClockSetRequest message object""" + """Initialize CirclePlusRealTimeClockSetRequest message object.""" super().__init__(b"0028", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH @@ -616,15 +606,14 @@ def __init__(self, mac: bytes, dt: datetime): class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): - """ - Request current real time clock of CirclePlus + """Request current real time clock of CirclePlus. Supported protocols : 1.0, 2.0 Response message : CirclePlusRealTimeClockResponse """ def __init__(self, mac: bytes): - """Initialize CirclePlusRealTimeClockGetRequest message object""" + """Initialize CirclePlusRealTimeClockGetRequest message object.""" super().__init__(b"0029", mac) self._reply_identifier = b"003A" @@ -635,29 +624,27 @@ def __init__(self, mac: bytes): class CircleClockGetRequest(PlugwiseRequest): - """ - Request current internal clock of node + """Request current internal clock of node. Supported protocols : 1.0, 2.0 Response message : CircleClockResponse """ def __init__(self, mac: bytes): - """Initialize CircleClockGetRequest message object""" + """Initialize CircleClockGetRequest message object.""" super().__init__(b"003E", mac) self._reply_identifier = b"003F" class CircleActivateScheduleRequest(PlugwiseRequest): - """ - Request to switch Schedule on or off + """Request to switch Schedule on or off. Supported protocols : 1.0, 2.0 Response message : TODO: """ def __init__(self, mac: bytes, on: bool) -> None: - """Initialize CircleActivateScheduleRequest message object""" + """Initialize CircleActivateScheduleRequest message object.""" super().__init__(b"0040", mac) val = 1 if on else 0 self._args.append(Int(val, length=2)) @@ -666,8 +653,7 @@ def __init__(self, mac: bytes, on: bool) -> None: class NodeAddToGroupRequest(PlugwiseRequest): - """ - Add node to group + """Add node to group. Response message: TODO: """ @@ -675,7 +661,7 @@ class NodeAddToGroupRequest(PlugwiseRequest): def __init__( self, mac: bytes, group_mac: bytes, task_id: str, port_mask: str ) -> None: - """Initialize NodeAddToGroupRequest message object""" + """Initialize NodeAddToGroupRequest message object.""" super().__init__(b"0045", mac) group_mac_val = String(group_mac, length=16) task_id_val = String(task_id, length=16) @@ -684,42 +670,39 @@ def __init__( class NodeRemoveFromGroupRequest(PlugwiseRequest): - """ - Remove node from group + """Remove node from group. Response message: TODO: """ def __init__(self, mac: bytes, group_mac: bytes) -> None: - """Initialize NodeRemoveFromGroupRequest message object""" + """Initialize NodeRemoveFromGroupRequest message object.""" super().__init__(b"0046", mac) group_mac_val = String(group_mac, length=16) self._args += [group_mac_val] class NodeBroadcastGroupSwitchRequest(PlugwiseRequest): - """ - Broadcast to group to switch + """Broadcast to group to switch. Response message: TODO: """ def __init__(self, group_mac: bytes, switch_state: bool) -> None: - """Initialize NodeBroadcastGroupSwitchRequest message object""" + """Initialize NodeBroadcastGroupSwitchRequest message object.""" super().__init__(b"0047", group_mac) val = 1 if switch_state else 0 self._args.append(Int(val, length=2)) class CircleEnergyLogsRequest(PlugwiseRequest): - """ - Request energy usage counters stored a given memory address + """Request energy usage counters stored a given memory address. Response message: CircleEnergyLogsResponse """ def __init__(self, mac: bytes, log_address: int) -> None: - """Initialize CircleEnergyLogsRequest message object""" + """Initialize CircleEnergyLogsRequest message object.""" super().__init__(b"0048", mac) self._reply_identifier = b"0049" self.priority = Priority.LOW @@ -727,32 +710,29 @@ def __init__(self, mac: bytes, log_address: int) -> None: class CircleHandlesOffRequest(PlugwiseRequest): - """ - ?PWSetHandlesOffRequestV1_0 + """?PWSetHandlesOffRequestV1_0. Response message: ? """ def __init__(self, mac: bytes) -> None: - """Initialize CircleHandlesOffRequest message object""" + """Initialize CircleHandlesOffRequest message object.""" super().__init__(b"004D", mac) class CircleHandlesOnRequest(PlugwiseRequest): - """ - ?PWSetHandlesOnRequestV1_0 + """?PWSetHandlesOnRequestV1_0. Response message: ? """ def __init__(self, mac: bytes) -> None: - """Initialize CircleHandlesOnRequest message object""" + """Initialize CircleHandlesOnRequest message object.""" super().__init__(b"004E", mac) class NodeSleepConfigRequest(PlugwiseRequest): - """ - Configure timers for SED nodes to minimize battery usage + """Configure timers for SED nodes to minimize battery usage. stay_active : Duration in seconds the SED will be awake for receiving commands @@ -777,7 +757,7 @@ def __init__( sync_clock: bool, clock_interval: int, ): - """Initialize NodeSleepConfigRequest message object""" + """Initialize NodeSleepConfigRequest message object.""" super().__init__(b"0050", mac) self._reply_identifier = b"0100" stay_active_val = Int(stay_active, length=2) @@ -796,8 +776,8 @@ def __init__( class NodeSelfRemoveRequest(PlugwiseRequest): - """ - TODO: + """TODO: Remove node?. + @@ -808,13 +788,12 @@ class NodeSelfRemoveRequest(PlugwiseRequest): """ def __init__(self, mac: bytes) -> None: - """Initialize NodeSelfRemoveRequest message object""" + """Initialize NodeSelfRemoveRequest message object.""" super().__init__(b"0051", mac) class CircleMeasureIntervalRequest(PlugwiseRequest): - """ - Configure the logging interval of energy measurement in minutes + """Configure the logging interval of energy measurement in minutes. FIXME: Make sure production interval is a multiply of consumption !! @@ -822,55 +801,51 @@ class CircleMeasureIntervalRequest(PlugwiseRequest): """ def __init__(self, mac: bytes, consumption: int, production: int): - """Initialize CircleMeasureIntervalRequest message object""" + """Initialize CircleMeasureIntervalRequest message object.""" super().__init__(b"0057", mac) self._args.append(Int(consumption, length=4)) self._args.append(Int(production, length=4)) class NodeClearGroupMacRequest(PlugwiseRequest): - """ - TODO: + """TODO: usage?. Response message: ???? """ def __init__(self, mac: bytes, taskId: int) -> None: - """Initialize NodeClearGroupMacRequest message object""" + """Initialize NodeClearGroupMacRequest message object.""" super().__init__(b"0058", mac) self._args.append(Int(taskId, length=2)) class CircleSetScheduleValueRequest(PlugwiseRequest): - """ - Send chunk of On/Off/StandbyKiller Schedule to Circle(+) + """Send chunk of On/Off/StandbyKiller Schedule to Circle(+). Response message: TODO: """ def __init__(self, mac: bytes, val: int) -> None: - """Initialize CircleSetScheduleValueRequest message object""" + """Initialize CircleSetScheduleValueRequest message object.""" super().__init__(b"0059", mac) self._args.append(SInt(val, length=4)) class NodeFeaturesRequest(PlugwiseRequest): - """ - Request feature set node supports + """Request feature set node supports. Response message: NodeFeaturesResponse """ def __init__(self, mac: bytes, val: int) -> None: - """Initialize NodeFeaturesRequest message object""" + """Initialize NodeFeaturesRequest message object.""" super().__init__(b"005F", mac) self._reply_identifier = b"0060" self._args.append(SInt(val, length=4)) class ScanConfigureRequest(PlugwiseRequest): - """ - Configure a Scan node + """Configure a Scan node. reset_timer : Delay in minutes when signal is send when no motion is detected @@ -885,7 +860,7 @@ class ScanConfigureRequest(PlugwiseRequest): def __init__( self, mac: bytes, reset_timer: int, sensitivity: int, light: bool ): - """Initialize ScanConfigureRequest message object""" + """Initialize ScanConfigureRequest message object.""" super().__init__(b"0101", mac) self._reply_identifier = b"0100" reset_timer_value = Int(reset_timer, length=2) @@ -901,44 +876,41 @@ def __init__( class ScanLightCalibrateRequest(PlugwiseRequest): - """ - Calibrate light sensitivity + """Calibrate light sensitivity. Response message: NodeAckResponse """ def __init__(self, mac: bytes): - """Initialize ScanLightCalibrateRequest message object""" + """Initialize ScanLightCalibrateRequest message object.""" super().__init__(b"0102", mac) self._reply_identifier = b"0100" class SenseReportIntervalRequest(PlugwiseRequest): - """ - Sets the Sense temperature and humidity measurement - report interval in minutes. Based on this interval, periodically - a 'SenseReportResponse' message is sent by the Sense node + """Sets the Sense temperature and humidity measurement report interval in minutes. + + Based on this interval, periodically a 'SenseReportResponse' message is sent by the Sense node Response message: NodeAckResponse """ def __init__(self, mac: bytes, interval: int): - """Initialize ScanLightCalibrateRequest message object""" + """Initialize ScanLightCalibrateRequest message object.""" super().__init__(b"0103", mac) self._reply_identifier = b"0100" self._args.append(Int(interval, length=2)) class CircleRelayInitStateRequest(PlugwiseRequest): - """ - Get or set initial relay state after power-up of Circle. + """Get or set initial relay state after power-up of Circle. Supported protocols : 2.6 Response message : CircleInitRelayStateResponse """ def __init__(self, mac: bytes, configure: bool, relay_state: bool) -> None: - """Initialize CircleRelayInitStateRequest message object""" + """Initialize CircleRelayInitStateRequest message object.""" super().__init__(b"0138", mac) self._reply_identifier = b"0139" self.priority = Priority.LOW diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 47da828cb..5d73bcbed 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -93,9 +93,7 @@ class NodeAwakeResponseType(int, Enum): class PlugwiseResponse(PlugwiseMessage): - """ - Base class for response messages received by USB-Stick. - """ + """Base class for response messages received by USB-Stick.""" timestamp: datetime | None = None @@ -105,7 +103,7 @@ def __init__( decode_ack: bool = False, decode_mac: bool = True, ) -> None: - """Initialize a response message""" + """Initialize a response message.""" super().__init__(identifier) self._ack_id: bytes | None = None self._decode_ack = decode_ack @@ -115,26 +113,27 @@ def __init__( self._notify_retries: int = 0 def __repr__(self) -> str: + """Convert request into writable str.""" return f"{self.__class__.__name__} from {self.mac_decoded} seq_id {self.seq_id}" @property def ack_id(self) -> bytes | None: - """Return the acknowledge id""" + """Return the acknowledge id.""" return self._ack_id @property def seq_id(self) -> bytes: - """Sequence ID""" + """Sequence ID.""" return self._seq_id @property def notify_retries(self) -> int: - """Return number of notifies""" + """Return number of notifies.""" return self._notify_retries @notify_retries.setter def notify_retries(self, retries: int) -> None: - """Set number of notification retries""" + """Set number of notification retries.""" self._notify_retries = retries def deserialize(self, response: bytes) -> None: @@ -218,23 +217,22 @@ def __len__(self) -> int: class StickResponse(PlugwiseResponse): - """ - Response message from USB-Stick + """Response message from USB-Stick. Response to: Any message request """ def __init__(self) -> None: - """Initialize StickResponse message object""" + """Initialize StickResponse message object.""" super().__init__(b"0000", decode_ack=True, decode_mac=False) def __repr__(self) -> str: + """Convert request into writable str.""" return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) class NodeResponse(PlugwiseResponse): - """ - Report status from node to a specific request + """Report status from node to a specific request. Supported protocols : 1.0, 2.0 Response to requests: TODO: complete list @@ -244,20 +242,19 @@ class NodeResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeResponse message object""" + """Initialize NodeResponse message object.""" super().__init__(b"0000", decode_ack=True) class StickNetworkInfoResponse(PlugwiseResponse): - """ - Report status of zigbee network + """Report status of zigbee network. Supported protocols : 1.0, 2.0 Response to request : NodeNetworkInfoRequest """ def __init__(self) -> None: - """Initialize NodeNetworkInfoResponse message object""" + """Initialize NodeNetworkInfoResponse message object.""" super().__init__(b"0002") self.channel = String(None, length=2) self.source_mac_id = String(None, length=16) @@ -277,6 +274,7 @@ def __init__(self) -> None: ] def deserialize(self, response: bytes) -> None: + """Extract data from bytes.""" super().deserialize(response) # Clear first two characters of mac ID, as they contain # part of the short PAN-ID @@ -284,8 +282,7 @@ def deserialize(self, response: bytes) -> None: class NodeSpecificResponse(PlugwiseResponse): - """ - TODO: Report some sort of status from node + """TODO: Report some sort of status from node. PWAckReplyV1_0 @@ -295,22 +292,21 @@ class NodeSpecificResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeSpecificResponse message object""" + """Initialize NodeSpecificResponse message object.""" super().__init__(b"0003") self.status = Int(0, 4) self._params += [self.status] class CirclePlusConnectResponse(PlugwiseResponse): - """ - CirclePlus connected to the network + """CirclePlus connected to the network. Supported protocols : 1.0, 2.0 Response to request : CirclePlusConnectRequest """ def __init__(self) -> None: - """Initialize CirclePlusConnectResponse message object""" + """Initialize CirclePlusConnectResponse message object.""" super().__init__(b"0005") self.existing = Int(0, 2) self.allowed = Int(0, 2) @@ -318,8 +314,7 @@ def __init__(self) -> None: class NodeJoinAvailableResponse(PlugwiseResponse): - """ - Request from Node to join a plugwise network + """Request from Node to join a plugwise network. Supported protocols : 1.0, 2.0 Response to request : No request as every unjoined node is requesting @@ -327,13 +322,12 @@ class NodeJoinAvailableResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeJoinAvailableResponse message object""" + """Initialize NodeJoinAvailableResponse message object.""" super().__init__(NODE_JOIN_ID) class NodePingResponse(PlugwiseResponse): - """ - Ping and RSSI (Received Signal Strength Indicator) response from node + """Ping and RSSI (Received Signal Strength Indicator) response from node. - rssi_in : Incoming last hop RSSI target - rssi_out : Last hop RSSI source @@ -344,7 +338,7 @@ class NodePingResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodePingResponse message object""" + """Initialize NodePingResponse message object.""" super().__init__(b"000E") self._rssi_in = Int(0, length=2) self._rssi_out = Int(0, length=2) @@ -357,38 +351,36 @@ def __init__(self) -> None: @property def rssi_in(self) -> int: - """Return inbound RSSI level""" + """Return inbound RSSI level.""" return self._rssi_in.value @property def rssi_out(self) -> int: - """Return outbound RSSI level""" + """Return outbound RSSI level.""" return self._rssi_out.value @property def rtt(self) -> int: - """Return round trip time""" + """Return round trip time.""" return self._rtt.value class NodeImageValidationResponse(PlugwiseResponse): - """ - TODO: Some kind of response to validate a firmware image for a node. + """TODO: Some kind of response to validate a firmware image for a node. Supported protocols : 1.0, 2.0 Response to request : NodeImageValidationRequest """ def __init__(self) -> None: - """Initialize NodePingResponse message object""" + """Initialize NodePingResponse message object.""" super().__init__(b"0010") self.image_timestamp = UnixTimestamp(0) self._params += [self.image_timestamp] class StickInitResponse(PlugwiseResponse): - """ - Returns the configuration and status of the USB-Stick + """Returns the configuration and status of the USB-Stick. Optional: - circle_plus_mac @@ -400,7 +392,7 @@ class StickInitResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize StickInitResponse message object""" + """Initialize StickInitResponse message object.""" super().__init__(b"0011") self.unknown1 = Int(0, length=2) self._network_online = Int(0, length=2) @@ -417,13 +409,13 @@ def __init__(self) -> None: @property def mac_network_controller(self) -> str: - """Return the mac of the network controller (Circle+)""" + """Return the mac of the network controller (Circle+).""" # Replace first 2 characters by 00 for mac of circle+ node return "00" + self._mac_nc.value[2:].decode(UTF8) @property def network_id(self) -> int: - """Return network ID""" + """Return network ID.""" return self._network_id.value @property @@ -433,15 +425,14 @@ def network_online(self) -> bool: class CirclePowerUsageResponse(PlugwiseResponse): - """ - Returns power usage as impulse counters for several different time frames + """Returns power usage as impulse counters for several different time frames. Supported protocols : 1.0, 2.0, 2.1, 2.3 Response to request : CirclePowerUsageRequest """ def __init__(self, protocol_version: str = "2.3") -> None: - """Initialize CirclePowerUsageResponse message object""" + """Initialize CirclePowerUsageResponse message object.""" super().__init__(b"0013") self._pulse_1s = Int(0, 4) self._pulse_8s = Int(0, 4) @@ -458,33 +449,33 @@ def __init__(self, protocol_version: str = "2.3") -> None: @property def pulse_1s(self) -> int: - """Return pulses last second""" + """Return pulses last second.""" return self._pulse_1s.value @property def pulse_8s(self) -> int: - """Return pulses last 8 seconds""" + """Return pulses last 8 seconds.""" return self._pulse_8s.value @property def offset(self) -> int: - """Return offset in nanoseconds""" + """Return offset in nanoseconds.""" return self._nanosecond_offset.value @property def consumed_counter(self) -> int: - """Return consumed pulses""" + """Return consumed pulses.""" return self._pulse_counter_consumed.value @property def produced_counter(self) -> int: - """Return consumed pulses""" + """Return consumed pulses.""" return self._pulse_counter_produced.value class CircleLogDataResponse(PlugwiseResponse): - """ - TODO: Returns some kind of log data from a node. + """TODO: Returns some kind of log data from a node. + Only supported at protocol version 1.0 ! @@ -497,7 +488,7 @@ class CircleLogDataResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize CircleLogDataResponse message object""" + """Initialize CircleLogDataResponse message object.""" super().__init__(b"0015") self.stored_abs = DateTime() self.powermeterinfo = Int(0, 8, False) @@ -510,16 +501,14 @@ def __init__(self) -> None: class CirclePlusScanResponse(PlugwiseResponse): - """ - Returns the MAC of a registered node at the specified memory address - of a Circle+ + """Returns the MAC of a registered node at the specified memory address of a Circle+. Supported protocols : 1.0, 2.0 Response to request : CirclePlusScanRequest """ def __init__(self) -> None: - """Initialize CirclePlusScanResponse message object""" + """Initialize CirclePlusScanResponse message object.""" super().__init__(b"0019") self._registered_mac = String(None, length=16) self._network_address = Int(0, 2, False) @@ -527,26 +516,26 @@ def __init__(self) -> None: @property def registered_mac(self) -> str: - """Return the mac of the node""" + """Return the mac of the node.""" return self._registered_mac.value.decode(UTF8) @property def network_address(self) -> int: - """Return the network address""" + """Return the network address.""" return self._network_address.value class NodeRemoveResponse(PlugwiseResponse): - """ - Returns conformation (or not) if node is removed from the Plugwise network - by having it removed from the memory of the Circle+ + """Confirmation (or not) if node is removed from the Plugwise network. + + Also confirmation it has been removed from the memory of the Circle+ Supported protocols : 1.0, 2.0 Response to request : NodeRemoveRequest """ def __init__(self) -> None: - """Initialize NodeRemoveResponse message object""" + """Initialize NodeRemoveResponse message object.""" super().__init__(b"001D") self.node_mac_id = String(None, length=16) self.status = Int(0, 2) @@ -554,15 +543,14 @@ def __init__(self) -> None: class NodeInfoResponse(PlugwiseResponse): - """ - Returns the status information of Node + """Returns the status information of Node. Supported protocols : 1.0, 2.0, 2.3 Response to request : NodeInfoRequest """ def __init__(self, protocol_version: str = "2.0") -> None: - """Initialize NodeInfoResponse message object""" + """Initialize NodeInfoResponse message object.""" super().__init__(b"0024") self._last_logaddress = LogAddr(0, length=8) @@ -604,45 +592,44 @@ def __init__(self, protocol_version: str = "2.0") -> None: @property def hardware(self) -> str: - """Return hardware id""" + """Return hardware id.""" return self._hw_ver.value.decode(UTF8) @property def firmware(self) -> datetime: - """Return timestamp of firmware""" + """Return timestamp of firmware.""" return self._fw_ver.value @property def node_type(self) -> NodeType: - """Return the type of node""" + """Return the type of node.""" return NodeType(self._node_type.value) @property def last_logaddress(self) -> int: - """Return the current energy log address""" + """Return the current energy log address.""" return self._last_logaddress.value @property def relay_state(self) -> bool: - """Return state of relay""" + """Return state of relay.""" return self._relay_state.value == 1 @property def frequency(self) -> int: - """Return frequency config of node""" + """Return frequency config of node.""" return self._frequency class EnergyCalibrationResponse(PlugwiseResponse): - """ - Returns the calibration settings of node + """Returns the calibration settings of node. Supported protocols : 1.0, 2.0 Response to request : EnergyCalibrationRequest """ def __init__(self) -> None: - """Initialize EnergyCalibrationResponse message object""" + """Initialize EnergyCalibrationResponse message object.""" super().__init__(b"0027") self._gain_a = Float(0, 8) self._gain_b = Float(0, 8) @@ -652,35 +639,34 @@ def __init__(self) -> None: @property def gain_a(self) -> float: - """Return the gain A""" + """Return the gain A.""" return self._gain_a.value @property def gain_b(self) -> float: - """Return the gain B""" + """Return the gain B.""" return self._gain_b.value @property def off_tot(self) -> float: - """Return the offset""" + """Return the offset.""" return self._off_tot.value @property def off_noise(self) -> float: - """Return the offset""" + """Return the offset.""" return self._off_noise.value class CirclePlusRealTimeClockResponse(PlugwiseResponse): - """ - returns the real time clock of CirclePlus node + """returns the real time clock of CirclePlus node. Supported protocols : 1.0, 2.0 Response to request : CirclePlusRealTimeClockGetRequest """ def __init__(self) -> None: - """Initialize CirclePlusRealTimeClockResponse message object""" + """Initialize CirclePlusRealTimeClockResponse message object.""" super().__init__(b"003A") self.time = RealClockTime() self.day_of_week = Int(0, 2, False) @@ -694,15 +680,14 @@ def __init__(self) -> None: class CircleClockResponse(PlugwiseResponse): - """ - Returns the current internal clock of Node + """Returns the current internal clock of Node. Supported protocols : 1.0, 2.0 Response to request : CircleClockGetRequest """ def __init__(self) -> None: - """Initialize CircleClockResponse message object""" + """Initialize CircleClockResponse message object.""" super().__init__(b"003F") self.time = Time() self.day_of_week = Int(0, 2, False) @@ -717,15 +702,15 @@ def __init__(self) -> None: class CircleEnergyLogsResponse(PlugwiseResponse): - """ - Returns historical energy usage of requested memory address + """Returns historical energy usage of requested memory address. + Each response contains 4 energy counters at specified 1 hour timestamp Response to: CircleEnergyLogsRequest """ def __init__(self) -> None: - """Initialize CircleEnergyLogsResponse message object""" + """Initialize CircleEnergyLogsResponse message object.""" super().__init__(b"0049") self.logdate1 = DateTime() self.pulses1 = Int(0, 8) @@ -750,9 +735,11 @@ def __init__(self) -> None: class NodeAwakeResponse(PlugwiseResponse): - """ - A sleeping end device (SED: Scan, Sense, Switch) sends - this message to announce that is awake. Awake types: + """Announce that a sleeping end device is awake. + + A sleeping end device (SED) like Scan, Sense, Switch) sends + this message to announce that is awake. + Possible awake types: - 0 : The SED joins the network for maintenance - 1 : The SED joins a network for the first time - 2 : The SED joins a network it has already joined, e.g. after @@ -766,14 +753,15 @@ class NodeAwakeResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeAwakeResponse message object""" + """Initialize NodeAwakeResponse message object.""" super().__init__(NODE_AWAKE_RESPONSE_ID) self.awake_type = Int(0, 2, False) self._params += [self.awake_type] class NodeSwitchGroupResponse(PlugwiseResponse): - """ + """Announce groups on/off. + A sleeping end device (SED: Scan, Sense, Switch) sends this message to switch groups on/off when the configured switching conditions have been met. @@ -782,7 +770,7 @@ class NodeSwitchGroupResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeSwitchGroupResponse message object""" + """Initialize NodeSwitchGroupResponse message object.""" super().__init__(NODE_SWITCH_GROUP_ID) self.group = Int(0, 2, False) self.power_state = Int(0, length=2) @@ -793,23 +781,23 @@ def __init__(self) -> None: class NodeFeaturesResponse(PlugwiseResponse): - """ - Returns supported features of node + """Returns supported features of node. + TODO: Feature Bit mask Response to: NodeFeaturesRequest """ def __init__(self) -> None: - """Initialize NodeFeaturesResponse message object""" + """Initialize NodeFeaturesResponse message object.""" super().__init__(b"0060") self.features = String(None, length=16) self._params += [self.features] class NodeRejoinResponse(PlugwiseResponse): - """ - Notification message when node (re)joined existing network again. + """Notification message when node (re)joined existing network again. + Sent when a SED (re)joins the network e.g. when you reinsert the battery of a Scan @@ -819,19 +807,20 @@ class NodeRejoinResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodeRejoinResponse message object""" + """Initialize NodeRejoinResponse message object.""" super().__init__(b"0061") class NodeAckResponse(PlugwiseResponse): - """Acknowledge message in regular format + """Acknowledge message in regular format. + Sent by nodes supporting plugwise 2.4 protocol version Response to: ? """ def __init__(self) -> None: - """Initialize NodeAckResponse message object""" + """Initialize NodeAckResponse message object.""" super().__init__(b"0100") @@ -845,7 +834,7 @@ class SenseReportResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize SenseReportResponse message object""" + """Initialize SenseReportResponse message object.""" super().__init__(SENSE_REPORT_ID) self.humidity = Int(0, length=4) self.temperature = Int(0, length=4) @@ -860,7 +849,7 @@ class CircleRelayInitStateResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize CircleRelayInitStateResponse message object""" + """Initialize CircleRelayInitStateResponse message object.""" super().__init__(b"0139") self.is_get = Int(0, length=2) self.relay = Int(0, length=2) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f6dea71fc..8c86f429b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -1,4 +1,5 @@ -""" Plugwise network """ +"""Plugwise network.""" + # region - Imports from __future__ import annotations @@ -91,7 +92,7 @@ def cache_enabled(self, enable: bool = True) -> None: @property def cache_folder(self) -> str: - """path to cache data of network register.""" + """Path to cache data of network register.""" return self._cache_folder @cache_folder.setter @@ -104,9 +105,7 @@ def cache_folder(self, cache_folder: str) -> None: @property def controller_active(self) -> bool: - """ - Return True if network controller (Circle+) is discovered and active. - """ + """Return True if network controller (Circle+) is discovered and active.""" if self._controller.mac_coordinator in self._nodes: return self._nodes[self._controller.mac_coordinator].available return False @@ -120,10 +119,7 @@ def is_running(self) -> bool: def nodes( self, ) -> dict[str, PlugwiseNode]: - """ - Return dictionary with all discovered network nodes - with the mac address as the key. - """ + """Dictionary with all discovered network nodes with the mac address as the key.""" return self._nodes @property @@ -140,7 +136,7 @@ async def register_node(self, mac: str) -> None: self._discover_node(address, mac, None) async def clear_cache(self) -> None: - """Clear register""" + """Clear register cache.""" await self._register.clear_register_cache() async def unregister_node(self, mac: str) -> None: @@ -174,7 +170,7 @@ def _subscribe_to_protocol_events(self) -> None: ) async def _handle_stick_event(self, event: StickEvent) -> None: - """Handle stick events""" + """Handle stick events.""" if event == StickEvent.CONNECTED: await gather( *[ @@ -210,8 +206,7 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: return if self._register.network_address(mac) is None: _LOGGER.warning( - "Skip node awake message for %s because network " + - "registry address is unknown", + "Skip node awake message for %s because network registry address is unknown", mac ) return @@ -286,7 +281,7 @@ def _create_node_object( address: int, node_type: NodeType, ) -> None: - """Create node object and update network registry""" + """Create node object and update network registry.""" if self._nodes.get(mac) is not None: _LOGGER.warning( "Skip creating node object because node object for mac " + @@ -385,9 +380,9 @@ async def _discover_node( mac: str, node_type: NodeType | None ) -> bool: - """ - Discover node and add it to list of nodes - Return True if discovery succeeded + """Discover node and add it to list of nodes. + + Return True if discovery succeeded. """ if self._nodes.get(mac) is not None: _LOGGER.warning("Skip discovery of already known node %s ", mac) @@ -416,7 +411,7 @@ async def _discover_node( await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) async def _discover_registered_nodes(self) -> None: - """Discover nodes""" + """Discover nodes.""" _LOGGER.debug("Start discovery of registered nodes") counter = 0 for address, registration in self._register.registry.items(): @@ -433,7 +428,7 @@ async def _discover_registered_nodes(self) -> None: ) async def _load_node(self, mac: str) -> bool: - """Load node""" + """Load node.""" if self._nodes.get(mac) is None: return False if self._nodes[mac].loaded: @@ -444,8 +439,8 @@ async def _load_node(self, mac: str) -> bool: return False async def _load_discovered_nodes(self) -> None: - """Load all nodes currently discovered""" await gather( + """Load all nodes currently discovered.""" *[ self._load_node(mac) for mac, node in self._nodes.items() @@ -454,7 +449,7 @@ async def _load_discovered_nodes(self) -> None: ) async def _unload_discovered_nodes(self) -> None: - """Unload all nodes""" + """Unload all nodes.""" await gather( *[ node.unload() @@ -466,7 +461,7 @@ async def _unload_discovered_nodes(self) -> None: # region - Network instance async def start(self) -> None: - """Start and activate network""" + """Start and activate network.""" self._register.quick_scan_finished(self._discover_registered_nodes) self._register.full_scan_finished(self._discover_registered_nodes) await self._register.start() @@ -474,7 +469,7 @@ async def start(self) -> None: self._is_running = True async def discover_nodes(self, load: bool = True) -> None: - """Discover nodes""" + """Discover nodes.""" if not self._is_running: await self.start() await self.discover_network_coordinator() @@ -515,8 +510,8 @@ def subscribe_to_network_events( node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], events: tuple[NodeEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified NodeEvent occurs. + """Subscribe callback when specified NodeEvent occurs. + Returns the function to be called to unsubscribe later. """ def remove_subscription() -> None: @@ -533,7 +528,7 @@ async def _notify_node_event_subscribers( event: NodeEvent, mac: str ) -> None: - """Call callback for node event subscribers""" + """Call callback for node event subscribers.""" callback_list: list[Callable] = [] for callback, filtered_events in list( self._node_event_subscribers.values() diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 81604d53c..dc1022ab5 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -1,4 +1,4 @@ -"""Caching for plugwise network""" +"""Caching for plugwise network.""" from __future__ import annotations @@ -17,7 +17,7 @@ class NetworkRegistrationCache: - """Class to cache node network information""" + """Class to cache node network information.""" def __init__(self, cache_root_dir: str = "") -> None: """Initialize NetworkCache class.""" @@ -49,7 +49,7 @@ def _set_cache_file(self, cache_root_dir: str) -> None: @property def registrations(self) -> dict[int, tuple[str, NodeType]]: - """Cached network information""" + """Cached network information.""" return self._registrations async def save_cache(self) -> None: @@ -147,7 +147,7 @@ async def restore_cache(self) -> bool: return True async def delete_cache_file(self) -> None: - """Delete cache file""" + """Delete cache file.""" if self._cache_file is None: return if not await aiofiles.os.path.exists(self._cache_file): @@ -157,7 +157,7 @@ async def delete_cache_file(self) -> None: def update_registration( self, address: int, mac: str, node_type: NodeType | None ) -> None: - """Save node information in cache""" + """Save node information in cache.""" if self._registrations.get(address) is not None: _, current_node_type = self._registrations[address] if current_node_type is not None and node_type is None: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 7261b4999..9f4141582 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -1,5 +1,4 @@ -"""Network register""" - +"""Register of network configuration.""" from __future__ import annotations from asyncio import Task, create_task, sleep @@ -25,15 +24,15 @@ _LOGGER = logging.getLogger(__name__) -class StickNetworkRegister(): - """Network register""" +class StickNetworkRegister: + """Network register.""" def __init__( self, mac_network_controller: bytes, send_fn: Callable[[Any], Coroutine[Any, Any, PlugwiseResponse]] ) -> None: - """Initialize network register""" + """Initialize network register.""" self._mac_nc = mac_network_controller self._send_to_controller = send_fn self._cache_folder: str = "" @@ -69,12 +68,12 @@ def cache_enabled(self, enable: bool = True) -> None: @property def cache_folder(self) -> str: - """path to cache data""" + """Path to folder to store cached data.""" return self._cache_folder @cache_folder.setter def cache_folder(self, cache_folder: str) -> None: - """Set path to cache data""" + """Set path to cache data.""" if cache_folder == self._cache_folder: return self._cache_folder = cache_folder @@ -85,17 +84,17 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: return deepcopy(self._registry) def quick_scan_finished(self, callback: Awaitable) -> None: - """Register method to be called when quick scan is finished""" + """Register method to be called when quick scan is finished.""" self._quick_scan_finished = callback def full_scan_finished(self, callback: Awaitable) -> None: - """Register method to be called when full scan is finished""" + """Register method to be called when full scan is finished.""" self._full_scan_finished = callback # endregion async def start(self) -> None: - """Initialize load the network registry""" + """Initialize load the network registry.""" if self._cache_enabled: await self.restore_network_cache() await sleep(0) @@ -104,7 +103,7 @@ async def start(self) -> None: await self.update_missing_registrations(quick=True) async def restore_network_cache(self) -> None: - """Restore previously saved cached network and node information""" + """Restore previously saved cached network and node information.""" if self._network_cache is None: _LOGGER.error( "Unable to restore cache when cache is not initialized" @@ -115,7 +114,7 @@ async def restore_network_cache(self) -> None: self._cache_restored = True async def load_registry_from_cache(self) -> None: - """Load network registry from cache""" + """Load network registry from cache.""" if self._network_cache is None: _LOGGER.error( "Unable to restore network registry because " + @@ -151,7 +150,7 @@ async def retrieve_network_registration( return (address, mac_of_node) def network_address(self, mac: str) -> int | None: - """Return the network registration address for given mac""" + """Return the network registration address for given mac.""" for address, registration in self._registry.items(): registered_mac, _ = registration if mac == registered_mac: @@ -167,7 +166,7 @@ def network_controller(self) -> tuple[int, NodeType | None]: def update_network_registration( self, address: int, mac: str, node_type: NodeType | None ) -> None: - """Add a network registration""" + """Add a network registration.""" if self._registry.get(address) is not None: _, current_type = self._registry[address] if current_type is not None and node_type is None: @@ -179,10 +178,7 @@ def update_network_registration( async def update_missing_registrations( self, quick: bool = False ) -> None: - """ - Retrieve all unknown network registrations - from network controller - """ + """Retrieve all unknown network registrations from network controller.""" for address in range(0, 64): if self._registry.get(address) is not None and not quick: mac, _ = self._registry[address] @@ -235,13 +231,13 @@ async def update_missing_registrations( self._full_scan_finished = None def _stop_registration_task(self) -> None: - """Stop the background registration task""" + """Stop the background registration task.""" if self._registration_task is None: return self._registration_task.cancel() async def save_registry_to_cache(self) -> None: - """Save network registry to cache""" + """Save network registry to cache.""" if self._network_cache is None: _LOGGER.error( "Unable to save network registry because " + @@ -261,10 +257,7 @@ async def save_registry_to_cache(self) -> None: ) async def register_node(self, mac: str) -> int: - """ - Register node to Plugwise network. - Return network address - """ + """Register node to Plugwise network and return network address.""" if not validate_mac(mac): raise NodeError(f"Invalid mac '{mac}' to register") @@ -312,7 +305,7 @@ async def clear_register_cache(self) -> None: self._cache_restored = False async def stop(self) -> None: - """Unload the network registry""" + """Unload the network registry.""" self._stop_registration_task() if self._cache_enabled: await self.save_registry_to_cache() diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 772087914..11bbf14c7 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -114,7 +114,7 @@ def cache_folder(self) -> str: @cache_folder.setter def cache_folder(self, cache_folder: str) -> None: - """Set path to cache folder""" + """Set path to cache folder.""" if cache_folder == self._cache_folder: return self._cache_folder = cache_folder @@ -145,7 +145,7 @@ def cache_enabled(self, enable: bool) -> None: @property def available(self) -> bool: - """Return network availability state""" + """Return network availability state.""" return self._available @property @@ -163,7 +163,7 @@ def features(self) -> tuple[NodeFeature, ...]: @property def node_info(self) -> NodeInfo: - """"Return node information""" + """"Return node information.""" return self._node_info @property @@ -182,7 +182,7 @@ def last_update(self) -> datetime: @property def loaded(self) -> bool: - """Return load status. """ + """Return load status.""" return self._loaded @property @@ -192,7 +192,7 @@ def mac(self) -> str: @property def motion(self) -> bool | None: - """Return motion detection state.""" + """Motion detection value.""" if NodeFeature.MOTION not in self._features: raise NodeError( f"Motion state is not supported for node {self.mac}" @@ -201,7 +201,7 @@ def motion(self) -> bool | None: @property def motion_state(self) -> MotionState: - """Return last known state of motion sensor""" + """Motion detection state.""" if NodeFeature.MOTION not in self._features: raise NodeError( f"Motion state is not supported for node {self.mac}" @@ -210,10 +210,12 @@ def motion_state(self) -> MotionState: @property def ping(self) -> NetworkStatistics: + """Ping statistics.""" return self._ping @property def power(self) -> PowerStatistics: + """Power statistics.""" if NodeFeature.POWER not in self._features: raise NodeError( f"Power state is not supported for node {self.mac}" @@ -222,6 +224,7 @@ def power(self) -> PowerStatistics: @property def switch(self) -> bool | None: + """Switch button value.""" if NodeFeature.SWITCH not in self._features: raise NodeError( f"Switch state is not supported for node {self.mac}" @@ -230,7 +233,7 @@ def switch(self) -> bool | None: @property def relay_state(self) -> RelayState: - """Return last known state of relay""" + """State of relay.""" if NodeFeature.RELAY not in self._features: raise NodeError( f"Relay state is not supported for node {self.mac}" @@ -239,7 +242,7 @@ def relay_state(self) -> RelayState: @property def relay(self) -> bool: - """Return state of relay""" + """Relay value.""" if NodeFeature.RELAY not in self._features: raise NodeError( f"Relay state is not supported for node {self.mac}" @@ -250,12 +253,12 @@ def relay(self) -> bool: @relay.setter def relay(self, state: bool) -> None: - """Request the relay to switch state.""" + """Change relay to state value.""" raise NotImplementedError() @property def temperature(self) -> float | None: - """Temperature sensor""" + """Temperature value.""" if NodeFeature.TEMPERATURE not in self._features: raise NodeError( f"Temperature state is not supported for node {self.mac}" @@ -279,10 +282,7 @@ def _setup_protocol( firmware: dict[datetime, SupportedVersions], node_features: tuple[NodeFeature], ) -> None: - """ - Determine protocol version based on firmware version - and enable supported additional supported features - """ + """Determine protocol version based on firmware version and enable supported additional supported features.""" if self._node_info.firmware is None: return self._node_protocols = firmware.get(self._node_info.firmware, None) @@ -496,10 +496,7 @@ def _node_info_update_state( node_type: NodeType | None, timestamp: datetime | None, ) -> bool: - """ - Process new node info and return true if - all fields are updated. - """ + """Process new node info and return true if all fields are updated.""" complete = True if firmware is None: complete = False @@ -601,10 +598,7 @@ async def switch_relay(self, state: bool) -> bool | None: async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: - """ - Retrieve latest state for given feature - - Return dict with values per feature.""" + """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: await sleep(0) @@ -676,10 +670,7 @@ async def save_cache(self) -> None: @staticmethod def skip_update(data_class: Any, seconds: int) -> bool: - """ - Return True if timestamp attribute of given dataclass - is less than given seconds old. - """ + """Check if update can be skipped when timestamp of given dataclass is less than given seconds old.""" if data_class is None: return False if not hasattr(data_class, "timestamp"): diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 71e62acd4..8ae48ac84 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -1,5 +1,4 @@ -""" -Plugwise Celsius node object. +"""Plugwise Celsius node. TODO: Finish node """ @@ -22,7 +21,7 @@ class PlugwiseCelsius(NodeSED): - """provides interface to the Plugwise Celsius nodes""" + """provides interface to the Plugwise Celsius nodes.""" async def load(self) -> bool: """Load and activate node features.""" diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 03d3d1a5b..1ab21e0c3 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -47,9 +47,7 @@ def raise_calibration_missing(func: FuncT) -> FuncT: - """ - Decorator function to make sure energy calibration settings are available. - """ + """Validate energy calibration settings are available.""" @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: @@ -61,16 +59,14 @@ def decorated(*args: Any, **kwargs: Any) -> Any: class PlugwiseCircle(PlugwiseNode): - """ - Provides interface to the Plugwise Circle nodes - and base class for Circle+ nodes - """ + """Plugwise Circle node.""" + _retrieve_energy_logs_task: None | Awaitable = None _last_energy_log_requested: bool = False @property def calibrated(self) -> bool: - """Return calibration retrieval state""" + """State of calibration.""" if self._calibration is not None: return True return False @@ -83,12 +79,13 @@ def energy(self) -> EnergyStatistics | None: @property @raise_not_loaded def relay(self) -> bool: + """Current value of relay.""" return bool(self._relay) @relay.setter @raise_not_loaded def relay(self, state: bool) -> None: - """Request the relay to switch state.""" + """Request to change relay state.""" create_task(self.switch_relay(state)) @raise_not_loaded @@ -348,7 +345,7 @@ async def energy_update( return None async def get_missing_energy_logs(self) -> None: - """Task to retrieve missing energy logs""" + """Task to retrieve missing energy logs.""" self._energy_counters.update() if self._energy_counters.log_addresses_missing is None: @@ -499,7 +496,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: return True async def _energy_log_records_save_to_cache(self) -> None: - """Save currently collected energy logs to cached file""" + """Save currently collected energy logs to cached file.""" if not self._cache_enabled: return logs: dict[int, dict[int, PulseLogRecord]] = ( @@ -558,8 +555,8 @@ async def _energy_log_record_update_state( self._set_cache("energy_collection", log_cache_record) async def switch_relay(self, state: bool) -> bool | None: - """ - Switch state of relay. + """Switch state of relay. + Return new state of relay """ _LOGGER.debug("switch_relay() start") @@ -640,7 +637,7 @@ async def _relay_update_state( create_task(self.save_cache()) async def clock_synchronize(self) -> bool: - """Synchronize clock. Returns true if successful""" + """Synchronize clock. Returns true if successful.""" clock_response: CircleClockResponse | None = await self._send( CircleClockGetRequest(self._mac_in_bytes) ) @@ -814,9 +811,7 @@ async def initialize(self) -> bool: async def node_info_update( self, node_info: NodeInfoResponse | None = None ) -> bool: - """ - Update Node hardware information. - """ + """Update Node (hardware) information.""" if node_info is None: node_info: NodeInfoResponse = await self._send( NodeInfoRequest(self._mac_in_bytes) @@ -912,10 +907,7 @@ async def _relay_init_set(self, state: bool) -> bool | None: return self._relay_init_state async def _relay_init_load_from_cache(self) -> bool: - """ - Load relay init state from cache. - Return True if retrieval was successful. - """ + """Load relay init state from cache. Return True if retrieval was successful.""" if (cached_relay_data := self._get_cache("relay_init")) is not None: relay_init_state = False if cached_relay_data == "True": @@ -983,7 +975,7 @@ def _calc_watts( return 0.0 def _correct_power_pulses(self, pulses: int, offset: int) -> float: - """Correct pulses based on given measurement time offset (ns)""" + """Correct pulses based on given measurement time offset (ns).""" # Sometimes the circle returns -1 for some of the pulse counters # likely this means the circle measures very little power and is diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index a5de321d2..7dd3b5695 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -1,4 +1,4 @@ -"""Plugwise Circle+ node object.""" +"""Plugwise Circle+ node.""" from __future__ import annotations @@ -24,7 +24,7 @@ class PlugwiseCirclePlus(PlugwiseCircle): - """provides interface to the Plugwise Circle+ nodes""" + """Plugwise Circle+ node.""" async def load(self) -> bool: """Load and activate Circle+ node features.""" diff --git a/plugwise_usb/nodes/helpers/__init__.py b/plugwise_usb/nodes/helpers/__init__.py index 5a8886025..0428b2e4d 100644 --- a/plugwise_usb/nodes/helpers/__init__.py +++ b/plugwise_usb/nodes/helpers/__init__.py @@ -1,3 +1,5 @@ +"""Helpers for Plugwise nodes.""" + from __future__ import annotations from collections.abc import Callable @@ -25,9 +27,7 @@ class EnergyCalibration: def raise_not_loaded(func: FuncT) -> FuncT: - """ - Decorator function to raise NodeError when node is not loaded. - """ + """Raise NodeError when node is not loaded.""" @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 4142ea724..141deac54 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -1,4 +1,4 @@ -"""Caching for plugwise node""" +"""Caching for plugwise node.""" from __future__ import annotations @@ -15,7 +15,7 @@ class NodeCache: - """Class to cache specific node configuration and states""" + """Class to cache specific node configuration and states.""" def __init__(self, mac: str, cache_root_dir: str = "") -> None: """Initialize NodeCache class.""" @@ -44,7 +44,7 @@ def _set_cache_file(self, cache_root_dir: str) -> None: @property def states(self) -> dict[str, str]: - """cached node state information""" + """Cached node state information.""" return self._states def add_state(self, state: str, value: str) -> None: @@ -57,7 +57,7 @@ def remove_state(self, state: str) -> None: self._states.pop(state) def get_state(self, state: str) -> str | None: - """Return current value for state""" + """Return current value for state.""" return self._states.get(state, None) async def save_cache(self) -> None: @@ -114,7 +114,7 @@ async def restore_cache(self) -> bool: return True async def delete_cache_file(self) -> None: - """Delete cache file""" + """Delete cache file.""" if self._cache_file is None: return if not await aiofiles.os.path.exists(self._cache_file): diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 4a4761430..e445946a5 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -1,3 +1,4 @@ +"""Energy counter.""" from __future__ import annotations from datetime import datetime, timedelta @@ -13,7 +14,8 @@ class EnergyType(Enum): - """Energy collection types""" + """Energy collection types.""" + CONSUMPTION_HOUR = auto() PRODUCTION_HOUR = auto() CONSUMPTION_DAY = auto() @@ -58,9 +60,7 @@ class EnergyType(Enum): class EnergyCounters: - """ - Class to hold all energy counters. - """ + """Hold all energy counters.""" def __init__(self, mac: str) -> None: """Initialize EnergyCounter class.""" @@ -74,7 +74,7 @@ def __init__(self, mac: str) -> None: @property def collected_logs(self) -> int: - """Total collected logs""" + """Total collected logs.""" return self._pulse_collection.collected_logs def add_pulse_log( @@ -85,7 +85,7 @@ def add_pulse_log( pulses: int, import_only: bool = False ) -> None: - """Add pulse log""" + """Add pulse log.""" if self._pulse_collection.add_log( address, slot, @@ -97,13 +97,13 @@ def add_pulse_log( self.update() def get_pulse_logs(self) -> dict[int, dict[int, PulseLogRecord]]: - """Return currently collected pulse logs""" + """Return currently collected pulse logs.""" return self._pulse_collection.logs def add_pulse_stats( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: - """Add pulse statistics""" + """Add pulse statistics.""" _LOGGER.debug( "add_pulse_stats | consumed=%s, for %s", str(pulses_consumed), @@ -131,7 +131,7 @@ def production_interval(self) -> int | None: @property def log_addresses_missing(self) -> list[int] | None: - """Return list of addresses of energy logs""" + """Return list of addresses of energy logs.""" return self._pulse_collection.log_addresses_missing @property @@ -152,7 +152,7 @@ def calibration(self, calibration: EnergyCalibration) -> None: self._calibration = calibration def update(self) -> None: - """Update counter collection""" + """Update counter collection.""" if self._calibration is None: return ( @@ -196,7 +196,7 @@ def update(self) -> None: @property def timestamp(self) -> datetime | None: - """Return the last valid timestamp or None""" + """Return the last valid timestamp or None.""" if self._calibration is None: return None if self._pulse_collection.log_addresses_missing is None: @@ -207,9 +207,7 @@ def timestamp(self) -> datetime | None: class EnergyCounter: - """ - Energy counter to convert pulses into energy - """ + """Energy counter to convert pulses into energy.""" def __init__( self, @@ -240,12 +238,12 @@ def __init__( @property def direction(self) -> str: - """Energy direction (consumption or production)""" + """Energy direction (consumption or production).""" return self._direction @property def duration(self) -> str: - """Energy timespan""" + """Energy time span.""" return self._duration @property @@ -303,7 +301,7 @@ def last_update(self) -> datetime | None: def update( self, pulse_collection: PulseCollection ) -> tuple[float | None, datetime | None]: - """Get pulse update""" + """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index c2e19c006..a554b865a 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -1,5 +1,4 @@ -""" -Firmware protocol support definitions +"""Firmware protocol support definitions. The minimum and maximum supported (custom) zigbee protocol versions are based on the utc timestamp of firmware. @@ -17,6 +16,8 @@ class SupportedVersions(NamedTuple): + """Range of supported version.""" + min: float max: float diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e62cb0c45..8a1028d5f 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -1,3 +1,4 @@ +"""Energy pulse helper.""" from __future__ import annotations from dataclasses import dataclass @@ -15,7 +16,7 @@ def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: - """Calculate address and slot for log based for specified offset""" + """Calculate address and slot for log based for specified offset.""" # FIXME: Handle max address (max is currently unknown) to guard # against address rollovers @@ -40,10 +41,7 @@ class PulseLogRecord: class PulseCollection: - """ - Class to store consumed and produced energy pulses of - the current interval and past (history log) intervals. - """ + """Store consumed and produced energy pulses of the current interval and past (history log) intervals.""" def __init__(self, mac: str) -> None: """Initialize PulseCollection class.""" @@ -88,7 +86,7 @@ def __init__(self, mac: str) -> None: @property def collected_logs(self) -> int: - """Total collected logs""" + """Total collected logs.""" counter = 0 if self._logs is None: return counter @@ -98,7 +96,7 @@ def collected_logs(self) -> int: @property def logs(self) -> dict[int, dict[int, PulseLogRecord]]: - """Return currently collected pulse logs in reversed order""" + """Return currently collected pulse logs in reversed order.""" if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} @@ -115,12 +113,12 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: @property def last_log(self) -> tuple[int, int] | None: - """Return address and slot of last imported log""" + """Return address and slot of last imported log.""" return (self._last_log_consumption_address, self._last_log_consumption_slot) @property def production_logging(self) -> bool | None: - """Indicate if production logging is active""" + """Indicate if production logging is active.""" return self._log_production @property @@ -135,7 +133,7 @@ def log_interval_production(self) -> int | None: @property def log_rollover(self) -> bool: - """Indicate if new log is required""" + """Indicate if new log is required.""" return ( self._rollover_log_consumption or self._rollover_log_production @@ -151,7 +149,7 @@ def last_update(self) -> datetime | None: def collected_pulses( self, from_timestamp: datetime, is_consumption: bool ) -> tuple[int | None, datetime | None]: - """Calculate total pulses from given timestamp""" + """Calculate total pulses from given timestamp.""" # _LOGGER.debug("collected_pulses | %s | is_cons=%s, from=%s", self._mac, is_consumption, from_timestamp) @@ -196,7 +194,7 @@ def collected_pulses( def _collect_pulses_from_logs( self, from_timestamp: datetime, is_consumption: bool ) -> int | None: - """Collect all pulses from logs""" + """Collect all pulses from logs.""" if self._logs is None: _LOGGER.debug("_collect_pulses_from_logs | %s | self._logs=None", self._mac) return None @@ -231,7 +229,7 @@ def _collect_pulses_from_logs( def update_pulse_counter( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: - """Update pulse counter""" + """Update pulse counter.""" if self._pulses_consumption is None: self._pulses_consumption = pulses_consumed if self._pulses_production is None: @@ -318,7 +316,7 @@ def add_log( return True def recalculate_missing_log_addresses(self) -> None: - """Recalculate missing log addresses""" + """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( datetime.now(timezone.utc) - timedelta( hours=MAX_LOG_HOURS @@ -348,8 +346,8 @@ def _add_log_record( def _update_log_direction( self, address: int, slot: int, timestamp: datetime ) -> None: - """ - Update Energy direction of log record. + """Update Energy direction of log record. + Two subsequential logs with the same timestamp indicates the first is consumption and second production. """ @@ -367,7 +365,7 @@ def _update_log_direction( next_address, next_slot = calc_log_address(address, slot, 1) if self._log_exists(next_address, next_slot): if self._logs[next_address][next_slot].timestamp == timestamp: - # Given log the first log with same timestamp, + # Given log is the first log with same timestamp, # mark direction as production of next log self._logs[next_address][next_slot].is_consumption = False self._log_production = True @@ -401,10 +399,7 @@ def _update_log_rollover(self, address: int, slot: int) -> None: self._rollover_log_production = True def _update_log_interval(self) -> None: - """ - Update the detected log interval based on - the most recent two logs. - """ + """Update the detected log interval based on the most recent two logs.""" if self._logs is None or self._log_production is None: _LOGGER.debug("_update_log_interval | %s | _logs=%s, _log_production=%s", self._mac, self._logs, self._log_production) return @@ -467,7 +462,7 @@ def _log_exists(self, address: int, slot: int) -> bool: def _update_last_log_reference( self, address: int, slot: int, timestamp ) -> None: - """Update references to last (most recent) log record""" + """Update references to last (most recent) log record.""" if ( self._last_log_timestamp is None or self._last_log_timestamp < timestamp @@ -497,7 +492,7 @@ def _update_last_consumption_log_reference( def _update_last_production_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: - """Update references to last (most recent) log production record""" + """Update references to last (most recent) log production record.""" if ( self._last_log_production_timestamp is None or self._last_log_production_timestamp < timestamp @@ -513,7 +508,7 @@ def _update_last_production_log_reference( def _update_first_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: - """Update references to first (oldest) log record""" + """Update references to first (oldest) log record.""" if ( self._first_log_timestamp is None or self._first_log_timestamp > timestamp @@ -577,13 +572,13 @@ def _update_log_references(self, address: int, slot: int) -> None: @property def log_addresses_missing(self) -> list[int] | None: - """Return the addresses of missing logs""" + """Return the addresses of missing logs.""" return self._log_addresses_missing def _last_log_reference( self, is_consumption: bool | None = None ) -> tuple[int | None, int | None]: - """Address and slot of last log""" + """Address and slot of last log.""" if is_consumption is None: return ( self._last_log_address, @@ -602,7 +597,7 @@ def _last_log_reference( def _first_log_reference( self, is_consumption: bool | None = None ) -> tuple[int | None, int | None]: - """Address and slot of first log""" + """Address and slot of first log.""" if is_consumption is None: return ( self._first_log_address, @@ -619,9 +614,7 @@ def _first_log_reference( ) def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: - """ - Calculate list of missing log addresses - """ + """Calculate list of missing log addresses.""" if self._logs is None: self._log_addresses_missing = None return None @@ -686,7 +679,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: return missing def _last_known_duration(self) -> timedelta: - """Duration for last known logs""" + """Duration for last known logs.""" if len(self.logs) < 2: return timedelta(hours=1) address, slot = self._last_log_reference() diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index 62dce4c74..2e862fde5 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -24,12 +24,12 @@ def subscribe_to_feature_update( ], features: tuple[NodeFeature], ) -> Callable[[], None]: - """ - Subscribe callback when specified NodeFeature state updates. + """Subscribe callback when specified NodeFeature state updates. + Returns the function to be called to unsubscribe later. """ def remove_subscription() -> None: - """Remove stick event subscription.""" + """Remove stick feature subscription.""" self._feature_update_subscribers.pop(remove_subscription) self._feature_update_subscribers[ diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 120c25434..b6cc1739e 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -38,7 +38,7 @@ class PlugwiseScan(NodeSED): - """provides interface to the Plugwise Scan nodes""" + """Plugwise Scan node.""" async def load(self) -> bool: """Load and activate Scan node features.""" diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 1422100a5..1fd285e22 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -46,7 +46,7 @@ class NodeSED(PlugwiseNode): - """provides base class for SED based nodes like Scan, Sense & Switch""" + """provides base class for SED based nodes like Scan, Sense & Switch.""" # SED configuration _sed_configure_at_awake = False @@ -98,10 +98,7 @@ async def initialize(self) -> bool: @property def maintenance_interval(self) -> int | None: - """ - Return the maintenance interval (seconds) a - battery powered node sends it heartbeat. - """ + """Heartbeat maintenance interval (seconds).""" return self._maintenance_interval async def _awake_response(self, message: NodeAwakeResponse) -> None: @@ -169,10 +166,7 @@ async def sed_configure( clock_interval: int = SED_CLOCK_INTERVAL, awake: bool = False, ) -> None: - """ - Reconfigure the sleep/awake settings for a SED - send at next awake of SED. - """ + """Reconfigure the sleep/awake settings for a SED send at next awake of SED.""" if not awake: self._sed_configure_at_awake = True self._sed_config_stay_active = stay_active diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 73fce3007..011ca2e8d 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -29,7 +29,7 @@ class PlugwiseSense(NodeSED): - """provides interface to the Plugwise Sense nodes""" + """Plugwise Sense node.""" _sense_subscription: Callable[[], None] | None = None @@ -79,10 +79,7 @@ async def unload(self) -> None: await super().unload() async def _sense_report(self, message: SenseReportResponse) -> None: - """ - process sense report message to extract - current temperature and humidity values. - """ + """Process sense report message to extract current temperature and humidity values.""" await self._available_update_state(True) if message.temperature.value != 65535: self._temperature = int( diff --git a/plugwise_usb/nodes/stealth.py b/plugwise_usb/nodes/stealth.py index 102e309c8..33be3907a 100644 --- a/plugwise_usb/nodes/stealth.py +++ b/plugwise_usb/nodes/stealth.py @@ -3,4 +3,4 @@ class PlugwiseStealth(PlugwiseCircle): - """provides interface to the Plugwise Stealth nodes""" + """provides interface to the Plugwise Stealth nodes.""" diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 1533bc4af..34bcecc00 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -16,7 +16,7 @@ class PlugwiseSwitch(NodeSED): - """provides interface to the Plugwise Switch nodes""" + """Plugwise Switch node.""" _switch_subscription: Callable[[], None] | None = None _switch_state: bool | None = None From d49f9dfa43b65647d80028847062cb4ea1557fdf Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:04:45 +0100 Subject: [PATCH 148/979] Blank line after docstring class --- plugwise_usb/api.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 50737e3d1..a92b57c0f 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -7,6 +7,7 @@ class StickEvent(Enum): """Plugwise USB Stick events for callback subscription.""" + CONNECTED = auto() DISCONNECTED = auto() MESSAGE_RECEIVED = auto() @@ -16,6 +17,7 @@ class StickEvent(Enum): class NodeEvent(Enum): """Plugwise Node events for callback subscription.""" + AWAKE = auto() DISCOVERED = auto() LOADED = auto() @@ -24,6 +26,7 @@ class NodeEvent(Enum): class NodeType(Enum): """USB Node types.""" + STICK = 0 CIRCLE_PLUS = 1 # AME_NC CIRCLE = 2 # AME_NR @@ -41,6 +44,7 @@ class NodeType(Enum): class NodeFeature(str, Enum): """USB Stick Node feature.""" + AVAILABLE = "available" ENERGY = "energy" HUMIDITY = "humidity" @@ -65,6 +69,7 @@ class NodeFeature(str, Enum): @dataclass class NodeInfo: """Node hardware information.""" + mac: str zigbee_address: int battery_powered: bool = False @@ -80,6 +85,7 @@ class NodeInfo: @dataclass class NetworkStatistics: """Zigbee network information.""" + timestamp: datetime | None = None rssi_in: int | None = None rssi_out: int | None = None @@ -89,6 +95,7 @@ class NetworkStatistics: @dataclass class PowerStatistics: """Power statistics collection.""" + last_second: float | None = None last_8_seconds: float | None = None timestamp: datetime | None = None @@ -97,6 +104,7 @@ class PowerStatistics: @dataclass class RelayState: """Status of relay.""" + relay_state: bool | None = None timestamp: datetime | None = None @@ -104,6 +112,7 @@ class RelayState: @dataclass class MotionState: """Status of motion sensor.""" + motion: bool | None = None timestamp: datetime | None = None From 3592625cd1af0bae2dd2789c930fa764fa259a3e Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:06:30 +0100 Subject: [PATCH 149/979] Remove unnecessary parentheses after class definition --- plugwise_usb/connection/__init__.py | 2 +- plugwise_usb/connection/manager.py | 2 +- plugwise_usb/connection/sender.py | 2 +- plugwise_usb/network/__init__.py | 2 +- plugwise_usb/nodes/helpers/subscription.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 5bfff3cc2..00e72b024 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -class StickController(): +class StickController: """Manage the connection and communication towards USB-Stick.""" def __init__(self) -> None: diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 6741a0e4c..ffcf0fd78 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -class StickConnectionManager(): +class StickConnectionManager: """Manage the message flow to and from USB-Stick.""" def __init__(self) -> None: diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index f85f72a41..c2ae227be 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) -class StickSender(): +class StickSender: """Send request messages though USB Stick transport connection.""" def __init__( diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 8c86f429b..82c82a919 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -42,7 +42,7 @@ # endregion -class StickNetwork(): +class StickNetwork: """USB-Stick zigbee network class.""" accept_join_request = False diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index 2e862fde5..1804426af 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -9,7 +9,7 @@ from ...api import NodeFeature -class FeaturePublisher(): +class FeaturePublisher: """Base Class to call awaitable of subscription when event happens.""" _feature_update_subscribers: dict[ From 0bbe068fc122bbf9884640492e0c5995f84683b5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:07:09 +0100 Subject: [PATCH 150/979] Replace try with context --- plugwise_usb/connection/queue.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 5a9de19dd..b78bb8d46 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -84,10 +84,9 @@ async def stop(self) -> None: not self._submit_worker_task.done() ): self._submit_worker_task.cancel() - try: + with contextlib.suppress(CancelledError, InvalidStateError): await self._submit_worker_task.result() - except (CancelledError, InvalidStateError): - pass + _LOGGER.debug("queue stopped") async def submit( From 5ad68b4f1430a9058c659ce7fd53408a1edb8600 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:10:18 +0100 Subject: [PATCH 151/979] Reformat log messages --- plugwise_usb/connection/receiver.py | 2 +- plugwise_usb/connection/sender.py | 5 ++++- plugwise_usb/messages/requests.py | 6 +++--- plugwise_usb/network/__init__.py | 8 +++----- plugwise_usb/network/cache.py | 9 +++------ plugwise_usb/network/registry.py | 6 ++---- plugwise_usb/nodes/__init__.py | 15 +++++---------- plugwise_usb/nodes/circle.py | 20 +++++++------------- plugwise_usb/nodes/circle_plus.py | 3 +-- plugwise_usb/nodes/helpers/__init__.py | 3 --- plugwise_usb/nodes/helpers/pulses.py | 7 ++++++- plugwise_usb/nodes/sed.py | 3 +-- plugwise_usb/nodes/sense.py | 3 +-- 13 files changed, 37 insertions(+), 53 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index a769d90e4..069609b15 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -178,7 +178,7 @@ def extract_message_from_buffer(self) -> bool: response = self._populate_message( _empty_message, self._buffer[: _footer_index + 2] ) - _LOGGER.debug('USB Got %s', response) + _LOGGER.debug("USB Got %s", response) # Parse remaining buffer self._reset_buffer(self._buffer[_footer_index:]) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index c2ae227be..fe2346ead 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -114,7 +114,10 @@ async def _process_stick_response(self, response: StickResponse) -> None: if response.ack_id == StickResponseType.TIMEOUT: _LOGGER.warning("%s TIMEOUT", response) if (request := self._open_requests.get(response.seq_id, None)): - _LOGGER.error("Failed to send %s because USB-Stick could not send the request to the node.", request) + _LOGGER.error( + "Failed to send %s because USB-Stick could not send the request to the node.", + request + ) request.assign_error( BaseException( StickTimeout( diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fd4aed5f7..0e3279e03 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -156,7 +156,7 @@ def assign_error(self, error: StickError) -> None: async def _process_node_response(self, response: PlugwiseResponse) -> None: """Process incoming message from node.""" if self._seq_id is not None and self._seq_id == response.seq_id: - _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) + _LOGGER.debug("Response %s for request %s id %d", response, self, self._id) self._unsubscribe_stick_response() self._response = response self._response_timeout.cancel() @@ -168,7 +168,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: if self._response_future.done(): return if self._seq_id is not None and self._seq_id == stick_response.seq_id: - _LOGGER.debug('%s for request %s id %d', stick_response, self, self._id) + _LOGGER.debug("%s for request %s id %d", stick_response, self, self._id) if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) elif stick_response.ack_id == StickResponseType.FAILED: @@ -182,7 +182,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: pass else: _LOGGER.debug( - 'Unknown StickResponseType %s at %s for request %s id %d', + "Unknown StickResponseType %s at %s for request %s id %d", str(stick_response.ack_id), stick_response, self, diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 82c82a919..1d23e1de2 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -284,8 +284,7 @@ def _create_node_object( """Create node object and update network registry.""" if self._nodes.get(mac) is not None: _LOGGER.warning( - "Skip creating node object because node object for mac " + - "%s already exists", + "Skip creating node object because node object for mac %s already exists", mac ) return @@ -385,12 +384,11 @@ async def _discover_node( Return True if discovery succeeded. """ if self._nodes.get(mac) is not None: - _LOGGER.warning("Skip discovery of already known node %s ", mac) + _LOGGER.debug("Skip discovery of already known node %s ", mac) return True if node_type is not None: self._create_node_object(mac, address, node_type) - _LOGGER.debug("Publish NODE_DISCOVERED for %s", mac) await self._notify_node_event_subscribers( NodeEvent.DISCOVERED, mac ) @@ -407,7 +405,6 @@ async def _discover_node( await self._nodes[mac].node_info_update(node_info) if node_ping is not None: await self._nodes[mac].ping_update(node_ping) - _LOGGER.debug("Publish NODE_DISCOVERED for %s", mac) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) async def _discover_registered_nodes(self) -> None: @@ -534,6 +531,7 @@ async def _notify_node_event_subscribers( self._node_event_subscribers.values() ): if event in filtered_events: + _LOGGER.debug("Publish %s for %s", event, mac) callback_list.append(callback(event, mac)) if len(callback_list) > 0: await gather(*callback_list) diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index dc1022ab5..6030ec337 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -93,8 +93,7 @@ async def restore_cache(self) -> bool: ) if not await aiofiles.os.path.exists(self._cache_file): _LOGGER.warning( - "Unable to restore from cache because " + - "file '%s' does not exists", + "Unable to restore from cache because file '%s' does not exists", self._cache_file.name, ) return False @@ -129,8 +128,7 @@ async def restore_cache(self) -> bool: node_type = NodeType[data[2][9:]] except KeyError: _LOGGER.warning( - "Skip invalid NodeType '%s' " + - "in data '%s' in cache file '%s'", + "Skip invalid NodeType '%s' in data '%s' in cache file '%s'", data[2][9:], line, self._cache_file.name, @@ -138,8 +136,7 @@ async def restore_cache(self) -> bool: break self._registrations[address] = (mac, node_type) _LOGGER.debug( - "Restore registry address %s with mac %s " + - "with node type %s", + "Restore registry address %s with mac %s with node type %s", address, mac if mac != "" else "", str(node_type), diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 9f4141582..05c74a4e4 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -117,8 +117,7 @@ async def load_registry_from_cache(self) -> None: """Load network registry from cache.""" if self._network_cache is None: _LOGGER.error( - "Unable to restore network registry because " + - "cache is not initialized" + "Unable to restore network registry because cache is not initialized" ) return if self._cache_restored: @@ -240,8 +239,7 @@ async def save_registry_to_cache(self) -> None: """Save network registry to cache.""" if self._network_cache is None: _LOGGER.error( - "Unable to save network registry because " + - "cache is not initialized" + "Unable to save network registry because cache is not initialized" ) return _LOGGER.debug( diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 11bbf14c7..83f61464c 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -288,8 +288,7 @@ def _setup_protocol( self._node_protocols = firmware.get(self._node_info.firmware, None) if self._node_protocols is None: _LOGGER.warning( - "Failed to determine the protocol version for node %s (%s)" - + " based on firmware version %s of list %s", + "Failed to determine the protocol version for node %s (%s) based on firmware version %s of list %s", self._node_info.mac, self.__class__.__name__, self._node_info.firmware, @@ -357,15 +356,13 @@ async def _load_cache_file(self) -> bool: return True if not self._cache_enabled: _LOGGER.warning( - "Unable to load node %s from cache " + - "because caching is disabled", + "Unable to load node %s from cache because caching is disabled", self.mac, ) return False if self._node_cache is None: _LOGGER.warning( - "Unable to load node %s from cache " + - "because cache configuration is not loaded", + "Unable to load node %s from cache because cache configuration is not loaded", self.mac, ) return False @@ -639,8 +636,7 @@ def _set_cache(self, setting: str, value: Any) -> None: return if self._node_cache is None: _LOGGER.warning( - "Failed to update '%s' in cache " + - "because cache is not initialized yet", + "Failed to update '%s' in cache because cache is not initialized yet", setting ) return @@ -661,8 +657,7 @@ async def save_cache(self) -> None: return if self._node_cache is None: _LOGGER.warning( - "Failed to save cache to disk " + - "because cache is not initialized yet" + "Failed to save cache to disk because cache is not initialized yet" ) return _LOGGER.debug("Save cache file for node %s", self.mac) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 1ab21e0c3..b16a789ba 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -282,8 +282,7 @@ async def energy_update( """Update energy usage statistics, returns True if successful.""" if self._last_log_address is None: _LOGGER.warning( - "Unable to update energy logs for node %s " - + "because last_log_address is unknown.", + "Unable to update energy logs for node %s because last_log_address is unknown.", self._node_info.mac, ) if not await self.node_info_update(): @@ -350,8 +349,7 @@ async def get_missing_energy_logs(self) -> None: self._energy_counters.update() if self._energy_counters.log_addresses_missing is None: _LOGGER.debug( - "Start with initial energy request for the last 10 log" - + " addresses for node %s.", + "Start with initial energy request for the last 10 log addresses for node %s.", self._node_info.mac, ) for address in range( @@ -584,8 +582,7 @@ async def switch_relay(self, state: bool) -> bool | None: ) return True _LOGGER.warning( - "Unexpected NodeResponseType %s response " - + "for CircleRelaySwitchRequest at node %s...", + "Unexpected NodeResponseType %s response for CircleRelaySwitchRequest at node %s...", str(response.ack_id), self.mac, ) @@ -607,8 +604,7 @@ async def _relay_load_from_cache(self) -> bool: await self._relay_update_state(relay_state) return True _LOGGER.info( - "Failed to restore relay state from cache for node %s, " + - "try to request node info", + "Failed to restore relay state from cache for node %s, try to request node info...", self.mac ) return await self.node_info_update() @@ -717,8 +713,7 @@ async def load(self) -> bool: # Get node info if not await self.node_info_update(): _LOGGER.warning( - "Failed to load Circle node %s because it is not responding" - + " to information request", + "Failed to load Circle node %s because it is not responding to information request", self._node_info.mac ) return False @@ -984,8 +979,7 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: # solar panels, so don't complain too loudly. if pulses == -1: _LOGGER.warning( - "Power pulse counter for node %s of " - + "value of -1, corrected to 0", + "Power pulse counter for node %s of value of -1, corrected to 0", self._node_info.mac, ) return 0.0 @@ -1010,7 +1004,7 @@ async def get_state( if not self._available: if not await self.is_online(): _LOGGER.warning( - "Node %s does not respond, unable to update state", + "Node %s did not respond, unable to update state", self.mac ) for feature in features: diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 7dd3b5695..f51188caf 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -64,8 +64,7 @@ async def load(self) -> bool: # Get node info if not await self.node_info_update(): _LOGGER.warning( - "Failed to load Circle+ node %s because it is not responding" - + " to information request", + "Failed to load Circle+ node %s because it is not responding to information request", self._node_info.mac ) return False diff --git a/plugwise_usb/nodes/helpers/__init__.py b/plugwise_usb/nodes/helpers/__init__.py index 0428b2e4d..023343120 100644 --- a/plugwise_usb/nodes/helpers/__init__.py +++ b/plugwise_usb/nodes/helpers/__init__.py @@ -5,13 +5,10 @@ from collections.abc import Callable from dataclasses import dataclass from functools import wraps -import logging from typing import Any, TypeVar, cast from ...exceptions import NodeError -_LOGGER = logging.getLogger(__name__) - @dataclass class EnergyCalibration: diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 8a1028d5f..5767f11a3 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -160,7 +160,12 @@ def collected_pulses( if is_consumption and ( self._rollover_log_consumption or self._rollover_pulses_consumption ): - _LOGGER.debug("collected_pulses | %s | is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", self._mac, self._rollover_log_consumption, self._rollover_pulses_consumption) + _LOGGER.debug( + "collected_pulses | %s | is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", + self._mac, + self._rollover_log_consumption, + self._rollover_pulses_consumption + ) return (None, None) if not is_consumption and ( self._rollover_log_production or self._rollover_pulses_production diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 1fd285e22..8ae65ff6c 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -146,8 +146,7 @@ async def reset_maintenance_awake(self, last_alive: datetime) -> None: # Mark node as unavailable if self._available: _LOGGER.info( - "No maintenance awake message received for %s within " - + "expected %s seconds. Mark node to be unavailable", + "No maintenance awake message received for %s within expected %s seconds.", self.mac, str(self._maintenance_interval * 1.05), ) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 011ca2e8d..b8b8f5f61 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -119,8 +119,7 @@ async def get_state( ) if feature not in self._features: raise NodeError( - f"Update of feature '{feature.name}' is " - + f"not supported for {self.mac}" + f"Update of feature '{feature.name}' is not supported for {self.mac}" ) if feature == NodeFeature.TEMPERATURE: states[NodeFeature.TEMPERATURE] = self._temperature From 59aa6a98d71a3c65eb28697abbd7d214e1f76e66 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:12:25 +0100 Subject: [PATCH 152/979] Rename function to match its functionality --- plugwise_usb/__init__.py | 2 +- plugwise_usb/network/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 32488801d..a0b55a56e 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -221,7 +221,7 @@ def subscribe_to_node_events( Returns the function to be called to unsubscribe later. """ - return self._network.subscribe_to_network_events( + return self._network.subscribe_to_node_events( node_event_callback, events, ) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 1d23e1de2..dc1d31613 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -502,7 +502,7 @@ async def allow_join_requests(self, state: bool) -> None: f"Unknown NodeResponseType '{response.ack_id!r}' received" ) - def subscribe_to_network_events( + def subscribe_to_node_events( self, node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], events: tuple[NodeEvent], From 1116e98630002c0ec94313fef9ae87fca5b592ad Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:15:35 +0100 Subject: [PATCH 153/979] Forward load event from node to network manager --- plugwise_usb/network/__init__.py | 6 ++++++ plugwise_usb/nodes/__init__.py | 4 ++++ plugwise_usb/nodes/circle.py | 1 + plugwise_usb/nodes/circle_plus.py | 1 + plugwise_usb/nodes/scan.py | 1 + plugwise_usb/nodes/sense.py | 1 + plugwise_usb/nodes/switch.py | 1 + 7 files changed, 15 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index dc1d31613..653a6f7da 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -294,6 +294,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Circle+ node %s added", mac) elif node_type == NodeType.CIRCLE: @@ -301,6 +302,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Circle node %s added", mac) elif node_type == NodeType.SWITCH: @@ -308,6 +310,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Switch node %s added", mac) elif node_type == NodeType.SENSE: @@ -315,6 +318,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Sense node %s added", mac) elif node_type == NodeType.SCAN: @@ -322,6 +326,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Scan node %s added", mac) elif node_type == NodeType.STEALTH: @@ -329,6 +334,7 @@ def _create_node_object( mac, address, self._controller, + self._notify_node_event_subscribers, ) _LOGGER.debug("Stealth node %s added", mac) else: diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 83f61464c..0ca335784 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -47,7 +47,11 @@ def __init__( mac: str, address: int, controller: StickController, + loaded_callback: Callable, ): + """Initialize Plugwise base node class.""" + self._loaded_callback = loaded_callback + self._message_subscribe = controller.subscribe_to_node_responses self._features = NODE_FEATURES self._last_update = datetime.now(timezone.utc) self._node_info = NodeInfo(mac, address) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b16a789ba..83bef8762 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -801,6 +801,7 @@ async def initialize(self) -> bool: ) self._initialized = False return False + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def node_info_update( diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index f51188caf..6dae29ec6 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -108,6 +108,7 @@ async def initialize(self) -> bool: ) return False self._initialized = True + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def realtime_clock_synchronize(self) -> bool: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index b6cc1739e..7e2b21115 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -76,6 +76,7 @@ async def initialize(self) -> bool: (NODE_SWITCH_GROUP_ID,), ) self._initialized = True + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index b8b8f5f61..6f2f5add2 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -70,6 +70,7 @@ async def initialize(self) -> bool: SENSE_REPORT_ID, ) self._initialized = True + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 34bcecc00..7bb28e0f1 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -55,6 +55,7 @@ async def initialize(self) -> bool: NODE_SWITCH_GROUP_ID, ) self._initialized = True + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: From c77da4a8f13e21aefb2993c3f099ca2d01799cfa Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:17:25 +0100 Subject: [PATCH 154/979] Fix initializing Sense and Switch nodes --- plugwise_usb/nodes/sense.py | 2 +- plugwise_usb/nodes/switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 6f2f5add2..773008f07 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -52,7 +52,7 @@ async def load(self) -> bool: NodeFeature.HUMIDITY ), ) - return True + return await self.initialize() _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) return False diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 7bb28e0f1..b9f9575b7 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -36,7 +36,7 @@ async def load(self) -> bool: SWITCH_FIRMWARE_SUPPORT, (NodeFeature.INFO, NodeFeature.SWITCH), ) - return True + return await self.initialize() _LOGGER.debug("Load of Switch node %s failed", self._node_info.mac) return False From 37d0eda736e981b957adcf2dee35c28ac83091d4 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:18:10 +0100 Subject: [PATCH 155/979] Fix loading discovered nodes --- plugwise_usb/network/__init__.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 653a6f7da..f0a907983 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -442,14 +442,32 @@ async def _load_node(self, mac: str) -> bool: return False async def _load_discovered_nodes(self) -> None: - await gather( """Load all nodes currently discovered.""" + _LOGGER.debug("_load_discovered_nodes | START | %s", len(self._nodes)) + for mac, node in self._nodes.items(): + _LOGGER.debug("_load_discovered_nodes | mac=%s | loaded=%s", mac, node.loaded) + + nodes_not_loaded = tuple( + mac + for mac, node in self._nodes.items() + if not node.loaded + ) + _LOGGER.debug("_load_discovered_nodes | nodes_not_loaded=%s", nodes_not_loaded) + load_result = await gather( *[ self._load_node(mac) - for mac, node in self._nodes.items() - if not node.loaded + for mac in nodes_not_loaded ] ) + _LOGGER.debug("_load_discovered_nodes | load_result=%s", load_result) + result_index = 0 + for mac in nodes_not_loaded: + if load_result[result_index]: + await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) + else: + _LOGGER.debug("_load_discovered_nodes | Load request for %s failed", mac) + result_index += 1 + _LOGGER.debug("_load_discovered_nodes | END") async def _unload_discovered_nodes(self) -> None: """Unload all nodes.""" From f3688ca1e5246612b930522639e13c3817fc025a Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:18:48 +0100 Subject: [PATCH 156/979] Remove useless sleeps --- plugwise_usb/network/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f0a907983..7754fd32d 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -495,7 +495,6 @@ async def discover_nodes(self, load: bool = True) -> None: await self.start() await self.discover_network_coordinator() await self._discover_registered_nodes() - await sleep(0) if load: await self._load_discovered_nodes() @@ -504,9 +503,7 @@ async def stop(self) -> None: _LOGGER.debug("Stopping") self._is_running = False self._unsubscribe_to_protocol_events() - await sleep(0) await self._unload_discovered_nodes() - await sleep(0) await self._register.stop() _LOGGER.debug("Stopping finished") From 53670df328a29054fd77a2652b62ed1d42e24369 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:20:39 +0100 Subject: [PATCH 157/979] Remove unnecessary if with elif --- plugwise_usb/nodes/circle.py | 9 ++++----- plugwise_usb/nodes/helpers/pulses.py | 21 +++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 83bef8762..f8a06d5d8 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -287,11 +287,10 @@ async def energy_update( ) if not await self.node_info_update(): return None - else: - # request node info update every 30 minutes. - if not self.skip_update(self._node_info, 1800): - if not await self.node_info_update(): - return None + # request node info update every 30 minutes. + elif not self.skip_update(self._node_info, 1800): + if not await self.node_info_update(): + return None # Always request last energy log records at initial startup if not self._last_energy_log_requested: diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 5767f11a3..483017c0e 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -274,19 +274,17 @@ def update_pulse_counter( if pulses_consumed < self._pulses_consumption: _LOGGER.debug("update_pulse_counter | %s | pulses_consumed=%s, _pulses_consumption=%s", self._mac, pulses_consumed, self._pulses_consumption) self._rollover_pulses_consumption = True - else: - if self._log_interval_consumption is not None and timestamp > ( + elif self._log_interval_consumption is not None and timestamp > ( self._next_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) - ): - _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) - self._rollover_pulses_consumption = True + ): + _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) + self._rollover_pulses_consumption = True if self._log_production: if self._pulses_production < pulses_produced: self._rollover_pulses_production = True - else: - if ( + elif ( self._next_log_production_timestamp is not None and self._log_interval_production is not None and timestamp @@ -294,8 +292,8 @@ def update_pulse_counter( self._next_log_production_timestamp + timedelta(minutes=self._log_interval_production) ) - ): - self._rollover_pulses_production = True + ): + self._rollover_pulses_production = True self._pulses_consumption = pulses_consumed self._pulses_production = pulses_produced @@ -374,9 +372,8 @@ def _update_log_direction( # mark direction as production of next log self._logs[next_address][next_slot].is_consumption = False self._log_production = True - else: - if self._log_production is None: - self._log_production = False + elif self._log_production is None: + self._log_production = False def _update_log_rollover(self, address: int, slot: int) -> None: if self._last_update is None: From ab82229db41208e379f554f037e84c292fe691a0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:21:42 +0100 Subject: [PATCH 158/979] Reformat exception messages --- plugwise_usb/connection/queue.py | 3 +-- plugwise_usb/nodes/__init__.py | 6 +++--- plugwise_usb/nodes/circle.py | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index b78bb8d46..aeeed4564 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -96,8 +96,7 @@ async def submit( _LOGGER.debug("Queueing %s", request) if not self._running or self._stick is None: raise StickError( - f"Cannot send message {request.__class__.__name__} for" + - f"{request.mac_decoded} because queue manager is stopped" + f"Cannot send message {request.__class__.__name__} for {request.mac_decoded} because queue manager is stopped" ) await self._add_request_to_queue(request) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 0ca335784..1eaaed797 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -231,7 +231,7 @@ def switch(self) -> bool | None: """Switch button value.""" if NodeFeature.SWITCH not in self._features: raise NodeError( - f"Switch state is not supported for node {self.mac}" + f"Switch value is not supported for node {self.mac}" ) return self._switch @@ -249,10 +249,10 @@ def relay(self) -> bool: """Relay value.""" if NodeFeature.RELAY not in self._features: raise NodeError( - f"Relay state is not supported for node {self.mac}" + f"Relay value is not supported for node {self.mac}" ) if self._relay is None: - raise NodeError(f"Relay state is unknown for node {self.mac}") + raise NodeError(f"Relay value is unknown for node {self.mac}") return self._relay @relay.setter diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f8a06d5d8..401a796fd 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1015,8 +1015,7 @@ async def get_state( await sleep(0) if feature not in self._features: raise NodeError( - f"Update of feature '{feature}' is " - + f"not supported for {self.mac}" + f"Update of feature '{feature}' is not supported for {self.mac}" ) if feature == NodeFeature.ENERGY: states[feature] = await self.energy_update() From 2497e15fed3d789511b0e15946a26fe72a740def Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:23:33 +0100 Subject: [PATCH 159/979] Utilize the raise_not_loaded decorator --- plugwise_usb/nodes/__init__.py | 6 ++---- plugwise_usb/nodes/circle.py | 9 +++------ plugwise_usb/nodes/scan.py | 7 +------ plugwise_usb/nodes/sense.py | 7 +------ 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 1eaaed797..31660100b 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -596,22 +596,20 @@ async def switch_relay(self, state: bool) -> bool | None: """Switch relay state.""" raise NodeError(f"Relay control is not supported for node {self.mac}") + @raise_not_loaded async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: - await sleep(0) if feature not in self._features: raise NodeError( f"Update of feature '{feature.name}' is " + f"not supported for {self.mac}" ) if feature == NodeFeature.INFO: - # Only request node info when information is > 5 minutes old - if not self.skip_update(self._node_info, 300): - await self.node_info_update(None) + await self.node_info_update(None) states[NodeFeature.INFO] = self._node_info elif feature == NodeFeature.AVAILABLE: states[NodeFeature.AVAILABLE] = self.available diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 401a796fd..ce5ddf3a4 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -808,6 +808,8 @@ async def node_info_update( ) -> bool: """Update Node (hardware) information.""" if node_info is None: + if self.skip_update(self._node_info, 30): + return True node_info: NodeInfoResponse = await self._send( NodeInfoRequest(self._mac_in_bytes) ) @@ -991,15 +993,11 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: return pulses return 0.0 + @raise_not_loaded async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" - if not self._loaded: - if not await self.load(): - _LOGGER.warning( - "Unable to update state because load node %s failed", self.mac - ) states: dict[NodeFeature, Any] = {} if not self._available: if not await self.is_online(): @@ -1012,7 +1010,6 @@ async def get_state( return states for feature in features: - await sleep(0) if feature not in self._features: raise NodeError( f"Update of feature '{feature}' is not supported for {self.mac}" diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 7e2b21115..a8d9d5efc 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -178,16 +178,11 @@ async def scan_calibrate_light(self) -> bool: return True return False + @raise_not_loaded async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" - if not self._loaded: - if not await self.load(): - _LOGGER.warning( - "Unable to update state because load node %s failed", - self.mac - ) states: dict[NodeFeature, Any] = {} for feature in features: _LOGGER.debug( diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 773008f07..fa4486e9c 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -101,16 +101,11 @@ async def _sense_report(self, message: SenseReportResponse) -> None: NodeFeature.HUMIDITY, self._humidity ) + @raise_not_loaded async def get_state( self, features: tuple[NodeFeature] ) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" - if not self._loaded: - if not await self.load(): - _LOGGER.warning( - "Unable to update state because load node %s failed", - self.mac - ) states: dict[NodeFeature, Any] = {} for feature in features: _LOGGER.debug( From 3eb00ae1a5ba2ba9f234a801437f066b0de03041 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:24:02 +0100 Subject: [PATCH 160/979] Use double quote --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 05c74a4e4..8678dec9a 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -144,7 +144,7 @@ async def retrieve_network_registration( return None address = response.network_address mac_of_node = response.registered_mac - if (mac_of_node := response.registered_mac) == 'FFFFFFFFFFFFFFFF': + if (mac_of_node := response.registered_mac) == "FFFFFFFFFFFFFFFF": mac_of_node = "" return (address, mac_of_node) From 707b67da5fa31e93b1482c7cc31e3ea4e642929c Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:24:18 +0100 Subject: [PATCH 161/979] Fix typing --- plugwise_usb/nodes/helpers/subscription.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index 1804426af..c662a5eb2 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -14,7 +14,7 @@ class FeaturePublisher: _feature_update_subscribers: dict[ Callable[[], None], - tuple[Callable[[NodeEvent], Awaitable[None]], NodeEvent | None] + tuple[Callable[[NodeFeature], Awaitable[None]], NodeFeature | None] ] = {} def subscribe_to_feature_update( From 79c7085cd9912971338f86f6d4e18838c2418a73 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:28:56 +0100 Subject: [PATCH 162/979] Restrict requesting node_info_update for SED's to once a day --- plugwise_usb/nodes/sed.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 8ae65ff6c..5e366e50a 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -15,6 +15,7 @@ NODE_AWAKE_RESPONSE_ID, NodeAwakeResponse, NodeAwakeResponseType, + NodeInfoResponse, NodePingResponse, NodeResponse, NodeResponseType, @@ -101,6 +102,15 @@ def maintenance_interval(self) -> int | None: """Heartbeat maintenance interval (seconds).""" return self._maintenance_interval + async def node_info_update( + self, node_info: NodeInfoResponse | None = None + ) -> bool: + """Update Node (hardware) information.""" + if node_info is None and self.skip_update(self._node_info, 86400): + return True + return await super().node_info_update(node_info) + + async def _awake_response(self, message: NodeAwakeResponse) -> None: """Process awake message.""" self._node_last_online = message.timestamp From 690434874e59f59d4d4271bd8161fe8bcffc0665 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:29:54 +0100 Subject: [PATCH 163/979] Set battery powered state at class initialization --- plugwise_usb/nodes/scan.py | 1 - plugwise_usb/nodes/sed.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index a8d9d5efc..af22b359a 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -44,7 +44,6 @@ async def load(self) -> bool: """Load and activate Scan node features.""" if self._loaded: return True - self._node_info.battery_powered = True if self._cache_enabled: _LOGGER.debug( "Load Scan node %s from cache", self._node_info.mac diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 5e366e50a..87d2326d7 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -71,10 +71,11 @@ def __init__( mac: str, address: int, controller: StickController, + loaded_callback: Callable, ): - """Initialize SED""" - super().__init__(mac, address, controller) - self._message_subscribe = controller.subscribe_to_node_responses + """Initialize base class for Sleeping End Device.""" + super().__init__(mac, address, controller, loaded_callback) + self._node_info.battery_powered = True async def unload(self) -> None: """Deactivate and unload node features.""" From 968b3d174a722d8d4a3c70748e8eb3ee3142b454 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:30:15 +0100 Subject: [PATCH 164/979] Reformat doc strings --- plugwise_usb/connection/__init__.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 00e72b024..671e8d647 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -58,23 +58,19 @@ def mac_stick(self) -> str: @property def mac_coordinator(self) -> str: - """ - Return MAC address of the Zigbee network coordinator (Circle+). + """Return MAC address of the Zigbee network coordinator (Circle+). + Raises StickError when not connected. """ if not self._manager.is_connected or self._mac_nc is None: raise StickError( - "No mac address available. " + - "Connect and initialize USB-Stick first." + "No mac address available. Connect and initialize USB-Stick first." ) return self._mac_nc @property def network_id(self) -> int: - """ - Returns the Zigbee network ID. - Raises StickError when not connected. - """ + """Returns the Zigbee network ID. Raises StickError when not connected.""" if not self._manager.is_connected or self._network_id is None: raise StickError( "No network ID available. " + @@ -93,7 +89,7 @@ def network_online(self) -> bool: return self._network_online async def connect_to_stick(self, serial_path: str) -> None: - """Setup connection to USB stick.""" + """Connect to USB stick.""" if self._manager.is_connected: raise StickError("Already connected") await self._manager.setup_connection_to_stick(serial_path) @@ -111,8 +107,8 @@ def subscribe_to_stick_events( stick_event_callback: Callable[[StickEvent], Awaitable[None]], events: tuple[StickEvent], ) -> Callable[[], None]: - """ - Subscribe callback when specified StickEvent occurs. + """Subscribe callback when specified StickEvent occurs. + Returns the function to be called to unsubscribe later. """ if self._manager is None: @@ -128,9 +124,8 @@ def subscribe_to_node_responses( mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: - """ - Subscribe a awaitable callback to be called when a specific - message is received. + """Subscribe a awaitable callback to be called when a specific message is received. + Returns function to unsubscribe. """ @@ -185,7 +180,7 @@ async def initialize_stick(self) -> None: async def send( self, request: PlugwiseRequest, suppress_node_errors: bool = True ) -> PlugwiseResponse | None: - """Submit request to queue and return response""" + """Submit request to queue and return response.""" if not suppress_node_errors: return await self._queue.submit(request) try: From 23a15ba52757d1c51249d1b1e3b6d29332107058 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:30:42 +0100 Subject: [PATCH 165/979] Remove useless if statement --- plugwise_usb/nodes/scan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index af22b359a..eadeff713 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -48,8 +48,8 @@ async def load(self) -> bool: _LOGGER.debug( "Load Scan node %s from cache", self._node_info.mac ) - if await self._load_from_cache(): - pass + await self._load_from_cache() + self._loaded = True self._setup_protocol( SCAN_FIRMWARE_SUPPORT, From 5a94182239374cc38b74962dc8c906b5c20aeaa0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:31:07 +0100 Subject: [PATCH 166/979] Correct timeout exception --- plugwise_usb/nodes/sed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 87d2326d7..d26b8bbbc 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -152,7 +152,7 @@ async def reset_maintenance_awake(self, last_alive: datetime) -> None: self._maintenance_future, timeout=(self._maintenance_interval * 1.05), ) - except AsyncTimeOutError: + except TimeoutError: # No maintenance awake message within expected time frame # Mark node as unavailable if self._available: From 0199024370c46098a6044b2a8fb14e0bceea38b3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:32:13 +0100 Subject: [PATCH 167/979] Add test for loading nodes --- tests/test_usb.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 9f3af4417..b8e5e5d37 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1146,3 +1146,18 @@ async def test_stick_network_down(self, monkeypatch): with pytest.raises(pw_exceptions.StickError): await stick.initialize() + @pytest.mark.asyncio + async def test_node_discovery_and_load(self, monkeypatch): + """Testing discovery of nodes.""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=True) From 0370aa8fbbedcfa9d25a762f90fae7925ac540e6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:32:39 +0100 Subject: [PATCH 168/979] Test relay_init too --- tests/test_usb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index b8e5e5d37..55ce2cb0d 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -719,6 +719,8 @@ async def test_node_relay_and_power(self, monkeypatch): # Test non-support init relay state with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].relay_init + with pytest.raises(pw_exceptions.NodeError): + stick.nodes["0098765432101234"].relay_init = True with pytest.raises(pw_exceptions.NodeError): await stick.nodes["0098765432101234"].switch_init_relay(True) with pytest.raises(pw_exceptions.NodeError): From 18eaeff55dc50ee93838e30d798043cb99a37d5e Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 6 Feb 2024 21:35:57 +0100 Subject: [PATCH 169/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6dbd4ac11..c5a43cad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a1" +version = "v0.40.0a2" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 87b4e0c712ac548e19d45086e95b543ff8b34fa6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 13:46:09 +0100 Subject: [PATCH 170/979] Use local dict for logs --- plugwise_usb/nodes/helpers/pulses.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 483017c0e..5bf7de3c0 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -549,13 +549,13 @@ def _update_log_references(self, address: int, slot: int) -> None: return if not self._log_exists(address, slot): return - log_time_stamp = self.logs[address][slot].timestamp + log_time_stamp = self._logs[address][slot].timestamp # Update log references self._update_first_log_reference(address, slot, log_time_stamp) self._update_last_log_reference(address, slot, log_time_stamp) - if self.logs[address][slot].is_consumption: + if self._logs[address][slot].is_consumption: # Consumption self._update_first_consumption_log_reference( address, slot, log_time_stamp @@ -658,18 +658,18 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: _LOGGER.debug("_logs_missing | %s | missing in range=%s", self._mac, missing) return missing - if first_address not in self.logs: + if first_address not in self._logs: return missing - if first_slot not in self.logs[first_address]: + if first_slot not in self._logs[first_address]: return missing - if self.logs[first_address][first_slot].timestamp < from_timestamp: + if self._logs[first_address][first_slot].timestamp < from_timestamp: return missing # calculate missing log addresses prior to first collected log address, slot = calc_log_address(first_address, first_slot, -1) - calculated_timestamp = self.logs[first_address][first_slot].timestamp - timedelta(hours=1) + calculated_timestamp = self._logs[first_address][first_slot].timestamp - timedelta(hours=1) while from_timestamp < calculated_timestamp: if address not in missing: missing.append(address) @@ -682,17 +682,17 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: def _last_known_duration(self) -> timedelta: """Duration for last known logs.""" - if len(self.logs) < 2: + if len(self._logs) < 2: return timedelta(hours=1) address, slot = self._last_log_reference() - last_known_timestamp = self.logs[address][slot].timestamp + last_known_timestamp = self._logs[address][slot].timestamp address, slot = calc_log_address(address, slot, -1) while ( self._log_exists(address, slot) or - self.logs[address][slot].timestamp == last_known_timestamp + self._logs[address][slot].timestamp == last_known_timestamp ): address, slot = calc_log_address(address, slot, -1) - return self.logs[address][slot].timestamp - last_known_timestamp + return self._logs[address][slot].timestamp - last_known_timestamp def _missing_addresses_before( self, address: int, slot: int, target: datetime From 12495075e772013c73d12cd81a55ea24bffa047f Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 13:46:47 +0100 Subject: [PATCH 171/979] Always update interval if possible --- plugwise_usb/nodes/helpers/pulses.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 5bf7de3c0..a358ed216 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -484,12 +484,10 @@ def _update_last_consumption_log_reference( self._last_log_consumption_timestamp = timestamp self._last_log_consumption_address = address self._last_log_consumption_slot = slot - if self._log_interval_consumption is not None: - self._next_log_consumption_timestamp = ( - timestamp + timedelta( - minutes=self.log_interval_consumption - ) - ) + if self._log_interval_consumption is not None: + self._next_log_consumption_timestamp = ( + self._last_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) + ) def _update_last_production_log_reference( self, address: int, slot: int, timestamp: datetime @@ -502,10 +500,10 @@ def _update_last_production_log_reference( self._last_log_production_timestamp = timestamp self._last_log_production_address = address self._last_log_production_slot = slot - if self._log_interval_production is not None: - self._next_log_production_timestamp = ( - timestamp + timedelta(minutes=self.log_interval_production) - ) + if self._log_interval_production is not None: + self._next_log_production_timestamp = ( + self._last_log_production_timestamp + timedelta(minutes=self._log_interval_production) + ) def _update_first_log_reference( self, address: int, slot: int, timestamp: datetime From 50791b6f54b6df20836425a38c8ab5cd00be8c63 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 13:48:03 +0100 Subject: [PATCH 172/979] Store pulse counters even when log records are not available --- plugwise_usb/nodes/helpers/pulses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a358ed216..703e0ff5c 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -249,12 +249,16 @@ def update_pulse_counter( self._log_production and self._next_log_production_timestamp is None ): + self._pulses_consumption = pulses_consumed + self._pulses_production = pulses_produced return if ( self._log_addresses_missing is None or len(self._log_addresses_missing) > 0 ): + self._pulses_consumption = pulses_consumed + self._pulses_production = pulses_produced return # Rollover of logs first From 11c5ef7008ed2a08312dd9d941313036bb0f42bb Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 13:50:14 +0100 Subject: [PATCH 173/979] Test rollover for pulses --- tests/test_usb.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 55ce2cb0d..b01eafb64 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -909,6 +909,42 @@ def test_pulse_collection(self): None, ) + # add missing logs + test_timestamp = fixed_this_hour - td(hours=3) + tst_consumption.add_log(99, 3, (fixed_this_hour - td(hours=3)), 1000) + tst_consumption.add_log(99, 2, (fixed_this_hour - td(hours=4)), 1000) + tst_consumption.add_log(99, 1, (fixed_this_hour - td(hours=5)), 1000) + tst_consumption.add_log(98, 4, (fixed_this_hour - td(hours=6)), 1000) + tst_consumption.add_log(98, 3, (fixed_this_hour - td(hours=7)), 1000) + tst_consumption.add_log(98, 2, (fixed_this_hour - td(hours=8)), 1000) + tst_consumption.add_log(98, 1, (fixed_this_hour - td(hours=9)), 1000) + tst_consumption.add_log(97, 4, (fixed_this_hour - td(hours=10)), 1000) + tst_consumption.add_log(97, 3, (fixed_this_hour - td(hours=11)), 1000) + tst_consumption.add_log(97, 2, (fixed_this_hour - td(hours=12)), 1000) + tst_consumption.add_log(97, 1, (fixed_this_hour - td(hours=13)), 1000) + tst_consumption.add_log(96, 4, (fixed_this_hour - td(hours=14)), 1000) + tst_consumption.add_log(96, 3, (fixed_this_hour - td(hours=15)), 1000) + tst_consumption.add_log(96, 2, (fixed_this_hour - td(hours=16)), 1000) + tst_consumption.add_log(96, 1, (fixed_this_hour - td(hours=17)), 1000) + tst_consumption.add_log(94, 4, (fixed_this_hour - td(hours=22)), 1000) + tst_consumption.add_log(94, 3, (fixed_this_hour - td(hours=23)), 1000) + tst_consumption.add_log(94, 2, (fixed_this_hour - td(hours=24)), 1000) + tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=25)), 1000) + + # Test rollover by updating pulses first + assert not tst_consumption.log_rollover + pulse_update_3 = fixed_this_hour + td(hours=1, seconds=3) + tst_consumption.update_pulse_counter(45, 0, pulse_update_3) + assert tst_consumption.log_rollover + test_timestamp = fixed_this_hour + td(hours=1, seconds=5) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (45, pulse_update_3) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (45 + 2222, pulse_update_3) + + # Set log hours back to 1 week + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 168) + # Test consumption and production logs tst_production = pw_energy_pulses.PulseCollection(mac="0098765432101234") assert tst_production.log_addresses_missing is None From cb5ff555a353bddce680d55863788c919ad46e9e Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 15:31:04 +0100 Subject: [PATCH 174/979] Use timestamp of last log as reference Although unlikely the pulse counter could possibly be higher. --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 703e0ff5c..046e33123 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -264,7 +264,7 @@ def update_pulse_counter( # Rollover of logs first if ( self._rollover_log_consumption - and pulses_consumed <= self._pulses_consumption + and timestamp > self._last_log_timestamp ): self._rollover_log_consumption = False if ( From f7971991cd56c1c7765439ad1bb56f78fb19b32d Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 7 Feb 2024 21:41:36 +0100 Subject: [PATCH 175/979] Fix log refences and add additional tests for log and pulse collection --- plugwise_usb/nodes/helpers/pulses.py | 153 ++++++++++++++++++--------- tests/test_usb.py | 107 ++++++++----------- 2 files changed, 149 insertions(+), 111 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 046e33123..aad0e06e7 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -315,8 +315,8 @@ def add_log( if not self._add_log_record(address, slot, log_record): return False self._update_log_direction(address, slot, timestamp) - self._update_log_interval() self._update_log_references(address, slot) + self._update_log_interval() self._update_log_rollover(address, slot) if not import_only: self.recalculate_missing_log_addresses() @@ -367,15 +367,29 @@ def _update_log_direction( # Given log is the second log with same timestamp, # mark direction as production self._logs[address][slot].is_consumption = False + self._logs[prev_address][prev_slot].is_consumption = True self._log_production = True + elif self._log_production: + self._logs[address][slot].is_consumption = True + if self._logs[prev_address][prev_slot].is_consumption: + self._logs[prev_address][prev_slot].is_consumption = False + self._reset_log_references() + elif self._log_production is None: + self._log_production = False next_address, next_slot = calc_log_address(address, slot, 1) if self._log_exists(next_address, next_slot): if self._logs[next_address][next_slot].timestamp == timestamp: # Given log is the first log with same timestamp, # mark direction as production of next log - self._logs[next_address][next_slot].is_consumption = False + self._logs[address][slot].is_consumption = True + if self._logs[next_address][next_slot].is_consumption: + self._logs[next_address][next_slot].is_consumption = False + self._reset_log_references() self._log_production = True + elif self._log_production: + self._logs[address][slot].is_consumption = False + self._logs[next_address][next_slot].is_consumption = True elif self._log_production is None: self._log_production = False @@ -409,19 +423,19 @@ def _update_log_interval(self) -> None: if self._logs is None or self._log_production is None: _LOGGER.debug("_update_log_interval | %s | _logs=%s, _log_production=%s", self._mac, self._logs, self._log_production) return - last_address, last_slot = self._last_log_reference() - if last_address is None or last_slot is None: + last_cons_address, last_cons_slot = self._last_log_reference(is_consumption=True) + if last_cons_address is None or last_cons_slot is None: return - last_timestamp = self._logs[last_address][last_slot].timestamp - last_direction = self._logs[last_address][last_slot].is_consumption - address1, slot1 = calc_log_address(last_address, last_slot, -1) - while self._log_exists(address1, slot1): - if last_direction == self._logs[address1][slot1].is_consumption: + last_cons_timestamp = self._logs[last_cons_address][last_cons_slot].timestamp + last_cons_direction = self._logs[last_cons_address][last_cons_slot].is_consumption + address, slot = calc_log_address(last_cons_address, last_cons_slot, -1) + while self._log_exists(address, slot): + if last_cons_direction == self._logs[address][slot].is_consumption: delta1: timedelta = ( - last_timestamp - self._logs[address1][slot1].timestamp + last_cons_timestamp - self._logs[address][slot].timestamp ) - if last_direction: + if last_cons_direction: self._log_interval_consumption = int( delta1.total_seconds() / MINUTE_IN_SECONDS ) @@ -432,20 +446,21 @@ def _update_log_interval(self) -> None: break if not self._log_production: return - address1, slot1 = calc_log_address(address1, slot1, -1) + address, slot = calc_log_address(address, slot, -1) # update interval of other direction too - address2, slot2 = self._last_log_reference(not last_direction) - if address2 is None or slot2 is None: + last_prod_address, last_prod_slot = self._last_log_reference(is_consumption=False) + if last_prod_address is None or last_prod_slot is None: return - timestamp = self._logs[address2][slot2].timestamp - address3, slot3 = calc_log_address(address2, slot2, -1) - while self._log_exists(address3, slot3): - if last_direction != self._logs[address3][slot3].is_consumption: + last_prod_timestamp = self._logs[last_prod_address][last_prod_slot].timestamp + last_prod_direction = self._logs[last_prod_address][last_prod_slot].is_consumption + address, slot = calc_log_address(last_prod_address, last_prod_slot, -1) + while self._log_exists(address, slot): + if last_prod_direction == self._logs[address][slot].is_consumption: delta2: timedelta = ( - timestamp - self._logs[address3][slot3].timestamp + last_prod_timestamp - self._logs[address][slot].timestamp ) - if last_direction: + if not last_prod_direction: self._log_interval_production = int( delta2.total_seconds() / MINUTE_IN_SECONDS ) @@ -454,7 +469,7 @@ def _update_log_interval(self) -> None: delta2.total_seconds() / MINUTE_IN_SECONDS ) break - address3, slot3 = calc_log_address(address3, slot3, -1) + address, slot = calc_log_address(address, slot, -1) def _log_exists(self, address: int, slot: int) -> bool: if self._logs is None: @@ -466,13 +481,14 @@ def _log_exists(self, address: int, slot: int) -> bool: return True def _update_last_log_reference( - self, address: int, slot: int, timestamp + self, address: int, slot: int, timestamp, is_consumption: bool ) -> None: """Update references to last (most recent) log record.""" - if ( - self._last_log_timestamp is None or - self._last_log_timestamp < timestamp - ): + if self._last_log_timestamp is None or self._last_log_timestamp < timestamp: + self._last_log_address = address + self._last_log_slot = slot + self._last_log_timestamp = timestamp + elif self._last_log_timestamp == timestamp and not is_consumption: self._last_log_address = address self._last_log_slot = slot self._last_log_timestamp = timestamp @@ -481,10 +497,7 @@ def _update_last_consumption_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log consumption record.""" - if ( - self._last_log_consumption_timestamp is None or - self._last_log_consumption_timestamp < timestamp - ): + if self._last_log_consumption_timestamp is None or self._last_log_consumption_timestamp <= timestamp: self._last_log_consumption_timestamp = timestamp self._last_log_consumption_address = address self._last_log_consumption_slot = slot @@ -493,14 +506,58 @@ def _update_last_consumption_log_reference( self._last_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) ) + def _reset_log_references(self) -> None: + """Reset log references.""" + self._last_log_consumption_address = None + self._last_log_consumption_slot = None + self._last_log_consumption_timestamp = None + self._first_log_consumption_address = None + self._first_log_consumption_slot = None + self._first_log_consumption_timestamp = None + self._last_log_production_address = None + self._last_log_production_slot = None + self._last_log_production_timestamp = None + self._first_log_production_address = None + self._first_log_production_slot = None + self._first_log_production_timestamp = None + for address in self._logs: + for slot, log_record in self._logs[address].items(): + if log_record.is_consumption: + if ( + self._last_log_consumption_timestamp is None + or self._last_log_consumption_timestamp < log_record.timestamp + ): + self._last_log_consumption_timestamp = log_record.timestamp + self._last_log_consumption_address = address + self._last_log_consumption_slot = slot + if ( + self._first_log_consumption_timestamp is None + or self._first_log_consumption_timestamp > log_record.timestamp + ): + self._first_log_consumption_timestamp = log_record.timestamp + self._first_log_consumption_address = address + self._first_log_consumption_slot = slot + else: + if ( + self._last_log_production_timestamp is None + or self._last_log_production_timestamp < log_record.timestamp + ): + self._last_log_production_timestamp = log_record.timestamp + self._last_log_production_address = address + self._last_log_production_slot = slot + if ( + self._first_log_production_timestamp is None + or self._first_log_production_timestamp > log_record.timestamp + ): + self._first_log_production_timestamp = log_record.timestamp + self._first_log_production_address = address + self._first_log_production_slot = slot + def _update_last_production_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log production record.""" - if ( - self._last_log_production_timestamp is None or - self._last_log_production_timestamp < timestamp - ): + if self._last_log_production_timestamp is None or self._last_log_production_timestamp <= timestamp: self._last_log_production_timestamp = timestamp self._last_log_production_address = address self._last_log_production_slot = slot @@ -510,13 +567,14 @@ def _update_last_production_log_reference( ) def _update_first_log_reference( - self, address: int, slot: int, timestamp: datetime + self, address: int, slot: int, timestamp: datetime, is_consumption: bool ) -> None: """Update references to first (oldest) log record.""" - if ( - self._first_log_timestamp is None or - self._first_log_timestamp > timestamp - ): + if self._first_log_timestamp is None or self._first_log_timestamp > timestamp: + self._first_log_address = address + self._first_log_slot = slot + self._first_log_timestamp = timestamp + elif self._first_log_timestamp == timestamp and is_consumption: self._first_log_address = address self._first_log_slot = slot self._first_log_timestamp = timestamp @@ -525,10 +583,7 @@ def _update_first_consumption_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log consumption record.""" - if ( - self._first_log_consumption_timestamp is None or - self._first_log_consumption_timestamp > timestamp - ): + if self._first_log_consumption_timestamp is None or self._first_log_consumption_timestamp >= timestamp: self._first_log_consumption_timestamp = timestamp self._first_log_consumption_address = address self._first_log_consumption_slot = slot @@ -537,10 +592,7 @@ def _update_first_production_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log production record.""" - if ( - self._first_log_production_timestamp is None or - self._first_log_production_timestamp > timestamp - ): + if self._first_log_production_timestamp is None or self._first_log_production_timestamp >= timestamp: self._first_log_production_timestamp = timestamp self._first_log_production_address = address self._first_log_production_slot = slot @@ -552,12 +604,13 @@ def _update_log_references(self, address: int, slot: int) -> None: if not self._log_exists(address, slot): return log_time_stamp = self._logs[address][slot].timestamp + is_consumption = self._logs[address][slot].is_consumption # Update log references - self._update_first_log_reference(address, slot, log_time_stamp) - self._update_last_log_reference(address, slot, log_time_stamp) + self._update_first_log_reference(address, slot, log_time_stamp, is_consumption) + self._update_last_log_reference(address, slot, log_time_stamp, is_consumption) - if self._logs[address][slot].is_consumption: + if is_consumption: # Consumption self._update_first_consumption_log_reference( address, slot, log_time_stamp diff --git a/tests/test_usb.py b/tests/test_usb.py index b01eafb64..130a5d190 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -776,8 +776,9 @@ async def test_node_relay_and_power(self, monkeypatch): await stick.disconnect() @freeze_time(dt.now()) - def test_pulse_collection(self): - """Testing pulse collection class""" + def test_pulse_collection(self, monkeypatch): + """Testing pulse collection class.""" + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) fixed_timestamp_utc = dt.now(tz.utc) fixed_this_hour = fixed_timestamp_utc.replace( @@ -797,17 +798,14 @@ def test_pulse_collection(self): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) assert tst_consumption.log_addresses_missing == missing_check # Test consumption - Log import #2, random log # return intermediate missing addresses test_timestamp = fixed_this_hour - td(hours=18) tst_consumption.add_log(95, 4, test_timestamp, 1000) - missing_check += [99, 98, 97, 96] + missing_check = [99, 98, 97, 96] assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None @@ -835,7 +833,6 @@ def test_pulse_collection(self): # Complete log import for address 95 so it must drop from missing list test_timestamp = fixed_this_hour - td(hours=21) tst_consumption.add_log(95, 1, test_timestamp, 1000) - assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False @@ -849,65 +846,35 @@ def test_pulse_collection(self): assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False assert tst_consumption.log_addresses_missing == missing_check - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) # Test consumption - pulse update #1 pulse_update_1 = fixed_this_hour + td(minutes=5) tst_consumption.update_pulse_counter(1234, 0, pulse_update_1) - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == ( - 1234, - pulse_update_1, - ) - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=False) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (1234, pulse_update_1) + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=False) == (None, None) + # Test consumption - pulse update #2 pulse_update_2 = fixed_this_hour + td(minutes=7) test_timestamp = fixed_this_hour tst_consumption.update_pulse_counter(2345, 0, pulse_update_2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( - 2345, - pulse_update_2, - ) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345, pulse_update_2) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + # Test consumption - pulses + log (address=100, slot=1) test_timestamp = fixed_this_hour - td(hours=1) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( - 2345 + 1000, - pulse_update_2, - ) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345 + 1000, pulse_update_2) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + # Test consumption - pulses + logs (address=100, slot=1 & address=99, slot=4) test_timestamp = fixed_this_hour - td(hours=2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( - 2345 + 1000 + 750, - pulse_update_2, - ) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345 + 1000 + 750, pulse_update_2) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) # Test consumption - pulses + missing logs test_timestamp = fixed_this_hour - td(hours=3) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == ( - None, - None, - ) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == ( - None, - None, - ) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) # add missing logs test_timestamp = fixed_this_hour - td(hours=3) @@ -931,7 +898,7 @@ def test_pulse_collection(self): tst_consumption.add_log(94, 2, (fixed_this_hour - td(hours=24)), 1000) tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=25)), 1000) - # Test rollover by updating pulses first + # Test log rollover by updating pulses first before log record assert not tst_consumption.log_rollover pulse_update_3 = fixed_this_hour + td(hours=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_3) @@ -941,6 +908,17 @@ def test_pulse_collection(self): tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (45, pulse_update_3) assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (45 + 2222, pulse_update_3) + assert not tst_consumption.log_rollover + + # Test log rollover by updating log first before updating pulses + tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) + assert tst_consumption.log_rollover + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + # fix log rollover + pulse_update_4 = fixed_this_hour + td(hours=2, seconds=10) + tst_consumption.update_pulse_counter(321, 0, pulse_update_4) + assert not tst_consumption.log_rollover + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (2222 + 3333 + 321, pulse_update_4) # Set log hours back to 1 week monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 168) @@ -950,7 +928,7 @@ def test_pulse_collection(self): assert tst_production.log_addresses_missing is None assert tst_production.production_logging is None - # Test consumption & production - Log import #1 + # Test consumption & production - Log import #1 - production # Missing addresses must be populated test_timestamp = fixed_this_hour - td(hours=1) tst_production.add_log(200, 2, test_timestamp, 2000) @@ -958,24 +936,24 @@ def test_pulse_collection(self): assert tst_production.log_addresses_missing == missing_check assert tst_production.production_logging is None - # Test consumption & production - Log import #2 + # Test consumption & production - Log import #2 - consumption # production must be enabled & intervals are unknown # Log at address 200 is known and expect production logs too test_timestamp = fixed_this_hour - td(hours=1) tst_production.add_log(200, 1, test_timestamp, 1000) assert tst_production.log_addresses_missing == missing_check - assert tst_production.log_interval_consumption == 0 + assert tst_production.log_interval_consumption is None assert tst_production.log_interval_production is None assert tst_production.production_logging - # Test consumption & production - Log import #3 - # Interval of production is not yet available + # Test consumption & production - Log import #3 - production + # Interval of consumption is not yet available test_timestamp = fixed_this_hour - td(hours=2) tst_production.add_log(199, 4, test_timestamp, 4000) missing_check = list(range(199, 157, -1)) assert tst_production.log_addresses_missing == missing_check - assert tst_production.log_interval_consumption == 0 # FIXME - assert tst_production.log_interval_production is None + assert tst_production.log_interval_consumption is None + assert tst_production.log_interval_production == 60 assert tst_production.production_logging # Test consumption & production - Log import #4 @@ -983,10 +961,17 @@ def test_pulse_collection(self): test_timestamp = fixed_this_hour - td(hours=2) tst_production.add_log(199, 3, test_timestamp, 3000) assert tst_production.log_addresses_missing == missing_check - assert tst_production.log_interval_consumption == 0 # FIXME + assert tst_production.log_interval_consumption == 60 assert tst_production.log_interval_production == 60 assert tst_production.production_logging + pulse_update_1 = fixed_this_hour + td(minutes=5) + tst_production.update_pulse_counter(100, 50, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour, is_consumption=True) == (100, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour, is_consumption=False) == (50, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=True) == (1000 + 100, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=False) == (2000 + 50, pulse_update_1) + _pulse_update = 0 def pulse_update(self, timestamp: dt, is_consumption: bool): From 0d988c1e546ef361e37e79394e295993b8c53e0c Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 9 Feb 2024 17:40:03 +0100 Subject: [PATCH 176/979] Update docstrings --- plugwise_usb/nodes/circle.py | 40 +++++++++--------------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ce5ddf3a4..9271d44f6 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -90,12 +90,12 @@ def relay(self, state: bool) -> None: @raise_not_loaded async def relay_off(self) -> None: - """Switch relay off""" + """Switch relay off.""" await self.switch_relay(False) @raise_not_loaded async def relay_on(self) -> None: - """Switch relay on""" + """Switch relay on.""" await self.switch_relay(True) @property @@ -121,10 +121,7 @@ def relay_init(self, state: bool) -> None: create_task(self._relay_init_set(state)) async def calibration_update(self) -> bool: - """ - Retrieve and update calibration settings. - Returns True if successful. - """ + """Retrieve and update calibration settings. Returns True if successful.""" _LOGGER.debug( "Start updating energy calibration for node %s", self._node_info.mac, @@ -194,10 +191,7 @@ def _calibration_update_state( off_noise: float | None, off_tot: float | None, ) -> bool: - """ - Process new energy calibration settings. - Returns True if successful. - """ + """Process new energy calibration settings. Returns True if successful.""" if ( gain_a is None or gain_b is None or @@ -225,8 +219,7 @@ def _calibration_update_state( @raise_calibration_missing async def power_update(self) -> PowerStatistics | None: - """ - Update the current power usage statistics. + """Update the current power usage statistics. Return power usage or None if retrieval failed """ @@ -387,10 +380,7 @@ async def get_missing_energy_logs(self) -> None: await self._energy_log_records_save_to_cache() async def energy_log_update(self, address: int) -> bool: - """ - Request energy log statistics from node. - Return true if successful - """ + """Request energy log statistics from node. Returns true if successful.""" if address <= 0: return False request = CircleEnergyLogsRequest(self._mac_in_bytes, address) @@ -723,10 +713,7 @@ async def load(self) -> bool: return await self.initialize() async def _load_from_cache(self) -> bool: - """ - Load states from previous cached information. - Return True if successful. - """ + """Load states from previous cached information. Returns True if successful.""" if not await super()._load_from_cache(): return False @@ -862,19 +849,12 @@ async def unload(self) -> None: self._loaded = False async def switch_init_relay(self, state: bool) -> bool: - """ - Switch state of initial power-up relay state. - Return new state of relay - """ + """Switch state of initial power-up relay state. Returns new state of relay.""" await self._relay_init_set(state) return self._relay_init_state async def _relay_init_get(self) -> bool | None: - """ - Get current configuration of the power-up state of the relay. - - Returns None if retrieval failed - """ + """Get current configuration of the power-up state of the relay. Returns None if retrieval failed.""" if NodeFeature.RELAY_INIT not in self._features: raise NodeError( "Retrieval of initial state of relay is not " @@ -904,7 +884,7 @@ async def _relay_init_set(self, state: bool) -> bool | None: return self._relay_init_state async def _relay_init_load_from_cache(self) -> bool: - """Load relay init state from cache. Return True if retrieval was successful.""" + """Load relay init state from cache. Returns True if retrieval was successful.""" if (cached_relay_data := self._get_cache("relay_init")) is not None: relay_init_state = False if cached_relay_data == "True": From eaf28ae893fe24b3711e2c86761aeaff02ba70a5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 9 Feb 2024 17:40:21 +0100 Subject: [PATCH 177/979] Remove useless sleep --- plugwise_usb/nodes/circle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 9271d44f6..c19881654 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -393,7 +393,6 @@ async def energy_log_update(self, address: int) -> bool: response: CircleEnergyLogsResponse | None = await self._send(request) except PlugwiseException: response = None - await sleep(0) if response is None: _LOGGER.warning( "Retrieving of energy log at address %s for node %s failed", From f739453f289d72dc20c60ebbbe4f4f65c167e309 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:14:41 +0100 Subject: [PATCH 178/979] Remove useless error catch --- plugwise_usb/nodes/circle.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c19881654..2a825c94c 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -16,7 +16,7 @@ PULSES_PER_KW_SECOND, SECOND_IN_NANOSECONDS, ) -from ..exceptions import NodeError, PlugwiseException +from ..exceptions import NodeError from ..messages.requests import ( CircleClockGetRequest, CircleClockSetRequest, @@ -389,10 +389,7 @@ async def energy_log_update(self, address: int) -> bool: str(address), self._mac_in_str, ) - try: - response: CircleEnergyLogsResponse | None = await self._send(request) - except PlugwiseException: - response = None + response: CircleEnergyLogsResponse | None = await self._send(request) if response is None: _LOGGER.warning( "Retrieving of energy log at address %s for node %s failed", From ffb3a9308a5385124d6894f46bb5d615118b77b4 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:16:30 +0100 Subject: [PATCH 179/979] Downgrade log level to info --- plugwise_usb/nodes/circle.py | 2 +- plugwise_usb/nodes/circle_plus.py | 2 +- plugwise_usb/nodes/helpers/cache.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 2a825c94c..c74852a20 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -680,7 +680,7 @@ async def load(self) -> bool: ), ) return await self.initialize() - _LOGGER.warning( + _LOGGER.info( "Load Circle node %s from cache failed", self._node_info.mac, ) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 6dae29ec6..6f5a9ca5f 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -46,7 +46,7 @@ async def load(self) -> bool: ), ) return await self.initialize() - _LOGGER.warning( + _LOGGER.info( "Load Circle+ node %s from cache failed", self._node_info.mac, ) diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 141deac54..b5d462f72 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -91,7 +91,7 @@ async def restore_cache(self) -> bool: ) as file_data: lines = await file_data.readlines() except OSError: - _LOGGER.warning( + _LOGGER.info( "Failed to read cache file %s", str(self._cache_file) ) return False From 9985b9faf7ab91de924749a05e8725e9860da92f Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:17:46 +0100 Subject: [PATCH 180/979] Do recalc before updating statistics --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index e445946a5..2d357602e 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -153,6 +153,7 @@ def calibration(self, calibration: EnergyCalibration) -> None: def update(self) -> None: """Update counter collection.""" + self._pulse_collection.recalculate_missing_log_addresses() if self._calibration is None: return ( @@ -192,7 +193,6 @@ def update(self) -> None: ) = self._counters[EnergyType.PRODUCTION_WEEK].update( self._pulse_collection ) - self._pulse_collection.recalculate_missing_log_addresses() @property def timestamp(self) -> datetime | None: From 11f8f4ffe034df4193fb87138ce5397eb5dc845c Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:27:05 +0100 Subject: [PATCH 181/979] Rewrite log rollover logic --- plugwise_usb/messages/responses.py | 8 +- plugwise_usb/nodes/circle.py | 36 ++- plugwise_usb/nodes/helpers/counter.py | 4 + plugwise_usb/nodes/helpers/pulses.py | 335 ++++++++++++-------------- 4 files changed, 189 insertions(+), 194 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 5d73bcbed..b1b11975b 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -720,7 +720,7 @@ def __init__(self) -> None: self.pulses3 = Int(0, 8) self.logdate4 = DateTime() self.pulses4 = Int(0, 8) - self.logaddr = LogAddr(0, length=8) + self._logaddr = LogAddr(0, length=8) self._params += [ self.logdate1, self.pulses1, @@ -730,9 +730,13 @@ def __init__(self) -> None: self.pulses3, self.logdate4, self.pulses4, - self.logaddr, + self._logaddr, ] + @property + def log_address(self) -> int: + """Return the gain A.""" + return self._logaddr.value class NodeAwakeResponse(PlugwiseResponse): """Announce that a sleeping end device is awake. diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c74852a20..f490df313 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -264,7 +264,6 @@ async def power_update(self) -> PowerStatistics | None: await self.publish_feature_update_to_subscribers( NodeFeature.ENERGY, self._energy_counters.energy_statistics ) - response = None return self._power @raise_not_loaded @@ -290,12 +289,28 @@ async def energy_update( self._last_energy_log_requested = await self.energy_log_update(self._last_log_address) if self._energy_counters.log_rollover: - _LOGGER.debug( - "async_energy_update | Log rollover for %s", - self._node_info.mac, - ) - if await self.node_info_update(): - await self.energy_log_update(self._last_log_address) + if not await self.node_info_update(): + _LOGGER.debug( + "async_energy_update | %s | Log rollover | node_info_update failed", self._node_info.mac, + ) + return None + + if not await self.energy_log_update(self._last_log_address): + _LOGGER.debug( + "async_energy_update | %s | Log rollover | energy_log_update failed", self._node_info.mac, + ) + return None + + if self._energy_counters.log_rollover: + # Retry with previous log address as Circle node pointer to self._last_log_address + # is the address of the current log period, not the address of the last log + if not await self.energy_log_update(self._last_log_address - 1): + _LOGGER.debug( + "async_energy_update | %s | Log rollover | energy_log_update %s failed", + self._node_info.mac, + self._last_log_address - 1, + ) + return if ( missing_addresses := self._energy_counters.log_addresses_missing @@ -408,15 +423,16 @@ async def energy_log_update(self, address: int) -> bool: response, "logdate%d" % (_slot,) ).value _log_pulses: int = getattr(response, "pulses%d" % (_slot,)).value - if _log_timestamp is not None: + if _log_timestamp is None: + self._energy_counters.add_empty_log(response.log_address, _slot) + else: await self._energy_log_record_update_state( - response.logaddr.value, + response.log_address, _slot, _log_timestamp.replace(tzinfo=timezone.utc), _log_pulses, import_only=True ) - await sleep(0) self._energy_counters.update() if self._cache_enabled: create_task(self.save_cache()) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 2d357602e..7e1838ade 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -77,6 +77,10 @@ def collected_logs(self) -> int: """Total collected logs.""" return self._pulse_collection.collected_logs + def add_empty_log(self, address: int, slot: int) -> None: + """Add empty energy log record to mark any start of beginning of energy log collection.""" + self._pulse_collection.add_empty_log(address, slot) + def add_pulse_log( self, address: int, diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index aad0e06e7..f28393572 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -56,6 +56,11 @@ def __init__(self, mac: str) -> None: self._first_log_slot: int | None = None self._first_log_timestamp: datetime | None = None + self._first_empty_log_address: int | None = None + self._first_empty_log_slot: int | None = None + self._last_empty_log_address: int | None = None + self._last_empty_log_slot: int | None = None + self._last_log_consumption_timestamp: datetime | None = None self._last_log_consumption_address: int | None = None self._last_log_consumption_slot: int | None = None @@ -72,17 +77,15 @@ def __init__(self, mac: str) -> None: self._first_log_production_slot: int | None = None self._next_log_production_timestamp: datetime | None = None - self._rollover_log_consumption = False - self._rollover_log_production = False - self._rollover_pulses_consumption = False - self._rollover_pulses_production = False + self._rollover_consumption = False + self._rollover_production = False self._logs: dict[int, dict[int, PulseLogRecord]] | None = None self._log_addresses_missing: list[int] | None = None self._log_production: bool | None = None self._pulses_consumption: int | None = None self._pulses_production: int | None = None - self._last_update: datetime | None = None + self._pulses_timestamp: datetime | None = None @property def collected_logs(self) -> int: @@ -134,17 +137,12 @@ def log_interval_production(self) -> int | None: @property def log_rollover(self) -> bool: """Indicate if new log is required.""" - return ( - self._rollover_log_consumption - or self._rollover_log_production - or self._rollover_pulses_consumption - or self._rollover_pulses_production - ) + return (self._rollover_consumption or self._rollover_production) @property def last_update(self) -> datetime | None: """Return timestamp of last update.""" - return self._last_update + return self._pulses_timestamp def collected_pulses( self, from_timestamp: datetime, is_consumption: bool @@ -157,38 +155,26 @@ def collected_pulses( if self._log_production is None or not self._log_production: return (None, None) - if is_consumption and ( - self._rollover_log_consumption or self._rollover_pulses_consumption - ): - _LOGGER.debug( - "collected_pulses | %s | is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", - self._mac, - self._rollover_log_consumption, - self._rollover_pulses_consumption - ) + if is_consumption and self._rollover_consumption: + _LOGGER.debug("collected_pulses | %s | _rollover_consumption", self._mac) return (None, None) - if not is_consumption and ( - self._rollover_log_production or self._rollover_pulses_production - ): - _LOGGER.debug("collected_pulses | %s | NOT is consumption: self._rollover_log_consumption=%s, self._rollover_pulses_consumption=%s", self._mac, self._rollover_log_consumption, self._rollover_pulses_consumption) + if not is_consumption and self._rollover_production: + _LOGGER.debug("collected_pulses | %s | _rollover_production", self._mac) return (None, None) - log_pulses = self._collect_pulses_from_logs( - from_timestamp, is_consumption - ) + log_pulses = self._collect_pulses_from_logs(from_timestamp, is_consumption) if log_pulses is None: _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) return (None, None) - # _LOGGER.debug("collected_pulses | %s | log_pulses=%s", self._mac, log_pulses) pulses: int | None = None timestamp: datetime | None = None if is_consumption and self._pulses_consumption is not None: pulses = self._pulses_consumption - timestamp = self._last_update + timestamp = self._pulses_timestamp if not is_consumption and self._pulses_production is not None: pulses = self._pulses_production - timestamp = self._last_update + timestamp = self._pulses_timestamp # _LOGGER.debug("collected_pulses | %s | pulses=%s", self._mac, pulses) if pulses is None: @@ -235,89 +221,101 @@ def update_pulse_counter( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: """Update pulse counter.""" - if self._pulses_consumption is None: - self._pulses_consumption = pulses_consumed - if self._pulses_production is None: - self._pulses_production = pulses_produced - self._last_update = timestamp - - if self._next_log_consumption_timestamp is None: - self._pulses_consumption = pulses_consumed - self._pulses_production = pulses_produced + self._pulses_timestamp = timestamp + self._update_rollover() + if not (self._rollover_consumption or self._rollover_production): + # No rollover based on time, check rollover based on counter reset + # Required for special cases like nodes which have been power off for several days + if self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed: + self._rollover_consumption = True + if self._pulses_production is not None and self._pulses_production > pulses_produced: + self._rollover_production = True + self._pulses_consumption = pulses_consumed + self._pulses_production = pulses_produced + + def _update_rollover(self) -> None: + """Update rollover states. Returns True if rollover is applicable.""" + if self._log_addresses_missing is not None and self._log_addresses_missing: return if ( - self._log_production - and self._next_log_production_timestamp is None + self._pulses_timestamp is None + or self._last_log_consumption_timestamp is None + or self._next_log_consumption_timestamp is None ): - self._pulses_consumption = pulses_consumed - self._pulses_production = pulses_produced + # Unable to determine rollover return + if self._pulses_timestamp > self._next_log_consumption_timestamp: + self._rollover_consumption = True + _LOGGER.warning("_update_rollover | %s | set consumption rollover => pulses newer", self._mac) + elif self._pulses_timestamp < self._last_log_consumption_timestamp: + self._rollover_consumption = True + _LOGGER.warning("_update_rollover | %s | set consumption rollover => log newer", self._mac) + elif self._last_log_consumption_timestamp < self._pulses_timestamp < self._next_log_consumption_timestamp: + if self._rollover_consumption: + _LOGGER.warning("_update_rollover | %s | reset consumption", self._mac) + self._rollover_consumption = False + else: + _LOGGER.warning("_update_rollover | %s | unexpected consumption", self._mac) - if ( - self._log_addresses_missing is None or - len(self._log_addresses_missing) > 0 - ): - self._pulses_consumption = pulses_consumed - self._pulses_production = pulses_produced + if not self._log_production: return - - # Rollover of logs first - if ( - self._rollover_log_consumption - and timestamp > self._last_log_timestamp - ): - self._rollover_log_consumption = False - if ( - self._log_production - and self._rollover_log_production - and self._pulses_production >= pulses_produced - ): - self._rollover_log_production = False - - # Rollover of pulses first - if pulses_consumed < self._pulses_consumption: - _LOGGER.debug("update_pulse_counter | %s | pulses_consumed=%s, _pulses_consumption=%s", self._mac, pulses_consumed, self._pulses_consumption) - self._rollover_pulses_consumption = True - elif self._log_interval_consumption is not None and timestamp > ( - self._next_log_consumption_timestamp - + timedelta(minutes=self._log_interval_consumption) - ): - _LOGGER.debug("update_pulse_counter | %s | _log_interval_consumption=%s, timestamp=%s, _next_log_consumption_timestamp=%s", self._mac, self._log_interval_consumption, timestamp, self._next_log_consumption_timestamp) - self._rollover_pulses_consumption = True - - if self._log_production: - if self._pulses_production < pulses_produced: - self._rollover_pulses_production = True + if self._last_log_production_timestamp is None or self._next_log_production_timestamp is None: + # Unable to determine rollover + return + if self._pulses_timestamp > self._next_log_production_timestamp: + self._rollover_production = True + _LOGGER.warning("_update_rollover | %s | set production rollover => pulses newer", self._mac) + elif self._pulses_timestamp < self._last_log_production_timestamp: + self._rollover_production = True + _LOGGER.warning("_update_rollover | %s | reset production rollover => log newer", self._mac) + elif self._last_log_production_timestamp < self._pulses_timestamp < self._next_log_production_timestamp: + if self._rollover_production: + _LOGGER.warning("_update_rollover | %s | reset production", self._mac) + self._rollover_production = False + else: + _LOGGER.warning("_update_rollover | %s | unexpected production", self._mac) + + def add_empty_log(self, address: int, slot: int) -> None: + """Add empty energy log record to mark any start of beginning of energy log collection.""" + recalc = False + if self._first_log_address is None or address <= self._first_log_address: + if self._first_empty_log_address is None or self._first_empty_log_address < address: + self._first_empty_log_address = address + self._first_empty_log_slot = slot + recalc = True elif ( - self._next_log_production_timestamp is not None - and self._log_interval_production is not None - and timestamp - > ( - self._next_log_production_timestamp - + timedelta(minutes=self._log_interval_production) - ) + self._first_empty_log_address == address + and (self._first_empty_log_slot is None or self._first_empty_log_slot < slot) ): - self._rollover_pulses_production = True - - self._pulses_consumption = pulses_consumed - self._pulses_production = pulses_produced + self._first_empty_log_slot = slot + recalc = True + + if self._last_log_address is None or address >= self._last_log_address: + if self._last_empty_log_address is None or self._last_empty_log_address > address: + self._last_empty_log_address = address + self._last_empty_log_slot = slot + recalc = True + elif ( + self._last_empty_log_address == address + and (self._last_empty_log_slot is None or self._last_empty_log_slot > slot) + ): + self._last_empty_log_slot = slot + recalc = True + if recalc: + self.recalculate_missing_log_addresses() - def add_log( - self, - address: int, - slot: int, - timestamp: datetime, - pulses: int, - import_only: bool = False - ) -> bool: + def add_log(self, address: int, slot: int, timestamp: datetime, pulses: int, import_only: bool = False) -> bool: """Store pulse log.""" log_record = PulseLogRecord(timestamp, pulses, CONSUMED) if not self._add_log_record(address, slot, log_record): - return False + if not self._log_exists(address, slot): + return False + if address != self._last_log_address and slot != self._last_log_slot: + return False self._update_log_direction(address, slot, timestamp) self._update_log_references(address, slot) self._update_log_interval() - self._update_log_rollover(address, slot) + self._update_rollover() if not import_only: self.recalculate_missing_log_addresses() return True @@ -325,29 +323,35 @@ def add_log( def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( - datetime.now(timezone.utc) - timedelta( - hours=MAX_LOG_HOURS - ) + datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) ) def _add_log_record( self, address: int, slot: int, log_record: PulseLogRecord ) -> bool: - """Add log record and return True if log did not exists.""" + """Add log record. + + Return False if log record already exists, or is not required because its timestamp is expired. + """ if self._logs is None: self._logs = {address: {slot: log_record}} return True if self._log_exists(address, slot): return False - # Drop unused log records - if log_record.timestamp < ( + # Drop useless log records when we have at least 4 logs + if self.collected_logs > 4 and log_record.timestamp < ( datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) ): return False if self._logs.get(address) is None: self._logs[address] = {slot: log_record} - else: - self._logs[address][slot] = log_record + self._logs[address][slot] = log_record + if address == self._first_empty_log_address and slot == self._first_empty_log_slot: + self._first_empty_log_address = None + self._first_empty_log_slot = None + if address == self._last_empty_log_address and slot == self._last_empty_log_slot: + self._last_empty_log_address = None + self._last_empty_log_slot = None return True def _update_log_direction( @@ -393,31 +397,6 @@ def _update_log_direction( elif self._log_production is None: self._log_production = False - def _update_log_rollover(self, address: int, slot: int) -> None: - if self._last_update is None: - return - if self._logs is None: - return - if ( - self._next_log_consumption_timestamp is not None - and self._rollover_pulses_consumption - and self._next_log_consumption_timestamp > self._last_update - ): - self._rollover_pulses_consumption = False - - if ( - self._next_log_production_timestamp is not None - and self._rollover_pulses_production - and self._next_log_production_timestamp > self._last_update - ): - self._rollover_pulses_production = False - - if self._logs[address][slot].timestamp > self._last_update: - if self._logs[address][slot].is_consumption: - self._rollover_log_consumption = True - else: - self._rollover_log_production = True - def _update_log_interval(self) -> None: """Update the detected log interval based on the most recent two logs.""" if self._logs is None or self._log_production is None: @@ -427,49 +406,48 @@ def _update_log_interval(self) -> None: if last_cons_address is None or last_cons_slot is None: return + # Update interval of consumption last_cons_timestamp = self._logs[last_cons_address][last_cons_slot].timestamp - last_cons_direction = self._logs[last_cons_address][last_cons_slot].is_consumption address, slot = calc_log_address(last_cons_address, last_cons_slot, -1) while self._log_exists(address, slot): - if last_cons_direction == self._logs[address][slot].is_consumption: + if self._logs[address][slot].is_consumption: delta1: timedelta = ( last_cons_timestamp - self._logs[address][slot].timestamp ) - if last_cons_direction: - self._log_interval_consumption = int( - delta1.total_seconds() / MINUTE_IN_SECONDS - ) - else: - self._log_interval_production = int( - delta1.total_seconds() / MINUTE_IN_SECONDS - ) + self._log_interval_consumption = int( + delta1.total_seconds() / MINUTE_IN_SECONDS + ) break if not self._log_production: return address, slot = calc_log_address(address, slot, -1) + if self._log_interval_consumption is not None: + self._next_log_consumption_timestamp = ( + self._last_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) + ) - # update interval of other direction too + if not self._log_production: + return + # Update interval of production last_prod_address, last_prod_slot = self._last_log_reference(is_consumption=False) if last_prod_address is None or last_prod_slot is None: return last_prod_timestamp = self._logs[last_prod_address][last_prod_slot].timestamp - last_prod_direction = self._logs[last_prod_address][last_prod_slot].is_consumption address, slot = calc_log_address(last_prod_address, last_prod_slot, -1) while self._log_exists(address, slot): - if last_prod_direction == self._logs[address][slot].is_consumption: + if not self._logs[address][slot].is_consumption: delta2: timedelta = ( last_prod_timestamp - self._logs[address][slot].timestamp ) - if not last_prod_direction: - self._log_interval_production = int( - delta2.total_seconds() / MINUTE_IN_SECONDS - ) - else: - self._log_interval_consumption = int( - delta2.total_seconds() / MINUTE_IN_SECONDS - ) + self._log_interval_production = int( + delta2.total_seconds() / MINUTE_IN_SECONDS + ) break address, slot = calc_log_address(address, slot, -1) + if self._log_interval_production is not None: + self._next_log_production_timestamp = ( + self._last_log_production_timestamp + timedelta(minutes=self._log_interval_production) + ) def _log_exists(self, address: int, slot: int) -> bool: if self._logs is None: @@ -501,10 +479,6 @@ def _update_last_consumption_log_reference( self._last_log_consumption_timestamp = timestamp self._last_log_consumption_address = address self._last_log_consumption_slot = slot - if self._log_interval_consumption is not None: - self._next_log_consumption_timestamp = ( - self._last_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) - ) def _reset_log_references(self) -> None: """Reset log references.""" @@ -561,10 +535,6 @@ def _update_last_production_log_reference( self._last_log_production_timestamp = timestamp self._last_log_production_address = address self._last_log_production_slot = slot - if self._log_interval_production is not None: - self._next_log_production_timestamp = ( - self._last_log_production_timestamp + timedelta(minutes=self._log_interval_production) - ) def _update_first_log_reference( self, address: int, slot: int, timestamp: datetime, is_consumption: bool @@ -599,10 +569,6 @@ def _update_first_production_log_reference( def _update_log_references(self, address: int, slot: int) -> None: """Update next expected log timestamps.""" - if self._logs is None: - return - if not self._log_exists(address, slot): - return log_time_stamp = self._logs[address][slot].timestamp is_consumption = self._logs[address][slot].is_consumption @@ -611,21 +577,12 @@ def _update_log_references(self, address: int, slot: int) -> None: self._update_last_log_reference(address, slot, log_time_stamp, is_consumption) if is_consumption: - # Consumption - self._update_first_consumption_log_reference( - address, slot, log_time_stamp - ) - self._update_last_consumption_log_reference( - address, slot, log_time_stamp - ) + self._update_first_consumption_log_reference(address, slot, log_time_stamp) + self._update_last_consumption_log_reference(address, slot, log_time_stamp) else: # production - self._update_first_production_log_reference( - address, slot, log_time_stamp - ) - self._update_last_production_log_reference( - address, slot, log_time_stamp - ) + self._update_first_production_log_reference(address, slot, log_time_stamp) + self._update_last_production_log_reference(address, slot, log_time_stamp) @property def log_addresses_missing(self) -> list[int] | None: @@ -675,6 +632,8 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if self._logs is None: self._log_addresses_missing = None return None + if self.collected_logs < 2: + return None last_address, last_slot = self._last_log_reference() if last_address is None or last_slot is None: _LOGGER.warning("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) @@ -688,8 +647,12 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: missing = [] _LOGGER.debug("_logs_missing | %s | first_address=%s, last_address=%s", self._mac, first_address, last_address) - if last_address <= first_address: - return [] + if ( + last_address == first_address + and self._logs[first_address][first_slot].timestamp == self._logs[last_address][last_slot].timestamp + ): + # Power consumption logging, so we need at least 4 logs. + return None finished = False # Collect any missing address in current range @@ -724,12 +687,20 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: # calculate missing log addresses prior to first collected log address, slot = calc_log_address(first_address, first_slot, -1) - calculated_timestamp = self._logs[first_address][first_slot].timestamp - timedelta(hours=1) + log_interval = 60 + if self._log_interval_consumption is not None: + log_interval = self._log_interval_consumption + if self._log_interval_production is not None and self._log_interval_production < log_interval: + log_interval = self._log_interval_production + + calculated_timestamp = self._logs[first_address][first_slot].timestamp - timedelta(minutes=log_interval) while from_timestamp < calculated_timestamp: + if address == self._first_empty_log_address and slot == self._first_empty_log_slot: + break if address not in missing: missing.append(address) + calculated_timestamp -= timedelta(minutes=log_interval) address, slot = calc_log_address(address, slot, -1) - calculated_timestamp -= timedelta(hours=1) missing.sort(reverse=True) _LOGGER.debug("_logs_missing | %s | calculated missing=%s", self._mac, missing) From 9942d0deb9a0feb2f412107cd736b7a72fe54289 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:32:20 +0100 Subject: [PATCH 182/979] Update some strings --- plugwise_usb/nodes/__init__.py | 5 +---- plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/circle_plus.py | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 31660100b..781ff1086 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -328,10 +328,7 @@ async def disconnect(self) -> None: @property def maintenance_interval(self) -> int | None: - """ - Return the maintenance interval (seconds) - a battery powered node sends it heartbeat. - """ + """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" raise NotImplementedError() async def scan_calibrate_light(self) -> bool: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f490df313..c644b5951 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -375,7 +375,7 @@ async def get_missing_energy_logs(self) -> None: await self._energy_log_records_save_to_cache() return if self._energy_counters.log_addresses_missing is not None: - _LOGGER.info('Task created to get missing logs of %s', self._mac_in_str) + _LOGGER.info("Task created to get missing logs of %s", self._mac_in_str) if ( missing_addresses := self._energy_counters.log_addresses_missing ) is not None: @@ -535,7 +535,7 @@ async def _energy_log_record_update_state( log_cache_record += f"-{timestamp.month}-{timestamp.day}" log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" log_cache_record += f"-{timestamp.second}:{pulses}" - if (cached_logs := self._get_cache('energy_collection')) is not None: + if (cached_logs := self._get_cache("energy_collection")) is not None: if log_cache_record not in cached_logs: _LOGGER.debug( "Add logrecord (%s, %s) to log cache of %s", diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 6f5a9ca5f..5b166db05 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -142,8 +142,7 @@ async def realtime_clock_synchronize(self) -> bool: ): return True _LOGGER.info( - "Reset realtime clock of node %s because time has drifted" - + " %s seconds while max drift is set to %s seconds)", + "Reset realtime clock of node %s because time has drifted %s seconds while max drift is set to %s seconds)", self._node_info.mac, str(clock_offset.seconds), str(MAX_TIME_DRIFT), From bd196bdb0832fd7f7d42d990b9d3306b73fd2d7a Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:32:58 +0100 Subject: [PATCH 183/979] Remove unused local variables --- plugwise_usb/nodes/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 781ff1086..4eaee2f7e 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -99,11 +99,8 @@ def __init__( self._relay_state = RelayState() self._relay_init_state: bool | None = None - # Local power & energy + # Power & energy self._calibration: EnergyCalibration | None = None - self._next_power: datetime | None = None - - # Energy self._energy_counters = EnergyCounters(mac) @property From e103a272dfcd6f564aa8c796d48ca399ffacffeb Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:34:07 +0100 Subject: [PATCH 184/979] Add log interval to energy statistics --- plugwise_usb/api.py | 2 ++ plugwise_usb/nodes/__init__.py | 19 +++++++++++++++++++ plugwise_usb/nodes/helpers/counter.py | 2 ++ 3 files changed, 23 insertions(+) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index a92b57c0f..09a09da81 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -121,6 +121,8 @@ class MotionState: class EnergyStatistics: """Energy statistics collection.""" + log_interval_consumption: int | None = None + log_interval_production: int | None = None hour_consumption: float | None = None hour_consumption_reset: datetime | None = None day_consumption: float | None = None diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 4eaee2f7e..33871a9ff 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -323,6 +323,25 @@ async def disconnect(self) -> None: self._available = False await self.publish_event(NodeFeature.AVAILABLE, False) + @property + def energy_consumption_interval(self) -> int | None: + """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError( + f"Energy log interval is not supported for node {self.mac}" + ) + return self._energy_counters.consumption_interval + + @property + def energy_production_interval(self) -> int | None: + """Interval (minutes) energy production counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError( + f"Energy log interval is not supported for node {self.mac}" + ) + return self._energy_counters.production_interval + + @property def maintenance_interval(self) -> int | None: """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 7e1838ade..50715543a 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -160,6 +160,8 @@ def update(self) -> None: self._pulse_collection.recalculate_missing_log_addresses() if self._calibration is None: return + self._energy_statistics.log_interval_consumption = self._pulse_collection.log_interval_consumption + self._energy_statistics.log_interval_production = self._pulse_collection.log_interval_production ( self._energy_statistics.hour_consumption, self._energy_statistics.hour_consumption_reset, From 4dbfdd7c854b214b0e60f53dbf1097e41923e84f Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:34:25 +0100 Subject: [PATCH 185/979] Correct logging level --- plugwise_usb/nodes/circle.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c644b5951..08847b4de 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -273,7 +273,7 @@ async def energy_update( ) -> EnergyStatistics | None: """Update energy usage statistics, returns True if successful.""" if self._last_log_address is None: - _LOGGER.warning( + _LOGGER.info( "Unable to update energy logs for node %s because last_log_address is unknown.", self._node_info.mac, ) @@ -365,7 +365,7 @@ async def get_missing_energy_logs(self) -> None: -1, ): if not await self.energy_log_update(address): - _LOGGER.warning( + _LOGGER.debug( "Failed to update energy log %s for %s", str(address), self._mac_in_str, @@ -406,7 +406,7 @@ async def energy_log_update(self, address: int) -> bool: ) response: CircleEnergyLogsResponse | None = await self._send(request) if response is None: - _LOGGER.warning( + _LOGGER.debug( "Retrieving of energy log at address %s for node %s failed", str(address), self._mac_in_str, @@ -705,7 +705,7 @@ async def load(self) -> bool: # Check if node is online if not self._available and not await self.is_online(): - _LOGGER.warning( + _LOGGER.info( "Failed to load Circle node %s because it is not online", self._node_info.mac ) @@ -713,7 +713,7 @@ async def load(self) -> bool: # Get node info if not await self.node_info_update(): - _LOGGER.warning( + _LOGGER.info( "Failed to load Circle node %s because it is not responding to information request", self._node_info.mac ) @@ -826,7 +826,7 @@ async def node_info_update( self._last_log_address > node_info.last_logaddress ): # Rollover of log address - _LOGGER.warning( + _LOGGER.debug( "Rollover log address from %s into %s for node %s", self._last_log_address, node_info.last_logaddress, @@ -972,7 +972,7 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: # pulse values are valid for power producing appliances, like # solar panels, so don't complain too loudly. if pulses == -1: - _LOGGER.warning( + _LOGGER.debug( "Power pulse counter for node %s of value of -1, corrected to 0", self._node_info.mac, ) From 3b25d2ad110153e3d7fc0140135b363f5961102b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:35:49 +0100 Subject: [PATCH 186/979] Append and extend test for energy log (rollover) --- tests/stick_test_data.py | 2 +- tests/test_usb.py | 160 +++++++++++++++++++++++++++++++++------ 2 files changed, 138 insertions(+), 24 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 172cc7251..c9be09e94 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -8,7 +8,7 @@ # generate energy log timestamps with fixed hour timestamp used in tests -hour_timestamp = utc_now.replace(hour=23, minute=0, second=0, microsecond=0) +hour_timestamp = utc_now.replace(minute=0, second=0, microsecond=0) LOG_TIMESTAMPS = {} _one_hour = timedelta(hours=1) diff --git a/tests/test_usb.py b/tests/test_usb.py index 130a5d190..c1b705180 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -706,11 +706,6 @@ async def test_node_relay_and_power(self, monkeypatch): assert stick.nodes["0098765432101234"].relay assert stick.nodes["0098765432101234"].relay_state.relay_state - # Test power state without request - assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) - pu = await stick.nodes["0098765432101234"].power_update() - assert pu.last_second == 21.2780505980402 - assert pu.last_8_seconds == 27.150578775440106 unsub_relay() # Check if node is online @@ -775,16 +770,81 @@ async def test_node_relay_and_power(self, monkeypatch): await stick.disconnect() + @pytest.mark.asyncio + async def test_energy_circle(self, monkeypatch): + """Testing energy retrieval.""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + + # Manually load node + assert await stick.nodes["0098765432101234"].load() + + # Test power state without request + assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) + pu = await stick.nodes["0098765432101234"].power_update() + assert pu.last_second == 21.2780505980402 + assert pu.last_8_seconds == 27.150578775440106 + + # Test energy state without request + assert stick.nodes["0098765432101234"].energy == pw_api.EnergyStatistics( + log_interval_consumption=None, + log_interval_production=None, + hour_consumption=None, + hour_consumption_reset=None, + day_consumption=None, + day_consumption_reset=None, + week_consumption=None, + week_consumption_reset=None, + hour_production=None, + hour_production_reset=None, + day_production=None, + day_production_reset=None, + week_production=None, + week_production_reset=None, + ) + # energy_update is not complete and should return none + utc_now = dt.utcnow().replace(tzinfo=tz.utc) + assert await stick.nodes["0098765432101234"].energy_update() is None + # Allow for background task to finish + await asyncio.sleep(1) + assert stick.nodes["0098765432101234"].energy == pw_api.EnergyStatistics( + log_interval_consumption=60, + log_interval_production=None, + hour_consumption=0.6654729637405271, + hour_consumption_reset=utc_now.replace(minute=0, second=0, microsecond=0), + day_consumption=None, + day_consumption_reset=None, + week_consumption=None, + week_consumption_reset=None, + hour_production=None, + hour_production_reset=None, + day_production=None, + day_production_reset=None, + week_production=None, + week_production_reset=None, + ) + await stick.disconnect() + @freeze_time(dt.now()) - def test_pulse_collection(self, monkeypatch): + def test_pulse_collection_consumption(self, monkeypatch): """Testing pulse collection class.""" - monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 25) fixed_timestamp_utc = dt.now(tz.utc) fixed_this_hour = fixed_timestamp_utc.replace( minute=0, second=0, microsecond=0 ) - missing_check = [] # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") @@ -799,17 +859,18 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.log_addresses_missing is None # Test consumption - Log import #2, random log + # No missing addresses yet # return intermediate missing addresses test_timestamp = fixed_this_hour - td(hours=18) tst_consumption.add_log(95, 4, test_timestamp, 1000) - missing_check = [99, 98, 97, 96] assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #3 # log next to existing with different timestamp @@ -819,7 +880,8 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #4, no change test_timestamp = fixed_this_hour - td(hours=20) @@ -827,7 +889,8 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #5 # Complete log import for address 95 so it must drop from missing list @@ -836,7 +899,7 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #6 # Add before last log so interval of consumption must be determined @@ -845,7 +908,7 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.log_interval_consumption == 60 assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is False - assert tst_consumption.log_addresses_missing == missing_check + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) # Test consumption - pulse update #1 @@ -853,6 +916,7 @@ def test_pulse_collection(self, monkeypatch): tst_consumption.update_pulse_counter(1234, 0, pulse_update_1) assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (1234, pulse_update_1) assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=False) == (None, None) + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - pulse update #2 pulse_update_2 = fixed_this_hour + td(minutes=7) @@ -876,6 +940,7 @@ def test_pulse_collection(self, monkeypatch): assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert not tst_consumption.log_rollover # add missing logs test_timestamp = fixed_this_hour - td(hours=3) tst_consumption.add_log(99, 3, (fixed_this_hour - td(hours=3)), 1000) @@ -895,10 +960,15 @@ def test_pulse_collection(self, monkeypatch): tst_consumption.add_log(96, 1, (fixed_this_hour - td(hours=17)), 1000) tst_consumption.add_log(94, 4, (fixed_this_hour - td(hours=22)), 1000) tst_consumption.add_log(94, 3, (fixed_this_hour - td(hours=23)), 1000) + + # Log 24 (max hours) must be dropped + assert tst_consumption.collected_logs == 23 tst_consumption.add_log(94, 2, (fixed_this_hour - td(hours=24)), 1000) + assert tst_consumption.collected_logs == 24 tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=25)), 1000) + assert tst_consumption.collected_logs == 24 - # Test log rollover by updating pulses first before log record + # Test rollover by updating pulses before log record assert not tst_consumption.log_rollover pulse_update_3 = fixed_this_hour + td(hours=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_3) @@ -906,34 +976,78 @@ def test_pulse_collection(self, monkeypatch): test_timestamp = fixed_this_hour + td(hours=1, seconds=5) assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) + assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (45, pulse_update_3) assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (45 + 2222, pulse_update_3) - assert not tst_consumption.log_rollover # Test log rollover by updating log first before updating pulses tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) assert tst_consumption.log_rollover assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) - # fix log rollover pulse_update_4 = fixed_this_hour + td(hours=2, seconds=10) tst_consumption.update_pulse_counter(321, 0, pulse_update_4) assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (2222 + 3333 + 321, pulse_update_4) - # Set log hours back to 1 week + @freeze_time(dt.now()) + def test_pulse_collection_consumption_empty(self, monkeypatch): + """Testing pulse collection class.""" + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) + + fixed_timestamp_utc = dt.now(tz.utc) + fixed_this_hour = fixed_timestamp_utc.replace( + minute=0, second=0, microsecond=0 + ) + + # Import consumption logs + tst_pc = pw_energy_pulses.PulseCollection(mac="0098765432101234") + tst_pc.add_log(100, 1, fixed_this_hour - td(hours=5), 1000) + assert tst_pc.log_addresses_missing is None + tst_pc.add_log(99, 4, fixed_this_hour - td(hours=6), 750) + assert tst_pc.log_addresses_missing == [99, 98, 97, 96, 95] + tst_pc.add_log(99, 3, fixed_this_hour - td(hours=7), 3750) + tst_pc.add_log(99, 2, fixed_this_hour - td(hours=8), 750) + tst_pc.add_log(99, 1, fixed_this_hour - td(hours=9), 2750) + assert tst_pc.log_addresses_missing == [98, 97, 96, 95] + tst_pc.add_log(98, 4, fixed_this_hour - td(hours=10), 1750) + assert tst_pc.log_addresses_missing == [98, 97, 96, 95] + + # test empty log prior + tst_pc.add_empty_log(98, 3) + assert tst_pc.log_addresses_missing == [] + + tst_pc.add_log(100, 2, fixed_this_hour - td(hours=5), 1750) + tst_pc.add_empty_log(100, 3) + assert tst_pc.log_addresses_missing == [] + + tst_pc.add_log(100, 3, fixed_this_hour - td(hours=4), 1750) + assert tst_pc.log_addresses_missing == [] + + tst_pc.add_log(101, 2, fixed_this_hour - td(hours=1), 1234) + assert tst_pc.log_addresses_missing == [100] + + @freeze_time(dt.now()) + def test_pulse_collection_production(self, monkeypatch): + """Testing pulse collection class.""" + + # Set log hours to 1 week monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 168) + fixed_timestamp_utc = dt.now(tz.utc) + fixed_this_hour = fixed_timestamp_utc.replace( + minute=0, second=0, microsecond=0 + ) + # Test consumption and production logs tst_production = pw_energy_pulses.PulseCollection(mac="0098765432101234") assert tst_production.log_addresses_missing is None assert tst_production.production_logging is None # Test consumption & production - Log import #1 - production - # Missing addresses must be populated + # Missing addresses can not be determined yet test_timestamp = fixed_this_hour - td(hours=1) tst_production.add_log(200, 2, test_timestamp, 2000) - missing_check = [] - assert tst_production.log_addresses_missing == missing_check + assert tst_production.log_addresses_missing is None assert tst_production.production_logging is None # Test consumption & production - Log import #2 - consumption @@ -941,7 +1055,7 @@ def test_pulse_collection(self, monkeypatch): # Log at address 200 is known and expect production logs too test_timestamp = fixed_this_hour - td(hours=1) tst_production.add_log(200, 1, test_timestamp, 1000) - assert tst_production.log_addresses_missing == missing_check + assert tst_production.log_addresses_missing is None assert tst_production.log_interval_consumption is None assert tst_production.log_interval_production is None assert tst_production.production_logging From a3c3b7f9077851d62da24d7b96d8803c433ebd02 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 10 Feb 2024 20:43:25 +0100 Subject: [PATCH 187/979] Correct naming of log address variable The log address received from node is the current address pointer which does not have to be the pointer of the last energy record i.e. when slot rollover happens. --- plugwise_usb/nodes/__init__.py | 2 +- plugwise_usb/nodes/circle.py | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 33871a9ff..3f0a6e6ef 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -92,7 +92,7 @@ def __init__( self._new_sensitivity: MotionSensitivity | None = None # Node info - self._last_log_address: int | None = None + self._current_log_address: int | None = None # Relay self._relay: bool | None = None diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 08847b4de..2528273f1 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -272,7 +272,7 @@ async def energy_update( self ) -> EnergyStatistics | None: """Update energy usage statistics, returns True if successful.""" - if self._last_log_address is None: + if self._current_log_address is None: _LOGGER.info( "Unable to update energy logs for node %s because last_log_address is unknown.", self._node_info.mac, @@ -286,7 +286,7 @@ async def energy_update( # Always request last energy log records at initial startup if not self._last_energy_log_requested: - self._last_energy_log_requested = await self.energy_log_update(self._last_log_address) + self._last_energy_log_requested = await self.energy_log_update(self._current_log_address) if self._energy_counters.log_rollover: if not await self.node_info_update(): @@ -295,20 +295,20 @@ async def energy_update( ) return None - if not await self.energy_log_update(self._last_log_address): + if not await self.energy_log_update(self._current_log_address): _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update failed", self._node_info.mac, ) return None if self._energy_counters.log_rollover: - # Retry with previous log address as Circle node pointer to self._last_log_address - # is the address of the current log period, not the address of the last log - if not await self.energy_log_update(self._last_log_address - 1): + # Retry with previous log address as Circle node pointer to self._current_log_address + # could be rolled over while the last log is at previous address/slot + if not await self.energy_log_update(self._current_log_address - 1): _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", self._node_info.mac, - self._last_log_address - 1, + self._current_log_address - 1, ) return @@ -360,8 +360,8 @@ async def get_missing_energy_logs(self) -> None: self._node_info.mac, ) for address in range( - self._last_log_address, - self._last_log_address - 11, + self._current_log_address, + self._current_log_address - 11, -1, ): if not await self.energy_log_update(address): @@ -822,18 +822,18 @@ async def node_info_update( node_info.relay_state, timestamp=node_info.timestamp ) if ( - self._last_log_address is not None and - self._last_log_address > node_info.last_logaddress + self._current_log_address is not None and + self._current_log_address > node_info.last_logaddress ): # Rollover of log address _LOGGER.debug( "Rollover log address from %s into %s for node %s", - self._last_log_address, + self._current_log_address, node_info.last_logaddress, self.mac ) - if self._last_log_address != node_info.last_logaddress: - self._last_log_address = node_info.last_logaddress + if self._current_log_address != node_info.last_logaddress: + self._current_log_address = node_info.last_logaddress self._set_cache( "last_log_address", node_info.last_logaddress ) @@ -847,7 +847,7 @@ async def _node_info_load_from_cache(self) -> bool: if ( last_log_address := self._get_cache("last_log_address") ) is not None: - self._last_log_address = int(last_log_address) + self._current_log_address = int(last_log_address) return result return False From d71a430468b67fedcf05f362a25dfca8897d0045 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:06:03 +0100 Subject: [PATCH 188/979] Fix Initialization and load mixed up --- plugwise_usb/network/__init__.py | 1 + plugwise_usb/nodes/celsius.py | 3 ++- plugwise_usb/nodes/circle.py | 17 +++++++++++++---- plugwise_usb/nodes/circle_plus.py | 9 +++++++-- plugwise_usb/nodes/scan.py | 2 +- plugwise_usb/nodes/sense.py | 6 +++--- plugwise_usb/nodes/switch.py | 6 +++--- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7754fd32d..ddd58d748 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -395,6 +395,7 @@ async def _discover_node( if node_type is not None: self._create_node_object(mac, address, node_type) + self._nodes[mac].initialize() await self._notify_node_event_subscribers( NodeEvent.DISCOVERED, mac ) diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 8ae48ac84..7458d44db 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -7,7 +7,7 @@ import logging from typing import Final -from ..api import NodeFeature +from ..api import NodeEvent, NodeFeature from ..nodes.sed import NodeSED from .helpers.firmware import CELSIUS_FIRMWARE_SUPPORT @@ -41,6 +41,7 @@ async def load(self) -> bool: (NodeFeature.INFO, NodeFeature.TEMPERATURE), ) if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) return False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 2528273f1..5affb2351 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -695,7 +695,9 @@ async def load(self) -> bool: NodeFeature.POWER, ), ) - return await self.initialize() + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True _LOGGER.info( "Load Circle node %s from cache failed", self._node_info.mac, @@ -720,9 +722,17 @@ async def load(self) -> bool: return False self._loaded = True self._setup_protocol( - CIRCLE_FIRMWARE_SUPPORT, (NodeFeature.RELAY_INIT,) + CIRCLE_FIRMWARE_SUPPORT, ( + NodeFeature.RELAY, + NodeFeature.RELAY_INIT, + NodeFeature.ENERGY, + NodeFeature.POWER, + ) ) - return await self.initialize() + if not await self.initialize(): + return False + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" @@ -799,7 +809,6 @@ async def initialize(self) -> bool: ) self._initialized = False return False - await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def node_info_update( diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 5b166db05..53d5a53ef 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -45,7 +45,9 @@ async def load(self) -> bool: NodeFeature.POWER, ), ) - return await self.initialize() + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True _LOGGER.info( "Load Circle+ node %s from cache failed", self._node_info.mac, @@ -78,7 +80,10 @@ async def load(self) -> bool: NodeFeature.POWER, ), ) - return await self.initialize() + if not await self.initialize(): + return False + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True @raise_not_loaded async def initialize(self) -> bool: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index eadeff713..e950270e2 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -56,6 +56,7 @@ async def load(self) -> bool: (NodeFeature.INFO, NodeFeature.MOTION), ) if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) return True _LOGGER.debug("Load of Scan node %s failed", self._node_info.mac) return False @@ -75,7 +76,6 @@ async def initialize(self) -> bool: (NODE_SWITCH_GROUP_ID,), ) self._initialized = True - await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index fa4486e9c..7519cd529 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -52,8 +52,9 @@ async def load(self) -> bool: NodeFeature.HUMIDITY ), ) - return await self.initialize() - + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) return False @@ -70,7 +71,6 @@ async def initialize(self) -> bool: SENSE_REPORT_ID, ) self._initialized = True - await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index b9f9575b7..d443e9d4f 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -36,8 +36,9 @@ async def load(self) -> bool: SWITCH_FIRMWARE_SUPPORT, (NodeFeature.INFO, NodeFeature.SWITCH), ) - return await self.initialize() - + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True _LOGGER.debug("Load of Switch node %s failed", self._node_info.mac) return False @@ -55,7 +56,6 @@ async def initialize(self) -> bool: NODE_SWITCH_GROUP_ID, ) self._initialized = True - await self._loaded_callback(NodeEvent.LOADED, self.mac) return True async def unload(self) -> None: From 4693b955a20d80c929e7b4b6e662fd04b1b76a09 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:13:36 +0100 Subject: [PATCH 189/979] Sort imports --- plugwise_usb/messages/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index b6e29ef83..924ef2a7a 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -1,7 +1,9 @@ """Plugwise messages.""" from __future__ import annotations + from typing import Any + from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..util import crc_fun From f8f0cef3656b90770da675e6b65a8707b30e14bb Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:14:36 +0100 Subject: [PATCH 190/979] Extend "repr" for energy log messages with log address --- plugwise_usb/messages/requests.py | 5 +++++ plugwise_usb/messages/responses.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 0e3279e03..27e463124 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -705,9 +705,14 @@ def __init__(self, mac: bytes, log_address: int) -> None: """Initialize CircleEnergyLogsRequest message object.""" super().__init__(b"0048", mac) self._reply_identifier = b"0049" + self._log_address = log_address self.priority = Priority.LOW self._args.append(LogAddr(log_address, 8)) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{self.__class__.__name__} for {self.mac_decoded} | log_address={self._log_address}" + class CircleHandlesOffRequest(PlugwiseRequest): """?PWSetHandlesOffRequestV1_0. diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index b1b11975b..7805740ef 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -738,6 +738,12 @@ def log_address(self) -> int: """Return the gain A.""" return self._logaddr.value + + def __repr__(self) -> str: + """Convert request into writable str.""" + return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) + " | log_address=" + str(self._logaddr.value) + + class NodeAwakeResponse(PlugwiseResponse): """Announce that a sleeping end device is awake. From 518d1d85bf46e8941bad42a10ceb7146e1915481 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:18:51 +0100 Subject: [PATCH 191/979] Fix log address pointer rollover --- plugwise_usb/constants.py | 2 +- plugwise_usb/nodes/circle.py | 2 -- plugwise_usb/nodes/helpers/pulses.py | 35 ++++++++++++++-------------- tests/test_usb.py | 30 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 48369564d..917ff5e87 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -68,7 +68,7 @@ # Energy log memory addresses LOGADDR_OFFSET: Final = 278528 # = b"00044000" -LOGADDR_MAX: Final = 65535 # TODO: Determine last log address, not used yet +LOGADDR_MAX: Final = 6016 # last address for energy log # Max seconds the internal clock of plugwise nodes # are allowed to drift in seconds diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5affb2351..ac067b7ac 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -396,8 +396,6 @@ async def get_missing_energy_logs(self) -> None: async def energy_log_update(self, address: int) -> bool: """Request energy log statistics from node. Returns true if successful.""" - if address <= 0: - return False request = CircleEnergyLogsRequest(self._mac_in_bytes, address) _LOGGER.debug( "Request of energy log at address %s for node %s", diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f28393572..9e0373f56 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -6,7 +6,7 @@ import logging from typing import Final -from ...constants import MINUTE_IN_SECONDS, WEEK_IN_HOURS +from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, WEEK_IN_HOURS _LOGGER = logging.getLogger(__name__) CONSUMED: Final = True @@ -18,15 +18,19 @@ def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: """Calculate address and slot for log based for specified offset.""" - # FIXME: Handle max address (max is currently unknown) to guard - # against address rollovers if offset < 0: while offset + slot < 1: address -= 1 + # Check for log address rollover + if address <= -1: + address = LOGADDR_MAX - 1 offset += 4 if offset > 0: while offset + slot > 4: address += 1 + # Check for log address rollover + if address >= LOGADDR_MAX: + address = 0 offset -= 4 return (address, slot + offset) @@ -649,27 +653,24 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if ( last_address == first_address + and last_slot == first_slot and self._logs[first_address][first_slot].timestamp == self._logs[last_address][last_slot].timestamp ): # Power consumption logging, so we need at least 4 logs. return None - finished = False # Collect any missing address in current range - for address in range(last_address - 1, first_address, -1): - for slot in range(4, 0, -1): - if address in missing: - break - if not self._log_exists(address, slot): - missing.append(address) - break - if self._logs[address][slot].timestamp <= from_timestamp: - finished = True - break - if finished: + address = last_address + slot = last_slot + while not (address == first_address and slot == first_slot): + address, slot = calc_log_address(address, slot, -1) + if address in missing: + continue + if not self._log_exists(address, slot): + missing.append(address) + continue + if self._logs[address][slot].timestamp <= from_timestamp: break - if finished: - return missing # return missing logs in range first if len(missing) > 0: diff --git a/tests/test_usb.py b/tests/test_usb.py index c1b705180..4fb6d213f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1088,6 +1088,36 @@ def test_pulse_collection_production(self, monkeypatch): _pulse_update = 0 + @freeze_time(dt.now()) + def test_log_address_rollover(self, monkeypatch): + """Test log address rollover.""" + + # Set log hours to 25 + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) + + fixed_timestamp_utc = dt.now(tz.utc) + fixed_this_hour = fixed_timestamp_utc.replace( + minute=0, second=0, microsecond=0 + ) + tst_pc = pw_energy_pulses.PulseCollection(mac="0098765432101234") + tst_pc.add_log(2, 1, fixed_this_hour - td(hours=1), 3000) + tst_pc.add_log(1, 4, fixed_this_hour - td(hours=2), 3000) + tst_pc.add_log(1, 3, fixed_this_hour - td(hours=3), 3000) + assert tst_pc.log_addresses_missing == [6015, 6014, 6013, 6012, 1, 0] + + # test + tst_pc = pw_energy_pulses.PulseCollection(mac="0098765432101234") + tst_pc.add_log(2, 4, fixed_this_hour - td(hours=1), 0) # prod + tst_pc.add_log(2, 3, fixed_this_hour - td(hours=1), 23935) # con + tst_pc.add_log(2, 2, fixed_this_hour - td(hours=2), 0) # prod + tst_pc.add_log(2, 1, fixed_this_hour - td(hours=2), 10786) # con + # <-- logs 0 & 1 are missing for hours 3, 4, 5 & 6 --> + tst_pc.add_log(6015, 4, fixed_this_hour - td(hours=7), 0) + tst_pc.add_log(6015, 3, fixed_this_hour - td(hours=7), 11709) + tst_pc.add_log(6015, 2, fixed_this_hour - td(hours=8), 0) + tst_pc.add_log(6015, 1, fixed_this_hour - td(hours=8), 10382) + assert tst_pc.log_addresses_missing == [1, 0] + def pulse_update(self, timestamp: dt, is_consumption: bool): """Callback helper for pulse updates for energy counter""" self._pulse_update += 1 From 246733d2322513d0fde503ee82d2a65df6fa20bd Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:20:46 +0100 Subject: [PATCH 192/979] Fix log pointer rollover detection --- plugwise_usb/nodes/circle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ac067b7ac..57a7aa1a6 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -829,8 +829,8 @@ async def node_info_update( node_info.relay_state, timestamp=node_info.timestamp ) if ( - self._current_log_address is not None and - self._current_log_address > node_info.last_logaddress + self._current_log_address is not None + and (self._current_log_address > node_info.last_logaddress or self._current_log_address == 1) ): # Rollover of log address _LOGGER.debug( From dff8be09b16838dab4214e8e2897d7d2d11566ca Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:22:19 +0100 Subject: [PATCH 193/979] Add extra test cases --- tests/test_usb.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 4fb6d213f..c41341382 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -779,7 +779,7 @@ async def test_energy_circle(self, monkeypatch): "create_serial_connection", mock_serial.mock_connection, ) - monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 25) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) stick = pw_stick.Stick("test_port", cache_enabled=False) @@ -929,6 +929,7 @@ def test_pulse_collection_consumption(self, monkeypatch): test_timestamp = fixed_this_hour - td(hours=1) assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345 + 1000, pulse_update_2) assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - pulses + logs (address=100, slot=1 & address=99, slot=4) test_timestamp = fixed_this_hour - td(hours=2) @@ -1024,6 +1025,9 @@ def test_pulse_collection_consumption_empty(self, monkeypatch): assert tst_pc.log_addresses_missing == [] tst_pc.add_log(101, 2, fixed_this_hour - td(hours=1), 1234) + assert tst_pc.log_addresses_missing == [101, 100] + + tst_pc.add_log(101, 1, fixed_this_hour - td(hours=1), 1234) assert tst_pc.log_addresses_missing == [100] @freeze_time(dt.now()) From 92afe4350343e9fe626e04cbdf6d11ea79cc1f96 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 12:23:52 +0100 Subject: [PATCH 194/979] Predict extra missing energy logs based on timestamp intervals --- plugwise_usb/nodes/helpers/pulses.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 9e0373f56..a0442c587 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -686,14 +686,23 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if self._logs[first_address][first_slot].timestamp < from_timestamp: return missing - # calculate missing log addresses prior to first collected log + # Check if we are able to calculate log interval address, slot = calc_log_address(first_address, first_slot, -1) - log_interval = 60 + log_interval: int | None = None if self._log_interval_consumption is not None: log_interval = self._log_interval_consumption - if self._log_interval_production is not None and self._log_interval_production < log_interval: + elif self._log_interval_production is not None: log_interval = self._log_interval_production + if ( + self._log_interval_production is not None + and log_interval is not None + and self._log_interval_production < log_interval + ): + log_interval = self._log_interval_production + if log_interval is None: + return None + # We have an suspected interval, so try to calculate missing log addresses prior to first collected log calculated_timestamp = self._logs[first_address][first_slot].timestamp - timedelta(minutes=log_interval) while from_timestamp < calculated_timestamp: if address == self._first_empty_log_address and slot == self._first_empty_log_slot: From 5484ff17914b2fdca6f8fe750dec9f6a493fbdde Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 19:20:05 +0100 Subject: [PATCH 195/979] Add missing disconnect to test --- tests/test_usb.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index c41341382..a9086b24d 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1332,3 +1332,4 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.connect() await stick.initialize() await stick.discover_nodes(load=True) + await stick.disconnect() From 53ba4cbe72d4448671e9bb19c2140d18067bdb5d Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 19:34:17 +0100 Subject: [PATCH 196/979] Use walrus operators --- plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/helpers/pulses.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 57a7aa1a6..36a35fdbb 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -402,8 +402,8 @@ async def energy_log_update(self, address: int) -> bool: str(address), self._mac_in_str, ) - response: CircleEnergyLogsResponse | None = await self._send(request) - if response is None: + response: CircleEnergyLogsResponse | None = None + if (response := await self._send(request)) is None: _LOGGER.debug( "Retrieving of energy log at address %s for node %s failed", str(address), diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a0442c587..a8febef95 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -166,8 +166,7 @@ def collected_pulses( _LOGGER.debug("collected_pulses | %s | _rollover_production", self._mac) return (None, None) - log_pulses = self._collect_pulses_from_logs(from_timestamp, is_consumption) - if log_pulses is None: + if (log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption)) is None: _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) return (None, None) From bbc2bc295cadba85384837f8c2d90b911bfa264a Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 21:40:12 +0100 Subject: [PATCH 197/979] Correct log level --- plugwise_usb/nodes/helpers/pulses.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a8febef95..b7c5f175c 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -249,16 +249,16 @@ def _update_rollover(self) -> None: return if self._pulses_timestamp > self._next_log_consumption_timestamp: self._rollover_consumption = True - _LOGGER.warning("_update_rollover | %s | set consumption rollover => pulses newer", self._mac) + _LOGGER.debug("_update_rollover | %s | set consumption rollover => pulses newer", self._mac) elif self._pulses_timestamp < self._last_log_consumption_timestamp: self._rollover_consumption = True - _LOGGER.warning("_update_rollover | %s | set consumption rollover => log newer", self._mac) + _LOGGER.debug("_update_rollover | %s | set consumption rollover => log newer", self._mac) elif self._last_log_consumption_timestamp < self._pulses_timestamp < self._next_log_consumption_timestamp: if self._rollover_consumption: - _LOGGER.warning("_update_rollover | %s | reset consumption", self._mac) + _LOGGER.debug("_update_rollover | %s | reset consumption", self._mac) self._rollover_consumption = False else: - _LOGGER.warning("_update_rollover | %s | unexpected consumption", self._mac) + _LOGGER.debug("_update_rollover | %s | unexpected consumption", self._mac) if not self._log_production: return @@ -267,16 +267,16 @@ def _update_rollover(self) -> None: return if self._pulses_timestamp > self._next_log_production_timestamp: self._rollover_production = True - _LOGGER.warning("_update_rollover | %s | set production rollover => pulses newer", self._mac) + _LOGGER.debug("_update_rollover | %s | set production rollover => pulses newer", self._mac) elif self._pulses_timestamp < self._last_log_production_timestamp: self._rollover_production = True - _LOGGER.warning("_update_rollover | %s | reset production rollover => log newer", self._mac) + _LOGGER.debug("_update_rollover | %s | reset production rollover => log newer", self._mac) elif self._last_log_production_timestamp < self._pulses_timestamp < self._next_log_production_timestamp: if self._rollover_production: - _LOGGER.warning("_update_rollover | %s | reset production", self._mac) + _LOGGER.debug("_update_rollover | %s | reset production", self._mac) self._rollover_production = False else: - _LOGGER.warning("_update_rollover | %s | unexpected production", self._mac) + _LOGGER.debug("_update_rollover | %s | unexpected production", self._mac) def add_empty_log(self, address: int, slot: int) -> None: """Add empty energy log record to mark any start of beginning of energy log collection.""" @@ -639,12 +639,12 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: return None last_address, last_slot = self._last_log_reference() if last_address is None or last_slot is None: - _LOGGER.warning("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) + _LOGGER.debug("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) return None first_address, first_slot = self._first_log_reference() if first_address is None or first_slot is None: - _LOGGER.warning("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) + _LOGGER.debug("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) return None missing = [] From e497c886f40230e955e924d09f2476abf5b83aa6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 21:44:33 +0100 Subject: [PATCH 198/979] Fix available state not at USB-Stick disconnect/reconnect --- plugwise_usb/nodes/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 3f0a6e6ef..c837e2b73 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -315,13 +315,12 @@ async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" if await self.ping_update() is not None: self._connected = True + await self._available_update_state(True) async def disconnect(self) -> None: """Disconnect node from Plugwise Zigbee network.""" self._connected = False - if self._available: - self._available = False - await self.publish_event(NodeFeature.AVAILABLE, False) + await self._available_update_state(False) @property def energy_consumption_interval(self) -> int | None: From 1a031b7fc5681e575583135408c4ce17ef35e9b3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 21:45:21 +0100 Subject: [PATCH 199/979] Apply pylint --- plugwise_usb/nodes/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index c837e2b73..f4169f7fe 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -340,17 +340,13 @@ def energy_production_interval(self) -> int | None: ) return self._energy_counters.production_interval - @property def maintenance_interval(self) -> int | None: """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" raise NotImplementedError() async def scan_calibrate_light(self) -> bool: - """ - Request to calibration light sensitivity of Scan device. - Returns True if successful. - """ + """Request to calibration light sensitivity of Scan device. Returns True if successful.""" raise NotImplementedError() async def scan_configure( @@ -390,10 +386,7 @@ async def clear_cache(self) -> None: await self._node_cache.clear_cache() async def _load_from_cache(self) -> bool: - """ - Load states from previous cached information. - Return True if successful. - """ + """Load states from previous cached information. Return True if successful.""" if self._loaded: return True if not await self._load_cache_file(): From f4da093d192ae036d428b7e42cc8aca84e532b15 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 22:26:57 +0100 Subject: [PATCH 200/979] Rename property name last_logaddress into current_logaddress_pointer --- plugwise_usb/messages/responses.py | 12 ++++++------ plugwise_usb/nodes/circle.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 7805740ef..0bb5c79f5 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -553,14 +553,14 @@ def __init__(self, protocol_version: str = "2.0") -> None: """Initialize NodeInfoResponse message object.""" super().__init__(b"0024") - self._last_logaddress = LogAddr(0, length=8) + self._logaddress_pointer = LogAddr(0, length=8) if protocol_version == "1.0": # FIXME: Define "absoluteHour" variable self.datetime = DateTime() self._relay_state = Int(0, length=2) self._params += [ self.datetime, - self._last_logaddress, + self._logaddress_pointer, self._relay_state, ] elif protocol_version == "2.0": @@ -568,7 +568,7 @@ def __init__(self, protocol_version: str = "2.0") -> None: self._relay_state = Int(0, length=2) self._params += [ self.datetime, - self._last_logaddress, + self._logaddress_pointer, self._relay_state, ] elif protocol_version == "2.3": @@ -576,7 +576,7 @@ def __init__(self, protocol_version: str = "2.0") -> None: self.state_mask = Int(0, length=2) self._params += [ self.datetime, - self._last_logaddress, + self._logaddress_pointer, self.state_mask, ] self._frequency = Int(0, length=2) @@ -606,9 +606,9 @@ def node_type(self) -> NodeType: return NodeType(self._node_type.value) @property - def last_logaddress(self) -> int: + def current_logaddress_pointer(self) -> int: """Return the current energy log address.""" - return self._last_logaddress.value + return self._logaddress_pointer.value @property def relay_state(self) -> bool: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 36a35fdbb..e9e075f95 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -830,19 +830,19 @@ async def node_info_update( ) if ( self._current_log_address is not None - and (self._current_log_address > node_info.last_logaddress or self._current_log_address == 1) + and (self._current_log_address > node_info.current_logaddress_pointer or self._current_log_address == 1) ): # Rollover of log address _LOGGER.debug( "Rollover log address from %s into %s for node %s", self._current_log_address, - node_info.last_logaddress, + node_info.current_logaddress_pointer, self.mac ) - if self._current_log_address != node_info.last_logaddress: - self._current_log_address = node_info.last_logaddress + if self._current_log_address != node_info.current_logaddress_pointer: + self._current_log_address = node_info.current_logaddress_pointer self._set_cache( - "last_log_address", node_info.last_logaddress + "last_log_address", node_info.current_logaddress_pointer ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) From 82426d4747e7aed907d0c48a9bad6b4f9c76883a Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 20:04:33 +0100 Subject: [PATCH 201/979] Do not add pulses from the start timestamp --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b7c5f175c..e20e3c1c7 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -215,7 +215,7 @@ def _collect_pulses_from_logs( for slot_item in log_item.values(): if ( slot_item.is_consumption == is_consumption - and slot_item.timestamp >= from_timestamp + and slot_item.timestamp > from_timestamp ): log_pulses += slot_item.pulses return log_pulses From 3bba9a3c93f395d6c2437f55b645c228a9914d0c Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 11 Feb 2024 22:27:43 +0100 Subject: [PATCH 202/979] Add log_address_pointer to __repr__ --- plugwise_usb/messages/responses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 0bb5c79f5..c09941aa9 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -620,6 +620,10 @@ def frequency(self) -> int: """Return frequency config of node.""" return self._frequency + def __repr__(self) -> str: + """Convert request into writable str.""" + return super().__repr__() + f" | log_address={self._logaddress_pointer.value}" + class EnergyCalibrationResponse(PlugwiseResponse): """Returns the calibration settings of node. From 6a753b72411c3cb5d740c09f429a656c0ce9c9d8 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 20:38:45 +0100 Subject: [PATCH 203/979] Test we do not add pulses from log record if timestamp equals start collecting timestamp --- tests/test_usb.py | 66 ++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index a9086b24d..cfb9330b1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -821,7 +821,7 @@ async def test_energy_circle(self, monkeypatch): assert stick.nodes["0098765432101234"].energy == pw_api.EnergyStatistics( log_interval_consumption=60, log_interval_production=None, - hour_consumption=0.6654729637405271, + hour_consumption=0.0026868922443345974, hour_consumption_reset=utc_now.replace(minute=0, second=0, microsecond=0), day_consumption=None, day_consumption_reset=None, @@ -839,7 +839,7 @@ async def test_energy_circle(self, monkeypatch): @freeze_time(dt.now()) def test_pulse_collection_consumption(self, monkeypatch): """Testing pulse collection class.""" - monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 25) + monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) fixed_timestamp_utc = dt.now(tz.utc) fixed_this_hour = fixed_timestamp_utc.replace( @@ -853,7 +853,7 @@ def test_pulse_collection_consumption(self, monkeypatch): # Test consumption - Log import #1 # No missing addresses yet - test_timestamp = fixed_this_hour - td(hours=1) + test_timestamp = fixed_this_hour tst_consumption.add_log(100, 1, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None @@ -864,7 +864,7 @@ def test_pulse_collection_consumption(self, monkeypatch): # Test consumption - Log import #2, random log # No missing addresses yet # return intermediate missing addresses - test_timestamp = fixed_this_hour - td(hours=18) + test_timestamp = fixed_this_hour - td(hours=17) tst_consumption.add_log(95, 4, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None @@ -875,7 +875,7 @@ def test_pulse_collection_consumption(self, monkeypatch): # Test consumption - Log import #3 # log next to existing with different timestamp # so 'production logging' should be marked as False now - test_timestamp = fixed_this_hour - td(hours=19) + test_timestamp = fixed_this_hour - td(hours=18) tst_consumption.add_log(95, 3, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None @@ -884,7 +884,7 @@ def test_pulse_collection_consumption(self, monkeypatch): assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #4, no change - test_timestamp = fixed_this_hour - td(hours=20) + test_timestamp = fixed_this_hour - td(hours=19) tst_consumption.add_log(95, 2, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None @@ -894,7 +894,7 @@ def test_pulse_collection_consumption(self, monkeypatch): # Test consumption - Log import #5 # Complete log import for address 95 so it must drop from missing list - test_timestamp = fixed_this_hour - td(hours=21) + test_timestamp = fixed_this_hour - td(hours=20) tst_consumption.add_log(95, 1, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None @@ -903,7 +903,7 @@ def test_pulse_collection_consumption(self, monkeypatch): # Test consumption - Log import #6 # Add before last log so interval of consumption must be determined - test_timestamp = fixed_this_hour - td(hours=2) + test_timestamp = fixed_this_hour - td(hours=1) tst_consumption.add_log(99, 4, test_timestamp, 750) assert tst_consumption.log_interval_consumption == 60 assert tst_consumption.log_interval_production is None @@ -911,6 +911,13 @@ def test_pulse_collection_consumption(self, monkeypatch): assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + tst_consumption.add_log(99, 3, fixed_this_hour - td(hours=2), 1111) + assert tst_consumption.log_interval_consumption == 60 + assert tst_consumption.log_interval_production is None + assert tst_consumption.production_logging is False + assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] + assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + # Test consumption - pulse update #1 pulse_update_1 = fixed_this_hour + td(minutes=5) tst_consumption.update_pulse_counter(1234, 0, pulse_update_1) @@ -944,29 +951,28 @@ def test_pulse_collection_consumption(self, monkeypatch): assert not tst_consumption.log_rollover # add missing logs test_timestamp = fixed_this_hour - td(hours=3) - tst_consumption.add_log(99, 3, (fixed_this_hour - td(hours=3)), 1000) - tst_consumption.add_log(99, 2, (fixed_this_hour - td(hours=4)), 1000) - tst_consumption.add_log(99, 1, (fixed_this_hour - td(hours=5)), 1000) - tst_consumption.add_log(98, 4, (fixed_this_hour - td(hours=6)), 1000) - tst_consumption.add_log(98, 3, (fixed_this_hour - td(hours=7)), 1000) - tst_consumption.add_log(98, 2, (fixed_this_hour - td(hours=8)), 1000) - tst_consumption.add_log(98, 1, (fixed_this_hour - td(hours=9)), 1000) - tst_consumption.add_log(97, 4, (fixed_this_hour - td(hours=10)), 1000) - tst_consumption.add_log(97, 3, (fixed_this_hour - td(hours=11)), 1000) - tst_consumption.add_log(97, 2, (fixed_this_hour - td(hours=12)), 1000) - tst_consumption.add_log(97, 1, (fixed_this_hour - td(hours=13)), 1000) - tst_consumption.add_log(96, 4, (fixed_this_hour - td(hours=14)), 1000) - tst_consumption.add_log(96, 3, (fixed_this_hour - td(hours=15)), 1000) - tst_consumption.add_log(96, 2, (fixed_this_hour - td(hours=16)), 1000) - tst_consumption.add_log(96, 1, (fixed_this_hour - td(hours=17)), 1000) - tst_consumption.add_log(94, 4, (fixed_this_hour - td(hours=22)), 1000) - tst_consumption.add_log(94, 3, (fixed_this_hour - td(hours=23)), 1000) + tst_consumption.add_log(99, 2, (fixed_this_hour - td(hours=3)), 1000) + tst_consumption.add_log(99, 1, (fixed_this_hour - td(hours=4)), 1000) + tst_consumption.add_log(98, 4, (fixed_this_hour - td(hours=5)), 1000) + tst_consumption.add_log(98, 3, (fixed_this_hour - td(hours=6)), 1000) + tst_consumption.add_log(98, 2, (fixed_this_hour - td(hours=7)), 1000) + tst_consumption.add_log(98, 1, (fixed_this_hour - td(hours=8)), 1000) + tst_consumption.add_log(97, 4, (fixed_this_hour - td(hours=9)), 1000) + tst_consumption.add_log(97, 3, (fixed_this_hour - td(hours=10)), 1000) + tst_consumption.add_log(97, 2, (fixed_this_hour - td(hours=11)), 1000) + tst_consumption.add_log(97, 1, (fixed_this_hour - td(hours=12)), 1000) + tst_consumption.add_log(96, 4, (fixed_this_hour - td(hours=13)), 1000) + tst_consumption.add_log(96, 3, (fixed_this_hour - td(hours=14)), 1000) + tst_consumption.add_log(96, 2, (fixed_this_hour - td(hours=15)), 1000) + tst_consumption.add_log(96, 1, (fixed_this_hour - td(hours=16)), 1000) + tst_consumption.add_log(94, 4, (fixed_this_hour - td(hours=21)), 1000) + tst_consumption.add_log(94, 3, (fixed_this_hour - td(hours=22)), 1000) # Log 24 (max hours) must be dropped assert tst_consumption.collected_logs == 23 - tst_consumption.add_log(94, 2, (fixed_this_hour - td(hours=24)), 1000) + tst_consumption.add_log(94, 2, (fixed_this_hour - td(hours=23)), 1000) assert tst_consumption.collected_logs == 24 - tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=25)), 1000) + tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=24)), 1000) assert tst_consumption.collected_logs == 24 # Test rollover by updating pulses before log record @@ -1087,8 +1093,10 @@ def test_pulse_collection_production(self, monkeypatch): tst_production.update_pulse_counter(100, 50, pulse_update_1) assert tst_production.collected_pulses(fixed_this_hour, is_consumption=True) == (100, pulse_update_1) assert tst_production.collected_pulses(fixed_this_hour, is_consumption=False) == (50, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=True) == (1000 + 100, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=False) == (2000 + 50, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=True) == (100, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=2), is_consumption=True) == (1000 + 100, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=False) == (50, pulse_update_1) + assert tst_production.collected_pulses(fixed_this_hour - td(hours=2), is_consumption=False) == (2000 + 50, pulse_update_1) _pulse_update = 0 From f73010b9a7d7054ce9a268b33eb8a9a36a0fc486 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 21:02:26 +0100 Subject: [PATCH 204/979] Make node_info_update consistent to other *_update functions Return the dataclass object instead of a boolean --- plugwise_usb/nodes/__init__.py | 15 ++++----------- plugwise_usb/nodes/circle.py | 29 ++++++++++++++--------------- plugwise_usb/nodes/circle_plus.py | 2 +- plugwise_usb/nodes/sed.py | 6 +++--- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index f4169f7fe..ee2d50f51 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -425,7 +425,7 @@ async def _available_update_state(self, available: bool) -> None: async def node_info_update( self, node_info: NodeInfoResponse | None = None - ) -> bool: + ) -> NodeInfo | None: """Update Node hardware information.""" if node_info is None: node_info = await self._send( @@ -437,22 +437,16 @@ async def node_info_update( self.mac ) await self._available_update_state(False) - return False - if node_info.mac_decoded != self.mac: - raise NodeError( - f"Incorrect node_info {node_info.mac_decoded} " + - f"!= {self.mac}, id={node_info}" - ) + return self._node_info await self._available_update_state(True) - self._node_info_update_state( firmware=node_info.firmware, node_type=node_info.node_type, hardware=node_info.hardware, timestamp=node_info.timestamp, ) - return True + return self._node_info async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" @@ -614,8 +608,7 @@ async def get_state( + f"not supported for {self.mac}" ) if feature == NodeFeature.INFO: - await self.node_info_update(None) - states[NodeFeature.INFO] = self._node_info + states[NodeFeature.INFO] = await self.node_info_update() elif feature == NodeFeature.AVAILABLE: states[NodeFeature.AVAILABLE] = self.available elif feature == NodeFeature.PING: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e9e075f95..0512f2f17 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -9,7 +9,7 @@ import logging from typing import Any, TypeVar, cast -from ..api import NodeEvent, NodeFeature +from ..api import NodeEvent, NodeFeature, NodeInfo from ..constants import ( MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, @@ -277,11 +277,11 @@ async def energy_update( "Unable to update energy logs for node %s because last_log_address is unknown.", self._node_info.mac, ) - if not await self.node_info_update(): + if await self.node_info_update() is None: return None # request node info update every 30 minutes. elif not self.skip_update(self._node_info, 1800): - if not await self.node_info_update(): + if await self.node_info_update() is None: return None # Always request last energy log records at initial startup @@ -289,7 +289,7 @@ async def energy_update( self._last_energy_log_requested = await self.energy_log_update(self._current_log_address) if self._energy_counters.log_rollover: - if not await self.node_info_update(): + if await self.node_info_update() is None: _LOGGER.debug( "async_energy_update | %s | Log rollover | node_info_update failed", self._node_info.mac, ) @@ -606,7 +606,9 @@ async def _relay_load_from_cache(self) -> bool: "Failed to restore relay state from cache for node %s, try to request node info...", self.mac ) - return await self.node_info_update() + if await self.node_info_update() is None: + return False + return True async def _relay_update_state( self, state: bool, timestamp: datetime | None = None @@ -712,7 +714,7 @@ async def load(self) -> bool: return False # Get node info - if not await self.node_info_update(): + if await self.node_info_update() is None: _LOGGER.info( "Failed to load Circle node %s because it is not responding to information request", self._node_info.mac @@ -782,7 +784,7 @@ async def initialize(self) -> bool: ) self._initialized = False return False - if not await self.node_info_update(): + if await self.node_info_update() is None: _LOGGER.debug( "Failed to retrieve node info for %s", self.mac @@ -811,20 +813,17 @@ async def initialize(self) -> bool: async def node_info_update( self, node_info: NodeInfoResponse | None = None - ) -> bool: + ) -> NodeInfo | None: """Update Node (hardware) information.""" if node_info is None: if self.skip_update(self._node_info, 30): - return True + return self._node_info node_info: NodeInfoResponse = await self._send( NodeInfoRequest(self._mac_in_bytes) ) - if not await super().node_info_update(node_info): - return False - if node_info is None: - return False - + return None + await super().node_info_update(node_info) await self._relay_update_state( node_info.relay_state, timestamp=node_info.timestamp ) @@ -846,7 +845,7 @@ async def node_info_update( ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) - return True + return self._node_info async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 53d5a53ef..2ff934718 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -64,7 +64,7 @@ async def load(self) -> bool: return False # Get node info - if not await self.node_info_update(): + if await self.node_info_update() is None: _LOGGER.warning( "Failed to load Circle+ node %s because it is not responding to information request", self._node_info.mac diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index d26b8bbbc..7c35bc7a0 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -8,6 +8,7 @@ import logging from typing import Final +from ..api import NodeInfo from ..connection import StickController from ..exceptions import NodeError, NodeTimeout from ..messages.requests import NodeSleepConfigRequest @@ -105,13 +106,12 @@ def maintenance_interval(self) -> int | None: async def node_info_update( self, node_info: NodeInfoResponse | None = None - ) -> bool: + ) -> NodeInfo | None: """Update Node (hardware) information.""" if node_info is None and self.skip_update(self._node_info, 86400): - return True + return self._node_info return await super().node_info_update(node_info) - async def _awake_response(self, message: NodeAwakeResponse) -> None: """Process awake message.""" self._node_last_online = message.timestamp From 3fcb52bb4f914d54c37ca685dabcb4dcea29e3fd Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 21:02:58 +0100 Subject: [PATCH 205/979] Rename cached parameter name --- plugwise_usb/nodes/circle.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0512f2f17..c2a80a02d 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -42,6 +42,8 @@ from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord +CURRENT_LOG_ADDRESS = "current_log_address" + FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -841,7 +843,7 @@ async def node_info_update( if self._current_log_address != node_info.current_logaddress_pointer: self._current_log_address = node_info.current_logaddress_pointer self._set_cache( - "last_log_address", node_info.current_logaddress_pointer + CURRENT_LOG_ADDRESS, node_info.current_logaddress_pointer ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) @@ -851,9 +853,9 @@ async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" result = await super()._node_info_load_from_cache() if ( - last_log_address := self._get_cache("last_log_address") + current_log_address := self._get_cache(CURRENT_LOG_ADDRESS) ) is not None: - self._current_log_address = int(last_log_address) + self._current_log_address = int(current_log_address) return result return False From 49d8d215739724d1ebad18056cf1924bdfc655c9 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 21:50:16 +0100 Subject: [PATCH 206/979] Use constants for cache variable names --- plugwise_usb/nodes/__init__.py | 20 ++++++++----- plugwise_usb/nodes/circle.py | 53 +++++++++++++++++++--------------- plugwise_usb/nodes/scan.py | 5 ++-- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index ee2d50f51..ccc3c970f 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -37,6 +37,10 @@ NodeFeature.INFO, NodeFeature.PING, ) +CACHE_FIRMWARE = "firmware" +CACHE_NODE_TYPE = "node_type" +CACHE_HARDWARE = "hardware" +CACHE_NODE_INFO_TIMESTAMP = "node_info_timestamp" class PlugwiseNode(FeaturePublisher, ABC): @@ -452,9 +456,9 @@ async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" firmware: datetime | None = None node_type: NodeType | None = None - hardware: str | None = self._get_cache("hardware") + hardware: str | None = self._get_cache(CACHE_HARDWARE) timestamp: datetime | None = None - if (firmware_str := self._get_cache("firmware")) is not None: + if (firmware_str := self._get_cache(CACHE_FIRMWARE)) is not None: data = firmware_str.split("-") if len(data) == 6: firmware = datetime( @@ -466,10 +470,10 @@ async def _node_info_load_from_cache(self) -> bool: second=int(data[5]), tzinfo=timezone.utc ) - if (node_type_str := self._get_cache("node_type")) is not None: + if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: node_type = NodeType(int(node_type_str)) if ( - timestamp_str := self._get_cache("node_info_timestamp") + timestamp_str := self._get_cache(CACHE_NODE_INFO_TIMESTAMP) ) is not None: data = timestamp_str.split("-") if len(data) == 6: @@ -502,7 +506,7 @@ def _node_info_update_state( complete = False else: self._node_info.firmware = firmware - self._set_cache("firmware", firmware) + self._set_cache(CACHE_FIRMWARE, firmware) if hardware is None: complete = False else: @@ -518,17 +522,17 @@ def _node_info_update_state( ) if self._node_info.model is not None: self._node_info.name = str(self._node_info.mac[-5:]) - self._set_cache("hardware", hardware) + self._set_cache(CACHE_HARDWARE, hardware) if timestamp is None: complete = False else: self._node_info.timestamp = timestamp - self._set_cache("node_info_timestamp", timestamp) + self._set_cache(CACHE_NODE_INFO_TIMESTAMP, timestamp) if node_type is None: complete = False else: self._node_info.type = NodeType(node_type) - self._set_cache("node_type", self._node_info.type.value) + self._set_cache(CACHE_NODE_TYPE, self._node_info.type.value) if self._loaded and self._initialized: create_task(self.save_cache()) return complete diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c2a80a02d..b4f12c195 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -42,7 +42,14 @@ from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord -CURRENT_LOG_ADDRESS = "current_log_address" +CACHE_CURRENT_LOG_ADDRESS = "current_log_address" +CACHE_CALIBRATION_GAIN_A = "calibration_gain_a" +CACHE_CALIBRATION_GAIN_B = "calibration_gain_b" +CACHE_CALIBRATION_NOISE = "calibration_noise" +CACHE_CALIBRATION_TOT = "calibration_tot" +CACHE_ENERGY_COLLECTION = "energy_collection" +CACHE_RELAY = "relay" +CACHE_RELAY_INIT = "relay_init" FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -158,13 +165,13 @@ async def _calibration_load_from_cache(self) -> bool: cal_gain_b: float | None = None cal_noise: float | None = None cal_tot: float | None = None - if (gain_a := self._get_cache("calibration_gain_a")) is not None: + if (gain_a := self._get_cache(CACHE_CALIBRATION_GAIN_A)) is not None: cal_gain_a = float(gain_a) - if (gain_b := self._get_cache("calibration_gain_b")) is not None: + if (gain_b := self._get_cache(CACHE_CALIBRATION_GAIN_B)) is not None: cal_gain_b = float(gain_b) - if (noise := self._get_cache("calibration_noise")) is not None: + if (noise := self._get_cache(CACHE_CALIBRATION_NOISE)) is not None: cal_noise = float(noise) - if (tot := self._get_cache("calibration_tot")) is not None: + if (tot := self._get_cache(CACHE_CALIBRATION_TOT)) is not None: cal_tot = float(tot) # Restore calibration @@ -211,10 +218,10 @@ def _calibration_update_state( self._energy_counters.calibration = self._calibration if self._cache_enabled: - self._set_cache("calibration_gain_a", gain_a) - self._set_cache("calibration_gain_b", gain_b) - self._set_cache("calibration_noise", off_noise) - self._set_cache("calibration_tot", off_tot) + self._set_cache(CACHE_CALIBRATION_GAIN_A, gain_a) + self._set_cache(CACHE_CALIBRATION_GAIN_B, gain_b) + self._set_cache(CACHE_CALIBRATION_NOISE, off_noise) + self._set_cache(CACHE_CALIBRATION_TOT, off_tot) if self._loaded and self._initialized: create_task(self.save_cache()) return True @@ -441,14 +448,14 @@ async def energy_log_update(self, address: int) -> bool: async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" - if self._get_cache("energy_collection") is None: + if self._get_cache(CACHE_ENERGY_COLLECTION) is None: _LOGGER.info( "Failed to restore energy log records from cache for node %s", self.mac ) return False restored_logs: dict[int, list[int]] = {} - log_data = self._get_cache("energy_collection").split("|") + log_data = self._get_cache(CACHE_ENERGY_COLLECTION).split("|") for log_record in log_data: log_fields = log_record.split(":") if len(log_fields) == 4: @@ -511,7 +518,7 @@ async def _energy_log_records_save_to_cache(self) -> None: cached_logs += f"-{log.timestamp.month}-{log.timestamp.day}" cached_logs += f"-{log.timestamp.hour}-{log.timestamp.minute}" cached_logs += f"-{log.timestamp.second}:{log.pulses}" - self._set_cache("energy_collection", cached_logs) + self._set_cache(CACHE_ENERGY_COLLECTION, cached_logs) async def _energy_log_record_update_state( self, @@ -535,7 +542,7 @@ async def _energy_log_record_update_state( log_cache_record += f"-{timestamp.month}-{timestamp.day}" log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" log_cache_record += f"-{timestamp.second}:{pulses}" - if (cached_logs := self._get_cache("energy_collection")) is not None: + if (cached_logs := self._get_cache(CACHE_ENERGY_COLLECTION)) is not None: if log_cache_record not in cached_logs: _LOGGER.debug( "Add logrecord (%s, %s) to log cache of %s", @@ -544,14 +551,14 @@ async def _energy_log_record_update_state( self.mac ) self._set_cache( - "energy_collection", cached_logs + "|" + log_cache_record + CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record ) else: _LOGGER.debug( "No existing energy collection log cached for %s", self.mac ) - self._set_cache("energy_collection", log_cache_record) + self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) async def switch_relay(self, state: bool) -> bool | None: """Switch state of relay. @@ -594,7 +601,7 @@ async def _relay_load_from_cache(self) -> bool: if self._relay is not None: # State already known, no need to load from cache return True - if (cached_relay_data := self._get_cache("relay")) is not None: + if (cached_relay_data := self._get_cache(CACHE_RELAY)) is not None: _LOGGER.debug( "Restore relay state cache for node %s", self.mac @@ -620,11 +627,11 @@ async def _relay_update_state( self._relay_state.timestamp = timestamp state_update = False if state: - self._set_cache("relay", "True") + self._set_cache(CACHE_RELAY, "True") if (self._relay is None or not self._relay): state_update = True if not state: - self._set_cache("relay", "False") + self._set_cache(CACHE_RELAY, "False") if (self._relay is None or self._relay): state_update = True self._relay = state @@ -843,7 +850,7 @@ async def node_info_update( if self._current_log_address != node_info.current_logaddress_pointer: self._current_log_address = node_info.current_logaddress_pointer self._set_cache( - CURRENT_LOG_ADDRESS, node_info.current_logaddress_pointer + CACHE_CURRENT_LOG_ADDRESS, node_info.current_logaddress_pointer ) if self.cache_enabled and self._loaded and self._initialized: create_task(self.save_cache()) @@ -853,7 +860,7 @@ async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" result = await super()._node_info_load_from_cache() if ( - current_log_address := self._get_cache(CURRENT_LOG_ADDRESS) + current_log_address := self._get_cache(CACHE_CURRENT_LOG_ADDRESS) ) is not None: self._current_log_address = int(current_log_address) return result @@ -905,7 +912,7 @@ async def _relay_init_set(self, state: bool) -> bool | None: async def _relay_init_load_from_cache(self) -> bool: """Load relay init state from cache. Returns True if retrieval was successful.""" - if (cached_relay_data := self._get_cache("relay_init")) is not None: + if (cached_relay_data := self._get_cache(CACHE_RELAY_INIT)) is not None: relay_init_state = False if cached_relay_data == "True": relay_init_state = True @@ -917,11 +924,11 @@ async def _relay_init_update_state(self, state: bool) -> None: """Process relay init state update.""" state_update = False if state: - self._set_cache("relay_init", "True") + self._set_cache(CACHE_RELAY_INIT, "True") if self._relay_init_state is None or not self._relay_init_state: state_update = True if not state: - self._set_cache("relay_init", "False") + self._set_cache(CACHE_RELAY_INIT, "False") if self._relay_init_state is None or self._relay_init_state: state_update = True if state_update: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index e950270e2..049a9c1eb 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__) +CACHE_MOTION = "motion" # Defaults for Scan Devices @@ -107,11 +108,11 @@ async def motion_state_update( self._motion_state.timestamp = timestamp state_update = False if motion_state: - self._set_cache("motion", "True") + self._set_cache(CACHE_MOTION, "True") if self._motion is None or not self._motion: state_update = True if not motion_state: - self._set_cache("motion", "False") + self._set_cache(CACHE_MOTION, "False") if self._motion is None or self._motion: state_update = True if state_update: From fe7a921ba1b0e1b45d0d642c2e9671b9b27b566e Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 12 Feb 2024 21:51:46 +0100 Subject: [PATCH 207/979] Use only first part of model type for name Add tests too --- plugwise_usb/nodes/__init__.py | 2 +- tests/stick_test_data.py | 2 +- tests/test_usb.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index ccc3c970f..9d94b4cbd 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -521,7 +521,7 @@ def _node_info_update_state( hardware, ) if self._node_info.model is not None: - self._node_info.name = str(self._node_info.mac[-5:]) + self._node_info.name = f"{self._node_info.model.split(' ')[0]} {self._node_info.mac[-5:]}" self._set_cache(CACHE_HARDWARE, hardware) if timestamp is None: complete = False diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index c9be09e94..95753c50b 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -47,7 +47,7 @@ + b"00044280" # log address 20 + b"01" # relay + b"01" # hz - + b"000000070073" # hw_ver + + b"000000730007" # hw_ver + b"4E0843A9" # fw_ver + b"01", # node_type (Circle+) ), diff --git a/tests/test_usb.py b/tests/test_usb.py index cfb9330b1..e7ac6d1d1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1340,4 +1340,14 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.connect() await stick.initialize() await stick.discover_nodes(load=True) + + assert stick.nodes["0098765432101234"].node_info.firmware == dt( + 2011, 6, 27, 8, 47, 37, tzinfo=tz.utc + ) + assert stick.nodes["0098765432101234"].node_info.version == "000000730007" + assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" + assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" + assert stick.nodes["0098765432101234"].available + assert not stick.nodes["0098765432101234"].node_info.battery_powered + await stick.disconnect() From dde6c085d10fa5e5b456117052fee46e40000da4 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 13 Feb 2024 20:07:22 +0100 Subject: [PATCH 208/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c5a43cad6..d8f313b1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a2" +version = "v0.40.0a3" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 32b1efae3116921e0d4d892073e5bde2582662d9 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 13 Feb 2024 20:25:50 +0100 Subject: [PATCH 209/979] bump cache version --- .github/workflows/verify.yml | 40 +++++++++++++++++------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a099c4d33..f5b8540c9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -22,7 +22,7 @@ jobs: name: Prepare steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -48,9 +48,8 @@ jobs: pip install virtualenv --upgrade python -m venv venv . venv/bin/activate - pip install uv - uv pip install -U pip setuptools wheel - uv pip install -r requirements_test.txt -r requirements_commit.txt + pip install -U pip setuptools wheel + pip install -r requirements_test.txt -r requirements_commit.txt - name: Restore pre-commit environment from cache id: cache-precommit uses: actions/cache@v4 @@ -72,7 +71,7 @@ jobs: needs: prepare steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -98,7 +97,7 @@ jobs: - name: Ruff (check) run: | . venv/bin/activate - #ruff check plugwise_usb/*py tests/*py + #ruff plugwise_usb/*py tests/*py echo "***" echo "***" echo "Code is not up to par for ruff, skipping" @@ -108,7 +107,7 @@ jobs: if: failure() run: | . venv/bin/activate - ruff check --fix plugwise_usb/*py tests/*py + ruff --fix plugwise_usb/*py tests/*py git config --global user.name 'autoruff' git config --global user.email 'plugwise_usb@users.noreply.github.com' git remote set-url origin https://x-access-token:${{ secrets.PAT_CT }}@github.com/$GITHUB_REPOSITORY @@ -125,7 +124,7 @@ jobs: - dependencies_check steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -176,7 +175,7 @@ jobs: python-version: ["3.13"] steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -200,12 +199,11 @@ jobs: run: | python -m venv venv . venv/bin/activate - pip install uv - uv pip install -U pip setuptools wheel - # uv pip install -r requirements_test.txt + pip install -U pip setuptools wheel + #pip install -r requirements_test.txt # 20220124 Mimic setup_test.sh - uv pip install --upgrade -r requirements_test.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt - uv pip install --upgrade pytest-asyncio + pip install --upgrade -r requirements_test.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt + pip install --upgrade pytest-asyncio pytest: runs-on: ubuntu-latest @@ -217,7 +215,7 @@ jobs: steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -255,7 +253,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -295,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master @@ -305,7 +303,7 @@ jobs: name: Dependency steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Run dependency checker run: scripts/dependencies_check.sh debug @@ -315,7 +313,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -360,7 +358,7 @@ jobs: needs: [coverage, mypy] steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -403,7 +401,7 @@ jobs: needs: coverage steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 From 81310bdbb300b65173407661047357f1be111642 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Thu, 8 Feb 2024 22:06:02 +0000 Subject: [PATCH 210/979] time out on node message sooner, no need waiting 20 seconds --- plugwise_usb/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 917ff5e87..40f591468 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -54,7 +54,7 @@ STICK_ACCEPT_TIME_OUT: Final = 6 # Stick accept respond. STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. QUEUE_TIME_OUT: Final = 45 # Total seconds to wait for queue -NODE_TIME_OUT: Final = 20 +NODE_TIME_OUT: Final = 5 DISCOVERY_TIME_OUT: Final = 45 REQUEST_TIMEOUT: Final = 0.5 MAX_RETRIES: Final = 3 From 47df6de9076f86b47bcf2e6535a19d285e9c34f0 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 10 Feb 2024 11:28:23 +0000 Subject: [PATCH 211/979] Receiver: - use 1 _msg_processing_task instead of starting short lived - Keep the last 20 messages to detect double responses (and disregard) - Message processing split by line.... maybe do something with the announcements maybe circle's coming online - _node_response_subscribers callbacks return a bool if the callback processed something. delayed_run(self._notify_node_response_subscribers( .. should not be called anymore... Queue: - use 1 running task instead of starting short lived - Resend while resend --- plugwise_usb/connection/queue.py | 75 ++++++++++------- plugwise_usb/connection/receiver.py | 123 +++++++++++++++------------- plugwise_usb/messages/requests.py | 36 ++++++-- plugwise_usb/messages/responses.py | 23 +++--- plugwise_usb/network/__init__.py | 11 ++- plugwise_usb/nodes/sed.py | 4 +- 6 files changed, 160 insertions(+), 112 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index aeeed4564..a42079145 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -6,8 +6,7 @@ InvalidStateError, PriorityQueue, Task, - get_running_loop, - sleep, + get_running_loop ) from collections.abc import Callable import contextlib @@ -15,7 +14,7 @@ import logging from ..api import StickEvent -from ..exceptions import StickError +from ..exceptions import StickError, StickTimeout, NodeTimeout from ..messages.requests import PlugwiseRequest from ..messages.responses import PlugwiseResponse from .manager import StickConnectionManager @@ -64,6 +63,9 @@ def start( (StickEvent.CONNECTED, StickEvent.DISCONNECTED) ) ) + self._submit_worker_task = self._loop.create_task( + self._submit_worker() + ) async def _handle_stick_event(self, event: StickEvent) -> None: """Handle events from stick.""" @@ -94,39 +96,50 @@ async def submit( ) -> PlugwiseResponse: """Add request to queue and return the response of node. Raises an error when something fails.""" _LOGGER.debug("Queueing %s", request) - if not self._running or self._stick is None: - raise StickError( - f"Cannot send message {request.__class__.__name__} for {request.mac_decoded} because queue manager is stopped" - ) - - await self._add_request_to_queue(request) - try: - response: PlugwiseResponse = await request.response_future() - except BaseException as exception: # [broad-exception-caught] - raise StickError( - f"No response received for {request.__class__.__name__} " + - f"to {request.mac_decoded}" - ) from exception - return response + while request.resend: + if not self._running or self._stick is None: + raise StickError( + f"Cannot send message {request.__class__.__name__} for" + + f"{request.mac_decoded} because queue manager is stopped" + ) + await self._add_request_to_queue(request) + try: + response: PlugwiseResponse = await request.response_future() + return response + except (NodeTimeout, StickTimeout) as e: + logging.warning('Node timeout %s on %s, retrying', e, request) + request.reset_future() + except StickError as exception: # [broad-exception-caught]\ + logging.exception(exception) + raise StickError( + f"No response received for {request.__class__.__name__} " + + f"to {request.mac_decoded}" + ) from exception + except BaseException as exception: # [broad-exception-caught] + raise StickError( + f"No response received for {request.__class__.__name__} " + + f"to {request.mac_decoded}" + ) from exception + + + raise StickError( + f"Failed to send {request.__class__.__name__} " + + f"to node {request.mac_decoded}, maximum number " + + f"of retries ({request.max_retries}) has been reached" + ) async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue and return the session id.""" - await self._queue.put(request) - self._start_submit_worker() - - def _start_submit_worker(self) -> None: - """Start the submit worker if submit worker is not yet running.""" - if self._submit_worker_task is None or self._submit_worker_task.done(): - self._submit_worker_task = self._loop.create_task( - self._submit_worker() - ) + await self._queue.put((request.priority, request)) + async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" - while (size := self._queue.qsize()) > 0: + #while self._queue.qsize() > 0: + while self._running: # Get item with highest priority from queue first - request = await self._queue.get() - sleeptime = (2**size) * 0.0001 - sleeptime = min(sleeptime, 0.05) - await sleep(sleeptime) + + _priority, request = await self._queue.get() await self._stick.write_to_stick(request) + + self._queue.task_done() diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 069609b15..4ccc9efd2 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -18,10 +18,10 @@ from asyncio import ( Future, + gather, + Task, Protocol, Queue, - create_task, - gather, get_running_loop, sleep, ) @@ -64,9 +64,11 @@ def __init__( self._buffer: bytes = bytes([]) self._connection_state = False self._request_queue = Queue() + self._last_20_processed_messages = [] self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None + self._msg_processing_task: Task | None = None # Subscribers self._stick_event_subscribers: dict[ Callable[[], None], @@ -84,7 +86,7 @@ def __init__( self._node_response_subscribers: dict[ Callable[[], None], tuple[ - Callable[[PlugwiseResponse], Awaitable[None]], bytes | None, + Callable[[PlugwiseResponse], Awaitable[bool]], bytes | None, tuple[bytes] | None, ] ] = {} @@ -127,72 +129,63 @@ def connection_made(self, transport: SerialTransport) -> None: self._loop.create_task( self._notify_stick_event_subscribers(StickEvent.CONNECTED) ) + self._msg_processing_task = self._loop.create_task( + self._msg_queue_processing_function() + ) async def close(self) -> None: """Close connection.""" + if self._transport is None: return if self._stick_future is not None and not self._stick_future.done(): self._stick_future.cancel() self._transport.close() + self._msg_processing_task.cancel() def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. This function is called by inherited asyncio.Protocol class """ - _LOGGER.debug("USB stick received [%s]", data) + #_LOGGER.info("USB stick received [%s]", data) self._buffer += data - if len(self._buffer) < 8: - return - while self.extract_message_from_buffer(): - pass - - def extract_message_from_buffer(self) -> bool: - """Parse data in buffer and extract any message. - - When buffer does not contain any message return False. - """ - # Lookup header of message - if (_header_index := self._buffer.find(MESSAGE_HEADER)) == -1: + if MESSAGE_FOOTER in self._buffer: + msgs = self._buffer.split(MESSAGE_FOOTER) + for msg in msgs[:-1]: + if (response := self.extract_message_from_line_buffer(msg)): + self._request_queue.put_nowait(response) + if len(msgs) > 4: + _LOGGER.debug('Stick gave %d messages at once', len(msgs)) + self._buffer = msgs[-1] # whatever was left over + if self._buffer == b"\x83": + self._buffer = b'' + + def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: + # Lookup header of message, there are stray \x83 + if (_header_index := msg.find(MESSAGE_HEADER)) == -1: return False - self._buffer = self._buffer[_header_index:] - - # Lookup footer of message - if (_footer_index := self._buffer.find(MESSAGE_FOOTER)) == -1: - return False - + msg = msg[_header_index:] # Detect response message type - _empty_message = get_message_object( - self._buffer[4:8], _footer_index, self._buffer[8:12] - ) - if _empty_message is None: - _raw_msg_data = self._buffer[2:][: _footer_index - 4] - self._buffer = self._buffer[_footer_index:] + identifier = msg[4:8] + seq_id = msg[8:12] + msg_length = len(msg) + if (response := get_message_object(identifier, msg_length, seq_id)) is None: + _raw_msg_data = msg[2:][: msg_length - 4] _LOGGER.warning("Drop unknown message type %s", str(_raw_msg_data)) - return True + return None # Populate response message object with data - response: PlugwiseResponse | None = None - response = self._populate_message( - _empty_message, self._buffer[: _footer_index + 2] - ) - _LOGGER.debug("USB Got %s", response) - # Parse remaining buffer - self._reset_buffer(self._buffer[_footer_index:]) - - if response is not None: - self._request_queue.put_nowait(response) - - if len(self._buffer) >= 8: - self.extract_message_from_buffer() - else: - self._loop.create_task( - self._msg_queue_processing_function() - ) - return False + try: + response.deserialize(msg, has_footer=False) + except MessageError as err: + _LOGGER.warning(err) + return None + _LOGGER.debug('USB Got %s', response) + return response + def _populate_message( self, message: PlugwiseResponse, data: bytes ) -> PlugwiseResponse | None: @@ -205,14 +198,18 @@ def _populate_message( return message async def _msg_queue_processing_function(self): - while self._request_queue.qsize() > 0: + + while self.is_connected: + #while self._request_queue.qsize() > 0: response: PlugwiseResponse | None = await self._request_queue.get() - _LOGGER.debug("Processing %s", response) + if isinstance(response, StickResponse): await self._notify_stick_response_subscribers(response) + elif response is None: + return else: + _LOGGER.debug("Processing %s", response) await self._notify_node_response_subscribers(response) - self._request_queue.task_done() def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -281,7 +278,7 @@ async def _notify_stick_response_subscribers( def subscribe_to_node_responses( self, - node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + node_response_callback: Callable[[PlugwiseResponse], Awaitable[bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: @@ -301,8 +298,10 @@ def remove_listener() -> None: async def _notify_node_response_subscribers( self, node_response: PlugwiseResponse ) -> None: - """Call callback for all node response message subscribers.""" - callback_list: list[Callable] = [] + + """Call callback for all node response message subscribers""" + #callback_list: list[Callable] = [] + processed = False for callback, mac, message_ids in list( self._node_response_subscribers.values() ): @@ -312,13 +311,19 @@ async def _notify_node_response_subscribers( if message_ids is not None: if node_response.identifier not in message_ids: continue - callback_list.append(callback(node_response)) - - if len(callback_list) > 0: - await gather(*callback_list) + processed = await callback(node_response) + + if processed: + self._last_20_processed_messages.append(node_response.seq_id) + if len(self._last_20_processed_messages) > 20: + self._last_20_processed_messages = self._last_20_processed_messages[:-20] + return + + if node_response.seq_id in self._last_20_processed_messages: + _LOGGER.warning("Got duplicate %s", node_response) return - # No subscription for response, retry in 0.5 sec. + node_response.notify_retries += 1 if node_response.notify_retries > 10: _LOGGER.warning( @@ -328,7 +333,7 @@ async def _notify_node_response_subscribers( node_response.mac_decoded, ) return - create_task( + self._loop.create_task( delayed_run( self._notify_node_response_subscribers( node_response diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 27e463124..b59d6a125 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -16,8 +16,9 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..exceptions import NodeError, StickError -from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType +from ..messages.responses import PlugwiseResponse, StickResponse, \ + StickResponseType +from ..exceptions import NodeError, StickError, NodeTimeout from ..util import ( DateTime, Int, @@ -71,6 +72,7 @@ def __init__( self._response_future: Future[PlugwiseResponse] = ( self._loop.create_future() ) + self._other = False def __repr__(self) -> str: """Convert request into writable str.""" @@ -80,6 +82,10 @@ def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" return self._response_future + def reset_future(self): + """Return awaitable future with response message""" + self._response_future = self._loop.create_future() + @property def response(self) -> PlugwiseResponse: """Return response message.""" @@ -139,7 +145,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) else: self._response_future.set_exception( - NodeError( + NodeTimeout( f"No response within {NODE_TIME_OUT} seconds from node " + f"{self.mac_decoded}" ) @@ -153,15 +159,33 @@ def assign_error(self, error: StickError) -> None: return self._response_future.set_exception(error) - async def _process_node_response(self, response: PlugwiseResponse) -> None: + async def _process_node_response(self, response: PlugwiseResponse) -> bool: """Process incoming message from node.""" if self._seq_id is not None and self._seq_id == response.seq_id: - _LOGGER.debug("Response %s for request %s id %d", response, self, self._id) self._unsubscribe_stick_response() self._response = response self._response_timeout.cancel() - self._response_future.set_result(response) + if not self._response_future.done(): + if self._send_counter > 1: + _LOGGER.info('Response %s for retried request %s id %d', response, self, self._id) + else: + if self._other: + _LOGGER.debug('Response %s for request %s after other', response, self) + else: + _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) + self._response_future.set_result(response) + else: + _LOGGER.warning('Response %s for request %s id %d already done', response, self, self._id) + self._unsubscribe_node_response() + return True + self._other = True + if self._seq_id: + _LOGGER.warning('Response %s for request %s is not mine %s', response, self, str(response.seq_id)) + else: + _LOGGER.warning('Response %s for request %s has not received seq_id', response, self) + return False + async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index c09941aa9..5c91f36b7 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -136,7 +136,7 @@ def notify_retries(self, retries: int) -> None: """Set number of notification retries.""" self._notify_retries = retries - def deserialize(self, response: bytes) -> None: + def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Deserialize bytes to actual message properties.""" self.timestamp = datetime.now(timezone.utc) # Header @@ -150,14 +150,15 @@ def deserialize(self, response: bytes) -> None: response = response[4:] # Footer - if response[-2:] != MESSAGE_FOOTER: - raise MessageError( - "Invalid message footer " - + str(response[-2:]) - + " for " - + self.__class__.__name__ - ) - response = response[:-2] + if has_footer: + if response[-2:] != MESSAGE_FOOTER: + raise MessageError( + "Invalid message footer " + + str(response[-2:]) + + " for " + + self.__class__.__name__ + ) + response = response[:-2] # Checksum if (check := self.calculate_checksum(response[:-4])) != response[-4:]: @@ -273,9 +274,9 @@ def __init__(self) -> None: self.idx, ] - def deserialize(self, response: bytes) -> None: + def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Extract data from bytes.""" - super().deserialize(response) + super().deserialize(response, has_footer) # Clear first two characters of mac ID, as they contain # part of the short PAN-ID self.new_node_mac_id.value = b"00" + self.new_node_mac_id.value[2:] diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index ddd58d748..f484201b1 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -190,7 +190,7 @@ async def _handle_stick_event(self, event: StickEvent) -> None: ) self._is_running = False - async def node_awake_message(self, response: NodeAwakeResponse) -> None: + async def node_awake_message(self, response: NodeAwakeResponse) -> bool: """Handle NodeAwakeResponse message.""" mac = response.mac_decoded if self._awake_discovery.get(mac) is None: @@ -203,25 +203,28 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: ): await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp - return + return True if self._register.network_address(mac) is None: _LOGGER.warning( "Skip node awake message for %s because network registry address is unknown", mac ) - return + return False address: int | None = self._register.network_address(mac) if self._nodes.get(mac) is None: await self._discover_node(address, mac, None) await self._load_node(mac) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + return False + async def node_join_available_message( self, response: NodeJoinAvailableResponse - ) -> None: + ) -> bool: """Handle NodeJoinAvailableResponse messages.""" mac = response.mac_decoded await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + return True def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 7c35bc7a0..6b9ecc1b1 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -117,7 +117,7 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: self._node_last_online = message.timestamp await self._available_update_state(True) if message.timestamp is None: - return + return False if ( NodeAwakeResponseType(message.awake_type.value) == NodeAwakeResponseType.MAINTENANCE @@ -129,6 +129,8 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: if ping_response is not None: self._ping_at_awake = False await self.reset_maintenance_awake(message.timestamp) + return True + return False async def reset_maintenance_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" From a9956aa391e06c47d81ff12cdd27069c46f8d9e1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 14 Feb 2024 22:32:57 +0100 Subject: [PATCH 212/979] Remove empty line --- plugwise_usb/network/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f484201b1..3fb6d2660 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -216,7 +216,6 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: await self._load_node(mac) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) return False - async def node_join_available_message( self, response: NodeJoinAvailableResponse From 030fdb73977459398130883cd397803ef36d2b1c Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 14 Feb 2024 22:33:56 +0100 Subject: [PATCH 213/979] Use address rollover for current log pointer --- plugwise_usb/nodes/circle.py | 39 +++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b4f12c195..f5afa0d98 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import create_task, sleep +from asyncio import create_task, gather, sleep from collections.abc import Awaitable, Callable from datetime import datetime, timezone from functools import wraps @@ -40,7 +40,7 @@ from ..nodes import EnergyStatistics, PlugwiseNode, PowerStatistics from .helpers import EnergyCalibration, raise_not_loaded from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT -from .helpers.pulses import PulseLogRecord +from .helpers.pulses import PulseLogRecord, calc_log_address CACHE_CURRENT_LOG_ADDRESS = "current_log_address" CACHE_CALIBRATION_GAIN_A = "calibration_gain_a" @@ -313,11 +313,12 @@ async def energy_update( if self._energy_counters.log_rollover: # Retry with previous log address as Circle node pointer to self._current_log_address # could be rolled over while the last log is at previous address/slot - if not await self.energy_log_update(self._current_log_address - 1): + _prev_log_address, _ = calc_log_address(self._current_log_address, 1, -4) + if not await self.energy_log_update(_prev_log_address): _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", self._node_info.mac, - self._current_log_address - 1, + _prev_log_address, ) return @@ -368,18 +369,20 @@ async def get_missing_energy_logs(self) -> None: "Start with initial energy request for the last 10 log addresses for node %s.", self._node_info.mac, ) - for address in range( - self._current_log_address, - self._current_log_address - 11, - -1, - ): - if not await self.energy_log_update(address): - _LOGGER.debug( - "Failed to update energy log %s for %s", - str(address), - self._mac_in_str, - ) - break + total_addresses = 11 + log_address = self._current_log_address + log_update_tasks = [] + while total_addresses > 0: + log_update_tasks.append(self.energy_log_update(log_address)) + log_address, _ = calc_log_address(log_address, 1, -4) + total_addresses -= 1 + + if not await gather(*log_update_tasks): + _LOGGER.warning( + "Failed to request one or more update energy log for %s", + self._mac_in_str, + ) + if self._cache_enabled: await self._energy_log_records_save_to_cache() return @@ -405,12 +408,12 @@ async def get_missing_energy_logs(self) -> None: async def energy_log_update(self, address: int) -> bool: """Request energy log statistics from node. Returns true if successful.""" - request = CircleEnergyLogsRequest(self._mac_in_bytes, address) - _LOGGER.debug( + _LOGGER.info( "Request of energy log at address %s for node %s", str(address), self._mac_in_str, ) + request = CircleEnergyLogsRequest(self._mac_in_bytes, address) response: CircleEnergyLogsResponse | None = None if (response := await self._send(request)) is None: _LOGGER.debug( From 1d469f843a168490f6cd8769fb23f404696516b6 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sun, 11 Feb 2024 22:27:51 +0100 Subject: [PATCH 214/979] remove usb data debug logging --- plugwise_usb/connection/receiver.py | 1 - plugwise_usb/connection/sender.py | 1 - 2 files changed, 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 4ccc9efd2..2a1d2926d 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -149,7 +149,6 @@ def data_received(self, data: bytes) -> None: This function is called by inherited asyncio.Protocol class """ - #_LOGGER.info("USB stick received [%s]", data) self._buffer += data if MESSAGE_FOOTER in self._buffer: msgs = self._buffer.split(MESSAGE_FOOTER) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index fe2346ead..815dc4554 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -71,7 +71,6 @@ async def write_request_to_port( _LOGGER.debug("Sending %s", request) # Write message to serial port buffer - _LOGGER.debug("USB write [%s]", str(serialized_data)) self._transport.write(serialized_data) request.add_send_attempt() request.start_response_timeout() From 4982de18515d256a8d1ffb4b9a7c60cf3a838ddb Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 14 Feb 2024 22:38:47 +0100 Subject: [PATCH 215/979] Use __repr__ from inherited class --- plugwise_usb/messages/responses.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 5c91f36b7..7001baaf4 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -743,10 +743,9 @@ def log_address(self) -> int: """Return the gain A.""" return self._logaddr.value - def __repr__(self) -> str: """Convert request into writable str.""" - return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) + " | log_address=" + str(self._logaddr.value) + return super().__repr__() + " | log_address=" + str(self._logaddr.value) class NodeAwakeResponse(PlugwiseResponse): From 25e90a44bb35d248e0ef331472462dde7760ef15 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 16 Feb 2024 17:29:56 +0100 Subject: [PATCH 216/979] Apply pylint formatting --- plugwise_usb/connection/queue.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index a42079145..245002bdb 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -6,7 +6,7 @@ InvalidStateError, PriorityQueue, Task, - get_running_loop + get_running_loop, ) from collections.abc import Callable import contextlib @@ -14,7 +14,7 @@ import logging from ..api import StickEvent -from ..exceptions import StickError, StickTimeout, NodeTimeout +from ..exceptions import NodeTimeout, StickError, StickTimeout from ..messages.requests import PlugwiseRequest from ..messages.responses import PlugwiseResponse from .manager import StickConnectionManager @@ -65,7 +65,7 @@ def start( ) self._submit_worker_task = self._loop.create_task( self._submit_worker() - ) + ) async def _handle_stick_event(self, event: StickEvent) -> None: """Handle events from stick.""" @@ -107,7 +107,7 @@ async def submit( response: PlugwiseResponse = await request.response_future() return response except (NodeTimeout, StickTimeout) as e: - logging.warning('Node timeout %s on %s, retrying', e, request) + logging.warning("Node timeout %s on %s, retrying", e, request) request.reset_future() except StickError as exception: # [broad-exception-caught]\ logging.exception(exception) @@ -119,9 +119,8 @@ async def submit( raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" - ) from exception - - + ) from exception + raise StickError( f"Failed to send {request.__class__.__name__} " + f"to node {request.mac_decoded}, maximum number " + @@ -132,13 +131,10 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue and return the session id.""" await self._queue.put((request.priority, request)) - async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" - #while self._queue.qsize() > 0: while self._running: # Get item with highest priority from queue first - _priority, request = await self._queue.get() await self._stick.write_to_stick(request) From cf21a7a4d0dad61d696fe374d050db14f08eda8e Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 16 Feb 2024 20:05:57 +0100 Subject: [PATCH 217/979] There is no need to set priority seperately PlugwiseRequest class has the required comparable functions: __gt__, __lt__, __ge__, __le__ --- plugwise_usb/connection/queue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 245002bdb..f6280269c 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -129,13 +129,13 @@ async def submit( async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue and return the session id.""" - await self._queue.put((request.priority, request)) + await self._queue.put(request) async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" while self._running: # Get item with highest priority from queue first - _priority, request = await self._queue.get() + request = await self._queue.get() await self._stick.write_to_stick(request) self._queue.task_done() From 80659012b82c980baafa5b0f5f7d071b9eba126b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:02:43 +0100 Subject: [PATCH 218/979] Correct typing and wait for task to be cancelled --- plugwise_usb/nodes/circle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f5afa0d98..39a7ec858 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,8 +2,8 @@ from __future__ import annotations -from asyncio import create_task, gather, sleep -from collections.abc import Awaitable, Callable +from asyncio import Task, create_task, gather, sleep, wait +from collections.abc import Callable from datetime import datetime, timezone from functools import wraps import logging @@ -70,7 +70,7 @@ def decorated(*args: Any, **kwargs: Any) -> Any: class PlugwiseCircle(PlugwiseNode): """Plugwise Circle node.""" - _retrieve_energy_logs_task: None | Awaitable = None + _retrieve_energy_logs_task: None | Task = None _last_energy_log_requested: bool = False @property @@ -873,6 +873,7 @@ async def unload(self) -> None: """Deactivate and unload node features.""" if self._retrieve_energy_logs_task is not None and not self._retrieve_energy_logs_task.done(): self._retrieve_energy_logs_task.cancel() + await wait([self._retrieve_energy_logs_task]) if self._cache_enabled: await self._energy_log_records_save_to_cache() await self.save_cache() From 243d91a1241825d4c363b0607c872342d1dbf848 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:03:17 +0100 Subject: [PATCH 219/979] Apply pylint --- plugwise_usb/connection/manager.py | 5 +---- plugwise_usb/connection/receiver.py | 17 +++++++++-------- plugwise_usb/messages/requests.py | 23 ++++++++++------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index ffcf0fd78..922244d96 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -90,10 +90,7 @@ def subscribe_to_stick_events( def remove_subscription() -> None: """Remove stick event subscription.""" self._stick_event_subscribers.pop(remove_subscription) - - self._stick_event_subscribers[ - remove_subscription - ] = (stick_event_callback, events) + self._stick_event_subscribers[remove_subscription] = (stick_event_callback, events) return remove_subscription def subscribe_to_stick_replies( diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2a1d2926d..49e777c5c 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -156,19 +156,20 @@ def data_received(self, data: bytes) -> None: if (response := self.extract_message_from_line_buffer(msg)): self._request_queue.put_nowait(response) if len(msgs) > 4: - _LOGGER.debug('Stick gave %d messages at once', len(msgs)) + _LOGGER.debug("Stick gave %d messages at once", len(msgs)) self._buffer = msgs[-1] # whatever was left over if self._buffer == b"\x83": self._buffer = b'' def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: - # Lookup header of message, there are stray \x83 + """Extract message from buffer.""" + # Lookup header of message, there are stray \x83 if (_header_index := msg.find(MESSAGE_HEADER)) == -1: return False msg = msg[_header_index:] # Detect response message type identifier = msg[4:8] - seq_id = msg[8:12] + seq_id = msg[8:12] msg_length = len(msg) if (response := get_message_object(identifier, msg_length, seq_id)) is None: _raw_msg_data = msg[2:][: msg_length - 4] @@ -182,9 +183,9 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: _LOGGER.warning(err) return None - _LOGGER.debug('USB Got %s', response) + _LOGGER.debug("USB Got %s", response) return response - + def _populate_message( self, message: PlugwiseResponse, data: bytes ) -> PlugwiseResponse | None: @@ -311,18 +312,18 @@ async def _notify_node_response_subscribers( if node_response.identifier not in message_ids: continue processed = await callback(node_response) - + if processed: self._last_20_processed_messages.append(node_response.seq_id) if len(self._last_20_processed_messages) > 20: self._last_20_processed_messages = self._last_20_processed_messages[:-20] return - + if node_response.seq_id in self._last_20_processed_messages: _LOGGER.warning("Got duplicate %s", node_response) return + # No subscription for response, retry in 0.5 sec. - node_response.notify_retries += 1 if node_response.notify_retries > 10: _LOGGER.warning( diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index b59d6a125..c8387c3af 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -16,9 +16,8 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..messages.responses import PlugwiseResponse, StickResponse, \ - StickResponseType -from ..exceptions import NodeError, StickError, NodeTimeout +from ..exceptions import NodeError, NodeTimeout, StickError +from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from ..util import ( DateTime, Int, @@ -83,7 +82,7 @@ def response_future(self) -> Future[PlugwiseResponse]: return self._response_future def reset_future(self): - """Return awaitable future with response message""" + """Return awaitable future with response message.""" self._response_future = self._loop.create_future() @property @@ -167,26 +166,24 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: self._response_timeout.cancel() if not self._response_future.done(): if self._send_counter > 1: - _LOGGER.info('Response %s for retried request %s id %d', response, self, self._id) + _LOGGER.info("Response %s for retried request %s id %d", response, self, self._id) + elif self._other: + _LOGGER.debug("Response %s for request %s after other", response, self) else: - if self._other: - _LOGGER.debug('Response %s for request %s after other', response, self) - else: - _LOGGER.debug('Response %s for request %s id %d', response, self, self._id) + _LOGGER.debug("Response %s for request %s id %d", response, self, self._id) self._response_future.set_result(response) else: - _LOGGER.warning('Response %s for request %s id %d already done', response, self, self._id) + _LOGGER.warning("Response %s for request %s id %d already done", response, self, self._id) self._unsubscribe_node_response() return True self._other = True if self._seq_id: - _LOGGER.warning('Response %s for request %s is not mine %s', response, self, str(response.seq_id)) + _LOGGER.warning("Response %s for request %s is not mine %s", response, self, str(response.seq_id)) else: - _LOGGER.warning('Response %s for request %s has not received seq_id', response, self) + _LOGGER.warning("Response %s for request %s has not received seq_id", response, self) return False - async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" if self._response_future.done(): From 3cf346e31650bdfa5569907b6f2a6710986978fa Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:09:19 +0100 Subject: [PATCH 220/979] Create submit worker task on demand and make sure we properly wait for task to be cancelled --- plugwise_usb/connection/queue.py | 42 ++++++++++++-------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index f6280269c..0f4aa1d4b 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,15 +1,8 @@ """Manage the communication sessions towards the USB-Stick.""" from __future__ import annotations -from asyncio import ( - CancelledError, - InvalidStateError, - PriorityQueue, - Task, - get_running_loop, -) +from asyncio import PriorityQueue, Task, get_running_loop, sleep, wait from collections.abc import Callable -import contextlib from dataclasses import dataclass import logging @@ -37,7 +30,7 @@ def __init__(self) -> None: """Initialize the message session controller.""" self._stick: StickConnectionManager | None = None self._loop = get_running_loop() - self._queue: PriorityQueue[PlugwiseRequest] = PriorityQueue() + self._submit_queue: PriorityQueue[PlugwiseRequest | None] = PriorityQueue() self._submit_worker_task: Task | None = None self._unsubscribe_connection_events: Callable[[], None] | None = None self._running = False @@ -63,9 +56,6 @@ def start( (StickEvent.CONNECTED, StickEvent.DISCONNECTED) ) ) - self._submit_worker_task = self._loop.create_task( - self._submit_worker() - ) async def _handle_stick_event(self, event: StickEvent) -> None: """Handle events from stick.""" @@ -81,14 +71,11 @@ async def stop(self) -> None: self._unsubscribe_connection_events() self._running = False self._stick = None - if ( - self._submit_worker_task is not None and - not self._submit_worker_task.done() - ): - self._submit_worker_task.cancel() - with contextlib.suppress(CancelledError, InvalidStateError): - await self._submit_worker_task.result() - + if self._submit_worker_task is not None: + self._submit_queue.put_nowait(None) + if self._submit_worker_task.cancel(): + await wait([self._submit_worker_task]) + self._submit_worker_task = None _LOGGER.debug("queue stopped") async def submit( @@ -129,13 +116,16 @@ async def submit( async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue and return the session id.""" - await self._queue.put(request) + await self._submit_queue.put(request) + if self._submit_worker_task is None or self._submit_worker_task.done(): + self._submit_worker_task = self._loop.create_task( + self._submit_worker() + ) async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" - while self._running: - # Get item with highest priority from queue first - request = await self._queue.get() + while self._running and self._submit_queue.qsize() > 0: + if (request := await self._submit_queue.get()) is None: + return await self._stick.write_to_stick(request) - - self._queue.task_done() + self._submit_queue.task_done() From b8030b3bffe8cf57df4291b18a28da5a4df30b23 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:10:08 +0100 Subject: [PATCH 221/979] Stop sender at stop of connection manager --- plugwise_usb/connection/manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 922244d96..b95816dd8 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -196,6 +196,8 @@ async def disconnect_from_stick(self) -> None: self._unsubscribe_stick_events() self._unsubscribe_stick_events = None self._connected = False + if self._sender is not None: + self._sender.stop() if self._receiver is not None: await self._receiver.close() self._receiver = None From ef544cc606eb3419d2cc42a72ee9c82b1565fd70 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:10:45 +0100 Subject: [PATCH 222/979] Stop queue at disconnect --- plugwise_usb/connection/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 671e8d647..590bd3bdd 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -200,4 +200,6 @@ async def disconnect_from_stick(self) -> None: if self._unsubscribe_stick_event is not None: self._unsubscribe_stick_event() self._unsubscribe_stick_event = None + if self._queue.is_running: + await self._queue.stop() await self._manager.disconnect_from_stick() From d50f42032eda983d56c0ade302cc1c52c0c1c5df Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:13:08 +0100 Subject: [PATCH 223/979] Create references to delayed tasks and cancel them at stop --- plugwise_usb/connection/receiver.py | 91 +++++++++++++++-------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 49e777c5c..c98946065 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -16,15 +16,7 @@ """ from __future__ import annotations -from asyncio import ( - Future, - gather, - Task, - Protocol, - Queue, - get_running_loop, - sleep, -) +from asyncio import Future, Protocol, Queue, Task, gather, get_running_loop, sleep, wait from collections.abc import Awaitable, Callable from concurrent import futures import logging @@ -64,7 +56,8 @@ def __init__( self._buffer: bytes = bytes([]) self._connection_state = False self._request_queue = Queue() - self._last_20_processed_messages = [] + self._last_20_processed_messages: list[bytes] = [] + self._delayed_response_task: dict[bytes, Task] = {} self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None @@ -94,19 +87,13 @@ def __init__( def connection_lost(self, exc: Exception | None = None) -> None: """Call when port was closed expectedly or unexpectedly.""" _LOGGER.debug("Connection lost") - if ( - self._connected_future is not None - and not self._connected_future.done() - ): - if exc is None: - self._connected_future.set_result(True) - else: - self._connected_future.set_exception(exc) + if exc is not None: + _LOGGER.warning("Connection lost %s", exc) + self._loop.create_task(self.close()) if len(self._stick_event_subscribers) > 0: self._loop.create_task( self._notify_stick_event_subscribers(StickEvent.DISCONNECTED) ) - self._transport = None self._connection_state = False @@ -129,20 +116,28 @@ def connection_made(self, transport: SerialTransport) -> None: self._loop.create_task( self._notify_stick_event_subscribers(StickEvent.CONNECTED) ) - self._msg_processing_task = self._loop.create_task( - self._msg_queue_processing_function() - ) async def close(self) -> None: """Close connection.""" - if self._transport is None: return if self._stick_future is not None and not self._stick_future.done(): self._stick_future.cancel() - self._transport.close() - self._msg_processing_task.cancel() + await self._stop_running_tasks() + + async def _stop_running_tasks(self) -> None: + """Cancel and stop any running task.""" + cancelled_tasks: list[Task] = [] + self._request_queue.put_nowait(None) + if self._msg_processing_task is not None and not self._msg_processing_task.done(): + self._msg_processing_task.cancel() + cancelled_tasks.append(self._msg_processing_task) + for task in self._delayed_response_task.values(): + task.cancel() + cancelled_tasks.append(task) + if cancelled_tasks: + await wait(cancelled_tasks) def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. @@ -154,12 +149,21 @@ def data_received(self, data: bytes) -> None: msgs = self._buffer.split(MESSAGE_FOOTER) for msg in msgs[:-1]: if (response := self.extract_message_from_line_buffer(msg)): - self._request_queue.put_nowait(response) + self._put_message_in_receiver_queue(response) if len(msgs) > 4: _LOGGER.debug("Stick gave %d messages at once", len(msgs)) self._buffer = msgs[-1] # whatever was left over if self._buffer == b"\x83": - self._buffer = b'' + self._buffer = b"" + + def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: + """Put message in queue.""" + self._request_queue.put_nowait(response) + if self._msg_processing_task is None or self._msg_processing_task.done(): + self._msg_processing_task = self._loop.create_task( + self._msg_queue_processing_function(), + name="Process received messages" + ) def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: """Extract message from buffer.""" @@ -198,18 +202,18 @@ def _populate_message( return message async def _msg_queue_processing_function(self): - - while self.is_connected: - #while self._request_queue.qsize() > 0: + """Process queue items.""" + while self.is_connected and self._request_queue.qsize() > 0: response: PlugwiseResponse | None = await self._request_queue.get() - if isinstance(response, StickResponse): await self._notify_stick_response_subscribers(response) elif response is None: + self._request_queue.task_done() return else: _LOGGER.debug("Processing %s", response) await self._notify_node_response_subscribers(response) + self._request_queue.task_done() def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -295,12 +299,10 @@ def remove_listener() -> None: ] = (node_response_callback, mac, message_ids) return remove_listener - async def _notify_node_response_subscribers( - self, node_response: PlugwiseResponse - ) -> None: - - """Call callback for all node response message subscribers""" - #callback_list: list[Callable] = [] + async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse, delayed: bool = False) -> None: + """Call callback for all node response message subscribers.""" + if delayed: + await sleep(0.5) processed = False for callback, mac, message_ids in list( self._node_response_subscribers.values() @@ -317,6 +319,8 @@ async def _notify_node_response_subscribers( self._last_20_processed_messages.append(node_response.seq_id) if len(self._last_20_processed_messages) > 20: self._last_20_processed_messages = self._last_20_processed_messages[:-20] + if self._delayed_response_task.get(node_response.seq_id): + del self._delayed_response_task[node_response.seq_id] return if node_response.seq_id in self._last_20_processed_messages: @@ -332,12 +336,11 @@ async def _notify_node_response_subscribers( node_response.seq_id, node_response.mac_decoded, ) + if self._delayed_response_task.get(node_response.seq_id): + del self._delayed_response_task[node_response.seq_id] return - self._loop.create_task( - delayed_run( - self._notify_node_response_subscribers( - node_response - ), - 0.5, - ) + self._delayed_response_task[node_response.seq_id] = self._loop.create_task( + self._notify_node_response_subscribers( + node_response, delayed=True + ), ) From ec19b0779a0082c395a1a7198ac892624f5bbbdf Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:26:09 +0100 Subject: [PATCH 224/979] Update doc strings --- tests/test_usb.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index e7ac6d1d1..fa9b337f2 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -168,7 +168,7 @@ class TestStick: @pytest.mark.asyncio async def test_sorting_request_messages(self): - """Test request message priority sorting""" + """Test request message priority sorting.""" node_add_request = pw_requests.NodeAddRequest( b"1111222233334444", True @@ -212,7 +212,7 @@ async def test_sorting_request_messages(self): @pytest.mark.asyncio async def test_stick_connect_without_port(self): - """Test connecting to stick without port config""" + """Test connecting to stick without port config.""" stick = pw_stick.Stick() assert stick.accept_join_request is None assert stick.nodes == {} @@ -258,7 +258,7 @@ async def test_stick_reconnect(self, monkeypatch): @pytest.mark.asyncio async def test_stick_connect_without_response(self, monkeypatch): - """Test connecting to stick without response""" + """Test connecting to stick without response.""" monkeypatch.setattr( pw_connection_manager, "create_serial_connection", @@ -285,7 +285,7 @@ async def test_stick_connect_without_response(self, monkeypatch): @pytest.mark.asyncio async def test_stick_connect_timeout(self, monkeypatch): - """Test connecting to stick""" + """Test connecting to stick.""" monkeypatch.setattr( pw_connection_manager, "create_serial_connection", @@ -307,7 +307,7 @@ async def test_stick_connect_timeout(self, monkeypatch): await stick.disconnect() async def connected(self, event): - """Callback helper for stick connected event""" + """Set connected state helper.""" if event is pw_api.StickEvent.CONNECTED: self.test_connected.set_result(True) else: @@ -315,7 +315,7 @@ async def connected(self, event): @pytest.mark.asyncio async def test_stick_connect(self, monkeypatch): - """Test connecting to stick""" + """Test connecting to stick.""" monkeypatch.setattr( pw_connection_manager, "create_serial_connection", @@ -348,7 +348,7 @@ async def test_stick_connect(self, monkeypatch): assert stick.mac_stick async def disconnected(self, event): - """Callback helper for stick disconnect event""" + """Handle disconnect event callback.""" if event is pw_api.StickEvent.DISCONNECTED: self.test_disconnected.set_result(True) else: @@ -356,7 +356,7 @@ async def disconnected(self, event): @pytest.mark.asyncio async def test_stick_connection_lost(self, monkeypatch): - """Test connecting to stick""" + """Test connecting to stick.""" mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -380,7 +380,7 @@ async def test_stick_connection_lost(self, monkeypatch): await stick.disconnect() async def node_discovered(self, event: pw_api.NodeEvent, mac: str): - """Callback helper for node discovery""" + """Handle discovered event callback.""" if event == pw_api.NodeEvent.DISCOVERED: self.test_node_discovered.set_result(mac) else: @@ -392,7 +392,7 @@ async def node_discovered(self, event: pw_api.NodeEvent, mac: str): ) async def node_awake(self, event: pw_api.NodeEvent, mac: str): - """Callback helper for node discovery""" + """Handle awake event callback.""" if event == pw_api.NodeEvent.AWAKE: self.test_node_awake.set_result(mac) else: @@ -408,7 +408,7 @@ async def node_motion_state( feature: pw_api.NodeFeature, state: pw_api.MotionState, ): - """Callback helper for node_motion event""" + """Handle motion event callback.""" if feature == pw_api.NodeFeature.MOTION: if state.motion: self.motion_on.set_result(state.motion) @@ -448,7 +448,7 @@ async def node_ping( @pytest.mark.asyncio async def test_stick_node_discovered_subscription(self, monkeypatch): - """Testing "new_node" subscription for Scan""" + """Testing "new_node" subscription for Scan.""" mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -532,7 +532,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): await stick.disconnect() async def node_join(self, event: pw_api.NodeEvent, mac: str): - """Callback helper for node_join event""" + """Handle join event callback.""" if event == pw_api.NodeEvent.JOIN: self.test_node_join.set_result(mac) else: @@ -545,7 +545,7 @@ async def node_join(self, event: pw_api.NodeEvent, mac: str): @pytest.mark.asyncio async def test_stick_node_join_subscription(self, monkeypatch): - """Testing "new_node" subscription""" + """Testing "new_node" subscription.""" mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -573,7 +573,7 @@ async def test_stick_node_join_subscription(self, monkeypatch): @pytest.mark.asyncio async def test_node_discovery(self, monkeypatch): - """Testing discovery of nodes""" + """Testing discovery of nodes.""" mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -595,7 +595,7 @@ async def node_relay_state( feature: pw_api.NodeFeature, state: pw_api.RelayState, ): - """Callback helper for relay event""" + """Handle relay event callback.""" if feature == pw_api.NodeFeature.RELAY: if state.relay_state: self.test_relay_state_on.set_result(state.relay_state) @@ -620,7 +620,7 @@ async def node_init_relay_state( feature: pw_api.NodeFeature, state: bool, ): - """Callback helper for relay event""" + """Callback helper for relay event.""" if feature == pw_api.NodeFeature.RELAY_INIT: if state: self.test_init_relay_state_on.set_result(state) @@ -1131,7 +1131,7 @@ def test_log_address_rollover(self, monkeypatch): assert tst_pc.log_addresses_missing == [1, 0] def pulse_update(self, timestamp: dt, is_consumption: bool): - """Callback helper for pulse updates for energy counter""" + """Update pulse helper for energy counter.""" self._pulse_update += 1 if self._pulse_update == 1: return (None, None) @@ -1145,7 +1145,7 @@ def pulse_update(self, timestamp: dt, is_consumption: bool): @freeze_time(dt.now()) def test_energy_counter(self): - """Testing energy counter class""" + """Testing energy counter class.""" pulse_col_mock = Mock() pulse_col_mock.collected_pulses.side_effect = self.pulse_update From 1d5a6d3b6dc4bef0a8d6208b77e17d1ba9908fe9 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:26:54 +0100 Subject: [PATCH 225/979] Simplify if statement --- tests/test_usb.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index fa9b337f2..8b7f444fa 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -414,21 +414,20 @@ async def node_motion_state( self.motion_on.set_result(state.motion) else: self.motion_off.set_result(state.motion) - else: - if state.motion: - self.motion_on.set_exception( - BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.MOTION}" - ) + elif state.motion: + self.motion_on.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" ) - else: - self.motion_off.set_exception( - BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.MOTION}" - ) + ) + else: + self.motion_off.set_exception( + BaseException( + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" ) + ) async def node_ping( self, From 91e7d8dffde4d47f634ee684cf5f295287f3da54 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:27:58 +0100 Subject: [PATCH 226/979] Additional node state asserts --- tests/test_usb.py | 59 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 8b7f444fa..2d332ba48 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1340,13 +1340,66 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.initialize() await stick.discover_nodes(load=True) - assert stick.nodes["0098765432101234"].node_info.firmware == dt( - 2011, 6, 27, 8, 47, 37, tzinfo=tz.utc - ) + assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=tz.utc) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.battery_powered + # Get state + get_state_timestamp = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0) + state = await stick.nodes["0098765432101234"].get_state((pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO)) + + # Check Ping + assert state[pw_api.NodeFeature.PING].rssi_in == 69 + assert state[pw_api.NodeFeature.PING].rssi_out == 70 + assert state[pw_api.NodeFeature.PING].rtt == 1074 + assert state[pw_api.NodeFeature.PING].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + + # 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].battery_powered + assert sorted(state[pw_api.NodeFeature.INFO].features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.ENERGY, + pw_api.NodeFeature.POWER, + ) + ) + assert state[pw_api.NodeFeature.INFO].firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=tz.utc) + assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" + assert state[pw_api.NodeFeature.INFO].model == "Circle+ type F" + assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE_PLUS + assert state[pw_api.NodeFeature.INFO].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert state[pw_api.NodeFeature.INFO].version == "000000730007" + + # Check 1111111111111111 + get_state_timestamp = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0) + state = await stick.nodes["1111111111111111"].get_state( + (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) + ) + + assert state[pw_api.NodeFeature.INFO].mac == "1111111111111111" + assert state[pw_api.NodeFeature.INFO].zigbee_address == 0 + assert not state[pw_api.NodeFeature.INFO].battery_powered + assert state[pw_api.NodeFeature.INFO].version == "000000070140" + assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE + assert state[pw_api.NodeFeature.INFO].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert sorted(state[pw_api.NodeFeature.INFO].features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.RELAY, + pw_api.NodeFeature.ENERGY, + pw_api.NodeFeature.POWER, + ) + ) + assert state[pw_api.NodeFeature.RELAY].relay_state + await stick.disconnect() From 78d7c0c291729d64fe269a9f448d8ddf9ff8d756 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:35:02 +0100 Subject: [PATCH 227/979] Remove unused import --- plugwise_usb/connection/queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 0f4aa1d4b..b97846163 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,7 +1,7 @@ """Manage the communication sessions towards the USB-Stick.""" from __future__ import annotations -from asyncio import PriorityQueue, Task, get_running_loop, sleep, wait +from asyncio import PriorityQueue, Task, get_running_loop, wait from collections.abc import Callable from dataclasses import dataclass import logging From 8e0e5e27b9a03f4431723a621d3f76177f2cac4a Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 21:56:24 +0100 Subject: [PATCH 228/979] Bump version and update change log --- CHANGELOG.md | 16 +++++++++++----- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3375a260..74b1f3d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # Changelog -## Ongoing +## v0.40.0 (a4) -- Ensure CI process remains operational -- Bumped pip to now prepend uv for using quicker dependency resolving and installing -- As for latest HA Core USB team should rework to python 3.12 (not still 3.10) +Full rewrite of library into async version. Main list of changes: + +- Full async and typed +- Improved protocol handling +- Support for local caching of collected data to improve startup and device detection +- Improved handling of edge cases especially for energy data collection +- Based on detected firmware version enable the supported features +- API details about supported data is combined into api.py +- Added tests ## v0.31.4(a0) @@ -12,7 +18,7 @@ ## v0.31.3 -- Bugfix midnight rollover for cicrles without power usage registered during first hour(s) +- Bugfix midnight rollover for circles without power usage registered during first hour(s) ## v0.31.2 diff --git a/pyproject.toml b/pyproject.toml index d8f313b1e..06e13ed45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a3" +version = "v0.40.0a4" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 277266dc60492e15dc0dc88a21c2d251197aa0d1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 22:18:40 +0100 Subject: [PATCH 229/979] Guard unsubscribe is possible and called once --- plugwise_usb/messages/requests.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index c8387c3af..68a88014e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -135,7 +135,9 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): return - self._unsubscribe_node_response() + if self._unsubscribe_node_response is not None: + self._unsubscribe_node_response() + self._unsubscribe_node_response = None if stick_timeout: self._response_future.set_exception( NodeError( @@ -193,7 +195,9 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) elif stick_response.ack_id == StickResponseType.FAILED: - self._unsubscribe_node_response() + if self._unsubscribe_node_response is not None: + self._unsubscribe_node_response() + self._unsubscribe_node_response = None self._response_future.set_exception( NodeError( f"Stick failed request {self._seq_id}" From 48ed38a618d70aa77d5e6fca1e9a814b8bdd5831 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sat, 17 Feb 2024 23:20:03 +0100 Subject: [PATCH 230/979] Remove unused constants --- plugwise_usb/constants.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 40f591468..9b70b59c7 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -12,27 +12,6 @@ CACHE_DIR: Final = ".plugwise-cache" CACHE_SEPARATOR: str = ";" -# Copied homeassistant.consts -ATTR_NAME: Final = "name" -ATTR_STATE: Final = "state" -ATTR_STATE_CLASS: Final = "state_class" -ATTR_UNIT_OF_MEASUREMENT: Final = "unit_of_measurement" -DEGREE: Final = "°" -ELECTRIC_POTENTIAL_VOLT: Final = "V" -ENERGY_KILO_WATT_HOUR: Final = "kWh" -ENERGY_WATT_HOUR: Final = "Wh" -PERCENTAGE: Final = "%" -POWER_WATT: Final = "W" -PRESET_AWAY: Final = "away" -PRESSURE_BAR: Final = "bar" -SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" -TEMP_CELSIUS: Final = "°C" -TEMP_KELVIN: Final = "°K" -TIME_MILLISECONDS: Final = "ms" -UNIT_LUMEN: Final = "lm" -VOLUME_CUBIC_METERS: Final = "m³" -VOLUME_CUBIC_METERS_PER_HOUR: Final = "m³/h" - LOCAL_TIMEZONE = dt.datetime.now(dt.timezone.utc).astimezone().tzinfo UTF8: Final = "utf-8" @@ -55,13 +34,8 @@ STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. QUEUE_TIME_OUT: Final = 45 # Total seconds to wait for queue NODE_TIME_OUT: Final = 5 -DISCOVERY_TIME_OUT: Final = 45 -REQUEST_TIMEOUT: Final = 0.5 MAX_RETRIES: Final = 3 -# Default sleep between sending messages -SLEEP_TIME: Final = 0.01 - # plugwise year information is offset from y2k PLUGWISE_EPOCH: Final = 2000 PULSES_PER_KW_SECOND: Final = 468.9385193 From 07ff9582bd10f7e5a2cf27cc49b305d26e39c50e Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 21:38:51 +0100 Subject: [PATCH 231/979] Properly cancel timeout handler in all cases --- plugwise_usb/messages/requests.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 68a88014e..a9cb91fdc 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -125,12 +125,16 @@ def subscribe_to_responses( def start_response_timeout(self) -> None: """Start timeout for node response.""" - if self._response_timeout is not None: - self._response_timeout.cancel() + self.stop_response_timeout() self._response_timeout = self._loop.call_later( NODE_TIME_OUT, self._response_timeout_expired ) + def stop_response_timeout(self) -> None: + """Stop timeout for node response.""" + if self._response_timeout is not None: + self._response_timeout.cancel() + def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): @@ -154,8 +158,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: def assign_error(self, error: StickError) -> None: """Assign error for this request.""" - if self._response_timeout is not None: - self._response_timeout.cancel() + self.stop_response_timeout() if self._response_future.done(): return self._response_future.set_exception(error) @@ -165,7 +168,7 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: if self._seq_id is not None and self._seq_id == response.seq_id: self._unsubscribe_stick_response() self._response = response - self._response_timeout.cancel() + self.stop_response_timeout() if not self._response_future.done(): if self._send_counter > 1: _LOGGER.info("Response %s for retried request %s id %d", response, self, self._id) From 41c95297b4fe0a82c6b0ae7117b5ad0764f88822 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 21:39:10 +0100 Subject: [PATCH 232/979] Correct typing --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index a9cb91fdc..ad06d083b 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -156,7 +156,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) ) - def assign_error(self, error: StickError) -> None: + def assign_error(self, error: BaseException) -> None: """Assign error for this request.""" self.stop_response_timeout() if self._response_future.done(): From 032019781804675b727c92c03cb03fee1fac7a20 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 21:44:09 +0100 Subject: [PATCH 233/979] Correct and simplify exception --- plugwise_usb/messages/requests.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index ad06d083b..3c294dcc2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -16,7 +16,7 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..exceptions import NodeError, NodeTimeout, StickError +from ..exceptions import NodeError, NodeTimeout, StickError, StickTimeout from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from ..util import ( DateTime, @@ -144,15 +144,14 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: self._unsubscribe_node_response = None if stick_timeout: self._response_future.set_exception( - NodeError( - "Timeout by stick to {self.mac_decoded}" + StickTimeout( + f"Stick Timeout: USB-stick responded with time out to {self}" ) ) else: self._response_future.set_exception( NodeTimeout( - f"No response within {NODE_TIME_OUT} seconds from node " + - f"{self.mac_decoded}" + f"Node Timeout: No response to {self} within {NODE_TIME_OUT} seconds" ) ) From 2068ba5d79c37c3b6615697c18364387dfb037f7 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 21:48:36 +0100 Subject: [PATCH 234/979] Only use the accept response from stick to determine seq_id From testing a USB-Stick does not seem to respond with timeout or failed the first time. Any second time is handled at the request itself already so no need to process (and log) it here. --- plugwise_usb/connection/sender.py | 58 ++++++------------------------- 1 file changed, 11 insertions(+), 47 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 815dc4554..045964be8 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -20,7 +20,7 @@ import logging from ..constants import STICK_TIME_OUT -from ..exceptions import StickError, StickFailed, StickTimeout +from ..exceptions import StickError from ..messages.requests import PlugwiseRequest from ..messages.responses import StickResponse, StickResponseType from .receiver import StickReceiver @@ -109,55 +109,19 @@ async def _process_stick_response(self, response: StickResponse) -> None: self._stick_response is None or self._stick_response.done() ): - - if response.ack_id == StickResponseType.TIMEOUT: - _LOGGER.warning("%s TIMEOUT", response) - if (request := self._open_requests.get(response.seq_id, None)): - _LOGGER.error( - "Failed to send %s because USB-Stick could not send the request to the node.", - request - ) - request.assign_error( - BaseException( - StickTimeout( - f"Failed to send {request.__class__.__name__} because USB-Stick could not send the {request} to the {request.mac}." - ) - ) - ) - del self._open_requests[response.seq_id] - return - - _LOGGER.warning( - "Unexpected stick response (ack_id=%s, seq_id=%s) received", - str(response.ack_id), - str(response.seq_id), - ) + _LOGGER.debug("No open request for %s", str(response)) return - _LOGGER.debug("Received stick %s", response) - if response.ack_id == StickResponseType.ACCEPT: - self._stick_response.set_result(response.seq_id) - elif response.ack_id == StickResponseType.FAILED: - self._stick_response.set_exception( - BaseException( - StickFailed( - "USB-Stick failed to submit " - + f"{self._current_request.__class__.__name__} to " - + f"node '{self._current_request.mac_decoded}'." - ) - ) - ) - elif response.ack_id == StickResponseType.TIMEOUT: - self._stick_response.set_exception( - BaseException( - StickTimeout( - "USB-Stick timeout to submit " - + f"{self._current_request.__class__.__name__} to " - + f"node '{self._current_request.mac_decoded}'." - ) - ) - ) + if response.ack_id != StickResponseType.ACCEPT: + # + # TODO + # Verify if we actually do receive any non ACCEPT stick response as the first response + # after submitting an request. + # + _LOGGER.warning("Received %s as POSSIBLE reply to %s", response, self._current_request) return + _LOGGER.debug("Received %s as reply to %s", response, self._current_request) + self._stick_response.set_result(response.seq_id) await self._stick_lock.acquire() if response.seq_id in self._open_requests: del self._open_requests[response.seq_id] From 1a22a6c4ed907e261fd0d773458a193a70ee227b Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 22:31:30 +0100 Subject: [PATCH 235/979] Add missing node_ack_type in NodeAckResponse message --- plugwise_usb/messages/responses.py | 7 +++++++ plugwise_usb/network/__init__.py | 2 +- plugwise_usb/nodes/scan.py | 9 +++------ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 7001baaf4..f057cb84d 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -836,6 +836,13 @@ class NodeAckResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeAckResponse message object.""" super().__init__(b"0100") + self._node_ack_type = BaseType(0, length=4) + self._params += [self._node_ack_type] + + @property + def node_ack_type(self) -> NodeAckResponseType: + """Return acknowledge response type.""" + return NodeAckResponseType(self._node_ack_type.value) class SenseReportResponse(PlugwiseResponse): diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 3fb6d2660..2be80ad80 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -521,7 +521,7 @@ async def allow_join_requests(self, state: bool) -> None: raise NodeError( "No response to get notifications for join request." ) - if response.ack_id != NodeResponseType.JOIN_ACCEPTED: + if response.node_ack_type != NodeResponseType.JOIN_ACCEPTED: raise MessageError( f"Unknown NodeResponseType '{response.ack_id!r}' received" ) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 049a9c1eb..542ddba1e 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -150,11 +150,11 @@ async def scan_configure( f"No response from Scan device {self.mac} " + "for configuration request." ) - if response.ack_id == NodeAckResponseType.SCAN_CONFIG_FAILED: + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: raise NodeError( f"Scan {self.mac} failed to configure scan settings" ) - if response.ack_id == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: self._motion_reset_timer = motion_reset_timer self._sensitivity_level = sensitivity_level self._daylight_mode = daylight_mode @@ -171,10 +171,7 @@ async def scan_calibrate_light(self) -> bool: f"No response from Scan device {self.mac} " + "to light calibration request." ) - if ( - response.ack_id - == NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED - ): + if response.node_ack_type == NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED: return True return False From 87210c12741ad68a90239c44278f3d1505b875b5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 22:32:58 +0100 Subject: [PATCH 236/979] Add awake_type property --- plugwise_usb/messages/responses.py | 10 ++++++++-- plugwise_usb/nodes/sed.py | 5 +---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index f057cb84d..91a4f5614 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -9,6 +9,7 @@ from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..exceptions import MessageError from ..util import ( + BaseType, DateTime, Float, Int, @@ -769,8 +770,13 @@ class NodeAwakeResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeAwakeResponse message object.""" super().__init__(NODE_AWAKE_RESPONSE_ID) - self.awake_type = Int(0, 2, False) - self._params += [self.awake_type] + self._awake_type = Int(0, 2, False) + self._params += [self._awake_type] + + @property + def awake_type(self) -> NodeAwakeResponseType: + """Return the node awake type.""" + return NodeAwakeResponseType(self._awake_type.value) class NodeSwitchGroupResponse(PlugwiseResponse): diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 6b9ecc1b1..5adfbc580 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -118,10 +118,7 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: await self._available_update_state(True) if message.timestamp is None: return False - if ( - NodeAwakeResponseType(message.awake_type.value) - == NodeAwakeResponseType.MAINTENANCE - ): + if message.awake_type == NodeAwakeResponseType.MAINTENANCE: if self._ping_at_awake: ping_response: NodePingResponse | None = ( await self.ping_update() # type: ignore [assignment] From 6d300a8649d19860769a08e3822d96969fbdac87 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 18 Feb 2024 22:34:41 +0100 Subject: [PATCH 237/979] Add more __repr__ to response classes Use f-strings too --- plugwise_usb/messages/responses.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 91a4f5614..7fecbeb02 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -115,7 +115,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} from {self.mac_decoded} seq_id {self.seq_id}" + return f"{self.__class__.__name__} from {self.mac_decoded} seq_id={self.seq_id}" @property def ack_id(self) -> bytes | None: @@ -230,7 +230,7 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return "StickResponse " + str(StickResponseType(self.ack_id).name) + " seq_id" + str(self.seq_id) + return f"StickResponse {StickResponseType(self.ack_id).name} seq_id={str(self.seq_id)}" class NodeResponse(PlugwiseResponse): @@ -247,6 +247,10 @@ def __init__(self) -> None: """Initialize NodeResponse message object.""" super().__init__(b"0000", decode_ack=True) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()} | ack={str(NodeResponseType(self.ack_id).name)}" + class StickNetworkInfoResponse(PlugwiseResponse): """Report status of zigbee network. @@ -624,7 +628,7 @@ def frequency(self) -> int: def __repr__(self) -> str: """Convert request into writable str.""" - return super().__repr__() + f" | log_address={self._logaddress_pointer.value}" + return f"{super().__repr__()} | log_address_pointer={self._logaddress_pointer.value}" class EnergyCalibrationResponse(PlugwiseResponse): @@ -746,7 +750,7 @@ def log_address(self) -> int: def __repr__(self) -> str: """Convert request into writable str.""" - return super().__repr__() + " | log_address=" + str(self._logaddr.value) + return f"{super().__repr__()} | log_address={self._logaddr.value}" class NodeAwakeResponse(PlugwiseResponse): @@ -778,6 +782,10 @@ def awake_type(self) -> NodeAwakeResponseType: """Return the node awake type.""" return NodeAwakeResponseType(self._awake_type.value) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()} | awake_type={self.awake_type.name}" + class NodeSwitchGroupResponse(PlugwiseResponse): """Announce groups on/off. @@ -850,6 +858,10 @@ def node_ack_type(self) -> NodeAckResponseType: """Return acknowledge response type.""" return NodeAckResponseType(self._node_ack_type.value) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()} | Ack={self.node_ack_type.name}" + class SenseReportResponse(PlugwiseResponse): """Returns the current temperature and humidity of a Sense node. From 314e1ef9033dee4f2d57310600b8995c6c1769ab Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 21:58:13 +0100 Subject: [PATCH 238/979] No need to return request object --- plugwise_usb/connection/manager.py | 6 ++---- plugwise_usb/connection/sender.py | 11 ++--------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index b95816dd8..e5728c327 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -172,9 +172,7 @@ async def setup_connection_to_stick( self._connected = True self._subscribe_to_stick_events() - async def write_to_stick( - self, request: PlugwiseRequest - ) -> PlugwiseRequest: + async def write_to_stick(self, request: PlugwiseRequest) -> None: """Write message to USB stick. Returns the updated request object.""" if not request.resend: raise StickError( @@ -187,7 +185,7 @@ async def write_to_stick( f"Failed to send {request.__class__.__name__}" + "because USB-Stick connection is not setup" ) - return await self._sender.write_request_to_port(request) + await self._sender.write_request_to_port(request) async def disconnect_from_stick(self) -> None: """Disconnect from USB-Stick.""" diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 045964be8..a2676a86b 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -48,13 +48,8 @@ def __init__( ) ) - async def write_request_to_port( - self, request: PlugwiseRequest - ) -> PlugwiseRequest: - """Send message to serial port of USB stick. - - Returns the updated request object. Raises StickError - """ + async def write_request_to_port(self, request: PlugwiseRequest) -> None: + """Send message to serial port of USB stick.""" await self._stick_lock.acquire() self._current_request = request @@ -101,8 +96,6 @@ async def write_request_to_port( self._stick_response = None self._stick_lock.release() - return request - async def _process_stick_response(self, response: StickResponse) -> None: """Process stick response.""" if ( From b2f03d09172e2dd02e74c0e8f38c129bcf39d7e1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:17:13 +0100 Subject: [PATCH 239/979] No need to retry processing message --- plugwise_usb/connection/receiver.py | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index c98946065..2d42ed61b 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -57,7 +57,6 @@ def __init__( self._connection_state = False self._request_queue = Queue() self._last_20_processed_messages: list[bytes] = [] - self._delayed_response_task: dict[bytes, Task] = {} self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None @@ -133,9 +132,6 @@ async def _stop_running_tasks(self) -> None: if self._msg_processing_task is not None and not self._msg_processing_task.done(): self._msg_processing_task.cancel() cancelled_tasks.append(self._msg_processing_task) - for task in self._delayed_response_task.values(): - task.cancel() - cancelled_tasks.append(task) if cancelled_tasks: await wait(cancelled_tasks) @@ -301,8 +297,6 @@ def remove_listener() -> None: async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse, delayed: bool = False) -> None: """Call callback for all node response message subscribers.""" - if delayed: - await sleep(0.5) processed = False for callback, mac, message_ids in list( self._node_response_subscribers.values() @@ -319,28 +313,15 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons self._last_20_processed_messages.append(node_response.seq_id) if len(self._last_20_processed_messages) > 20: self._last_20_processed_messages = self._last_20_processed_messages[:-20] - if self._delayed_response_task.get(node_response.seq_id): - del self._delayed_response_task[node_response.seq_id] return if node_response.seq_id in self._last_20_processed_messages: _LOGGER.warning("Got duplicate %s", node_response) return - # No subscription for response, retry in 0.5 sec. - node_response.notify_retries += 1 - if node_response.notify_retries > 10: - _LOGGER.warning( - "No subscriber to handle %s, seq_id=%s from %s", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, - ) - if self._delayed_response_task.get(node_response.seq_id): - del self._delayed_response_task[node_response.seq_id] - return - self._delayed_response_task[node_response.seq_id] = self._loop.create_task( - self._notify_node_response_subscribers( - node_response, delayed=True - ), + _LOGGER.warning( + "No subscriber to handle %s, seq_id=%s from %s", + node_response.__class__.__name__, + node_response.seq_id, + node_response.mac_decoded, ) From c1f9b2a356dec6dece8531e24c429202b537d72a Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:21:54 +0100 Subject: [PATCH 240/979] Use special 'reset' request message to sop submit worker --- plugwise_usb/connection/queue.py | 20 +++++++++++--------- plugwise_usb/messages/requests.py | 1 + 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index b97846163..b3db05b7e 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -8,7 +8,7 @@ from ..api import StickEvent from ..exceptions import NodeTimeout, StickError, StickTimeout -from ..messages.requests import PlugwiseRequest +from ..messages.requests import PlugwiseRequest, Priority from ..messages.responses import PlugwiseResponse from .manager import StickConnectionManager @@ -30,7 +30,7 @@ def __init__(self) -> None: """Initialize the message session controller.""" self._stick: StickConnectionManager | None = None self._loop = get_running_loop() - self._submit_queue: PriorityQueue[PlugwiseRequest | None] = PriorityQueue() + self._submit_queue: PriorityQueue[PlugwiseRequest] = PriorityQueue() self._submit_worker_task: Task | None = None self._unsubscribe_connection_events: Callable[[], None] | None = None self._running = False @@ -70,12 +70,12 @@ async def stop(self) -> None: if self._unsubscribe_connection_events is not None: self._unsubscribe_connection_events() self._running = False + if self._submit_worker_task is not None and not self._submit_worker_task.done(): + cancel_request = PlugwiseRequest(b"0000", None) + cancel_request.priority = Priority.CANCEL + await self._submit_queue.put(cancel_request) + self._submit_worker_task = None self._stick = None - if self._submit_worker_task is not None: - self._submit_queue.put_nowait(None) - if self._submit_worker_task.cancel(): - await wait([self._submit_worker_task]) - self._submit_worker_task = None _LOGGER.debug("queue stopped") async def submit( @@ -124,8 +124,10 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: async def _submit_worker(self) -> None: """Send messages from queue at the order of priority.""" - while self._running and self._submit_queue.qsize() > 0: - if (request := await self._submit_queue.get()) is None: + while self._running: + request = await self._submit_queue.get() + if request.priority == Priority.CANCEL: + self._submit_queue.task_done() return await self._stick.write_to_stick(request) self._submit_queue.task_done() diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 3c294dcc2..6a7c4c6d8 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -36,6 +36,7 @@ class Priority(int, Enum): """Message priority levels for USB-stick message requests.""" + CANCEL = 0 HIGH = 1 MEDIUM = 2 LOW = 3 From 3d6ce55f491b349c53eac62fbca21e892793dd21 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:24:44 +0100 Subject: [PATCH 241/979] Resetting future before requesting it --- plugwise_usb/connection/queue.py | 10 ++++++---- plugwise_usb/messages/requests.py | 10 ++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index b3db05b7e..66da041b3 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -94,10 +94,12 @@ async def submit( response: PlugwiseResponse = await request.response_future() return response except (NodeTimeout, StickTimeout) as e: - logging.warning("Node timeout %s on %s, retrying", e, request) - request.reset_future() - except StickError as exception: # [broad-exception-caught]\ - logging.exception(exception) + if request.resend: + _LOGGER.info("%s, retrying", e) + else: + _LOGGER.warning("%s after %s attempts. Cancel request", e, request.max_retries) + except StickError as exception: + _LOGGER.error(exception) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6a7c4c6d8..fd02da88d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -80,12 +80,10 @@ def __repr__(self) -> str: def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" + if self._response_future.done(): + self._response_future = self._loop.create_future() return self._response_future - def reset_future(self): - """Return awaitable future with response message.""" - self._response_future = self._loop.create_future() - @property def response(self) -> PlugwiseResponse: """Return response message.""" @@ -152,7 +150,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: else: self._response_future.set_exception( NodeTimeout( - f"Node Timeout: No response to {self} within {NODE_TIME_OUT} seconds" + f"No response to {self} within {NODE_TIME_OUT} seconds" ) ) @@ -186,7 +184,7 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: if self._seq_id: _LOGGER.warning("Response %s for request %s is not mine %s", response, self, str(response.seq_id)) else: - _LOGGER.warning("Response %s for request %s has not received seq_id", response, self) + _LOGGER.debug("Response %s for request %s has not received seq_id", response, self) return False async def _process_stick_response(self, stick_response: StickResponse) -> None: From 42e324c4ce54739a502054160b5ad36451c15a0a Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:25:25 +0100 Subject: [PATCH 242/979] No need for message id b'0000' --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fd02da88d..ecff00a4a 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -117,7 +117,7 @@ def subscribe_to_responses( node_subscription_fn( self._process_node_response, mac=self._mac, - message_ids=(b"0000", self._reply_identifier), + message_ids=(self._reply_identifier,), ) ) self._stick_subscription_fn = stick_subscription_fn From 0d5ba234ae6a6de78c57068948d538f7ffccb661 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:26:05 +0100 Subject: [PATCH 243/979] Remove unused notify_retries --- plugwise_usb/messages/responses.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 7fecbeb02..24346e020 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -111,7 +111,6 @@ def __init__( self._decode_mac = decode_mac self._params: list[Any] = [] self._seq_id: bytes = b"FFFF" - self._notify_retries: int = 0 def __repr__(self) -> str: """Convert request into writable str.""" @@ -127,16 +126,6 @@ def seq_id(self) -> bytes: """Sequence ID.""" return self._seq_id - @property - def notify_retries(self) -> int: - """Return number of notifies.""" - return self._notify_retries - - @notify_retries.setter - def notify_retries(self, retries: int) -> None: - """Set number of notification retries.""" - self._notify_retries = retries - def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Deserialize bytes to actual message properties.""" self.timestamp = datetime.now(timezone.utc) From 74ea7092fc4da121a3fcbbb7e1aea02215182241 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:27:13 +0100 Subject: [PATCH 244/979] Process awake message is successfull --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 2be80ad80..62b2f85e8 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -215,7 +215,7 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: await self._discover_node(address, mac, None) await self._load_node(mac) await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) - return False + return True async def node_join_available_message( self, response: NodeJoinAvailableResponse From 29b6189ed65712c67b643975dcb1266d1408c407 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:28:00 +0100 Subject: [PATCH 245/979] Simplify stopping running tasks --- plugwise_usb/connection/receiver.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2d42ed61b..7c9e8419c 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -127,13 +127,10 @@ async def close(self) -> None: async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" - cancelled_tasks: list[Task] = [] - self._request_queue.put_nowait(None) + await self._request_queue.put(None) if self._msg_processing_task is not None and not self._msg_processing_task.done(): self._msg_processing_task.cancel() - cancelled_tasks.append(self._msg_processing_task) - if cancelled_tasks: - await wait(cancelled_tasks) + await wait([self._msg_processing_task]) def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. From 76fa643c591b78542295eaab51ba4ef508ba3aa3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:28:31 +0100 Subject: [PATCH 246/979] Correct log message --- plugwise_usb/connection/receiver.py | 2 +- plugwise_usb/messages/requests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 7c9e8419c..085610574 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -313,7 +313,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons return if node_response.seq_id in self._last_20_processed_messages: - _LOGGER.warning("Got duplicate %s", node_response) + _LOGGER.debug("Drop duplicate %s", node_response) return _LOGGER.warning( diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index ecff00a4a..3c9132baf 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -144,7 +144,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: if stick_timeout: self._response_future.set_exception( StickTimeout( - f"Stick Timeout: USB-stick responded with time out to {self}" + f"USB-stick responded with time out to {self}" ) ) else: From 21b93ab3e7d752fd0593ef914c0e3169da41b8a0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:29:29 +0100 Subject: [PATCH 247/979] Correct formatting --- plugwise_usb/connection/sender.py | 5 +---- plugwise_usb/constants.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index a2676a86b..ce5573cb0 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -98,10 +98,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: async def _process_stick_response(self, response: StickResponse) -> None: """Process stick response.""" - if ( - self._stick_response is None - or self._stick_response.done() - ): + if self._stick_response is None or self._stick_response.done(): _LOGGER.debug("No open request for %s", str(response)) return diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 9b70b59c7..f91cdfa73 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -89,7 +89,7 @@ class MotionSensitivity(Enum): - """Motion sensitivity levels for Scan devices""" + """Motion sensitivity levels for Scan devices.""" HIGH = auto() MEDIUM = auto() From f21cc17825e603cb1cb4a2b0803d93d498273318 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:30:18 +0100 Subject: [PATCH 248/979] Sort imports --- tests/test_usb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 2d332ba48..f5ae6ad6e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -2,12 +2,13 @@ from datetime import datetime as dt, timedelta as td, timezone as tz import importlib import logging +import random from unittest.mock import Mock -import crcmod -from freezegun import freeze_time import pytest +import crcmod +from freezegun import freeze_time crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) @@ -116,7 +117,6 @@ def write(self, data: bytes) -> None: self._msg += 1 async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: - import random delay = random.uniform(0.05, 0.25) await asyncio.sleep(delay) self.message_response(data, seq_id) From 05c98df1718f2dfd87ec4042e91901923a6eec91 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 20 Feb 2024 22:30:53 +0100 Subject: [PATCH 249/979] Remove non tested subscriptions --- tests/test_usb.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index f5ae6ad6e..54c7e3c75 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -225,14 +225,6 @@ async def test_stick_connect_without_port(self): assert stick.network_id assert not stick.network_discovered assert not stick.network_state - unsub_connect = stick.subscribe_to_stick_events( - stick_event_callback=lambda x: print(x), - events=(pw_api.StickEvent.CONNECTED,), - ) - unsub_nw_online = stick.subscribe_to_stick_events( - stick_event_callback=lambda x: print(x), - events=(pw_api.StickEvent.NETWORK_ONLINE,), - ) with pytest.raises(pw_exceptions.StickError): await stick.connect() stick.port = "null" From ce1163f178395691854a747614e892152308c873 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:02:24 +0100 Subject: [PATCH 250/979] Improve debug messages --- plugwise_usb/connection/queue.py | 4 ++-- plugwise_usb/connection/receiver.py | 6 ++++-- plugwise_usb/connection/sender.py | 8 +++----- plugwise_usb/messages/requests.py | 12 +++++++----- plugwise_usb/messages/responses.py | 14 +++++++------- plugwise_usb/nodes/circle.py | 5 ++--- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 66da041b3..4de715c7c 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -95,9 +95,9 @@ async def submit( return response except (NodeTimeout, StickTimeout) as e: if request.resend: - _LOGGER.info("%s, retrying", e) + _LOGGER.debug("%s, retrying", e) else: - _LOGGER.warning("%s after %s attempts. Cancel request", e, request.max_retries) + _LOGGER.warning("%s after %s attempts, cancel request", e, request.max_retries) except StickError as exception: _LOGGER.error(exception) raise StickError( diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 085610574..e3021ff44 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -144,7 +144,7 @@ def data_received(self, data: bytes) -> None: if (response := self.extract_message_from_line_buffer(msg)): self._put_message_in_receiver_queue(response) if len(msgs) > 4: - _LOGGER.debug("Stick gave %d messages at once", len(msgs)) + _LOGGER.debug("Reading %d messages at once from USB-Stick", len(msgs)) self._buffer = msgs[-1] # whatever was left over if self._buffer == b"\x83": self._buffer = b"" @@ -180,7 +180,7 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: _LOGGER.warning(err) return None - _LOGGER.debug("USB Got %s", response) + _LOGGER.debug("Reading '%s' from USB-Stick", response) return response def _populate_message( @@ -198,6 +198,7 @@ async def _msg_queue_processing_function(self): """Process queue items.""" while self.is_connected and self._request_queue.qsize() > 0: response: PlugwiseResponse | None = await self._request_queue.get() + _LOGGER.debug("Processing started for %s", response) if isinstance(response, StickResponse): await self._notify_stick_response_subscribers(response) elif response is None: @@ -207,6 +208,7 @@ async def _msg_queue_processing_function(self): _LOGGER.debug("Processing %s", response) await self._notify_node_response_subscribers(response) self._request_queue.task_done() + _LOGGER.debug("Processing finished for %s", response) def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index ce5573cb0..f9c3ef05c 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -64,7 +64,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._receiver.subscribe_to_node_responses, ) - _LOGGER.debug("Sending %s", request) + _LOGGER.debug("Writing '%s' to USB-Stick", request) # Write message to serial port buffer self._transport.write(serialized_data) request.add_send_attempt() @@ -79,9 +79,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: request.assign_error( BaseException( StickError( - f"Failed to send {request.__class__.__name__} " + - "because USB-Stick did not respond " + - f"within {STICK_TIME_OUT} seconds." + f"USB-Stick did not respond within {STICK_TIME_OUT} seconds after writing {request}" ) ) ) @@ -89,7 +87,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: request.assign_error(exc) else: # Update request with session id - _LOGGER.debug("Request %s assigned seq_id %s", request, str(seq_id)) + _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(seq_id)) request.seq_id = seq_id self._open_requests[seq_id] = request finally: diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 3c9132baf..ddd14845d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -76,7 +76,9 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} for {self.mac_decoded}" + if self._seq_id is None: + return f"{self.__class__.__name__} for {self.mac_decoded}" + return f"{self.__class__.__name__} (seq_id={self._seq_id}) for {self.mac_decoded}" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" @@ -171,9 +173,9 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: if self._send_counter > 1: _LOGGER.info("Response %s for retried request %s id %d", response, self, self._id) elif self._other: - _LOGGER.debug("Response %s for request %s after other", response, self) + _LOGGER.info("Received '%s' as reply to retried '%s' id %d", response, self, self._id) else: - _LOGGER.debug("Response %s for request %s id %d", response, self, self._id) + _LOGGER.debug("Received '%s' as reply to '%s' id %d", response, self, self._id) self._response_future.set_result(response) else: _LOGGER.warning("Response %s for request %s id %d already done", response, self, self._id) @@ -182,9 +184,9 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: return True self._other = True if self._seq_id: - _LOGGER.warning("Response %s for request %s is not mine %s", response, self, str(response.seq_id)) + _LOGGER.warning("Received '%s' as reply to '%s' which is not correct (seq_id=%s)", response, self, str(response.seq_id)) else: - _LOGGER.debug("Response %s for request %s has not received seq_id", response, self) + _LOGGER.debug("Received '%s' as reply to '%s' has not received seq_id", response, self) return False async def _process_stick_response(self, stick_response: StickResponse) -> None: diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 24346e020..450441368 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -114,7 +114,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} from {self.mac_decoded} seq_id={self.seq_id}" + return f"{self.__class__.__name__} from {self.mac_decoded} (seq_id={self.seq_id})" @property def ack_id(self) -> bytes | None: @@ -219,7 +219,7 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"StickResponse {StickResponseType(self.ack_id).name} seq_id={str(self.seq_id)}" + return f"StickResponse (ack={StickResponseType(self.ack_id).name}, seq_id={str(self.seq_id)})" class NodeResponse(PlugwiseResponse): @@ -238,7 +238,7 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()} | ack={str(NodeResponseType(self.ack_id).name)}" + return f"{super().__repr__()[:-1]}, ack={str(NodeResponseType(self.ack_id).name)})" class StickNetworkInfoResponse(PlugwiseResponse): @@ -617,7 +617,7 @@ def frequency(self) -> int: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()} | log_address_pointer={self._logaddress_pointer.value}" + return f"{super().__repr__()[:-1]}, log_address_pointer={self._logaddress_pointer.value})" class EnergyCalibrationResponse(PlugwiseResponse): @@ -739,7 +739,7 @@ def log_address(self) -> int: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()} | log_address={self._logaddr.value}" + return f"{super().__repr__()[:-1]}, log_address={self._logaddr.value})" class NodeAwakeResponse(PlugwiseResponse): @@ -773,7 +773,7 @@ def awake_type(self) -> NodeAwakeResponseType: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()} | awake_type={self.awake_type.name}" + return f"{super().__repr__()[:-1]}, awake_type={self.awake_type.name})" class NodeSwitchGroupResponse(PlugwiseResponse): @@ -849,7 +849,7 @@ def node_ack_type(self) -> NodeAckResponseType: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()} | Ack={self.node_ack_type.name}" + return f"{super().__repr__()[:-1]}, Ack={self.node_ack_type.name})" class SenseReportResponse(PlugwiseResponse): diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 39a7ec858..44f5026bc 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -328,16 +328,15 @@ async def energy_update( if len(missing_addresses) == 0: await self.power_update() _LOGGER.debug( - "async_energy_update for %s | .. == 0 | %s", + "async_energy_update for %s | no missing log records", self.mac, - missing_addresses, ) return self._energy_counters.energy_statistics if len(missing_addresses) == 1: if await self.energy_log_update(missing_addresses[0]): await self.power_update() _LOGGER.debug( - "async_energy_update for %s | .. == 1 | %s", + "async_energy_update for %s | single energy log is missing | %s", self.mac, missing_addresses, ) From bd18c77e97d63d78812d86218b22865b18fb97fa Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:35:20 +0100 Subject: [PATCH 251/979] Improve handling of received messages --- plugwise_usb/connection/receiver.py | 70 +++++++++++++++++------------ 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index e3021ff44..bfb41014f 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -20,6 +20,7 @@ from collections.abc import Awaitable, Callable from concurrent import futures import logging +from typing import Final from serial_asyncio import SerialTransport @@ -33,6 +34,7 @@ StickEvent.CONNECTED, StickEvent.DISCONNECTED ) +CACHED_REQUESTS: Final = 50 async def delayed_run(coroutine: Callable, seconds: float): @@ -55,8 +57,8 @@ def __init__( self._transport: SerialTransport | None = None self._buffer: bytes = bytes([]) self._connection_state = False - self._request_queue = Queue() - self._last_20_processed_messages: list[bytes] = [] + self._receive_queue: Queue[PlugwiseResponse | None] = Queue() + self._last_processed_messages: list[bytes] = [] self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None @@ -127,7 +129,7 @@ async def close(self) -> None: async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" - await self._request_queue.put(None) + await self._receive_queue.put(None) if self._msg_processing_task is not None and not self._msg_processing_task.done(): self._msg_processing_task.cancel() await wait([self._msg_processing_task]) @@ -151,7 +153,7 @@ def data_received(self, data: bytes) -> None: def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: """Put message in queue.""" - self._request_queue.put_nowait(response) + self._receive_queue.put_nowait(response) if self._msg_processing_task is None or self._msg_processing_task.done(): self._msg_processing_task = self._loop.create_task( self._msg_queue_processing_function(), @@ -196,19 +198,19 @@ def _populate_message( async def _msg_queue_processing_function(self): """Process queue items.""" - while self.is_connected and self._request_queue.qsize() > 0: - response: PlugwiseResponse | None = await self._request_queue.get() + while self.is_connected: + response: PlugwiseResponse | None = await self._receive_queue.get() _LOGGER.debug("Processing started for %s", response) if isinstance(response, StickResponse): await self._notify_stick_response_subscribers(response) elif response is None: - self._request_queue.task_done() + self._receive_queue.task_done() return else: - _LOGGER.debug("Processing %s", response) await self._notify_node_response_subscribers(response) - self._request_queue.task_done() _LOGGER.debug("Processing finished for %s", response) + self._receive_queue.task_done() + await sleep(0) def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -280,6 +282,7 @@ def subscribe_to_node_responses( node_response_callback: Callable[[PlugwiseResponse], Awaitable[bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, + seq_id: bytes | None = None, ) -> Callable[[], None]: """Subscribe a awaitable callback to be called when a specific message is received. @@ -291,36 +294,45 @@ def remove_listener() -> None: self._node_response_subscribers[ remove_listener - ] = (node_response_callback, mac, message_ids) + ] = (node_response_callback, mac, message_ids, seq_id) return remove_listener - async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse, delayed: bool = False) -> None: + async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse, retries: int = 0) -> None: """Call callback for all node response message subscribers.""" processed = False - for callback, mac, message_ids in list( + for callback, mac, message_ids, seq_id in list( self._node_response_subscribers.values() ): - if mac is not None: - if mac != node_response.mac: - continue - if message_ids is not None: - if node_response.identifier not in message_ids: - continue - processed = await callback(node_response) + if mac is not None and mac != node_response.mac: + continue + if message_ids is not None and node_response.identifier not in message_ids: + continue + if seq_id is not None and seq_id != node_response.seq_id: + continue + processed = True + try: + await callback(node_response) + except Exception as err: + _LOGGER.error("ERROR AT _notify_node_response_subscribers: %s", err) if processed: - self._last_20_processed_messages.append(node_response.seq_id) - if len(self._last_20_processed_messages) > 20: - self._last_20_processed_messages = self._last_20_processed_messages[:-20] + self._last_processed_messages.append(node_response.seq_id) + if len(self._last_processed_messages) > CACHED_REQUESTS: + self._last_processed_messages = self._last_processed_messages[:-CACHED_REQUESTS] return - if node_response.seq_id in self._last_20_processed_messages: + if node_response.seq_id in self._last_processed_messages: _LOGGER.debug("Drop duplicate %s", node_response) return - _LOGGER.warning( - "No subscriber to handle %s, seq_id=%s from %s", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, - ) + if retries > 10: + _LOGGER.warning( + "No subscriber to handle %s, seq_id=%s from %s after 10 retries", + node_response.__class__.__name__, + node_response.seq_id, + node_response.mac_decoded, + ) + return + retries += 1 + await sleep(0.01) + await self._notify_node_response_subscribers(node_response, retries) From a524f90bc3af0b7f348d91794ccc3b2c75b68a6c Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:55:24 +0100 Subject: [PATCH 252/979] Correct parameter type --- plugwise_usb/nodes/circle.py | 2 +- plugwise_usb/nodes/scan.py | 2 +- plugwise_usb/nodes/sense.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 44f5026bc..0d6eab5bb 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1048,6 +1048,6 @@ async def get_state( states[feature], ) else: - state_result = await super().get_state([feature]) + state_result = await super().get_state((feature,)) states[feature] = state_result[feature] return states diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 542ddba1e..3e666a821 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -195,6 +195,6 @@ async def get_state( if feature == NodeFeature.MOTION: states[NodeFeature.MOTION] = self._motion_state else: - state_result = await super().get_state([feature]) + state_result = await super().get_state((feature,)) states[feature] = state_result[feature] return states diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 7519cd529..61ec28ba6 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -124,7 +124,7 @@ async def get_state( elif feature == NodeFeature.PING: states[NodeFeature.PING] = await self.ping_update() else: - state_result = await super().get_state([feature]) + state_result = await super().get_state((feature,)) states[feature] = state_result[feature] return states From a57bf0ee647e619fc7048fd33aa8b0e3d97e5324 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:55:39 +0100 Subject: [PATCH 253/979] Increase node timeout --- plugwise_usb/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index f91cdfa73..fee1b7f39 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -33,7 +33,7 @@ STICK_ACCEPT_TIME_OUT: Final = 6 # Stick accept respond. STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. QUEUE_TIME_OUT: Final = 45 # Total seconds to wait for queue -NODE_TIME_OUT: Final = 5 +NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. MAX_RETRIES: Final = 3 # plugwise year information is offset from y2k From fb3b93fb1c7700c650f82ebcfd20075b42584169 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:57:22 +0100 Subject: [PATCH 254/979] Cleanup stick acceptance handling --- plugwise_usb/connection/sender.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index f9c3ef05c..8c60c05d3 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -41,7 +41,6 @@ def __init__( self._stick_response: Future[bytes] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None - self._open_requests: dict[bytes, PlugwiseRequest] = {} self._unsubscribe_stick_response = ( self._receiver.subscribe_to_stick_responses( self._process_stick_response @@ -89,9 +88,8 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: # Update request with session id _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(seq_id)) request.seq_id = seq_id - self._open_requests[seq_id] = request finally: - self._stick_response = None + self._stick_response.cancel() self._stick_lock.release() async def _process_stick_response(self, response: StickResponse) -> None: @@ -101,21 +99,11 @@ async def _process_stick_response(self, response: StickResponse) -> None: return if response.ack_id != StickResponseType.ACCEPT: - # - # TODO - # Verify if we actually do receive any non ACCEPT stick response as the first response - # after submitting an request. - # - _LOGGER.warning("Received %s as POSSIBLE reply to %s", response, self._current_request) + # Only ACCEPT stick responses contain the seq_id we need for this request. + # Other stick responses are not related to this request. return _LOGGER.debug("Received %s as reply to %s", response, self._current_request) self._stick_response.set_result(response.seq_id) - await self._stick_lock.acquire() - if response.seq_id in self._open_requests: - del self._open_requests[response.seq_id] - else: - return - self._stick_lock.release() def stop(self) -> None: """Stop sender.""" From 991cd36395f58ca7e8b20b621802b10ea9139d5d Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:58:42 +0100 Subject: [PATCH 255/979] Improve discovery of battery powered nodes --- plugwise_usb/network/__init__.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 62b2f85e8..59f6ac6a9 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations -from asyncio import gather +from asyncio import create_task, gather, sleep from collections.abc import Awaitable, Callable from datetime import datetime, timedelta import logging @@ -205,16 +205,16 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: self._awake_discovery[mac] = response.timestamp return True if self._register.network_address(mac) is None: - _LOGGER.warning( + _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac ) return False address: int | None = self._register.network_address(mac) if self._nodes.get(mac) is None: - await self._discover_node(address, mac, None) - await self._load_node(mac) - await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + create_task( + self._discover_battery_powered_node(address, mac) + ) return True async def node_join_available_message( @@ -381,11 +381,25 @@ async def get_node_details( ) # type: ignore [assignment] return (info_response, ping_response) + 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. + """ + await self._discover_node(address, mac, node_type=None, ping_first=False) + await self._load_node(mac) + await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + async def _discover_node( self, address: int, mac: str, - node_type: NodeType | None + node_type: NodeType | None, + ping_first: bool = True, ) -> bool: """Discover node and add it to list of nodes. @@ -397,7 +411,6 @@ async def _discover_node( if node_type is not None: self._create_node_object(mac, address, node_type) - self._nodes[mac].initialize() await self._notify_node_event_subscribers( NodeEvent.DISCOVERED, mac ) @@ -405,7 +418,7 @@ async def _discover_node( # Node type is unknown, so we need to discover it first _LOGGER.debug("Starting the discovery of node %s", mac) - node_info, node_ping = await self.get_node_details(mac, True) + node_info, node_ping = await self.get_node_details(mac, ping_first) if node_info is None: return False self._create_node_object(mac, address, node_info.node_type) @@ -428,6 +441,7 @@ async def _discover_registered_nodes(self) -> None: address, mac, node_type ) counter += 1 + await sleep(0) _LOGGER.debug( "Total %s registered node(s)", str(counter) From a7a2d56fd9ebe34def66205707d8d276067a9167 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 21:58:58 +0100 Subject: [PATCH 256/979] Add battery powered property --- plugwise_usb/nodes/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 9d94b4cbd..912f87713 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -153,6 +153,11 @@ def available(self) -> bool: """Return network availability state.""" return self._available + @property + def battery_powered(self) -> bool: + """Return if node is battery powered.""" + return self._node_info.battery_powered + @property def energy(self) -> EnergyStatistics | None: """"Return energy statistics.""" From 40611766e2176c3ba2858ca80a07fc7182b34a38 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 22:00:02 +0100 Subject: [PATCH 257/979] Handle node info state for SED devices --- plugwise_usb/nodes/sed.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 5adfbc580..6b15312e3 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -6,9 +6,9 @@ from collections.abc import Callable from datetime import datetime import logging -from typing import Final +from typing import Any, Final -from ..api import NodeInfo +from ..api import NodeFeature, NodeInfo from ..connection import StickController from ..exceptions import NodeError, NodeTimeout from ..messages.requests import NodeSleepConfigRequest @@ -202,3 +202,21 @@ async def sed_configure( raise NodeError("SED failed to configure sleep settings") if response.ack_id == NodeResponseType.SLEEP_CONFIG_ACCEPTED: self._maintenance_interval = maintenance_interval + + @raise_not_loaded + async def get_state( + self, features: tuple[NodeFeature] + ) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + states: dict[NodeFeature, Any] = {} + for feature in features: + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.INFO: + states[NodeFeature.INFO] = await self.node_info_update() + else: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] From c4a1524884e2388941fa7cc14adaa07835a0f194 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 22:02:17 +0100 Subject: [PATCH 258/979] Accept timeouts for ping requests --- plugwise_usb/connection/queue.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 4de715c7c..3fe63a99e 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,14 +1,14 @@ """Manage the communication sessions towards the USB-Stick.""" from __future__ import annotations -from asyncio import PriorityQueue, Task, get_running_loop, wait +from asyncio import PriorityQueue, Task, get_running_loop from collections.abc import Callable from dataclasses import dataclass import logging from ..api import StickEvent from ..exceptions import NodeTimeout, StickError, StickTimeout -from ..messages.requests import PlugwiseRequest, Priority +from ..messages.requests import NodePingRequest, PlugwiseRequest, Priority from ..messages.responses import PlugwiseResponse from .manager import StickConnectionManager @@ -96,6 +96,9 @@ async def submit( except (NodeTimeout, StickTimeout) as e: if request.resend: _LOGGER.debug("%s, retrying", e) + elif isinstance(request, NodePingRequest): + # For ping requests it is expected to receive timeouts, so lower log level + _LOGGER.debug("%s after %s attempts. Cancel request", e, request.max_retries) else: _LOGGER.warning("%s after %s attempts, cancel request", e, request.max_retries) except StickError as exception: From 3d4b18dc22b93dec5ef8f57954d69f5df4fdbb76 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 22:03:08 +0100 Subject: [PATCH 259/979] Improve subscription handling for response messages --- plugwise_usb/messages/requests.py | 53 +++++++++++++++++-------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index ddd14845d..fcff55ff6 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -66,13 +66,13 @@ def __init__( self._reply_identifier: bytes = b"0000" self._response: PlugwiseResponse | None = None self._stick_subscription_fn: Callable[[], None] | None = None + self._node_subscription_fn: Callable[[], None] | None = None self._unsubscribe_stick_response: Callable[[], None] | None = None self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None self._response_future: Future[PlugwiseResponse] = ( self._loop.create_future() ) - self._other = False def __repr__(self) -> str: """Convert request into writable str.""" @@ -102,12 +102,32 @@ def seq_id(self) -> bytes | None: def seq_id(self, seq_id: bytes) -> None: """Assign sequence id.""" self._seq_id = seq_id - if self._unsubscribe_stick_response is not None: - return + self._unsubscribe_from_stick() self._unsubscribe_stick_response = self._stick_subscription_fn( self._process_stick_response, seq_id=seq_id ) + self._unsubscribe_from_node() + self._unsubscribe_node_response = ( + self._node_subscription_fn( + self._process_node_response, + mac=self._mac, + message_ids=(self._reply_identifier,), + seq_id=seq_id + ) + ) + + def _unsubscribe_from_stick(self) -> None: + """Unsubscribe from StickResponse messages.""" + if self._unsubscribe_stick_response is not None: + self._unsubscribe_stick_response() + self._unsubscribe_stick_response = None + + def _unsubscribe_from_node(self) -> None: + """Unsubscribe from NodeResponse messages.""" + if self._unsubscribe_node_response is not None: + self._unsubscribe_node_response() + self._unsubscribe_node_response = None def subscribe_to_responses( self, @@ -115,13 +135,7 @@ def subscribe_to_responses( node_subscription_fn: Callable[[], None] ) -> None: """Register for response messages.""" - self._unsubscribe_node_response = ( - node_subscription_fn( - self._process_node_response, - mac=self._mac, - message_ids=(self._reply_identifier,), - ) - ) + self._node_subscription_fn = node_subscription_fn self._stick_subscription_fn = stick_subscription_fn def start_response_timeout(self) -> None: @@ -140,9 +154,8 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): return - if self._unsubscribe_node_response is not None: - self._unsubscribe_node_response() - self._unsubscribe_node_response = None + self._unsubscribe_from_stick() + self._unsubscribe_from_node() if stick_timeout: self._response_future.set_exception( StickTimeout( @@ -166,23 +179,19 @@ def assign_error(self, error: BaseException) -> None: async def _process_node_response(self, response: PlugwiseResponse) -> bool: """Process incoming message from node.""" if self._seq_id is not None and self._seq_id == response.seq_id: - self._unsubscribe_stick_response() self._response = response self.stop_response_timeout() if not self._response_future.done(): if self._send_counter > 1: - _LOGGER.info("Response %s for retried request %s id %d", response, self, self._id) - elif self._other: _LOGGER.info("Received '%s' as reply to retried '%s' id %d", response, self, self._id) else: _LOGGER.debug("Received '%s' as reply to '%s' id %d", response, self, self._id) self._response_future.set_result(response) else: - _LOGGER.warning("Response %s for request %s id %d already done", response, self, self._id) - - self._unsubscribe_node_response() + _LOGGER.warning("Received '%s' as reply to '%s' id %d already done", response, self, self._id) + self._unsubscribe_from_stick() + self._unsubscribe_from_node() return True - self._other = True if self._seq_id: _LOGGER.warning("Received '%s' as reply to '%s' which is not correct (seq_id=%s)", response, self, str(response.seq_id)) else: @@ -198,9 +207,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) elif stick_response.ack_id == StickResponseType.FAILED: - if self._unsubscribe_node_response is not None: - self._unsubscribe_node_response() - self._unsubscribe_node_response = None + self._unsubscribe_from_node() self._response_future.set_exception( NodeError( f"Stick failed request {self._seq_id}" From a50f79ad2d47171b72ed775f547d523131ab1add Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 22:09:09 +0100 Subject: [PATCH 260/979] Update test_usb.py --- tests/test_usb.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 54c7e3c75..ca4526e50 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -52,7 +52,7 @@ def inc_seq_id(seq_id: bytes) -> bytes: def construct_message(data: bytes, seq_id: bytes = b"0000") -> bytes: - """construct plugwise message.""" + """Construct plugwise message.""" body = data[:4] + seq_id + data[4:] return ( pw_constants.MESSAGE_HEADER @@ -225,15 +225,17 @@ async def test_stick_connect_without_port(self): assert stick.network_id assert not stick.network_discovered assert not stick.network_state + with pytest.raises(pw_exceptions.StickError): await stick.connect() stick.port = "null" with pytest.raises(pw_exceptions.StickError): await stick.connect() + await stick.disconnect() @pytest.mark.asyncio async def test_stick_reconnect(self, monkeypatch): - """Test connecting to stick while already connected""" + """Test connecting to stick while already connected.""" monkeypatch.setattr( pw_connection_manager, "create_serial_connection", @@ -291,7 +293,7 @@ async def test_stick_connect_timeout(self, monkeypatch): } ).mock_connection, ) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 5) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.5) stick = pw_stick.Stick() await stick.connect("test_port") with pytest.raises(pw_exceptions.StickError): @@ -446,8 +448,8 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): "create_serial_connection", mock_serial.mock_connection, ) - monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() @@ -543,8 +545,8 @@ async def test_stick_node_join_subscription(self, monkeypatch): "create_serial_connection", mock_serial.mock_connection, ) - monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() @@ -1310,7 +1312,7 @@ async def test_stick_network_down(self, monkeypatch): mock_serial.mock_connection, ) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 1.0) stick = pw_stick.Stick(port="test_port", cache_enabled=False) await stick.connect() with pytest.raises(pw_exceptions.StickError): From e0736ee85b07a897adf4594ffae632078b54f2b1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Sun, 3 Mar 2024 22:09:59 +0100 Subject: [PATCH 261/979] Bump version to v0.40.0a5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 06e13ed45..05c68ada5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a4" +version = "v0.40.0a5" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 5900f30ebcb2aac1b8d35614eb2d5c04128d4224 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 7 Mar 2024 21:04:08 +0100 Subject: [PATCH 262/979] Return state from local variable --- plugwise_usb/nodes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 912f87713..0bb421156 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -619,7 +619,7 @@ async def get_state( if feature == NodeFeature.INFO: states[NodeFeature.INFO] = await self.node_info_update() elif feature == NodeFeature.AVAILABLE: - states[NodeFeature.AVAILABLE] = self.available + states[NodeFeature.AVAILABLE] = self._available elif feature == NodeFeature.PING: states[NodeFeature.PING] = await self.ping_update() else: From 92c3ca2b2d32e221d2efcc7957f3aa234b8dc7a6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 20:31:27 +0100 Subject: [PATCH 263/979] Reuse ping_update() --- plugwise_usb/nodes/__init__.py | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 0bb421156..eb92df657 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -544,27 +544,7 @@ def _node_info_update_state( async def is_online(self) -> bool: """Check if node is currently online.""" - try: - ping_response: NodePingResponse | None = await self._send( - NodePingRequest( - self._mac_in_bytes, retries=1 - ) - ) - except StickError: - _LOGGER.warning( - "StickError for is_online() for %s", - self.mac - ) - await self._available_update_state(False) - return False - except NodeError: - _LOGGER.warning( - "NodeError for is_online() for %s", - self.mac - ) - await self._available_update_state(False) - return False - + ping_response: NodePingResponse | None = await self.ping_update() if ping_response is None: _LOGGER.info( "No response to ping for %s", @@ -572,7 +552,7 @@ async def is_online(self) -> bool: ) await self._available_update_state(False) return False - await self.ping_update(ping_response) + await self._available_update_state(True) return True async def ping_update( From 6339f0611c0627e0830bd590d4eecbd20304d0ed Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 20:34:59 +0100 Subject: [PATCH 264/979] Return available state based on current state --- plugwise_usb/nodes/circle.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0d6eab5bb..02e0d4b71 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1050,4 +1050,6 @@ async def get_state( else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] + + states[NodeFeature.AVAILABLE] = self._available return states From 6cdaadff91ae42c41c5e4b8733166eb5e4c90568 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 20:36:52 +0100 Subject: [PATCH 265/979] Remove unused import --- plugwise_usb/nodes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index eb92df657..edaeead80 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -21,7 +21,7 @@ ) from ..connection import StickController from ..constants import UTF8, MotionSensitivity -from ..exceptions import NodeError, StickError +from ..exceptions import NodeError from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse from ..util import version_to_model From 92c056916da3d26714eaf6d25d541017a3b1338f Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 20:40:10 +0100 Subject: [PATCH 266/979] Decrease log level --- plugwise_usb/connection/queue.py | 2 +- plugwise_usb/nodes/__init__.py | 2 +- plugwise_usb/nodes/circle.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 3fe63a99e..7e968a9b4 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -100,7 +100,7 @@ async def submit( # For ping requests it is expected to receive timeouts, so lower log level _LOGGER.debug("%s after %s attempts. Cancel request", e, request.max_retries) else: - _LOGGER.warning("%s after %s attempts, cancel request", e, request.max_retries) + _LOGGER.info("%s after %s attempts, cancel request", e, request.max_retries) except StickError as exception: _LOGGER.error(exception) raise StickError( diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index edaeead80..4d381a5dc 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -546,7 +546,7 @@ async def is_online(self) -> bool: """Check if node is currently online.""" ping_response: NodePingResponse | None = await self.ping_update() if ping_response is None: - _LOGGER.info( + _LOGGER.debug( "No response to ping for %s", self.mac ) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 02e0d4b71..3a33a28fe 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1011,7 +1011,7 @@ async def get_state( states: dict[NodeFeature, Any] = {} if not self._available: if not await self.is_online(): - _LOGGER.warning( + _LOGGER.debug( "Node %s did not respond, unable to update state", self.mac ) From 46ffea567699e5608ffc0b566bd368cb3c50526b Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 20:58:14 +0100 Subject: [PATCH 267/979] Add available state when circle is off-line --- plugwise_usb/nodes/circle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 3a33a28fe..1b990fb2e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1017,6 +1017,7 @@ async def get_state( ) for feature in features: states[feature] = None + states[NodeFeature.AVAILABLE] = False return states for feature in features: From 7bd519c8fcc5b5cfe0694a570d168536a195ef5b Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 21:00:10 +0100 Subject: [PATCH 268/979] Add available state to Scan & Sense --- plugwise_usb/nodes/scan.py | 2 ++ plugwise_usb/nodes/sense.py | 1 + 2 files changed, 3 insertions(+) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 3e666a821..9c516e371 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -197,4 +197,6 @@ async def get_state( else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] + + states[NodeFeature.AVAILABLE] = self._available return states diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 61ec28ba6..a3b7d824f 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -127,4 +127,5 @@ async def get_state( state_result = await super().get_state((feature,)) states[feature] = state_result[feature] + states[NodeFeature.AVAILABLE] = self._available return states From 0989f7a78e1f4c76fc2f71657d0b9750f30e76c6 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 21:01:54 +0100 Subject: [PATCH 269/979] Bump version to v0.40.0a6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 05c68ada5..8d646c52f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a5" +version = "v0.40.0a6" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From edce81c4ab5efd84924d0f99b23dfa24c4b3fa94 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 8 Mar 2024 21:09:29 +0100 Subject: [PATCH 270/979] No need to update ping state --- plugwise_usb/nodes/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 4d381a5dc..594ea7984 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -544,15 +544,12 @@ def _node_info_update_state( async def is_online(self) -> bool: """Check if node is currently online.""" - ping_response: NodePingResponse | None = await self.ping_update() - if ping_response is None: + if await self.ping_update() is None: _LOGGER.debug( "No response to ping for %s", self.mac ) - await self._available_update_state(False) return False - await self._available_update_state(True) return True async def ping_update( From 4ed2336d5fbbda72831236633cf607b462eabefc Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:33:31 +0100 Subject: [PATCH 271/979] Only guard for negative miscalculations --- plugwise_usb/nodes/helpers/counter.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 50715543a..6e620346d 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -289,9 +289,8 @@ def energy(self) -> float | None: + self._calibration.off_tot ) calc_value = corrected_pulses / PULSES_PER_KW_SECOND / HOUR_IN_SECONDS - # Fix minor miscalculations? - if -0.001 < calc_value < 0.001: - calc_value = 0.0 + # Guard for minor negative miscalculations + calc_value = max(calc_value, 0.0) return calc_value @property From 2353186d616aac0a88ec72537f89a554f6834b3e Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:34:04 +0100 Subject: [PATCH 272/979] Correct doc string --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 1b990fb2e..d200dba99 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -280,7 +280,7 @@ async def power_update(self) -> PowerStatistics | None: async def energy_update( self ) -> EnergyStatistics | None: - """Update energy usage statistics, returns True if successful.""" + """Return updated energy usage statistics.""" if self._current_log_address is None: _LOGGER.info( "Unable to update energy logs for node %s because last_log_address is unknown.", From 3e045a0532eb3b8c8bc7a778ca787460f1af2f21 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:36:48 +0100 Subject: [PATCH 273/979] Reduce logging during startup --- plugwise_usb/connection/__init__.py | 10 ++++++++++ plugwise_usb/connection/manager.py | 10 ++++++++++ plugwise_usb/connection/receiver.py | 11 +++++++++++ plugwise_usb/network/__init__.py | 3 ++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 590bd3bdd..f7c61c153 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -34,6 +34,16 @@ def __init__(self) -> None: self._network_id: int | None = None self._network_online = False + @property + def reduce_receive_logging(self) -> bool: + """Return if logging must reduced.""" + return self._manager.reduce_receive_logging + + @reduce_receive_logging.setter + def reduce_receive_logging(self, state: bool) -> None: + """Reduce logging of unhandled received messages.""" + self._manager.reduce_receive_logging = state + @property def is_initialized(self) -> bool: """Returns True if UBS-Stick connection is active and initialized.""" diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index e5728c327..9a156dd07 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -36,6 +36,16 @@ def __init__(self) -> None: ] = {} self._unsubscribe_stick_events: Callable[[], None] | None = None + @property + def reduce_receive_logging(self) -> bool: + """Return if logging must reduced.""" + return self._receiver.reduce_logging + + @reduce_receive_logging.setter + def reduce_receive_logging(self, state: bool) -> None: + """Reduce logging of unhandled received messages.""" + self._receiver.reduce_logging = state + @property def serial_path(self) -> str: """Return current port.""" diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index bfb41014f..694e7b710 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -57,6 +57,7 @@ def __init__( self._transport: SerialTransport | None = None self._buffer: bytes = bytes([]) self._connection_state = False + self._reduce_logging = True self._receive_queue: Queue[PlugwiseResponse | None] = Queue() self._last_processed_messages: list[bytes] = [] self._stick_future: futures.Future | None = None @@ -103,6 +104,16 @@ def is_connected(self) -> bool: """Return current connection state of the USB-Stick.""" return self._connection_state + @property + def reduce_logging(self) -> bool: + """Return if logging must reduced.""" + return self._reduce_logging + + @reduce_logging.setter + def reduce_logging(self, reduce_logging: bool) -> None: + """Reduce logging.""" + self._reduce_logging = reduce_logging + def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.debug("Connection made") diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 59f6ac6a9..f7c3ecc93 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -285,7 +285,7 @@ def _create_node_object( ) -> None: """Create node object and update network registry.""" if self._nodes.get(mac) is not None: - _LOGGER.warning( + _LOGGER.debug( "Skip creating node object because node object for mac %s already exists", mac ) @@ -446,6 +446,7 @@ async def _discover_registered_nodes(self) -> None: "Total %s registered node(s)", str(counter) ) + self._controller.reduce_receive_logging = False async def _load_node(self, mac: str) -> bool: """Load node.""" From a450159171f6299a99054242988a8c8197728132 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:37:36 +0100 Subject: [PATCH 274/979] Add retries property --- plugwise_usb/messages/responses.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 450441368..729dfa37e 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -111,11 +111,22 @@ def __init__( self._decode_mac = decode_mac self._params: list[Any] = [] self._seq_id: bytes = b"FFFF" + self._retries = 0 def __repr__(self) -> str: """Convert request into writable str.""" return f"{self.__class__.__name__} from {self.mac_decoded} (seq_id={self.seq_id})" + @property + def retries(self) -> int: + """Number of retries for processing.""" + return self._retries + + @retries.setter + def retries(self, retries: int) -> None: + """Set number of retries for processing.""" + self._retries = retries + @property def ack_id(self) -> bytes | None: """Return the acknowledge id.""" From e186125525ab1ce19d2b0f8c35d2b9d3d21ca7cf Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:38:10 +0100 Subject: [PATCH 275/979] Add response type property to StickResponse message --- plugwise_usb/messages/responses.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 729dfa37e..57582eac8 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -232,6 +232,11 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"StickResponse (ack={StickResponseType(self.ack_id).name}, seq_id={str(self.seq_id)})" + @property + def response_type(self) -> StickResponseType: + """Return acknowledge response type.""" + return StickResponseType(self.ack_id) + class NodeResponse(PlugwiseResponse): """Report status from node to a specific request. From 2d393e8733acdaba84aa0ba1770d8a11050ebc02 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:42:29 +0100 Subject: [PATCH 276/979] Guard for duplicate subscriptions --- plugwise_usb/messages/requests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fcff55ff6..4a4782f3b 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -101,6 +101,8 @@ def seq_id(self) -> bytes | None: @seq_id.setter def seq_id(self, seq_id: bytes) -> None: """Assign sequence id.""" + if self._seq_id == seq_id: + return self._seq_id = seq_id self._unsubscribe_from_stick() self._unsubscribe_stick_response = self._stick_subscription_fn( From c99e78770448f1c11683dc334df24f994f9d99f8 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 21:44:58 +0100 Subject: [PATCH 277/979] Allow to scribe to specific StickResponseTypes --- plugwise_usb/connection/receiver.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 694e7b710..2b89a7231 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -27,7 +27,12 @@ from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError -from ..messages.responses import PlugwiseResponse, StickResponse, get_message_object +from ..messages.responses import ( + PlugwiseResponse, + StickResponse, + StickResponseType, + get_message_object, +) _LOGGER = logging.getLogger(__name__) STICK_RECEIVER_EVENTS = ( @@ -266,7 +271,8 @@ async def _notify_stick_event_subscribers( def subscribe_to_stick_responses( self, callback: Callable[[StickResponse], Awaitable[None]], - seq_id: bytes | None = None + seq_id: bytes | None = None, + response_type: StickResponseType | None = None ) -> Callable[[], None]: """Subscribe to response messages from stick.""" def remove_subscription() -> None: @@ -275,17 +281,19 @@ def remove_subscription() -> None: self._stick_response_subscribers[ remove_subscription - ] = callback, seq_id + ] = callback, seq_id, response_type return remove_subscription async def _notify_stick_response_subscribers( self, stick_response: StickResponse ) -> None: """Call callback for all stick response message subscribers.""" - for callback, seq_id in list(self._stick_response_subscribers.values()): + for callback, seq_id, response_type in list(self._stick_response_subscribers.values()): if seq_id is not None: if seq_id != stick_response.seq_id: continue + if response_type is not None and response_type != stick_response.response_type: + continue await callback(stick_response) def subscribe_to_node_responses( From c29f748b96f67119f18df6c3f347e0bda2ed7a0c Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:05:54 +0100 Subject: [PATCH 278/979] Subscribe to 'accept' StickResponseType only --- plugwise_usb/connection/sender.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 8c60c05d3..dd41e9bad 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -41,9 +41,12 @@ def __init__( self._stick_response: Future[bytes] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None + + # Subscribe to ACCEPT stick responses, which contain the seq_id we need. + # Other stick responses are not related to this request. self._unsubscribe_stick_response = ( self._receiver.subscribe_to_stick_responses( - self._process_stick_response + self._process_stick_response, None, StickResponseType.ACCEPT ) ) @@ -97,11 +100,6 @@ async def _process_stick_response(self, response: StickResponse) -> None: if self._stick_response is None or self._stick_response.done(): _LOGGER.debug("No open request for %s", str(response)) return - - if response.ack_id != StickResponseType.ACCEPT: - # Only ACCEPT stick responses contain the seq_id we need for this request. - # Other stick responses are not related to this request. - return _LOGGER.debug("Received %s as reply to %s", response, self._current_request) self._stick_response.set_result(response.seq_id) From 7de2c2d116313a14bd4a0d68454053733cbb59a0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:07:53 +0100 Subject: [PATCH 279/979] Use context manager & direct assign seq_id property --- plugwise_usb/connection/sender.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index dd41e9bad..b6192678d 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -16,7 +16,7 @@ """ from __future__ import annotations -from asyncio import Future, Lock, Transport, get_running_loop, wait_for +from asyncio import Future, Lock, Transport, get_running_loop, timeout import logging from ..constants import STICK_TIME_OUT @@ -74,9 +74,8 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: # Wait for USB stick to accept request try: - seq_id: bytes = await wait_for( - self._stick_response, timeout=STICK_TIME_OUT - ) + async with timeout(STICK_TIME_OUT): + request.seq_id = await self._stick_response except TimeoutError: request.assign_error( BaseException( @@ -88,9 +87,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: except BaseException as exc: # pylint: disable=broad-exception-caught request.assign_error(exc) else: - # Update request with session id - _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(seq_id)) - request.seq_id = seq_id + _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(request.seq_id)) finally: self._stick_response.cancel() self._stick_lock.release() From b9d70779e304328e4f9dbbcaba1be331f43708a1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:08:58 +0100 Subject: [PATCH 280/979] Utilize retries property of response message --- plugwise_usb/connection/receiver.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2b89a7231..8370fa555 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -316,7 +316,7 @@ def remove_listener() -> None: ] = (node_response_callback, mac, message_ids, seq_id) return remove_listener - async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse, retries: int = 0) -> None: + async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse) -> None: """Call callback for all node response message subscribers.""" processed = False for callback, mac, message_ids, seq_id in list( @@ -344,14 +344,22 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons _LOGGER.debug("Drop duplicate %s", node_response) return - if retries > 10: - _LOGGER.warning( - "No subscriber to handle %s, seq_id=%s from %s after 10 retries", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, - ) + if node_response.retries > 10: + if self._reduce_logging: + _LOGGER.debug( + "No subscriber to handle %s, seq_id=%s from %s after 10 retries", + node_response.__class__.__name__, + node_response.seq_id, + node_response.mac_decoded, + ) + else: + _LOGGER.warning( + "No subscriber to handle %s, seq_id=%s from %s after 10 retries", + node_response.__class__.__name__, + node_response.seq_id, + node_response.mac_decoded, + ) return - retries += 1 + node_response.retries += 1 await sleep(0.01) await self._notify_node_response_subscribers(node_response, retries) From d11f34d90aacb9f9dd9a281ad6bcb91bb45b378d Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:09:47 +0100 Subject: [PATCH 281/979] Put unhandled response messages back to queue --- plugwise_usb/connection/receiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 8370fa555..5d582ebd8 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -362,4 +362,4 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons return node_response.retries += 1 await sleep(0.01) - await self._notify_node_response_subscribers(node_response, retries) + self._put_message_in_receiver_queue(node_response) From 7ca27abe13c00778311670b85590abe063e9c147 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:10:30 +0100 Subject: [PATCH 282/979] Remove useless sleep() --- plugwise_usb/connection/receiver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 5d582ebd8..93f90e04c 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -226,7 +226,6 @@ async def _msg_queue_processing_function(self): await self._notify_node_response_subscribers(response) _LOGGER.debug("Processing finished for %s", response) self._receive_queue.task_done() - await sleep(0) def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: From d34d1a905a042501490fd15fe2a3fa2f123cbe36 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:11:16 +0100 Subject: [PATCH 283/979] Correct test --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ca4526e50..e5f209005 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -579,7 +579,7 @@ async def test_node_discovery(self, monkeypatch): await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - assert stick.joined_nodes == 11 + assert stick.joined_nodes == 12 assert len(stick.nodes) == 6 # Discovered nodes await stick.disconnect() From 4fb25282146b0b9c5a8ade0e17579a0f080c6d40 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 11 Mar 2024 22:17:47 +0100 Subject: [PATCH 284/979] Bump version to v0.40.0a7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8d646c52f..d06a70802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a6" +version = "v0.40.0a7" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 61988691aa70e31daa37a72aa6e7aaf9ec6f00ad Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 21:16:14 +0100 Subject: [PATCH 285/979] Add warning message when USB-Stick doesn't respond --- plugwise_usb/connection/sender.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index b6192678d..a46a5d1cf 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -77,6 +77,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: async with timeout(STICK_TIME_OUT): request.seq_id = await self._stick_response except TimeoutError: + _LOGGER.warning("USB-Stick did not respond within %s seconds after writing %s", STICK_TIME_OUT, request) request.assign_error( BaseException( StickError( From 66bccd1a75c9953956c3b882042b01b546f69922 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 21:32:39 +0100 Subject: [PATCH 286/979] Remove unused return value --- plugwise_usb/network/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f7c3ecc93..c6f4a36b0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -190,7 +190,7 @@ async def _handle_stick_event(self, event: StickEvent) -> None: ) self._is_running = False - async def node_awake_message(self, response: NodeAwakeResponse) -> bool: + async def node_awake_message(self, response: NodeAwakeResponse) -> None: """Handle NodeAwakeResponse message.""" mac = response.mac_decoded if self._awake_discovery.get(mac) is None: @@ -203,19 +203,18 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: ): await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp - return True + return if self._register.network_address(mac) is None: _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac ) - return False + return address: int | None = self._register.network_address(mac) if self._nodes.get(mac) is None: create_task( self._discover_battery_powered_node(address, mac) ) - return True async def node_join_available_message( self, response: NodeJoinAvailableResponse From 0570b47e15cf133fa500662b85b878601bb7aff5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 21:34:13 +0100 Subject: [PATCH 287/979] Drop support for python 3.10 This version is not supported in HA either --- scripts/python-venv.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/python-venv.sh b/scripts/python-venv.sh index a791ca28b..75b374fb8 100755 --- a/scripts/python-venv.sh +++ b/scripts/python-venv.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -eu -pyversions=(3.12 3.11 3.10) +pyversions=(3.12 3.11) my_path=$(git rev-parse --show-toplevel) my_venv=${my_path}/venv From f0a078bb3d33b9ba2d4a6a1305002d3ca220d00b Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:02:07 +0100 Subject: [PATCH 288/979] Use UTC --- plugwise_usb/constants.py | 2 +- plugwise_usb/messages/requests.py | 4 +- plugwise_usb/messages/responses.py | 4 +- plugwise_usb/nodes/__init__.py | 10 +- plugwise_usb/nodes/circle.py | 8 +- plugwise_usb/nodes/circle_plus.py | 4 +- plugwise_usb/nodes/helpers/firmware.py | 154 ++++++++++++------------- plugwise_usb/nodes/helpers/pulses.py | 8 +- plugwise_usb/util.py | 2 +- tests/stick_test_data.py | 4 +- 10 files changed, 100 insertions(+), 100 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index fee1b7f39..22d6f2a2e 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -12,7 +12,7 @@ CACHE_DIR: Final = ".plugwise-cache" CACHE_SEPARATOR: str = ";" -LOCAL_TIMEZONE = dt.datetime.now(dt.timezone.utc).astimezone().tzinfo +LOCAL_TIMEZONE = dt.datetime.now(dt.UTC).astimezone().tzinfo UTF8: Final = "utf-8" # Time diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 4a4782f3b..0130de7a9 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -3,7 +3,7 @@ from asyncio import Future, TimerHandle, get_running_loop from collections.abc import Callable -from datetime import datetime, timezone +from datetime import datetime, timezone, UTC from enum import Enum import logging @@ -60,7 +60,7 @@ def __init__( self._mac = mac self._send_counter: int = 0 self._max_retries: int = MAX_RETRIES - self.timestamp = datetime.now(timezone.utc) + self.timestamp = datetime.now(UTC) self._loop = get_running_loop() self._id = id(self) self._reply_identifier: bytes = b"0000" diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 57582eac8..d1ee98af2 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,7 +1,7 @@ """All known response messages to be received from plugwise devices.""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime, timezone from enum import Enum from typing import Any, Final @@ -139,7 +139,7 @@ def seq_id(self) -> bytes: def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Deserialize bytes to actual message properties.""" - self.timestamp = datetime.now(timezone.utc) + self.timestamp = datetime.now(UTC) # Header if response[:4] != MESSAGE_HEADER: raise MessageError( diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 594ea7984..1723468fb 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -5,7 +5,7 @@ from abc import ABC from asyncio import create_task from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone import logging from typing import Any @@ -57,7 +57,7 @@ def __init__( self._loaded_callback = loaded_callback self._message_subscribe = controller.subscribe_to_node_responses self._features = NODE_FEATURES - self._last_update = datetime.now(timezone.utc) + self._last_update = datetime.now(UTC) self._node_info = NodeInfo(mac, address) self._ping = NetworkStatistics() self._power = PowerStatistics() @@ -473,7 +473,7 @@ async def _node_info_load_from_cache(self) -> bool: hour=int(data[3]), minute=int(data[4]), second=int(data[5]), - tzinfo=timezone.utc + tzinfo=UTC ) if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: node_type = NodeType(int(node_type_str)) @@ -489,7 +489,7 @@ async def _node_info_load_from_cache(self) -> bool: hour=int(data[3]), minute=int(data[4]), second=int(data[5]), - tzinfo=timezone.utc + tzinfo=UTC ) return self._node_info_update_state( firmware=firmware, @@ -660,6 +660,6 @@ def skip_update(data_class: Any, seconds: int) -> bool: return False if data_class.timestamp + timedelta( seconds=seconds - ) > datetime.now(timezone.utc): + ) > datetime.now(UTC): return True return False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d200dba99..c7f78112e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -4,7 +4,7 @@ from asyncio import Task, create_task, gather, sleep, wait from collections.abc import Callable -from datetime import datetime, timezone +from datetime import UTC, datetime from functools import wraps import logging from typing import Any, TypeVar, cast @@ -438,7 +438,7 @@ async def energy_log_update(self, address: int) -> bool: await self._energy_log_record_update_state( response.log_address, _slot, - _log_timestamp.replace(tzinfo=timezone.utc), + _log_timestamp.replace(tzinfo=UTC), _log_pulses, import_only=True ) @@ -475,7 +475,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: hour=int(timestamp_energy_log[3]), minute=int(timestamp_energy_log[4]), second=int(timestamp_energy_log[5]), - tzinfo=timezone.utc + tzinfo=UTC ), pulses=int(log_fields[3]), import_only=True, @@ -656,7 +656,7 @@ async def clock_synchronize(self) -> bool: minute=clock_response.time.minute.value, second=clock_response.time.second.value, microsecond=0, - tzinfo=timezone.utc, + tzinfo=UTC, ) clock_offset = ( clock_response.timestamp.replace(microsecond=0) - _dt_of_circle diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 2ff934718..5923f2cf3 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import logging from ..api import NodeEvent, NodeFeature @@ -137,7 +137,7 @@ async def realtime_clock_synchronize(self) -> bool: minute=clock_response.time.value.minute, second=clock_response.time.value.second, microsecond=0, - tzinfo=timezone.utc, + tzinfo=UTC, ) clock_offset = ( clock_response.timestamp.replace(microsecond=0) - _dt_of_circle diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index a554b865a..7d6555007 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Final, NamedTuple from ...api import NodeFeature @@ -24,146 +24,146 @@ class SupportedVersions(NamedTuple): # region - node firmware versions CIRCLE_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=timezone.utc): SupportedVersions( + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( min=1.0, max=1.1, ), - datetime(2009, 9, 8, 13, 50, 31, tzinfo=timezone.utc): SupportedVersions( + datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 56, 23, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 14, 9, 6, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 40, 37, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 5, 55, 19, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2010, 8, 31, 10, 21, 2, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 46, 38, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 29, 38, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 40, 20, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 19, 23, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 52, 18, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 12, 57, 57, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 0, 42, tzinfo=timezone.utc): SupportedVersions( + datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Beta release - datetime(2015, 6, 18, 14, 42, 54, tzinfo=timezone.utc): SupportedVersions( + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Proto release - datetime(2015, 6, 16, 21, 9, 10, tzinfo=timezone.utc): SupportedVersions( + datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), - datetime(2015, 6, 18, 14, 0, 54, tzinfo=timezone.utc): SupportedVersions( + datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # New Flash Update - datetime(2017, 7, 11, 16, 6, 59, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), } CIRCLE_PLUS_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=timezone.utc): SupportedVersions( + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( min=1.0, max=1.1 ), - datetime(2009, 9, 8, 14, 0, 32, tzinfo=timezone.utc): SupportedVersions( + datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 54, 15, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 12, 56, 59, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 37, 57, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 10, 9, 18, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 49, 29, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 24, 49, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 37, 55, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 17, 7, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 47, 37, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 12, 55, 23, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 3, 55, tzinfo=timezone.utc): SupportedVersions( + datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # SMA firmware 2015-06-16 - datetime(2015, 6, 18, 14, 42, 54, tzinfo=timezone.utc): SupportedVersions( + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # New Flash Update - datetime(2017, 7, 11, 16, 5, 57, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), } SCAN_FIRMWARE_SUPPORT: Final = { - datetime(2010, 11, 4, 16, 58, 46, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # Beta Scan Release - datetime(2011, 1, 12, 8, 32, 56, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5, ), # Beta Scan Release - datetime(2011, 3, 4, 14, 43, 31, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Scan RC1 - datetime(2011, 3, 28, 9, 0, 24, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 21, 55, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 11, 3, 13, 0, 56, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Legrand - datetime(2011, 6, 27, 8, 55, 44, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2017, 7, 11, 16, 8, 3, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # New Flash Update @@ -178,132 +178,132 @@ class SupportedVersions(NamedTuple): datetime(2011, 1, 11, 14, 19, 36): ( "2.0, max=2.5", ), - datetime(2011, 3, 4, 14, 52, 30, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 3, 25, 17, 43, 2, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 24, 26, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 8, 58, 19, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 13, 7, 33, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=timezone.utc): SupportedVersions( + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5, ), # New Flash Update - datetime(2017, 7, 11, 16, 9, 5, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), } SWITCH_FIRMWARE_SUPPORT: Final = { - datetime(2009, 9, 8, 14, 7, 4, tzinfo=timezone.utc): SupportedVersions( + datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 1, 16, 14, 7, 13, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 4, 27, 11, 59, 31, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 4, 14, 15, 25, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 17, 7, 44, 24, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 8, 31, 10, 23, 32, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 10, 7, 14, 29, 55, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2010, 11, 1, 13, 41, 30, tzinfo=timezone.utc): SupportedVersions( + datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): SupportedVersions( min=2.0, max=2.4 ), - datetime(2011, 3, 25, 17, 46, 41, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 5, 13, 7, 26, 54, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), - datetime(2011, 6, 27, 9, 4, 10, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5 ), # Legrand - datetime(2011, 11, 3, 13, 10, 18, tzinfo=timezone.utc): SupportedVersions( + datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6 ), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=timezone.utc): SupportedVersions( + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( min=2.0, max=2.5, ), # New Flash Update - datetime(2017, 7, 11, 16, 11, 10, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), } CELSIUS_FIRMWARE_SUPPORT: Final = { # Celsius Proto - datetime(2013, 9, 25, 15, 9, 44, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 9, 25, 15, 9, 44, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 10, 11, 15, 15, 58, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 10, 11, 15, 15, 58, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 10, 17, 10, 13, 12, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 10, 17, 10, 13, 12, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 11, 19, 17, 35, 48, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 11, 19, 17, 35, 48, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 12, 5, 16, 25, 33, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 12, 5, 16, 25, 33, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2013, 12, 11, 10, 53, 55, tzinfo=timezone.utc): SupportedVersions( + datetime(2013, 12, 11, 10, 53, 55, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 1, 30, 8, 56, 21, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 1, 30, 8, 56, 21, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 2, 3, 10, 9, 27, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 2, 3, 10, 9, 27, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 3, 7, 16, 7, 42, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 3, 7, 16, 7, 42, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), - datetime(2014, 3, 24, 11, 12, 23, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 3, 24, 11, 12, 23, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # MSPBootloader Image - Required to allow # a MSPBootload image for OTA update - datetime(2014, 4, 14, 15, 45, 26, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 4, 14, 15, 45, 26, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # CelsiusV Image - datetime(2014, 7, 23, 19, 24, 18, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 7, 23, 19, 24, 18, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # CelsiusV Image - datetime(2014, 9, 12, 11, 36, 40, tzinfo=timezone.utc): SupportedVersions( + datetime(2014, 9, 12, 11, 36, 40, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), # New Flash Update - datetime(2017, 7, 11, 16, 2, 50, tzinfo=timezone.utc): SupportedVersions( + datetime(2017, 7, 11, 16, 2, 50, tzinfo=UTC): SupportedVersions( min=2.0, max=2.6, ), } diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e20e3c1c7..f82b5564d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from typing import Final @@ -107,7 +107,7 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} - skip_before = datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) + skip_before = datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) sorted_addresses = sorted(self._logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(self._logs[address].keys(), reverse=True) @@ -326,7 +326,7 @@ def add_log(self, address: int, slot: int, timestamp: datetime, pulses: int, imp def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( - datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) ) def _add_log_record( @@ -343,7 +343,7 @@ def _add_log_record( return False # Drop useless log records when we have at least 4 logs if self.collected_logs > 4 and log_record.timestamp < ( - datetime.now(timezone.utc) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) ): return False if self._logs.get(address) is None: diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 357903a10..44236b9a1 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -162,7 +162,7 @@ def __init__(self, value: float, length: int = 8) -> None: def deserialize(self, val: bytes) -> None: self.value = datetime.datetime.fromtimestamp( - int(val, 16), datetime.timezone.utc + int(val, 16), datetime.UTC ) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 95753c50b..1862c64c6 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -1,10 +1,10 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import importlib pw_constants = importlib.import_module("plugwise_usb.constants") # test using utc timezone -utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) +utc_now = datetime.utcnow().replace(tzinfo=UTC) # generate energy log timestamps with fixed hour timestamp used in tests From cf9d8710bd87c05194591e0472b6225c88807b82 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:02:34 +0100 Subject: [PATCH 289/979] Correct test --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index e5f209005..ca4526e50 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -579,7 +579,7 @@ async def test_node_discovery(self, monkeypatch): await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - assert stick.joined_nodes == 12 + assert stick.joined_nodes == 11 assert len(stick.nodes) == 6 # Discovered nodes await stick.disconnect() From 2a7d04dbb681067b09f252e178d0d542b727467d Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:14:27 +0100 Subject: [PATCH 290/979] Update imports --- plugwise_usb/messages/requests.py | 2 +- plugwise_usb/messages/responses.py | 2 +- plugwise_usb/nodes/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 0130de7a9..59c10897f 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -3,7 +3,7 @@ from asyncio import Future, TimerHandle, get_running_loop from collections.abc import Callable -from datetime import datetime, timezone, UTC +from datetime import UTC, datetime from enum import Enum import logging diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index d1ee98af2..1a080fc9a 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,7 +1,7 @@ """All known response messages to be received from plugwise devices.""" from __future__ import annotations -from datetime import UTC, datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import Any, Final diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 1723468fb..28477aa33 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -5,7 +5,7 @@ from abc import ABC from asyncio import create_task from collections.abc import Callable -from datetime import UTC, datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from typing import Any From 25eea6f8f50c59a1bb7450e7db0073e100d35414 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:19:14 +0100 Subject: [PATCH 291/979] Use timezone aware now() --- plugwise_usb/nodes/__init__.py | 1 + plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/circle_plus.py | 4 ++-- tests/stick_test_data.py | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 28477aa33..301b7de4c 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -68,6 +68,7 @@ def __init__( self._node_cache: NodeCache | None = None self._cache_enabled: bool = False self._cache_folder: str = "" + self._cache_task: Task | None = None # Sensors self._available: bool = False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c7f78112e..2adee40e1 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -651,7 +651,7 @@ async def clock_synchronize(self) -> bool: ) if clock_response is None or clock_response.timestamp is None: return False - _dt_of_circle = datetime.utcnow().replace( + _dt_of_circle = datetime.now(tz=UTC).replace( hour=clock_response.time.hour.value, minute=clock_response.time.minute.value, second=clock_response.time.second.value, @@ -672,7 +672,7 @@ async def clock_synchronize(self) -> bool: node_response: NodeResponse | None = await self._send( CircleClockSetRequest( self._mac_in_bytes, - datetime.utcnow(), + datetime.now(tz=UTC), self._node_protocols.max ) ) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 5923f2cf3..c94903679 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -132,7 +132,7 @@ async def realtime_clock_synchronize(self) -> bool: return False await self._available_update_state(True) - _dt_of_circle: datetime = datetime.utcnow().replace( + _dt_of_circle: datetime = datetime.now(tz=UTC).replace( hour=clock_response.time.value.hour, minute=clock_response.time.value.minute, second=clock_response.time.value.second, @@ -155,7 +155,7 @@ async def realtime_clock_synchronize(self) -> bool: node_response: NodeResponse | None = await self._send( CirclePlusRealTimeClockSetRequest( self._mac_in_bytes, - datetime.utcnow() + datetime.now(tz=UTC) ), ) if node_response is None: diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 1862c64c6..c316d3d45 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -4,7 +4,7 @@ pw_constants = importlib.import_module("plugwise_usb.constants") # test using utc timezone -utc_now = datetime.utcnow().replace(tzinfo=UTC) +utc_now = datetime.now(tz=UTC).replace(tzinfo=UTC) # generate energy log timestamps with fixed hour timestamp used in tests From 0ded0858a83ebbe7f481429620a8495b33568995 Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:20:49 +0100 Subject: [PATCH 292/979] Reformat debug logging --- plugwise_usb/messages/requests.py | 7 ++++++- plugwise_usb/nodes/helpers/pulses.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 59c10897f..04ae85513 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -195,7 +195,12 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: self._unsubscribe_from_node() return True if self._seq_id: - _LOGGER.warning("Received '%s' as reply to '%s' which is not correct (seq_id=%s)", response, self, str(response.seq_id)) + _LOGGER.warning( + "Received '%s' as reply to '%s' which is not correct (seq_id=%s)", + response, + self, + str(response.seq_id) + ) else: _LOGGER.debug("Received '%s' as reply to '%s' has not received seq_id", response, self) return False diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f82b5564d..5eebf81f1 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -403,7 +403,12 @@ def _update_log_direction( def _update_log_interval(self) -> None: """Update the detected log interval based on the most recent two logs.""" if self._logs is None or self._log_production is None: - _LOGGER.debug("_update_log_interval | %s | _logs=%s, _log_production=%s", self._mac, self._logs, self._log_production) + _LOGGER.debug( + "_update_log_interval | %s | _logs=%s, _log_production=%s", + self._mac, + self._logs, + self._log_production + ) return last_cons_address, last_cons_slot = self._last_log_reference(is_consumption=True) if last_cons_address is None or last_cons_slot is None: From 05a5ec739187add8e619cc2859ec28df2ab4daef Mon Sep 17 00:00:00 2001 From: Breugel Date: Tue, 12 Mar 2024 22:25:40 +0100 Subject: [PATCH 293/979] Remove unused variable --- plugwise_usb/nodes/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 301b7de4c..28477aa33 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -68,7 +68,6 @@ def __init__( self._node_cache: NodeCache | None = None self._cache_enabled: bool = False self._cache_folder: str = "" - self._cache_task: Task | None = None # Sensors self._available: bool = False From 2c5b3edc74f97b96c96eeb703125365442cd08c7 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:15:41 +0100 Subject: [PATCH 294/979] Skip previous processed messages first --- plugwise_usb/connection/receiver.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 93f90e04c..101cbe003 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -317,6 +317,10 @@ def remove_listener() -> None: async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse) -> None: """Call callback for all node response message subscribers.""" + if node_response.seq_id in self._last_processed_messages: + _LOGGER.debug("Drop duplicate already processed %s", node_response) + return + processed = False for callback, mac, message_ids, seq_id in list( self._node_response_subscribers.values() @@ -339,10 +343,6 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons self._last_processed_messages = self._last_processed_messages[:-CACHED_REQUESTS] return - if node_response.seq_id in self._last_processed_messages: - _LOGGER.debug("Drop duplicate %s", node_response) - return - if node_response.retries > 10: if self._reduce_logging: _LOGGER.debug( From b9667d399dbe759b23dc2367e9f6c6383a181658 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:23:58 +0100 Subject: [PATCH 295/979] Fix caching processed requests Should resolve the "No subscriber to handle...." warnings for duplicates --- plugwise_usb/connection/receiver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 101cbe003..f19b5d0c9 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -339,8 +339,8 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons if processed: self._last_processed_messages.append(node_response.seq_id) - if len(self._last_processed_messages) > CACHED_REQUESTS: - self._last_processed_messages = self._last_processed_messages[:-CACHED_REQUESTS] + # Limit tracking to only the last appended request (FIFO) + self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] return if node_response.retries > 10: From 113aab1283c1f45ed8764978f3e5300e00c059bc Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:27:43 +0100 Subject: [PATCH 296/979] Remove unused dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d06a70802..05188a89c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ maintainers = [ requires-python = ">=3.13.0" dependencies = [ "pyserial-asyncio", - "async_timeout", "aiofiles", "crcmod", "semver", From 5c4ac16461ba9b8ca7d94ccc6a80a6a6102c3640 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:28:11 +0100 Subject: [PATCH 297/979] Bump to version 0.40.0a8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 05188a89c..35f0a8f56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a7" +version = "v0.40.0a8" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From e9572b5b94cf95c247332a51aeee8b74d34131b0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:36:38 +0100 Subject: [PATCH 298/979] Do not track processing of broadcast messages --- plugwise_usb/connection/receiver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index f19b5d0c9..6550ec321 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -28,6 +28,7 @@ from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError from ..messages.responses import ( + BROADCAST_IDS, PlugwiseResponse, StickResponse, StickResponseType, @@ -337,7 +338,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons except Exception as err: _LOGGER.error("ERROR AT _notify_node_response_subscribers: %s", err) - if processed: + if processed and node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) # Limit tracking to only the last appended request (FIFO) self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] From cf3a1daa11d772a57a175e6706b7be87b9a83d49 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:42:49 +0100 Subject: [PATCH 299/979] Never retry when message is processed --- plugwise_usb/connection/receiver.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 6550ec321..2c129dbba 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -338,8 +338,9 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons except Exception as err: _LOGGER.error("ERROR AT _notify_node_response_subscribers: %s", err) - if processed and node_response.seq_id not in BROADCAST_IDS: - self._last_processed_messages.append(node_response.seq_id) + if processed: + if node_response.seq_id not in BROADCAST_IDS: + self._last_processed_messages.append(node_response.seq_id) # Limit tracking to only the last appended request (FIFO) self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] return From ae9f22bac872cc6b61936233b6101a183f76aca5 Mon Sep 17 00:00:00 2001 From: Breugel Date: Wed, 13 Mar 2024 13:44:51 +0100 Subject: [PATCH 300/979] Bump to version 0.40.0a9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 35f0a8f56..6036238de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a8" +version = "v0.40.0a9" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 9cd7a3a18cd58046449d0ff176ffa84e8509bc80 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 15 Mar 2024 14:13:01 +0100 Subject: [PATCH 301/979] Allow feature result task to run --- plugwise_usb/connection/sender.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index a46a5d1cf..bd75bd30b 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -16,7 +16,7 @@ """ from __future__ import annotations -from asyncio import Future, Lock, Transport, get_running_loop, timeout +from asyncio import Future, Lock, Transport, get_running_loop, sleep, timeout import logging from ..constants import STICK_TIME_OUT @@ -100,6 +100,7 @@ async def _process_stick_response(self, response: StickResponse) -> None: return _LOGGER.debug("Received %s as reply to %s", response, self._current_request) self._stick_response.set_result(response.seq_id) + await sleep(0) def stop(self) -> None: """Stop sender.""" From a67ae8a458018f3ea88771be6010f370f131620d Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 15 Mar 2024 14:13:53 +0100 Subject: [PATCH 302/979] Increase log level to track special cases --- plugwise_usb/connection/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index bd75bd30b..01703cbcf 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -96,7 +96,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: async def _process_stick_response(self, response: StickResponse) -> None: """Process stick response.""" if self._stick_response is None or self._stick_response.done(): - _LOGGER.debug("No open request for %s", str(response)) + _LOGGER.warning("No open request for %s", str(response)) return _LOGGER.debug("Received %s as reply to %s", response, self._current_request) self._stick_response.set_result(response.seq_id) From 11bf9066063e898215b479fb6e6c60b42b1f7563 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 15 Mar 2024 14:16:34 +0100 Subject: [PATCH 303/979] Wait for task to be finished --- plugwise_usb/connection/receiver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2c129dbba..e7642df7f 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -148,8 +148,8 @@ async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" await self._receive_queue.put(None) if self._msg_processing_task is not None and not self._msg_processing_task.done(): - self._msg_processing_task.cancel() - await wait([self._msg_processing_task]) + self._receive_queue.put_nowait(None) + await self._msg_processing_task def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. From 9c6d7d196d4b09bcd917f354527286020ebf5405 Mon Sep 17 00:00:00 2001 From: Breugel Date: Fri, 15 Mar 2024 14:18:18 +0100 Subject: [PATCH 304/979] Minor test improvements --- tests/test_usb.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ca4526e50..acae3c294 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,5 +1,5 @@ import asyncio -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import UTC, datetime as dt, timedelta as td, timezone as tz import importlib import logging import random @@ -276,6 +276,7 @@ async def test_stick_connect_without_response(self, monkeypatch): # Still raise StickError connected but without response with pytest.raises(pw_exceptions.StickError): await stick.initialize() + await stick.disconnect() @pytest.mark.asyncio async def test_stick_connect_timeout(self, monkeypatch): @@ -428,7 +429,7 @@ async def node_ping( feature: pw_api.NodeFeature, ping_collection, ): - """Callback helper for node ping collection""" + """Callback helper for node ping collection.""" if feature == pw_api.NodeFeature.PING: self.node_ping_result.set_result(ping_collection) else: @@ -1142,8 +1143,8 @@ def test_energy_counter(self): pulse_col_mock = Mock() pulse_col_mock.collected_pulses.side_effect = self.pulse_update - fixed_timestamp_utc = dt.now(tz.utc) - fixed_timestamp_local = dt.now(dt.now(tz.utc).astimezone().tzinfo) + fixed_timestamp_utc = dt.now(UTC) + fixed_timestamp_local = dt.now(dt.now(UTC).astimezone().tzinfo) _LOGGER.debug( "test_energy_counter | fixed_timestamp-utc = %s", str(fixed_timestamp_utc) @@ -1210,7 +1211,7 @@ def test_energy_counter(self): @pytest.mark.asyncio async def test_creating_request_messages(self): - + """Test create request message.""" node_network_info_request = pw_requests.StickNetworkInfoRequest() assert node_network_info_request.serialize() == b"\x05\x05\x03\x030001CAAB\r\n" circle_plus_connect_request = pw_requests.CirclePlusConnectRequest( @@ -1290,7 +1291,7 @@ async def test_creating_request_messages(self): @pytest.mark.asyncio async def test_stick_network_down(self, monkeypatch): - """Testing timeout circle+ discovery""" + """Testing timeout circle+ discovery.""" mock_serial = MockSerial( { b"\x05\x05\x03\x03000AB43C\r\n": ( @@ -1317,6 +1318,7 @@ async def test_stick_network_down(self, monkeypatch): await stick.connect() with pytest.raises(pw_exceptions.StickError): await stick.initialize() + await stick.disconnect() @pytest.mark.asyncio async def test_node_discovery_and_load(self, monkeypatch): From ef788ba5207705203fe4cc3fce468f5342e5e08c Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 21 Mar 2024 22:25:35 +0100 Subject: [PATCH 305/979] Rewrite file caching --- plugwise_usb/constants.py | 3 +- plugwise_usb/helpers/__init__.py | 0 plugwise_usb/helpers/cache.py | 144 +++++++++++++ plugwise_usb/network/cache.py | 159 ++++---------- plugwise_usb/network/registry.py | 14 +- plugwise_usb/nodes/__init__.py | 29 ++- plugwise_usb/nodes/circle.py | 33 ++- plugwise_usb/nodes/helpers/cache.py | 87 ++------ plugwise_usb/nodes/scan.py | 4 +- plugwise_usb/nodes/sed.py | 3 +- plugwise_usb/util.py | 8 - tests/test_usb.py | 309 +++++++++++++++++++++++++--- 12 files changed, 519 insertions(+), 274 deletions(-) create mode 100644 plugwise_usb/helpers/__init__.py create mode 100644 plugwise_usb/helpers/cache.py diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 22d6f2a2e..98350716c 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -10,7 +10,8 @@ # Cache folder name CACHE_DIR: Final = ".plugwise-cache" -CACHE_SEPARATOR: str = ";" +CACHE_KEY_SEPARATOR: str = ";" +CACHE_DATA_SEPARATOR: str = "|" LOCAL_TIMEZONE = dt.datetime.now(dt.UTC).astimezone().tzinfo UTF8: Final = "utf-8" diff --git a/plugwise_usb/helpers/__init__.py b/plugwise_usb/helpers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py new file mode 100644 index 000000000..02167f1e6 --- /dev/null +++ b/plugwise_usb/helpers/cache.py @@ -0,0 +1,144 @@ +"""Base class for local caching of data.""" + +from __future__ import annotations + +from asyncio import get_running_loop +import logging +from os import getenv as os_getenv, name as os_name +from os.path import expanduser as os_path_expand_user, join as os_path_join + +from aiofiles import open as aiofiles_open, ospath +from aiofiles.os import makedirs, remove as aiofiles_os_remove + +from ..constants import CACHE_DIR, CACHE_KEY_SEPARATOR, UTF8 +from ..exceptions import CacheError + +_LOGGER = logging.getLogger(__name__) + + +class PlugwiseCache: + """Base class to cache plugwise information.""" + + def __init__(self, file_name: str, root_dir: str = "") -> None: + """Initialize class.""" + self._root_dir = root_dir + self._file_name = file_name + self._cache_path: str | None = None + self._cache_file: str | None = None + self._initialized = False + self._loop = get_running_loop() + + @property + def initialized(self) -> bool: + """Indicate if cache file is initialized.""" + return self._initialized + + @property + def cache_root_directory(self) -> str: + """Root directory to store the plugwise cache directory.""" + return self._root_dir + + @cache_root_directory.setter + def cache_root_directory(self, cache_root_dir: str = "") -> None: + """Root directory to store the plugwise cache directory.""" + if self._root_dir != cache_root_dir: + self._initialized = False + self._root_dir = cache_root_dir + + async def initialize_cache(self) -> None: + """Set (and create) the plugwise cache directory to store cache file.""" + if self._root_dir != "": + if not await ospath.exists(self._root_dir): + raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") + cache_dir = os_path_join(self._root_dir, CACHE_DIR) + else: + cache_dir = await self._loop.run_in_executor( + None, + self._get_writable_os_dir + ) + await makedirs(cache_dir, exist_ok=True) + self._cache_path = cache_dir + self._cache_file = f"{cache_dir}/{self._file_name}" + self._initialized = True + _LOGGER.debug("Start using network cache file: %s", self._cache_file) + + def _get_writable_os_dir(self) -> str: + """Return the default caching directory based on the OS.""" + if self._root_dir != "": + return self._root_dir + if os_name == "nt": + if (data_dir := os_getenv("APPDATA")) is not None: + return os_path_join(data_dir, CACHE_DIR) + raise CacheError("Unable to detect writable cache folder based on 'APPDATA' environment variable.") + return os_path_join(os_path_expand_user("~"), CACHE_DIR) + + async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None: + """"Save information to cache file.""" + if not self._initialized: + raise CacheError(f"Unable to save cache. Initialize cache file '{self._file_name}' first.") + + current_data: dict[str, str] = {} + if not rewrite: + current_data = await self.read_cache() + processed_keys: list[str] = [] + data_to_write: list[str] = [] + for _cur_key, _cur_val in current_data.items(): + _write_val = _cur_val + if _cur_key in data: + _write_val = data[_cur_key] + processed_keys.append(_cur_key) + data_to_write.append(f"{_cur_key}{CACHE_KEY_SEPARATOR}{_write_val}\n") + # Write remaining new data + for _key, _value in data.items(): + if _key not in processed_keys: + data_to_write.append(f"{_key}{CACHE_KEY_SEPARATOR}{_value}\n") + + async with aiofiles_open( + file=self._cache_file, + mode="w", + encoding=UTF8, + ) as file_data: + await file_data.writelines(data_to_write) + _LOGGER.info( + "Saved %s lines to network cache file %s", + str(len(data)), + self._cache_file + ) + + async def read_cache(self) -> dict[str, str]: + """Return current data from cache file.""" + if not self._initialized: + raise CacheError(f"Unable to save cache. Initialize cache file '{self._file_name}' first.") + current_data: dict[str, str] = {} + try: + async with aiofiles_open( + file=self._cache_file, + mode="r", + encoding=UTF8, + ) as read_file_data: + lines: list[str] = await read_file_data.readlines() + except OSError as exc: + # suppress file errors + _LOGGER.warning( + "OS error %s while reading cache file %s", exc, str(self._cache_file) + ) + return current_data + + for line in lines: + data = line.strip() + if (index_separator := data.find(CACHE_KEY_SEPARATOR)) == -1: + _LOGGER.warning( + "Skip invalid line '%s' in cache file %s", + data, + str(self._cache_file) + ) + break + current_data[data[:index_separator]] = data[index_separator + 1:] + return current_data + + async def delete_cache(self) -> None: + """Delete cache file.""" + if self._cache_file is None: + return + if await ospath.exists(self._cache_file): + await aiofiles_os_remove(self._cache_file) diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 6030ec337..857b490cb 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -3,49 +3,22 @@ from __future__ import annotations import logging -from pathlib import Path, PurePath - -import aiofiles -import aiofiles.os from ..api import NodeType -from ..constants import CACHE_SEPARATOR, UTF8 -from ..exceptions import CacheError -from ..util import get_writable_cache_dir +from ..constants import CACHE_DATA_SEPARATOR +from ..helpers.cache import PlugwiseCache _LOGGER = logging.getLogger(__name__) +_NETWORK_CACHE_FILE_NAME = "nodes.cache" -class NetworkRegistrationCache: +class NetworkRegistrationCache(PlugwiseCache): """Class to cache node network information.""" def __init__(self, cache_root_dir: str = "") -> None: """Initialize NetworkCache class.""" + super().__init__(_NETWORK_CACHE_FILE_NAME, cache_root_dir) self._registrations: dict[int, tuple[str, NodeType | None]] = {} - self._cache_file: PurePath | None = None - self._cache_root_dir = cache_root_dir - self._set_cache_file(cache_root_dir) - - @property - def cache_root_directory(self) -> str: - """Root directory to store the plugwise cache directory.""" - return self._cache_root_dir - - @cache_root_directory.setter - def cache_root_directory(self, cache_root_dir: str = "") -> None: - """Root directory to store the plugwise cache directory.""" - self._cache_root_dir = cache_root_dir - self._set_cache_file(cache_root_dir) - - def _set_cache_file(self, cache_root_dir: str) -> None: - """Set (and create) the plugwise cache directory to store cache.""" - self._cache_root_dir = get_writable_cache_dir(cache_root_dir) - Path(self._cache_root_dir).mkdir(parents=True, exist_ok=True) - self._cache_file = Path(f"{self._cache_root_dir}/nodes.cache") - _LOGGER.info( - "Start using network cache file: %s/nodes.cache", - self._cache_root_dir - ) @property def registrations(self) -> dict[int, tuple[str, NodeType]]: @@ -54,102 +27,50 @@ def registrations(self) -> dict[int, tuple[str, NodeType]]: async def save_cache(self) -> None: """Save the node information to file.""" - _LOGGER.debug("Save network cache %s", str(self._cache_file)) - counter = 0 - async with aiofiles.open( - file=self._cache_file, - mode="w", - encoding=UTF8, - ) as file_data: - for address in sorted(self._registrations.keys()): - counter += 1 - mac, node_reg = self._registrations[address] - if node_reg is None: - node_type = "" - else: - node_type = str(node_reg) - await file_data.write( - f"{address}{CACHE_SEPARATOR}" + - f"{mac}{CACHE_SEPARATOR}" + - f"{node_type}\n" - ) - _LOGGER.info( - "Saved %s lines to network cache %s", - str(counter), - str(self._cache_file) - ) + cache_data_to_save: dict[str, str] = {} + for address in range(-1, 64, 1): + mac, node_type = self._registrations.get(address, ("", None)) + if node_type is None: + node_value = "" + else: + node_value = str(node_type) + cache_data_to_save[address] = f"{mac}{CACHE_DATA_SEPARATOR}{node_value}" + await self.write_cache(cache_data_to_save) async def clear_cache(self) -> None: """Clear current cache.""" self._registrations = {} - await self.delete_cache_file() + await self.delete_cache() - async def restore_cache(self) -> bool: + async def restore_cache(self) -> None: """Load the previously stored information.""" - if self._cache_file is None: - raise CacheError( - "Cannot restore cached information " + - "without reference to cache file" - ) - if not await aiofiles.os.path.exists(self._cache_file): - _LOGGER.warning( - "Unable to restore from cache because file '%s' does not exists", - self._cache_file.name, - ) - return False - try: - async with aiofiles.open( - file=self._cache_file, - mode="r", - encoding=UTF8, - ) as file_data: - lines = await file_data.readlines() - except OSError: - _LOGGER.warning( - "Failed to read cache file %s", str(self._cache_file) - ) - return False - + data: dict[str, str] = await self.read_cache() self._registrations = {} - for line in lines: - data = line.strip().split(CACHE_SEPARATOR) - if len(data) != 3: + for _key, _data in data.items(): + address = int(_key) + try: + if CACHE_DATA_SEPARATOR in _data: + values = _data.split(CACHE_DATA_SEPARATOR) + else: + # legacy data separator can by remove at next version + values = _data.split(";") + mac = values[0] + node_type: NodeType | None = None + if values[1] != "": + node_type = NodeType[values[1][9:]] + self._registrations[address] = (mac, node_type) + _LOGGER.debug( + "Restore registry address %s with mac %s with node type %s", + address, + mac if mac != "" else "", + str(node_type), + ) + except (KeyError, IndexError): _LOGGER.warning( - "Skip invalid line '%s' in cache file %s", - line, - self._cache_file.name, + "Skip invalid data '%s' in cache file '%s'", + _data, + self._cache_file, ) - break - address = int(data[0]) - mac = data[1] - node_type: NodeType | None = None - if data[2] != "": - try: - node_type = NodeType[data[2][9:]] - except KeyError: - _LOGGER.warning( - "Skip invalid NodeType '%s' in data '%s' in cache file '%s'", - data[2][9:], - line, - self._cache_file.name, - ) - break - self._registrations[address] = (mac, node_type) - _LOGGER.debug( - "Restore registry address %s with mac %s with node type %s", - address, - mac if mac != "" else "", - str(node_type), - ) - return True - - async def delete_cache_file(self) -> None: - """Delete cache file.""" - if self._cache_file is None: - return - if not await aiofiles.os.path.exists(self._cache_file): - return - await aiofiles.os.remove(self._cache_file) def update_registration( self, address: int, mac: str, node_type: NodeType | None diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 8678dec9a..723712c48 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -43,6 +43,7 @@ def __init__( self._registry: dict[int, tuple[str, NodeType | None]] = {} self._first_free_address: int = 65 self._registration_task: Task | None = None + self._network_cache_file_task: Task | None = None self._quick_scan_finished: Awaitable | None = None self._full_scan_finished: Awaitable | None = None # region Properties @@ -58,10 +59,13 @@ def cache_enabled(self, enable: bool = True) -> None: if enable and not self._cache_enabled: _LOGGER.debug("Cache is enabled") self._network_cache = NetworkRegistrationCache(self._cache_folder) + self._network_cache_file_task = create_task( + self._network_cache.initialize_cache() + ) elif not enable and self._cache_enabled: if self._network_cache is not None: - create_task( - self._network_cache.delete_cache_file() + self._network_cache_file_task = create_task( + self._network_cache.delete_cache() ) _LOGGER.debug("Cache is disabled") self._cache_enabled = enable @@ -77,6 +81,8 @@ def cache_folder(self, cache_folder: str) -> None: if cache_folder == self._cache_folder: return self._cache_folder = cache_folder + if self._network_cache is not None: + self._network_cache.cache_root_directory = cache_folder @property def registry(self) -> dict[int, tuple[str, NodeType | None]]: @@ -110,6 +116,8 @@ async def restore_network_cache(self) -> None: ) return if not self._cache_restored: + if not self._network_cache.initialized: + await self._network_cache.initialize_cache() await self._network_cache.restore_cache() self._cache_restored = True @@ -305,5 +313,5 @@ async def clear_register_cache(self) -> None: async def stop(self) -> None: """Unload the network registry.""" self._stop_registration_task() - if self._cache_enabled: + if self._cache_enabled and self._network_cache.initialized: await self.save_registry_to_cache() diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 28477aa33..9b9ae9912 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC -from asyncio import create_task +from asyncio import Task, create_task from collections.abc import Callable from datetime import UTC, datetime, timedelta import logging @@ -68,6 +68,7 @@ def __init__( self._node_cache: NodeCache | None = None self._cache_enabled: bool = False self._cache_folder: str = "" + self._cache_save_task: Task | None = None # Sensors self._available: bool = False @@ -387,6 +388,8 @@ async def _load_cache_file(self) -> bool: self.mac, ) return False + if not self._node_cache.initialized: + await self._node_cache.initialize_cache() return await self._node_cache.restore_cache() async def clear_cache(self) -> None: @@ -449,7 +452,7 @@ async def node_info_update( return self._node_info await self._available_update_state(True) - self._node_info_update_state( + await self._node_info_update_state( firmware=node_info.firmware, node_type=node_info.node_type, hardware=node_info.hardware, @@ -491,14 +494,14 @@ async def _node_info_load_from_cache(self) -> bool: second=int(data[5]), tzinfo=UTC ) - return self._node_info_update_state( + return await self._node_info_update_state( firmware=firmware, hardware=hardware, node_type=node_type, timestamp=timestamp, ) - def _node_info_update_state( + async def _node_info_update_state( self, firmware: datetime | None, hardware: str | None, @@ -538,8 +541,7 @@ def _node_info_update_state( else: self._node_info.type = NodeType(node_type) self._set_cache(CACHE_NODE_TYPE, self._node_info.type.value) - if self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() return complete async def is_online(self) -> bool: @@ -608,7 +610,9 @@ async def get_state( async def unload(self) -> None: """Deactivate and unload node features.""" - raise NotImplementedError() + if self._cache_save_task is not None and not self._cache_save_task.done(): + await self._cache_save_task + await self.save_cache(trigger_only=False, full_write=True) def _get_cache(self, setting: str) -> str | None: """Retrieve value of specified setting from cache memory.""" @@ -637,9 +641,9 @@ def _set_cache(self, setting: str, value: Any) -> None: else: self._node_cache.add_state(setting, str(value)) - async def save_cache(self) -> None: + async def save_cache(self, trigger_only: bool = True, full_write: bool = False) -> None: """Save current cache to cache file.""" - if not self._cache_enabled: + if not self._cache_enabled or not self._loaded or not self._initialized: return if self._node_cache is None: _LOGGER.warning( @@ -647,7 +651,12 @@ async def save_cache(self) -> None: ) return _LOGGER.debug("Save cache file for node %s", self.mac) - await self._node_cache.save_cache() + if self._cache_save_task is not None and not self._cache_save_task.done(): + await self._cache_save_task + if trigger_only: + self._cache_save_task = create_task(self._node_cache.save_cache()) + else: + await self._node_cache.save_cache(rewrite=full_write) @staticmethod def skip_update(data_class: Any, seconds: int) -> bool: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 2adee40e1..9906aa398 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather, sleep, wait +from asyncio import Task, create_task, gather, sleep from collections.abc import Callable from datetime import UTC, datetime from functools import wraps @@ -146,8 +146,7 @@ async def calibration_update(self) -> bool: await self._available_update_state(False) return False await self._available_update_state(True) - - self._calibration_update_state( + await self._calibration_update_state( calibration_response.gain_a, calibration_response.gain_b, calibration_response.off_noise, @@ -175,7 +174,7 @@ async def _calibration_load_from_cache(self) -> bool: cal_tot = float(tot) # Restore calibration - result = self._calibration_update_state( + result = await self._calibration_update_state( cal_gain_a, cal_gain_b, cal_noise, @@ -193,7 +192,7 @@ async def _calibration_load_from_cache(self) -> bool: ) return False - def _calibration_update_state( + async def _calibration_update_state( self, gain_a: float | None, gain_b: float | None, @@ -222,8 +221,7 @@ def _calibration_update_state( self._set_cache(CACHE_CALIBRATION_GAIN_B, gain_b) self._set_cache(CACHE_CALIBRATION_NOISE, off_noise) self._set_cache(CACHE_CALIBRATION_TOT, off_tot) - if self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() return True @raise_calibration_missing @@ -400,7 +398,7 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) for address in missing_addresses: await self.energy_log_update(address) - await sleep(0.3) + await sleep(0.01) if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -443,9 +441,7 @@ async def energy_log_update(self, address: int) -> bool: import_only=True ) self._energy_counters.update() - if self._cache_enabled: - create_task(self.save_cache()) - response = None + await self.save_cache() return True async def _energy_log_records_load_from_cache(self) -> bool: @@ -641,8 +637,7 @@ async def _relay_update_state( await self.publish_feature_update_to_subscribers( NodeFeature.RELAY, self._relay_state ) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" @@ -854,8 +849,7 @@ async def node_info_update( self._set_cache( CACHE_CURRENT_LOG_ADDRESS, node_info.current_logaddress_pointer ) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() return self._node_info async def _node_info_load_from_cache(self) -> bool: @@ -870,13 +864,13 @@ async def _node_info_load_from_cache(self) -> bool: async def unload(self) -> None: """Deactivate and unload node features.""" + self._loaded = False if self._retrieve_energy_logs_task is not None and not self._retrieve_energy_logs_task.done(): self._retrieve_energy_logs_task.cancel() - await wait([self._retrieve_energy_logs_task]) + await self._retrieve_energy_logs_task if self._cache_enabled: await self._energy_log_records_save_to_cache() - await self.save_cache() - self._loaded = False + await super().unload() async def switch_init_relay(self, state: bool) -> bool: """Switch state of initial power-up relay state. Returns new state of relay.""" @@ -939,8 +933,7 @@ async def _relay_init_update_state(self, state: bool) -> None: await self.publish_feature_update_to_subscribers( NodeFeature.RELAY_INIT, self._relay_init_state ) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() @raise_calibration_missing def _calc_watts( diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index b5d462f72..209c89b7a 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -3,53 +3,32 @@ from __future__ import annotations import logging -from pathlib import Path, PurePath -import aiofiles -import aiofiles.os - -from ...constants import CACHE_SEPARATOR, UTF8 -from ...util import get_writable_cache_dir +from ...helpers.cache import PlugwiseCache _LOGGER = logging.getLogger(__name__) -class NodeCache: +class NodeCache(PlugwiseCache): """Class to cache specific node configuration and states.""" def __init__(self, mac: str, cache_root_dir: str = "") -> None: """Initialize NodeCache class.""" self._mac = mac + self._node_cache_file_name = f"{mac}.cache" + super().__init__(self._node_cache_file_name, cache_root_dir) self._states: dict[str, str] = {} - self._cache_file: PurePath | None = None - self._cache_root_dir = cache_root_dir - self._set_cache_file(cache_root_dir) - - @property - def cache_root_directory(self) -> str: - """Root directory to store the plugwise cache directory.""" - return self._cache_root_dir - - @cache_root_directory.setter - def cache_root_directory(self, cache_root_dir: str = "") -> None: - """Root directory to store the plugwise cache directory.""" - self._cache_root_dir = cache_root_dir - self._set_cache_file(cache_root_dir) - - def _set_cache_file(self, cache_root_dir: str) -> None: - """Set (and create) the plugwise cache directory to store cache.""" - self._cache_root_dir = get_writable_cache_dir(cache_root_dir) - Path(self._cache_root_dir).mkdir(parents=True, exist_ok=True) - self._cache_file = Path(f"{self._cache_root_dir}/{self._mac}.cache") @property def states(self) -> dict[str, str]: """Cached node state information.""" return self._states - def add_state(self, state: str, value: str) -> None: + def add_state(self, state: str, value: str, save: bool = False) -> None: """Add configuration state to cache.""" self._states[state] = value + if save: + self.write_cache({state: value}) def remove_state(self, state: str) -> None: """Remove configuration state from cache.""" @@ -60,17 +39,9 @@ def get_state(self, state: str) -> str | None: """Return current value for state.""" return self._states.get(state, None) - async def save_cache(self) -> None: + async def save_cache(self, rewrite: bool = False) -> None: """Save the node configuration to file.""" - async with aiofiles.open( - file=self._cache_file, - mode="w", - encoding=UTF8, - ) as file_data: - for key, state in self._states.copy().items(): - await file_data.write( - f"{key}{CACHE_SEPARATOR}{state}\n" - ) + await self.write_cache(self._states, rewrite) _LOGGER.debug( "Cached settings saved to cache file %s", str(self._cache_file), @@ -79,44 +50,12 @@ async def save_cache(self) -> None: async def clear_cache(self) -> None: """Clear current cache.""" self._states = {} - await self.delete_cache_file() + await self.delete_cache() async def restore_cache(self) -> bool: """Load the previously store state information.""" - try: - async with aiofiles.open( - file=self._cache_file, - mode="r", - encoding=UTF8, - ) as file_data: - lines = await file_data.readlines() - except OSError: - _LOGGER.info( - "Failed to read cache file %s", str(self._cache_file) - ) - return False + data: dict[str, str] = await self.read_cache() self._states.clear() - for line in lines: - data = line.strip().split(CACHE_SEPARATOR) - if len(data) != 2: - _LOGGER.warning( - "Skip invalid line '%s' in cache file %s", - line, - str(self._cache_file) - ) - break - self._states[data[0]] = data[1] - _LOGGER.debug( - "Cached settings restored %s lines from cache file %s", - str(len(self._states)), - str(self._cache_file), - ) + for key, value in data.items(): + self._states[key] = value return True - - async def delete_cache_file(self) -> None: - """Delete cache file.""" - if self._cache_file is None: - return - if not await aiofiles.os.path.exists(self._cache_file): - return - await aiofiles.os.remove(self._cache_file) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 9c516e371..7ac60a78e 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -2,7 +2,6 @@ from __future__ import annotations -from asyncio import create_task from datetime import datetime import logging from typing import Any, Final @@ -120,8 +119,7 @@ async def motion_state_update( await self.publish_feature_update_to_subscribers( NodeFeature.MOTION, self._motion_state, ) - if self.cache_enabled and self._loaded and self._initialized: - create_task(self.save_cache()) + await self.save_cache() async def scan_configure( self, diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 6b15312e3..a084bc664 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -84,8 +84,7 @@ async def unload(self) -> None: self._maintenance_future.cancel() if self._awake_subscription is not None: self._awake_subscription() - await self.save_cache() - self._loaded = False + await super().unload() @raise_not_loaded async def initialize(self) -> bool: diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 44236b9a1..b464e962f 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -24,14 +24,6 @@ ) -def get_writable_cache_dir(root_directory: str = "") -> str: - """Put together the default caching directory based on the OS.""" - if root_directory != "": - return root_directory - if os.name == "nt" and (data_dir := os.getenv("APPDATA")) is not None: - return os.path.join(data_dir, CACHE_DIR) - return os.path.join(os.path.expanduser("~"), CACHE_DIR) - crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) diff --git a/tests/test_usb.py b/tests/test_usb.py index acae3c294..b1a5efd81 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -3,10 +3,11 @@ import importlib import logging import random -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock, call, patch import pytest +import aiofiles import crcmod from freezegun import freeze_time @@ -19,7 +20,9 @@ pw_connection_manager = importlib.import_module( "plugwise_usb.connection.manager" ) -pw_network = importlib.import_module("plugwise_usb.network") +pw_helpers_cache = importlib.import_module("plugwise_usb.helpers.cache") +pw_network_cache = importlib.import_module("plugwise_usb.network.cache") +pw_node_cache = importlib.import_module("plugwise_usb.nodes.helpers.cache") pw_receiver = importlib.import_module("plugwise_usb.connection.receiver") pw_sender = importlib.import_module("plugwise_usb.connection.sender") pw_constants = importlib.import_module("plugwise_usb.constants") @@ -164,6 +167,25 @@ async def mock_connection(self, loop, protocol_factory, **kwargs): return self._transport, self._protocol +class MockOsPath: + """Mock aiofiles.path class.""" + + async def exists(self, file_or_path: str) -> bool: + """Exists folder.""" + if file_or_path == "mock_folder_that_exists": + return True + return file_or_path == "mock_folder_that_exists/file_that_exists.ext" + + async def mkdir(self, path: str) -> None: + """Make dir.""" + return + + +aiofiles.threadpool.wrap.register(MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase(*args, **kwargs) +) + + class TestStick: @pytest.mark.asyncio @@ -374,18 +396,6 @@ async def test_stick_connection_lost(self, monkeypatch): unsub_disconnect() await stick.disconnect() - async def node_discovered(self, event: pw_api.NodeEvent, mac: str): - """Handle discovered event callback.""" - if event == pw_api.NodeEvent.DISCOVERED: - self.test_node_discovered.set_result(mac) - else: - self.test_node_discovered.set_exception( - BaseException( - f"Invalid {event} event, expected " + - f"{pw_api.NodeEvent.DISCOVERED}" - ) - ) - async def node_awake(self, event: pw_api.NodeEvent, mac: str): """Handle awake event callback.""" if event == pw_api.NodeEvent.AWAKE: @@ -424,22 +434,6 @@ async def node_motion_state( ) ) - async def node_ping( - self, - feature: pw_api.NodeFeature, - ping_collection, - ): - """Callback helper for node ping collection.""" - if feature == pw_api.NodeFeature.PING: - self.node_ping_result.set_result(ping_collection) - else: - self.node_ping_result.set_exception( - BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.PING}" - ) - ) - @pytest.mark.asyncio async def test_stick_node_discovered_subscription(self, monkeypatch): """Testing "new_node" subscription for Scan.""" @@ -1320,6 +1314,239 @@ async def test_stick_network_down(self, monkeypatch): await stick.initialize() await stick.disconnect() + def fake_env(self, env: str) -> str | None: + if env == "APPDATA": + return "c:\\user\\tst\\appdata" + if env == "~": + return "/home/usr" + return None + + def os_path_join(self, strA: str, strB: str) -> str: + return f"{strA}/{strB}" + + @pytest.mark.asyncio + async def test_cache(self, monkeypatch): + """Test PlugwiseCache class.""" + monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") + monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) + + async def aiofiles_os_remove(file) -> None: + if file.name() == "mock_folder_that_exists/file_that_exists.ext": + return + if file.name() == "mock_folder_that_exists/nodes.cache": + return + if file.name() == "mock_folder_that_exists/0123456789ABCDEF.cache": + return + raise pw_exceptions.CacheError("Invalid file") + + monkeypatch.setattr(pw_helpers_cache, "aiofiles_os_remove", aiofiles_os_remove) + monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) + + pw_cache = pw_helpers_cache.PlugwiseCache("test-file", "non_existing_folder") + assert not pw_cache.initialized + assert pw_cache.cache_root_directory == "non_existing_folder" + with pytest.raises(pw_exceptions.CacheError): + await pw_cache.initialize_cache() + + # Windows + pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") + pw_cache.cache_root_directory = "mock_folder_that_exists" + assert not pw_cache.initialized + await pw_cache.initialize_cache() + assert pw_cache.initialized + + # Mock reading + mock_read_data = [ + "key1;value a\n", + "key2;first duplicate is ignored\n\r", + "key2;value b|value c\n\r", + "key3;value d \r\n", + ] + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + assert await pw_cache.read_cache() == { + "key1": "value a", + "key2": "value b|value c", + "key3": "value d", + } + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await pw_cache.write_cache({"key1": "value z"}) + mock_file_stream.writelines.assert_called_with( + [ + "key1;value z\n", + "key2;value b|value c\n", + "key3;value d\n" + ] + ) + + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await pw_cache.write_cache({"key4": "value e"}, rewrite=True) + mock_file_stream.writelines.assert_called_with( + [ + "key4;value e\n", + ] + ) + + monkeypatch.setattr(pw_helpers_cache, "os_name", "linux") + pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") + pw_cache.cache_root_directory = "mock_folder_that_exists" + assert not pw_cache.initialized + await pw_cache.initialize_cache() + assert pw_cache.initialized + await pw_cache.delete_cache() + pw_cache.cache_root_directory = "mock_folder_that_does_not_exists" + await pw_cache.delete_cache() + pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_does_not_exists") + await pw_cache.delete_cache() + + @pytest.mark.asyncio + async def test_network_cache(self, monkeypatch): + """Test NetworkRegistrationCache class.""" + monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") + monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) + monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) + + pw_nw_cache = pw_network_cache.NetworkRegistrationCache("mock_folder_that_exists") + await pw_nw_cache.initialize_cache() + # test with invalid data + mock_read_data = [ + "-1;0123456789ABCDEF;NodeType.CIRCLE_PLUS", + "0;FEDCBA9876543210xxxNodeType.CIRCLE", + "invalid129834765AFBECD|NodeType.CIRCLE", + ] + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await pw_nw_cache.restore_cache() + assert pw_nw_cache.registrations == { + -1: ("0123456789ABCDEF", pw_api.NodeType.CIRCLE_PLUS), + } + + # test with valid data + mock_read_data = [ + "-1;0123456789ABCDEF;NodeType.CIRCLE_PLUS", + "0;FEDCBA9876543210;NodeType.CIRCLE", + "1;1298347650AFBECD;NodeType.SCAN", + "2;;", + ] + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await pw_nw_cache.restore_cache() + assert pw_nw_cache.registrations == { + -1: ("0123456789ABCDEF", pw_api.NodeType.CIRCLE_PLUS), + 0: ("FEDCBA9876543210", pw_api.NodeType.CIRCLE), + 1: ("1298347650AFBECD", pw_api.NodeType.SCAN), + 2: ("", None), + } + pw_nw_cache.update_registration(3, "1234ABCD4321FEDC", pw_api.NodeType.STEALTH) + + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await pw_nw_cache.save_cache() + mock_file_stream.writelines.assert_called_with( + [ + "-1;0123456789ABCDEF|NodeType.CIRCLE_PLUS\n", + "0;FEDCBA9876543210|NodeType.CIRCLE\n", + "1;1298347650AFBECD|NodeType.SCAN\n", + "2;|\n", + "3;1234ABCD4321FEDC|NodeType.STEALTH\n", + "4;|\n", + ] + [f"{address};|\n" for address in range(5, 64)] + ) + assert pw_nw_cache.registrations == { + -1: ("0123456789ABCDEF", pw_api.NodeType.CIRCLE_PLUS), + 0: ("FEDCBA9876543210", pw_api.NodeType.CIRCLE), + 1: ("1298347650AFBECD", pw_api.NodeType.SCAN), + 2: ("", None), + 3: ("1234ABCD4321FEDC", pw_api.NodeType.STEALTH), + } + + @pytest.mark.asyncio + async def test_node_cache(self, monkeypatch): + """Test NodeCache class.""" + monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) + monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") + monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) + + node_cache = pw_node_cache.NodeCache("0123456789ABCDEF", "mock_folder_that_exists") + await node_cache.initialize_cache() + # test with invalid data + mock_read_data = [ + "firmware;2011-6-27-8-52-18", + "hardware;000004400107", + "node_info_timestamp;2024-3-18-19-30-28", + "node_type;2", + "relay;True", + "current_log_address;127", + "calibration_gain_a;0.9903987646102905", + "calibration_gain_b;-1.8206795857622637e-06", + "calibration_noise;0.0", + "calibration_tot;0.023882506415247917", + "energy_collection;102:4:2024-3-14-19-0-0:47|102:3:2024-3-14-18-0-0:48|102:2:2024-3-14-17-0-0:45", + ] + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await node_cache.restore_cache() + assert node_cache.states == { + "firmware": "2011-6-27-8-52-18", + "hardware": "000004400107", + "node_info_timestamp": "2024-3-18-19-30-28", + "node_type": "2", + "relay": "True", + "current_log_address": "127", + "calibration_gain_a": "0.9903987646102905", + "calibration_gain_b": "-1.8206795857622637e-06", + "calibration_noise": "0.0", + "calibration_tot": "0.023882506415247917", + "energy_collection": "102:4:2024-3-14-19-0-0:47|102:3:2024-3-14-18-0-0:48|102:2:2024-3-14-17-0-0:45", + } + assert node_cache.get_state("hardware") == "000004400107" + node_cache.add_state("current_log_address", "128") + assert node_cache.get_state("current_log_address") == "128" + node_cache.remove_state("calibration_gain_a") + assert node_cache.get_state("calibration_gain_a") is None + + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await node_cache.save_cache() + mock_file_stream.writelines.assert_called_with( + [ + "firmware;2011-6-27-8-52-18\n", + "hardware;000004400107\n", + "node_info_timestamp;2024-3-18-19-30-28\n", + "node_type;2\n", + "relay;True\n", + "current_log_address;128\n", + "calibration_gain_b;-1.8206795857622637e-06\n", + "calibration_noise;0.0\n", + "calibration_tot;0.023882506415247917\n", + "energy_collection;102:4:2024-3-14-19-0-0:47|102:3:2024-3-14-18-0-0:48|102:2:2024-3-14-17-0-0:45\n", + ] + ) + @pytest.mark.asyncio async def test_node_discovery_and_load(self, monkeypatch): """Testing discovery of nodes.""" @@ -1331,10 +1558,22 @@ async def test_node_discovery_and_load(self, monkeypatch): ) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) - stick = pw_stick.Stick("test_port", cache_enabled=False) + monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) + monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") + monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) + monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) + mock_read_data = [""] + file_chunks_iter = iter(mock_read_data) + mock_file_stream = MagicMock( + readlines=lambda *args, **kwargs: file_chunks_iter + ) + + stick = pw_stick.Stick("test_port", cache_enabled=True) await stick.connect() - await stick.initialize() - await stick.discover_nodes(load=True) + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await stick.initialize() + await stick.discover_nodes(load=True) assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=tz.utc) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" @@ -1398,4 +1637,6 @@ async def test_node_discovery_and_load(self, monkeypatch): ) assert state[pw_api.NodeFeature.RELAY].relay_state - await stick.disconnect() + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): + await stick.disconnect() + await asyncio.sleep(1) From db81f0e468f64995185608fcdc3a97af22dd19c0 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 21 Mar 2024 22:26:16 +0100 Subject: [PATCH 306/979] Set load state at unload --- plugwise_usb/nodes/sense.py | 1 + plugwise_usb/nodes/switch.py | 1 + 2 files changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index a3b7d824f..98f035b4c 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -75,6 +75,7 @@ async def initialize(self) -> bool: async def unload(self) -> None: """Unload node.""" + self._loaded = False if self._sense_subscription is not None: self._sense_subscription() await super().unload() diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index d443e9d4f..0c0aadc07 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -60,6 +60,7 @@ async def initialize(self) -> bool: async def unload(self) -> None: """Unload node.""" + self._loaded = False if self._switch_subscription is not None: self._switch_subscription() await super().unload() From 4453e9353d46ed6babbe1082f2f2ba784a5f240f Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 21 Mar 2024 22:26:44 +0100 Subject: [PATCH 307/979] Add closing in mocked transport --- tests/test_usb.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index b1a5efd81..b9f12ad17 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -78,9 +78,10 @@ def __init__(self, loop, test_data=None) -> None: self._first_response = pw_userdata.RESPONSE_MESSAGES self._second_response = pw_userdata.SECOND_RESPONSE_MESSAGES self.random_extra_byte = 0 + self._closing = False def is_closing(self) -> bool: - return False + return self._closing def write(self, data: bytes) -> None: log = None @@ -111,7 +112,7 @@ def write(self, data: bytes) -> None: else: self.message_response(ack, self._seq_id) self._processed.append(data) - if response is None: + if response is None or self._closing: return self._loop.create_task( # 0.5, @@ -147,7 +148,7 @@ def message_response_at_once(self, ack: bytes, data: bytes, seq_id: bytes) -> No self.protocol_data_received(construct_message(ack, seq_id) + construct_message(data, seq_id)) def close(self) -> None: - pass + self._closing = True class MockSerial: From 40901f4801aff809bdb2c233039b0e22a979eba4 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 21 Mar 2024 22:27:17 +0100 Subject: [PATCH 308/979] Use UTC constant in test --- tests/test_usb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index b9f12ad17..69610d0ce 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -465,7 +465,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): assert await stick.nodes["5555555555555555"].load() assert stick.nodes["5555555555555555"].node_info.firmware == dt( - 2011, 6, 27, 8, 55, 44, tzinfo=tz.utc + 2011, 6, 27, 8, 55, 44, tzinfo=UTC ) assert stick.nodes["5555555555555555"].node_info.version == "000000070008" assert stick.nodes["5555555555555555"].node_info.model == "Scan" @@ -803,7 +803,7 @@ async def test_energy_circle(self, monkeypatch): week_production_reset=None, ) # energy_update is not complete and should return none - utc_now = dt.utcnow().replace(tzinfo=tz.utc) + utc_now = dt.utcnow().replace(tzinfo=UTC) assert await stick.nodes["0098765432101234"].energy_update() is None # Allow for background task to finish await asyncio.sleep(1) @@ -1576,7 +1576,7 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.initialize() await stick.discover_nodes(load=True) - assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=tz.utc) + assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" @@ -1607,7 +1607,7 @@ async def test_node_discovery_and_load(self, monkeypatch): pw_api.NodeFeature.POWER, ) ) - assert state[pw_api.NodeFeature.INFO].firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=tz.utc) + assert state[pw_api.NodeFeature.INFO].firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" assert state[pw_api.NodeFeature.INFO].model == "Circle+ type F" assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE_PLUS From 2ef519fa7cec5ad0a93a3064b7480a515c33d91b Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 09:49:31 +0100 Subject: [PATCH 309/979] Do not append subfolder when path is specified --- plugwise_usb/helpers/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 02167f1e6..4c5331059 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -50,7 +50,7 @@ async def initialize_cache(self) -> None: if self._root_dir != "": if not await ospath.exists(self._root_dir): raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") - cache_dir = os_path_join(self._root_dir, CACHE_DIR) + cache_dir = self._root_dir else: cache_dir = await self._loop.run_in_executor( None, From 87c5101499cea49e479887abb83102ca792c4c88 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 09:49:44 +0100 Subject: [PATCH 310/979] No need to run in executor --- plugwise_usb/helpers/cache.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 4c5331059..9d0b64fdc 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -52,10 +52,7 @@ async def initialize_cache(self) -> None: raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") cache_dir = self._root_dir else: - cache_dir = await self._loop.run_in_executor( - None, - self._get_writable_os_dir - ) + cache_dir = self._get_writable_os_dir() await makedirs(cache_dir, exist_ok=True) self._cache_path = cache_dir self._cache_file = f"{cache_dir}/{self._file_name}" From d877f7b70c94e01797665e33cd24ceb44e27fc52 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 09:50:00 +0100 Subject: [PATCH 311/979] Correct imports --- plugwise_usb/util.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index b464e962f..3d90e3748 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -8,22 +8,13 @@ import binascii import datetime -import os import re import struct from typing import Any import crcmod -from .constants import ( - CACHE_DIR, - HW_MODELS, - LOGADDR_OFFSET, - PLUGWISE_EPOCH, - UTF8, -) - - +from .constants import HW_MODELS, LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8 crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) From 141c0c489f312514f9f792b85794f227ad9518f4 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 09:50:42 +0100 Subject: [PATCH 312/979] Correct removing files --- tests/test_usb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 69610d0ce..7581463ff 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1334,11 +1334,11 @@ async def test_cache(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) async def aiofiles_os_remove(file) -> None: - if file.name() == "mock_folder_that_exists/file_that_exists.ext": + if file == "mock_folder_that_exists/file_that_exists.ext": return - if file.name() == "mock_folder_that_exists/nodes.cache": + if file == "mock_folder_that_exists/nodes.cache": return - if file.name() == "mock_folder_that_exists/0123456789ABCDEF.cache": + if file == "mock_folder_that_exists/0123456789ABCDEF.cache": return raise pw_exceptions.CacheError("Invalid file") From 04c9aa501341d8a2a3872ca61c3757bdb42cf433 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 10:33:32 +0100 Subject: [PATCH 313/979] Another attempt to delay unhandled messages --- plugwise_usb/connection/receiver.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index e7642df7f..b3a181e17 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -16,7 +16,16 @@ """ from __future__ import annotations -from asyncio import Future, Protocol, Queue, Task, gather, get_running_loop, sleep, wait +from asyncio import ( + Future, + Protocol, + Queue, + Task, + TimerHandle, + gather, + get_running_loop, + sleep, +) from collections.abc import Awaitable, Callable from concurrent import futures import logging @@ -70,6 +79,7 @@ def __init__( self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None self._msg_processing_task: Task | None = None + self._delayed_processing_tasks: dict[bytes, TimerHandle] = {} # Subscribers self._stick_event_subscribers: dict[ Callable[[], None], @@ -146,6 +156,8 @@ async def close(self) -> None: async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" + for task in self._delayed_processing_tasks.values(): + task.cancel() await self._receive_queue.put(None) if self._msg_processing_task is not None and not self._msg_processing_task.done(): self._receive_queue.put_nowait(None) @@ -341,6 +353,8 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons if processed: if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) + if node_response.seq_id in self._delayed_processing_tasks: + del self._delayed_processing_tasks[node_response.seq_id] # Limit tracking to only the last appended request (FIFO) self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] return @@ -362,5 +376,8 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons ) return node_response.retries += 1 - await sleep(0.01) - self._put_message_in_receiver_queue(node_response) + self._delayed_processing_tasks[node_response.seq_id] = self._loop.call_later( + 0.1 * node_response.retries, + self._put_message_in_receiver_queue, + node_response, + ) From 0285aad89e8cfac56f5a4cb493fdcca07d5be5c9 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 10:35:49 +0100 Subject: [PATCH 314/979] Bump to version 0.40.0a10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6036238de..fa336d1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a9" +version = "v0.40.0a10" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 35a6e3a95c60d0384f70bdbf2007637cba55abf3 Mon Sep 17 00:00:00 2001 From: Breugel Date: Thu, 28 Mar 2024 10:58:03 +0100 Subject: [PATCH 315/979] Mock get_missing_energy _logs to prevent side effects of background updates --- tests/test_usb.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 7581463ff..af4eab648 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -29,6 +29,7 @@ pw_requests = importlib.import_module("plugwise_usb.messages.requests") pw_responses = importlib.import_module("plugwise_usb.messages.responses") pw_userdata = importlib.import_module("stick_test_data") +pw_circle = importlib.import_module("plugwise_usb.nodes.circle") pw_energy_counter = importlib.import_module( "plugwise_usb.nodes.helpers.counter" ) @@ -771,6 +772,11 @@ async def test_energy_circle(self, monkeypatch): monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 25) monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) + + async def fake_get_missing_energy_logs(address) -> None: + pass + + monkeypatch.setattr(pw_circle.PlugwiseCircle, "get_missing_energy_logs", fake_get_missing_energy_logs) stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() @@ -806,7 +812,7 @@ async def test_energy_circle(self, monkeypatch): utc_now = dt.utcnow().replace(tzinfo=UTC) assert await stick.nodes["0098765432101234"].energy_update() is None # Allow for background task to finish - await asyncio.sleep(1) + assert stick.nodes["0098765432101234"].energy == pw_api.EnergyStatistics( log_interval_consumption=60, log_interval_production=None, From 6c37c907cf9f5d5e606825edb358a29228b9389c Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 1 Apr 2024 21:46:49 +0200 Subject: [PATCH 316/979] Move message properties to separate file --- plugwise_usb/messages/properties.py | 273 ++++++++++++++++++++++++++++ plugwise_usb/messages/requests.py | 4 +- plugwise_usb/messages/responses.py | 4 +- plugwise_usb/util.py | 212 --------------------- 4 files changed, 277 insertions(+), 216 deletions(-) create mode 100644 plugwise_usb/messages/properties.py diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py new file mode 100644 index 000000000..763ee710a --- /dev/null +++ b/plugwise_usb/messages/properties.py @@ -0,0 +1,273 @@ +"""Message property types.""" + +import binascii +import datetime +import struct +from typing import Any + +from ..constants import LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8 +from ..helpers.util import int_to_uint + + +class BaseType: + """Generic single instance property.""" + + def __init__(self, value: Any, length: int) -> None: + """Initialize single instance property.""" + self.value = value + self.length = length + + def serialize(self) -> bytes: + """Return current value into an iterable list of bytes.""" + return bytes(self.value, UTF8) + + def deserialize(self, val: bytes) -> None: + """Convert current value into single data object.""" + self.value = val + + def __len__(self) -> int: + """Return length of property object.""" + return self.length + + +class CompositeType: + """Generic multi instance property.""" + + def __init__(self) -> None: + """Initialize multi instance property.""" + self.contents: list = [] + + def serialize(self) -> bytes: + """Return current value of all properties into an iterable list of bytes.""" + return b"".join(a.serialize() for a in self.contents) + + def deserialize(self, val: bytes) -> None: + """Convert data into multiple data objects.""" + for content in self.contents: + _val = val[: len(content)] + content.deserialize(_val) + val = val[len(_val):] + + def __len__(self) -> int: + """Return length of property objects.""" + return sum(len(x) for x in self.contents) + + +class String(BaseType): + """String based property.""" + + +class Int(BaseType): + """Integer based property.""" + + def __init__( + self, value: int, length: int = 2, negative: bool = True + ) -> None: + """Initialize integer based property.""" + super().__init__(value, length) + self.negative = negative + + def serialize(self) -> bytes: + """Return current string formatted value into an iterable list of bytes.""" + fmt = "%%0%dX" % self.length + return bytes(fmt % self.value, UTF8) + + def deserialize(self, val: bytes) -> None: + """Convert current value into single string formatted object.""" + self.value = int(val, 16) + if self.negative: + mask = 1 << (self.length * 4 - 1) + self.value = -(self.value & mask) + (self.value & ~mask) + + +class SInt(BaseType): + """String formatted data with integer value property.""" + + def __init__(self, value: int, length: int = 2) -> None: + """Initialize string formatted data with integer value property.""" + super().__init__(value, length) + + @staticmethod + def negative(val: int, octals: int) -> int: + """Compute the 2's compliment of int value val for negative values.""" + bits = octals << 2 + if (val & (1 << (bits - 1))) != 0: + val = val - (1 << bits) + return val + + def serialize(self) -> bytes: + """Return current string formatted integer value into an iterable list of bytes.""" + fmt = "%%0%dX" % self.length + return bytes(fmt % int_to_uint(self.value, self.length), UTF8) + + def deserialize(self, val: bytes) -> None: + """Convert current string formatted value into integer value.""" + # TODO: negative is not initialized! 20220405 + self.value = self.negative(int(val, 16), self.length) + + +class UnixTimestamp(Int): + """Unix formatted timestamp property.""" + + def __init__(self, value: float, length: int = 8) -> None: + """Initialize Unix formatted timestamp property.""" + Int.__init__(self, int(value), length, False) + + def deserialize(self, val: bytes) -> None: + """Convert data into datetime based on Unix timestamp format.""" + self.value = datetime.datetime.fromtimestamp( + int(val, 16), datetime.UTC + ) + + +class Year2k(Int): + """Year formatted property. + + Based on offset from the year 2000. + """ + + def deserialize(self, val: bytes) -> None: + """Convert data into year valued based value with offset to Y2k.""" + Int.deserialize(self, val) + self.value += PLUGWISE_EPOCH + + +class DateTime(CompositeType): + """Date time formatted property. + + format is: YYMMmmmm + where year is offset value from the epoch which is Y2K + and last four bytes are offset from the beginning of the month in minutes. + """ + + def __init__( + self, year: int = 0, month: int = 1, minutes: int = 0 + ) -> None: + """Initialize Date time formatted property.""" + CompositeType.__init__(self) + self.year = Year2k(year - PLUGWISE_EPOCH, 2) + self.month = Int(month, 2, False) + self.minutes = Int(minutes, 4, False) + self.contents += [self.year, self.month, self.minutes] + self.value: datetime.datetime | None = None + + def deserialize(self, val: bytes) -> None: + """Convert data into datetime based on timestamp with offset to Y2k.""" + if val == b"FFFFFFFF": + self.value = None + else: + CompositeType.deserialize(self, val) + self.value = datetime.datetime( + year=self.year.value, month=self.month.value, day=1 + ) + datetime.timedelta(minutes=self.minutes.value) + + +class Time(CompositeType): + """Time formatted property.""" + + def __init__( + self, hour: int = 0, minute: int = 0, second: int = 0 + ) -> None: + """Initialize time formatted property.""" + CompositeType.__init__(self) + self.hour = Int(hour, 2, False) + self.minute = Int(minute, 2, False) + self.second = Int(second, 2, False) + self.contents += [self.hour, self.minute, self.second] + self.value: datetime.time | None = None + + def deserialize(self, val: bytes) -> None: + """Convert data into time value.""" + CompositeType.deserialize(self, val) + self.value = datetime.time( + self.hour.value, self.minute.value, self.second.value + ) + + +class IntDec(BaseType): + """Integer as string formatted data with integer value property.""" + + def __init__(self, value: int, length: int = 2) -> None: + """Initialize integer based property.""" + super().__init__(value, length) + + def serialize(self) -> bytes: + """Return current string formatted integer value into an iterable list of bytes.""" + fmt = "%%0%dd" % self.length + return bytes(fmt % self.value, UTF8) + + def deserialize(self, val: bytes) -> None: + """Convert data into integer value based on string formatted data format.""" + self.value = val.decode(UTF8) + + +class RealClockTime(CompositeType): + """Time value property based on integer values.""" + + def __init__( + self, hour: int = 0, minute: int = 0, second: int = 0 + ) -> None: + """Initialize time formatted property.""" + CompositeType.__init__(self) + self.hour = IntDec(hour, 2) + self.minute = IntDec(minute, 2) + self.second = IntDec(second, 2) + self.contents += [self.second, self.minute, self.hour] + self.value: datetime.time | None = None + + def deserialize(self, val: bytes) -> None: + """Convert data into time value based on integer formatted data.""" + CompositeType.deserialize(self, val) + self.value = datetime.time( + int(self.hour.value), + int(self.minute.value), + int(self.second.value), + ) + + +class RealClockDate(CompositeType): + """Date value property based on integer values.""" + + def __init__(self, day: int = 0, month: int = 0, year: int = 0) -> None: + """Initialize date formatted property.""" + CompositeType.__init__(self) + self.day = IntDec(day, 2) + self.month = IntDec(month, 2) + self.year = IntDec(year - PLUGWISE_EPOCH, 2) + self.contents += [self.day, self.month, self.year] + self.value: datetime.date | None = None + + def deserialize(self, val: bytes) -> None: + """Convert data into date value based on integer formatted data.""" + CompositeType.deserialize(self, val) + self.value = datetime.date( + int(self.year.value) + PLUGWISE_EPOCH, + int(self.month.value), + int(self.day.value), + ) + + +class Float(BaseType): + """Float value property.""" + + def __init__(self, value: float, length: int = 4) -> None: + """Initialize float value property.""" + super().__init__(value, length) + + def deserialize(self, val: bytes) -> None: + """Convert data into float value.""" + hex_val = binascii.unhexlify(val) + self.value = float(struct.unpack("!f", hex_val)[0]) + + +class LogAddr(Int): + """Log address value property.""" + + def serialize(self) -> bytes: + """Return current log address formatted value into an iterable list of bytes.""" + return bytes("%08X" % ((self.value * 32) + LOGADDR_OFFSET), UTF8) + + def deserialize(self, val: bytes) -> None: + """Convert data into integer value based on log address formatted data.""" + Int.deserialize(self, val) + self.value = (self.value - LOGADDR_OFFSET) // 32 diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 04ae85513..70933ecbc 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -18,7 +18,8 @@ ) from ..exceptions import NodeError, NodeTimeout, StickError, StickTimeout from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType -from ..util import ( +from . import PlugwiseMessage +from .properties import ( DateTime, Int, LogAddr, @@ -28,7 +29,6 @@ String, Time, ) -from . import PlugwiseMessage _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 1a080fc9a..d81394a51 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -8,7 +8,8 @@ from ..api import NodeType from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..exceptions import MessageError -from ..util import ( +from . import PlugwiseMessage +from .properties import ( BaseType, DateTime, Float, @@ -20,7 +21,6 @@ Time, UnixTimestamp, ) -from . import PlugwiseMessage NODE_JOIN_ID: Final = b"0006" NODE_AWAKE_RESPONSE_ID: Final = b"004F" diff --git a/plugwise_usb/util.py b/plugwise_usb/util.py index 3d90e3748..681f95e52 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/util.py @@ -62,215 +62,3 @@ def int_to_uint(val: int, octals: int) -> int: if val < 0: val = val + (1 << bits) return val - - -class BaseType: - def __init__(self, value: Any, length: int) -> None: - self.value = value - self.length = length - - def serialize(self) -> bytes: - return bytes(self.value, UTF8) - - def deserialize(self, val: bytes) -> None: - self.value = val - - def __len__(self) -> int: - return self.length - - -class CompositeType: - def __init__(self) -> None: - self.contents: list = [] - - def serialize(self) -> bytes: - return b"".join(a.serialize() for a in self.contents) - - def deserialize(self, val: bytes) -> None: - for content in self.contents: - myval = val[: len(content)] - content.deserialize(myval) - val = val[len(myval):] - - def __len__(self) -> int: - return sum(len(x) for x in self.contents) - - -class String(BaseType): - pass - - -class Int(BaseType): - def __init__( - self, value: int, length: int = 2, negative: bool = True - ) -> None: - super().__init__(value, length) - self.negative = negative - - def serialize(self) -> bytes: - fmt = "%%0%dX" % self.length - return bytes(fmt % self.value, UTF8) - - def deserialize(self, val: bytes) -> None: - self.value = int(val, 16) - if self.negative: - mask = 1 << (self.length * 4 - 1) - self.value = -(self.value & mask) + (self.value & ~mask) - - -class SInt(BaseType): - def __init__(self, value: int, length: int = 2) -> None: - super().__init__(value, length) - - @staticmethod - def negative(val: int, octals: int) -> int: - """compute the 2's compliment of int value val for negative values""" - bits = octals << 2 - if (val & (1 << (bits - 1))) != 0: - val = val - (1 << bits) - return val - - def serialize(self) -> bytes: - fmt = "%%0%dX" % self.length - return bytes(fmt % int_to_uint(self.value, self.length), UTF8) - - def deserialize(self, val: bytes) -> None: - # TODO: negative is not initialized! 20220405 - self.value = self.negative(int(val, 16), self.length) - - -class UnixTimestamp(Int): - def __init__(self, value: float, length: int = 8) -> None: - Int.__init__(self, int(value), length, False) - - def deserialize(self, val: bytes) -> None: - self.value = datetime.datetime.fromtimestamp( - int(val, 16), datetime.UTC - ) - - -class Year2k(Int): - """year value that is offset from the year 2000""" - - def deserialize(self, val: bytes) -> None: - Int.deserialize(self, val) - self.value += PLUGWISE_EPOCH - - -class DateTime(CompositeType): - """datetime value as used in the general info response - format is: YYMMmmmm - where year is offset value from the epoch which is Y2K - and last four bytes are offset from the beginning of the month in minutes - """ - - def __init__( - self, year: int = 0, month: int = 1, minutes: int = 0 - ) -> None: - CompositeType.__init__(self) - self.year = Year2k(year - PLUGWISE_EPOCH, 2) - self.month = Int(month, 2, False) - self.minutes = Int(minutes, 4, False) - self.contents += [self.year, self.month, self.minutes] - self.value: datetime.datetime | None = None - - def deserialize(self, val: bytes) -> None: - if val == b"FFFFFFFF": - self.value = None - else: - CompositeType.deserialize(self, val) - self.value = datetime.datetime( - year=self.year.value, month=self.month.value, day=1 - ) + datetime.timedelta(minutes=self.minutes.value) - - -class Time(CompositeType): - """time value as used in the clock info response""" - - def __init__( - self, hour: int = 0, minute: int = 0, second: int = 0 - ) -> None: - CompositeType.__init__(self) - self.hour = Int(hour, 2, False) - self.minute = Int(minute, 2, False) - self.second = Int(second, 2, False) - self.contents += [self.hour, self.minute, self.second] - self.value: datetime.time | None = None - - def deserialize(self, val: bytes) -> None: - CompositeType.deserialize(self, val) - self.value = datetime.time( - self.hour.value, self.minute.value, self.second.value - ) - - -class IntDec(BaseType): - def __init__(self, value: int, length: int = 2) -> None: - super().__init__(value, length) - - def serialize(self) -> bytes: - fmt = "%%0%dd" % self.length - return bytes(fmt % self.value, UTF8) - - def deserialize(self, val: bytes) -> None: - self.value = val.decode(UTF8) - - -class RealClockTime(CompositeType): - """time value as used in the realtime clock info response""" - - def __init__( - self, hour: int = 0, minute: int = 0, second: int = 0 - ) -> None: - CompositeType.__init__(self) - self.hour = IntDec(hour, 2) - self.minute = IntDec(minute, 2) - self.second = IntDec(second, 2) - self.contents += [self.second, self.minute, self.hour] - self.value: datetime.time | None = None - - def deserialize(self, val: bytes) -> None: - CompositeType.deserialize(self, val) - self.value = datetime.time( - int(self.hour.value), - int(self.minute.value), - int(self.second.value), - ) - - -class RealClockDate(CompositeType): - """date value as used in the realtime clock info response""" - - def __init__(self, day: int = 0, month: int = 0, year: int = 0) -> None: - CompositeType.__init__(self) - self.day = IntDec(day, 2) - self.month = IntDec(month, 2) - self.year = IntDec(year - PLUGWISE_EPOCH, 2) - self.contents += [self.day, self.month, self.year] - self.value: datetime.date | None = None - - def deserialize(self, val: bytes) -> None: - CompositeType.deserialize(self, val) - self.value = datetime.date( - int(self.year.value) + PLUGWISE_EPOCH, - int(self.month.value), - int(self.day.value), - ) - - -class Float(BaseType): - def __init__(self, value: float, length: int = 4) -> None: - super().__init__(value, length) - - def deserialize(self, val: bytes) -> None: - hexval = binascii.unhexlify(val) - self.value = float(struct.unpack("!f", hexval)[0]) - - -class LogAddr(Int): - def serialize(self) -> bytes: - return bytes("%08X" % ((self.value * 32) + LOGADDR_OFFSET), UTF8) - - def deserialize(self, val: bytes) -> None: - Int.deserialize(self, val) - self.value = (self.value - LOGADDR_OFFSET) // 32 From 4fb6c02984a392964a96d861ba206ac12f40a74c Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 1 Apr 2024 21:47:57 +0200 Subject: [PATCH 317/979] Move util functions to helpers folder --- plugwise_usb/{ => helpers}/util.py | 18 +++++------------- plugwise_usb/messages/__init__.py | 2 +- plugwise_usb/network/__init__.py | 2 +- plugwise_usb/network/registry.py | 2 +- plugwise_usb/nodes/__init__.py | 2 +- 5 files changed, 9 insertions(+), 17 deletions(-) rename plugwise_usb/{ => helpers}/util.py (83%) diff --git a/plugwise_usb/util.py b/plugwise_usb/helpers/util.py similarity index 83% rename from plugwise_usb/util.py rename to plugwise_usb/helpers/util.py index 681f95e52..13d410f09 100644 --- a/plugwise_usb/util.py +++ b/plugwise_usb/helpers/util.py @@ -1,25 +1,17 @@ -""" -Use of this source code is governed by the MIT license found -in the LICENSE file. - -Plugwise protocol helpers -""" +"""Plugwise utility helpers.""" from __future__ import annotations -import binascii -import datetime import re -import struct -from typing import Any import crcmod -from .constants import HW_MODELS, LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8 +from ..constants import HW_MODELS crc_fun = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000) def validate_mac(mac: str) -> bool: + """Validate the supplied string to be an MAC address.""" if not re.match("^[A-F0-9]+$", mac): return False try: @@ -47,7 +39,7 @@ def version_to_model(version: str | None) -> str | None: # octals (and hex) type as int according to # https://docs.python.org/3/library/stdtypes.html def uint_to_int(val: int, octals: int) -> int: - """Compute the 2's compliment of int value val for negative values""" + """Compute the 2's compliment of int value val for negative values.""" bits = octals << 2 if (val & (1 << (bits - 1))) != 0: val = val - (1 << bits) @@ -57,7 +49,7 @@ def uint_to_int(val: int, octals: int) -> int: # octals (and hex) type as int according to # https://docs.python.org/3/library/stdtypes.html def int_to_uint(val: int, octals: int) -> int: - """Compute the 2's compliment of int value val for negative values""" + """Compute the 2's compliment of int value val for negative values.""" bits = octals << 2 if val < 0: val = val + (1 << bits) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 924ef2a7a..b77c85916 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -5,7 +5,7 @@ from typing import Any from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 -from ..util import crc_fun +from ..helpers.util import crc_fun class PlugwiseMessage: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index c6f4a36b0..282b78015 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -35,7 +35,7 @@ from ..nodes.sense import PlugwiseSense from ..nodes.stealth import PlugwiseStealth from ..nodes.switch import PlugwiseSwitch -from ..util import validate_mac +from ..helpers.util import validate_mac from .registry import StickNetworkRegister _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 723712c48..7b41694ec 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -18,7 +18,7 @@ NodeResponseType, PlugwiseResponse, ) -from ..util import validate_mac +from ..helpers.util import validate_mac from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 9b9ae9912..88ff97125 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -24,7 +24,7 @@ from ..exceptions import NodeError from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse -from ..util import version_to_model +from ..helpers.util import version_to_model from .helpers import raise_not_loaded from .helpers.cache import NodeCache from .helpers.counter import EnergyCalibration, EnergyCounters From 0b2fe823a5f62f0ad80dc699702f38f9676c9961 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 1 Apr 2024 21:48:19 +0200 Subject: [PATCH 318/979] Add file doc string --- plugwise_usb/helpers/__init__.py | 1 + tests/test_usb.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/plugwise_usb/helpers/__init__.py b/plugwise_usb/helpers/__init__.py index e69de29bb..15f0820b9 100644 --- a/plugwise_usb/helpers/__init__.py +++ b/plugwise_usb/helpers/__init__.py @@ -0,0 +1 @@ +"""Helper functions for Plugwise USB.""" diff --git a/tests/test_usb.py b/tests/test_usb.py index af4eab648..101ea3f6a 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,3 +1,5 @@ +"""Test plugwise USB Stick.""" + import asyncio from datetime import UTC, datetime as dt, timedelta as td, timezone as tz import importlib From dc13f7920f9d949b9d3b9fd0705c04632c2a31e1 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 1 Apr 2024 21:49:24 +0200 Subject: [PATCH 319/979] Reorder and fix imports --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 101ea3f6a..c35ab9fd8 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -5,7 +5,7 @@ import importlib import logging import random -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -22,12 +22,12 @@ pw_connection_manager = importlib.import_module( "plugwise_usb.connection.manager" ) +pw_constants = importlib.import_module("plugwise_usb.constants") pw_helpers_cache = importlib.import_module("plugwise_usb.helpers.cache") pw_network_cache = importlib.import_module("plugwise_usb.network.cache") pw_node_cache = importlib.import_module("plugwise_usb.nodes.helpers.cache") pw_receiver = importlib.import_module("plugwise_usb.connection.receiver") pw_sender = importlib.import_module("plugwise_usb.connection.sender") -pw_constants = importlib.import_module("plugwise_usb.constants") pw_requests = importlib.import_module("plugwise_usb.messages.requests") pw_responses = importlib.import_module("plugwise_usb.messages.responses") pw_userdata = importlib.import_module("stick_test_data") From 236bf5a8b1d8b834bfee34de3569552a3a37ef4b Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Thu, 9 May 2024 13:52:00 +0200 Subject: [PATCH 320/979] Implement pyserial-asyncio_fast --- plugwise_usb/connection/manager.py | 2 +- plugwise_usb/connection/receiver.py | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 9a156dd07..081819208 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -8,7 +8,7 @@ from typing import Any from serial import EIGHTBITS, PARITY_NONE, STOPBITS_ONE, SerialException -from serial_asyncio import SerialTransport, create_serial_connection +from serial_asyncio_fast import SerialTransport, create_serial_connection from ..api import StickEvent from ..exceptions import StickError diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index b3a181e17..83087dca0 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -31,7 +31,7 @@ import logging from typing import Final -from serial_asyncio import SerialTransport +from serial_asyncio_fast import SerialTransport from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER diff --git a/pyproject.toml b/pyproject.toml index fa336d1ec..f999333f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ maintainers = [ ] requires-python = ">=3.13.0" dependencies = [ - "pyserial-asyncio", + "pyserial-asyncio-fast", "aiofiles", "crcmod", "semver", diff --git a/requirements_test.txt b/requirements_test.txt index f1ffa09c3..37a5e6d77 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,6 +4,6 @@ pytest-asyncio radon==6.0.1 types-python-dateutil -pyserial-asyncio +pyserial-asyncio-fast aiofiles freezegun \ No newline at end of file From 04134d2c6de29e4e1658eb72499f4fae5caa8587 Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 20 May 2024 18:59:11 +0200 Subject: [PATCH 321/979] Bump to version 0.40.0a11 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f999333f8..b17963e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a10" +version = "v0.40.0a11" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 0248958ff99d6806ac0b1041fd888c1aa116517d Mon Sep 17 00:00:00 2001 From: Breugel Date: Mon, 20 May 2024 19:08:45 +0200 Subject: [PATCH 322/979] Raise at unsupported protocol version for CircleClockSetRequest --- plugwise_usb/messages/requests.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 70933ecbc..25ca6f2c0 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -16,7 +16,7 @@ MESSAGE_HEADER, NODE_TIME_OUT, ) -from ..exceptions import NodeError, NodeTimeout, StickError, StickTimeout +from ..exceptions import MessageError, NodeError, NodeTimeout, StickError, StickTimeout from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType from . import PlugwiseMessage from .properties import ( @@ -530,21 +530,21 @@ def __init__( protocol_version: float, reset: bool = False, ) -> None: - """Initialize CircleLogDataRequest message object.""" + """Initialize CircleClockSetRequest message object.""" + if protocol_version < 2.0: + # FIXME: Define "absoluteHour" variable + raise MessageError("CircleClockSetRequest for protocol version < 2.0 is not supported") + super().__init__(b"0016", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH - if protocol_version == 1.0: - pass - # FIXME: Define "absoluteHour" variable - elif protocol_version >= 2.0: - passed_days = dt.day - 1 - month_minutes = ( - (passed_days * DAY_IN_MINUTES) - + (dt.hour * HOUR_IN_MINUTES) - + dt.minute - ) - this_date = DateTime(dt.year, dt.month, month_minutes) + passed_days = dt.day - 1 + month_minutes = ( + (passed_days * DAY_IN_MINUTES) + + (dt.hour * HOUR_IN_MINUTES) + + dt.minute + ) + this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) if reset: From c22ab399611174beb59deed26f6003b99c5aa2ce Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 16:58:28 +0200 Subject: [PATCH 323/979] Return result for processing awake message --- plugwise_usb/network/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 282b78015..e6ec7a9d2 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -190,7 +190,7 @@ async def _handle_stick_event(self, event: StickEvent) -> None: ) self._is_running = False - async def node_awake_message(self, response: NodeAwakeResponse) -> None: + async def node_awake_message(self, response: NodeAwakeResponse) -> bool: """Handle NodeAwakeResponse message.""" mac = response.mac_decoded if self._awake_discovery.get(mac) is None: @@ -203,18 +203,19 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> None: ): await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp - return + return True if self._register.network_address(mac) is None: _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac ) - return + return False address: int | None = self._register.network_address(mac) if self._nodes.get(mac) is None: create_task( self._discover_battery_powered_node(address, mac) ) + return True async def node_join_available_message( self, response: NodeJoinAvailableResponse From 9a377acff487c7b68fc809dec36512dad41eba6b Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 16:59:35 +0200 Subject: [PATCH 324/979] Skip ping coordinator as it's expected to be online --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index e6ec7a9d2..c9499da8b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -268,7 +268,7 @@ async def discover_network_coordinator( address, node_type = self._register.network_controller() if await self._discover_node( - address, self._controller.mac_coordinator, node_type, + address, self._controller.mac_coordinator, node_type, ping_first=False ): if load: return await self._load_node(self._controller.mac_coordinator) From 95b35baa129043aa9364f5b49a70857ab329e7d2 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:02:17 +0200 Subject: [PATCH 325/979] Improve naming for send and receive tasks --- plugwise_usb/connection/queue.py | 5 +++-- plugwise_usb/connection/receiver.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 7e968a9b4..7d326e864 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -124,10 +124,11 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: await self._submit_queue.put(request) if self._submit_worker_task is None or self._submit_worker_task.done(): self._submit_worker_task = self._loop.create_task( - self._submit_worker() + self._send_queue_worker(), + name="Send queue worker" ) - async def _submit_worker(self) -> None: + async def _send_queue_worker(self) -> None: """Send messages from queue at the order of priority.""" while self._running: request = await self._submit_queue.get() diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 83087dca0..1c61ce307 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -185,8 +185,8 @@ def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: self._receive_queue.put_nowait(response) if self._msg_processing_task is None or self._msg_processing_task.done(): self._msg_processing_task = self._loop.create_task( - self._msg_queue_processing_function(), - name="Process received messages" + self._receive_queue_worker(), + name="Receive queue worker" ) def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: @@ -225,7 +225,7 @@ def _populate_message( return None return message - async def _msg_queue_processing_function(self): + async def _receive_queue_worker(self): """Process queue items.""" while self.is_connected: response: PlugwiseResponse | None = await self._receive_queue.get() From 2d662e36f8f23668da6b3edf328765596d128f13 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:08:54 +0200 Subject: [PATCH 326/979] Add send counter to request message representation --- plugwise_usb/messages/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 25ca6f2c0..eef84e2c9 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -77,8 +77,8 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: - return f"{self.__class__.__name__} for {self.mac_decoded}" - return f"{self.__class__.__name__} (seq_id={self._seq_id}) for {self.mac_decoded}" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempts={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempts={self._send_counter})" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" From 1973bd9e0d8dbae4bcd0a983c1c0c3cea03d9ef5 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:11:08 +0200 Subject: [PATCH 327/979] Unsubscribe to messages when error is assigned --- plugwise_usb/messages/requests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index eef84e2c9..0a1d1f474 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -174,6 +174,8 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: def assign_error(self, error: BaseException) -> None: """Assign error for this request.""" self.stop_response_timeout() + self._unsubscribe_from_stick() + self._unsubscribe_from_node() if self._response_future.done(): return self._response_future.set_exception(error) From 90f22a02dcb367b37d84aa072b07145934ccddd4 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:13:06 +0200 Subject: [PATCH 328/979] Simplify representation of CircleEnergyLogsRequest --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 0a1d1f474..cbe6944ba 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -755,7 +755,7 @@ def __init__(self, mac: bytes, log_address: int) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} for {self.mac_decoded} | log_address={self._log_address}" + return f"{super().__repr__()[:-1]}, log_address={self._log_address})" class CircleHandlesOffRequest(PlugwiseRequest): From c0312696ab6b9aac2d280ab7e336f8bef009f0d3 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:13:33 +0200 Subject: [PATCH 329/979] Add address to representation for CirclePlusScanRequest --- plugwise_usb/messages/requests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index cbe6944ba..abed5bc3d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -589,6 +589,9 @@ def __init__(self, mac: bytes, network_address: int) -> None: self._args.append(Int(network_address, length=2)) self.network_address = network_address + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()[:-1]}, network_address={self.network_address})" class NodeRemoveRequest(PlugwiseRequest): """Request node to be removed from Plugwise network by removing it from memory of Circle+ node. From b1ba498f7300e0a9e0c8413b6e298b81c1d7f3f1 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:15:59 +0200 Subject: [PATCH 330/979] Expand representation of CirclePlusScanResponse --- plugwise_usb/messages/responses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index d81394a51..a3e91e2bb 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -535,6 +535,9 @@ def network_address(self) -> int: """Return the network address.""" return self._network_address.value + def __repr__(self) -> str: + """Convert response into writable str.""" + return f"{super().__repr__()[:-1]}, network_address={self.network_address}, registered_mac={self.registered_mac})" class NodeRemoveResponse(PlugwiseResponse): """Confirmation (or not) if node is removed from the Plugwise network. From 2e936d028d6aebc3d7175b2a8f4bab6867162195 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:16:10 +0200 Subject: [PATCH 331/979] Expand representation of StickInitResponse --- plugwise_usb/messages/responses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index a3e91e2bb..6b67b2886 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -434,6 +434,9 @@ def network_online(self) -> bool: """Return state of network.""" return self._network_online.value == 1 + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()[:-1]}, network_controller={self.mac_network_controller}, network_online={self.network_online})" class CirclePowerUsageResponse(PlugwiseResponse): """Returns power usage as impulse counters for several different time frames. From b177659e39b3f7a15090072e2b2ff29e7d1a68a2 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:18:06 +0200 Subject: [PATCH 332/979] Add retries to representation of Response messages --- plugwise_usb/messages/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 6b67b2886..b924a97b0 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -115,7 +115,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} from {self.mac_decoded} (seq_id={self.seq_id})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, retries={self._retries})" @property def retries(self) -> int: @@ -230,7 +230,7 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"StickResponse (ack={StickResponseType(self.ack_id).name}, seq_id={str(self.seq_id)})" + return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" @property def response_type(self) -> StickResponseType: From 04c214fa57d10ed845ca7c2736a6bd852f8149ff Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:20:01 +0200 Subject: [PATCH 333/979] Return outside of try block --- plugwise_usb/connection/queue.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 7d326e864..53facf8aa 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -92,7 +92,6 @@ async def submit( await self._add_request_to_queue(request) try: response: PlugwiseResponse = await request.response_future() - return response except (NodeTimeout, StickTimeout) as e: if request.resend: _LOGGER.debug("%s, retrying", e) @@ -112,6 +111,8 @@ async def submit( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" ) from exception + else: + return response raise StickError( f"Failed to send {request.__class__.__name__} " + From a02243fbe6b6954c5fae6ef87517f0a6de6bcc10 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:21:22 +0200 Subject: [PATCH 334/979] Remove unused function --- plugwise_usb/connection/receiver.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 1c61ce307..abfde1687 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -214,17 +214,6 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: _LOGGER.debug("Reading '%s' from USB-Stick", response) return response - def _populate_message( - self, message: PlugwiseResponse, data: bytes - ) -> PlugwiseResponse | None: - """Return plugwise response message based on data.""" - try: - message.deserialize(data) - except MessageError as err: - _LOGGER.warning(err) - return None - return message - async def _receive_queue_worker(self): """Process queue items.""" while self.is_connected: From fcfbf1a9d4af058f67d74de689276e511b238468 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:23:53 +0200 Subject: [PATCH 335/979] Keep track and log result of subscribers --- plugwise_usb/connection/receiver.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index abfde1687..c14494e4c 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -323,7 +323,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons _LOGGER.debug("Drop duplicate already processed %s", node_response) return - processed = False + notify_tasks: list[Callable] = [] for callback, mac, message_ids, seq_id in list( self._node_response_subscribers.values() ): @@ -333,19 +333,22 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons continue if seq_id is not None and seq_id != node_response.seq_id: continue - processed = True - try: - await callback(node_response) - except Exception as err: - _LOGGER.error("ERROR AT _notify_node_response_subscribers: %s", err) + notify_tasks.append(callback(node_response)) - if processed: + if len(notify_tasks) > 0: if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) if node_response.seq_id in self._delayed_processing_tasks: del self._delayed_processing_tasks[node_response.seq_id] # Limit tracking to only the last appended request (FIFO) self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] + + # execute callbacks + task_result = await gather(*notify_tasks) + + # Log execution result for special cases + if not all(task_result): + _LOGGER.warning("Executed %s tasks (result=%s) for %s", len(notify_tasks), task_result, node_response) return if node_response.retries > 10: From 8a5587a51cc263a3bbc85e47ca566e3c8ebf16f4 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:28:19 +0200 Subject: [PATCH 336/979] Check for connection before sending requests --- plugwise_usb/connection/sender.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 01703cbcf..88a97457b 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -52,12 +52,11 @@ def __init__( async def write_request_to_port(self, request: PlugwiseRequest) -> None: """Send message to serial port of USB stick.""" - await self._stick_lock.acquire() - self._current_request = request - if self._transport is None: raise StickError("USB-Stick transport missing.") + await self._stick_lock.acquire() + self._current_request = request self._stick_response: Future[bytes] = self._loop.create_future() serialized_data = request.serialize() From eb3c185d1084140c12af3e6ff87b477352558721 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:34:11 +0200 Subject: [PATCH 337/979] Use stick response type to validate successful sending request --- plugwise_usb/connection/sender.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 88a97457b..7bdd67ed2 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -16,7 +16,7 @@ """ from __future__ import annotations -from asyncio import Future, Lock, Transport, get_running_loop, sleep, timeout +from asyncio import Future, Lock, Transport, get_running_loop, timeout import logging from ..constants import STICK_TIME_OUT @@ -59,7 +59,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._current_request = request self._stick_response: Future[bytes] = self._loop.create_future() - serialized_data = request.serialize() + request.add_send_attempt() request.subscribe_to_responses( self._receiver.subscribe_to_stick_responses, self._receiver.subscribe_to_node_responses, @@ -67,14 +67,14 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: _LOGGER.debug("Writing '%s' to USB-Stick", request) # Write message to serial port buffer + serialized_data = request.serialize() self._transport.write(serialized_data) - request.add_send_attempt() request.start_response_timeout() # Wait for USB stick to accept request try: async with timeout(STICK_TIME_OUT): - request.seq_id = await self._stick_response + response: StickResponse = await self._stick_response except TimeoutError: _LOGGER.warning("USB-Stick did not respond within %s seconds after writing %s", STICK_TIME_OUT, request) request.assign_error( @@ -88,6 +88,24 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: request.assign_error(exc) else: _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(request.seq_id)) + if response.response_type == StickResponseType.ACCEPT: + request.seq_id = response.seq_id + elif response.response_type == StickResponseType.TIMEOUT: + request.assign_error( + BaseException( + StickError( + f"USB-Stick responded with timeout for {request}" + ) + ) + ) + elif response.response_type == StickResponseType.FAILED: + request.assign_error( + BaseException( + StickError( + f"USB-Stick failed communication for {request}" + ) + ) + ) finally: self._stick_response.cancel() self._stick_lock.release() @@ -98,8 +116,7 @@ async def _process_stick_response(self, response: StickResponse) -> None: _LOGGER.warning("No open request for %s", str(response)) return _LOGGER.debug("Received %s as reply to %s", response, self._current_request) - self._stick_response.set_result(response.seq_id) - await sleep(0) + self._stick_response.set_result(response) def stop(self) -> None: """Stop sender.""" From 5e844e9e44f84a2f14c6d7f41b4627fa8438283a Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:37:05 +0200 Subject: [PATCH 338/979] Raise when seq_id is changed --- plugwise_usb/messages/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index abed5bc3d..0378fe5bb 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -101,8 +101,8 @@ def seq_id(self) -> bytes | None: @seq_id.setter def seq_id(self, seq_id: bytes) -> None: """Assign sequence id.""" - if self._seq_id == seq_id: - return + if self._seq_id is not None: + raise MessageError(f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}") self._seq_id = seq_id self._unsubscribe_from_stick() self._unsubscribe_stick_response = self._stick_subscription_fn( From d29b7edade48ba46e7317152a785d8c0655bd9b7 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:39:29 +0200 Subject: [PATCH 339/979] Reset seq_id at timeout --- plugwise_usb/messages/requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 0378fe5bb..59a17dd7d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -156,6 +156,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): return + self._seq_id = None self._unsubscribe_from_stick() self._unsubscribe_from_node() if stick_timeout: From 19e78f8cb0af85ba42979c61850de3a50862f160 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:40:55 +0200 Subject: [PATCH 340/979] Remove object_id --- plugwise_usb/messages/requests.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 59a17dd7d..21c16f60c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -62,7 +62,6 @@ def __init__( self._max_retries: int = MAX_RETRIES self.timestamp = datetime.now(UTC) self._loop = get_running_loop() - self._id = id(self) self._reply_identifier: bytes = b"0000" self._response: PlugwiseResponse | None = None self._stick_subscription_fn: Callable[[], None] | None = None @@ -234,11 +233,6 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: self._id ) - @property - def object_id(self) -> int: - """Return the object id.""" - return self._id - @property def max_retries(self) -> int: """Return the maximum retries.""" From 3f911434031d2aef5653394ecfb9520c45bd45d2 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:42:15 +0200 Subject: [PATCH 341/979] Simplify stick response --- plugwise_usb/messages/requests.py | 42 ++++++++++++++++--------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 21c16f60c..4dbc3678d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -211,27 +211,29 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" if self._response_future.done(): return - if self._seq_id is not None and self._seq_id == stick_response.seq_id: - _LOGGER.debug("%s for request %s id %d", stick_response, self, self._id) - if stick_response.ack_id == StickResponseType.TIMEOUT: - self._response_timeout_expired(stick_timeout=True) - elif stick_response.ack_id == StickResponseType.FAILED: - self._unsubscribe_from_node() - self._response_future.set_exception( - NodeError( - f"Stick failed request {self._seq_id}" - ) - ) - elif stick_response.ack_id == StickResponseType.ACCEPT: - pass - else: - _LOGGER.debug( - "Unknown StickResponseType %s at %s for request %s id %d", - str(stick_response.ack_id), - stick_response, - self, - self._id + if self._seq_id is None or self._seq_id != stick_response.seq_id: + return + _LOGGER.warning("%s for request %s id %d", stick_response, self, self._id) + if stick_response.ack_id == StickResponseType.TIMEOUT: + self._response_timeout_expired(stick_timeout=True) + elif stick_response.ack_id == StickResponseType.FAILED: + self._unsubscribe_from_node() + self._seq_id = None + self._response_future.set_exception( + NodeError( + f"Stick failed request {self._seq_id}" ) + ) + elif stick_response.ack_id == StickResponseType.ACCEPT: + pass + else: + _LOGGER.debug( + "Unknown StickResponseType %s at %s for request %s id %d", + str(stick_response.ack_id), + stick_response, + self, + self._id + ) @property def max_retries(self) -> int: From eb8b0e2d56bc3f36036251834277f060a4e6b585 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:52:18 +0200 Subject: [PATCH 342/979] Rewrite response message subscription --- plugwise_usb/messages/requests.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 4dbc3678d..999ff6eaf 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -103,18 +103,17 @@ def seq_id(self, seq_id: bytes) -> None: if self._seq_id is not None: raise MessageError(f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}") self._seq_id = seq_id - self._unsubscribe_from_stick() + # Subscribe to receive the response messages self._unsubscribe_stick_response = self._stick_subscription_fn( self._process_stick_response, - seq_id=seq_id + seq_id=self._seq_id ) - self._unsubscribe_from_node() self._unsubscribe_node_response = ( self._node_subscription_fn( self._process_node_response, mac=self._mac, message_ids=(self._reply_identifier,), - seq_id=seq_id + seq_id=self._seq_id ) ) From 8c1b6a841ab0423679025c0ec8bb71bc844584e1 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:53:33 +0200 Subject: [PATCH 343/979] Rewrite node response handling --- plugwise_usb/messages/requests.py | 41 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 999ff6eaf..eee87719c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -3,6 +3,7 @@ from asyncio import Future, TimerHandle, get_running_loop from collections.abc import Callable +from copy import copy from datetime import UTC, datetime from enum import Enum import logging @@ -181,30 +182,30 @@ def assign_error(self, error: BaseException) -> None: async def _process_node_response(self, response: PlugwiseResponse) -> bool: """Process incoming message from node.""" - if self._seq_id is not None and self._seq_id == response.seq_id: - self._response = response - self.stop_response_timeout() - if not self._response_future.done(): - if self._send_counter > 1: - _LOGGER.info("Received '%s' as reply to retried '%s' id %d", response, self, self._id) - else: - _LOGGER.debug("Received '%s' as reply to '%s' id %d", response, self, self._id) - self._response_future.set_result(response) - else: - _LOGGER.warning("Received '%s' as reply to '%s' id %d already done", response, self, self._id) - self._unsubscribe_from_stick() - self._unsubscribe_from_node() - return True - if self._seq_id: + if self._seq_id is None: + _LOGGER.warning("Received %s as reply to %s without a seq_id assigned", self._response, self) + return False + if self._seq_id != response.seq_id: _LOGGER.warning( - "Received '%s' as reply to '%s' which is not correct (seq_id=%s)", - response, + "Received %s as reply to %s which is not correct (expected seq_id=%s)", + self._response, self, - str(response.seq_id) + str(self.seq_id) ) + return False + if self._response_future.done(): + return False + + self._response = copy(response) + self.stop_response_timeout() + self._unsubscribe_from_stick() + self._unsubscribe_from_node() + if self._send_counter > 1: + _LOGGER.info("Received %s after %s retries as reply to %s", self._response, self._send_counter, self) else: - _LOGGER.debug("Received '%s' as reply to '%s' has not received seq_id", response, self) - return False + _LOGGER.debug("Received %s as reply to %s", self._response, self) + self._response_future.set_result(self._response) + return True async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" From 7f2e5483cc52c40732f94b416c349fc472bc8962 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 17:56:43 +0200 Subject: [PATCH 344/979] Fix protocol support for clock set request --- plugwise_usb/messages/requests.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index eee87719c..3ee02cec7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -529,21 +529,21 @@ def __init__( protocol_version: float, reset: bool = False, ) -> None: - """Initialize CircleClockSetRequest message object.""" - if protocol_version < 2.0: - # FIXME: Define "absoluteHour" variable - raise MessageError("CircleClockSetRequest for protocol version < 2.0 is not supported") - + """Initialize CircleLogDataRequest message object.""" super().__init__(b"0016", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH - passed_days = dt.day - 1 - month_minutes = ( - (passed_days * DAY_IN_MINUTES) - + (dt.hour * HOUR_IN_MINUTES) - + dt.minute - ) - this_date = DateTime(dt.year, dt.month, month_minutes) + if protocol_version == 1.0: + pass + # FIXME: Define "absoluteHour" variable + elif protocol_version >= 2.0: + passed_days = dt.day - 1 + month_minutes = ( + (passed_days * DAY_IN_MINUTES) + + (dt.hour * HOUR_IN_MINUTES) + + dt.minute + ) + this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) if reset: From 96fe1b2578013488f9088020723872b8f57ecd95 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 18:46:10 +0200 Subject: [PATCH 345/979] Correct typing --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 9906aa398..d6b83f43e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -426,7 +426,7 @@ async def energy_log_update(self, address: int) -> bool: # Each response message contains 4 log counters (slots) of the # energy pulses collected during the previous hour of given timestamp for _slot in range(4, 0, -1): - _log_timestamp: datetime = getattr( + _log_timestamp: datetime | None = getattr( response, "logdate%d" % (_slot,) ).value _log_pulses: int = getattr(response, "pulses%d" % (_slot,)).value From a7d56afe79c3b76389d0efa936f6d91fb3e3e061 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 18:48:01 +0200 Subject: [PATCH 346/979] Only save cache when energy record is updated --- plugwise_usb/nodes/circle.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d6b83f43e..7208f8563 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -421,6 +421,7 @@ async def energy_log_update(self, address: int) -> bool: return False await self._available_update_state(True) + energy_record_update = False # Forward historical energy log information to energy counters # Each response message contains 4 log counters (slots) of the @@ -433,15 +434,17 @@ async def energy_log_update(self, address: int) -> bool: if _log_timestamp is None: self._energy_counters.add_empty_log(response.log_address, _slot) else: - await self._energy_log_record_update_state( + if await self._energy_log_record_update_state( response.log_address, _slot, _log_timestamp.replace(tzinfo=UTC), _log_pulses, import_only=True - ) + ): + energy_record_update = True self._energy_counters.update() - await self.save_cache() + if energy_record_update: + await self.save_cache() return True async def _energy_log_records_load_from_cache(self) -> bool: @@ -525,8 +528,8 @@ async def _energy_log_record_update_state( timestamp: datetime, pulses: int, import_only: bool = False, - ) -> None: - """Process new energy log record.""" + ) -> bool: + """Process new energy log record. Returns true if record is new or changed.""" self._energy_counters.add_pulse_log( address, slot, @@ -535,7 +538,7 @@ async def _energy_log_record_update_state( import_only=import_only ) if not self._cache_enabled: - return + return False log_cache_record = f"{address}:{slot}:{timestamp.year}" log_cache_record += f"-{timestamp.month}-{timestamp.day}" log_cache_record += f"-{timestamp.hour}-{timestamp.minute}" @@ -551,12 +554,15 @@ async def _energy_log_record_update_state( self._set_cache( CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record ) + return True + return False else: _LOGGER.debug( "No existing energy collection log cached for %s", self.mac ) self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) + return True async def switch_relay(self, state: bool) -> bool | None: """Switch state of relay. From b96ea63a2c38439a79f92cc896cd9ce99a5e67a2 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 18:54:26 +0200 Subject: [PATCH 347/979] Only request node info when outdated --- plugwise_usb/nodes/circle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 7208f8563..bbadfeaee 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -726,7 +726,7 @@ async def load(self) -> bool: return False # Get node info - if await self.node_info_update() is None: + if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: _LOGGER.info( "Failed to load Circle node %s because it is not responding to information request", self._node_info.mac @@ -796,7 +796,7 @@ async def initialize(self) -> bool: ) self._initialized = False return False - if await self.node_info_update() is None: + if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: _LOGGER.debug( "Failed to retrieve node info for %s", self.mac From 822a5051b805139fbfdb7cef7e240d554c9fee4b Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 18:57:38 +0200 Subject: [PATCH 348/979] Remove redundant check --- plugwise_usb/nodes/circle.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index bbadfeaee..d02d8047f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -243,11 +243,6 @@ async def power_update(self) -> PowerStatistics | None: ) await self._available_update_state(False) return None - if response.mac_decoded != self.mac: - raise NodeError( - f"Incorrect power response for {response.mac_decoded} " + - f"!= {self.mac} = {self._mac_in_str} | {request.mac_decoded}" - ) await self._available_update_state(True) # Update power stats From 4485773c699f007af55068f604638e1ab8009810 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 18:59:32 +0200 Subject: [PATCH 349/979] Cleanup and correct doc strings --- plugwise_usb/connection/manager.py | 2 +- plugwise_usb/connection/queue.py | 2 +- plugwise_usb/messages/__init__.py | 2 +- plugwise_usb/messages/responses.py | 2 +- plugwise_usb/network/__init__.py | 4 +--- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 081819208..1d3a35807 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -183,7 +183,7 @@ async def setup_connection_to_stick( self._subscribe_to_stick_events() async def write_to_stick(self, request: PlugwiseRequest) -> None: - """Write message to USB stick. Returns the updated request object.""" + """Write message to USB stick.""" if not request.resend: raise StickError( f"Failed to send {request.__class__.__name__} " + diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 53facf8aa..3ba32b928 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -121,7 +121,7 @@ async def submit( ) async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: - """Add request to send queue and return the session id.""" + """Add request to send queue.""" await self._submit_queue.put(request) if self._submit_worker_task is None or self._submit_worker_task.done(): self._submit_worker_task = self._loop.create_task( diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index b77c85916..1a933ff04 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -21,7 +21,7 @@ def __init__(self, identifier: bytes) -> None: @property def seq_id(self) -> bytes | None: - """Return sequence id assigned to this request.""" + """Return sequence id.""" return self._seq_id @seq_id.setter diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index b924a97b0..96273b6df 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -384,7 +384,7 @@ class NodeImageValidationResponse(PlugwiseResponse): """ def __init__(self) -> None: - """Initialize NodePingResponse message object.""" + """Initialize NodeImageValidationResponse message object.""" super().__init__(b"0010") self.image_timestamp = UnixTimestamp(0) self._params += [self.image_timestamp] diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index c9499da8b..6b607e0f1 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -364,10 +364,8 @@ async def get_node_details( """Return node discovery type.""" ping_response: NodePingResponse | None = None if ping_first: - # Define ping request with custom timeout + # Define ping request with one retry ping_request = NodePingRequest(bytes(mac, UTF8), retries=1) - # ping_request.timeout = 3 - ping_response: NodePingResponse | None = ( await self._controller.send( ping_request From b15e084dc4376598db8651fa4ab7e6651ac4207b Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:01:00 +0200 Subject: [PATCH 350/979] Improve log messages --- plugwise_usb/connection/manager.py | 1 + plugwise_usb/connection/queue.py | 17 +++++++++++------ plugwise_usb/connection/receiver.py | 21 ++++++++++++++------- plugwise_usb/connection/sender.py | 8 ++++++-- plugwise_usb/helpers/cache.py | 4 ++-- plugwise_usb/messages/requests.py | 3 +++ plugwise_usb/network/registry.py | 2 +- 7 files changed, 38 insertions(+), 18 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 1d3a35807..6496aab9b 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -184,6 +184,7 @@ async def setup_connection_to_stick( async def write_to_stick(self, request: PlugwiseRequest) -> None: """Write message to USB stick.""" + _LOGGER.debug("Write to USB-stick: %s", request) if not request.resend: raise StickError( f"Failed to send {request.__class__.__name__} " + diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 3ba32b928..d503dc6f7 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -82,7 +82,7 @@ async def submit( self, request: PlugwiseRequest ) -> PlugwiseResponse: """Add request to queue and return the response of node. Raises an error when something fails.""" - _LOGGER.debug("Queueing %s", request) + _LOGGER.debug("Submit %s", request) while request.resend: if not self._running or self._stick is None: raise StickError( @@ -93,13 +93,13 @@ async def submit( try: response: PlugwiseResponse = await request.response_future() except (NodeTimeout, StickTimeout) as e: - if request.resend: - _LOGGER.debug("%s, retrying", e) - elif isinstance(request, NodePingRequest): + if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level - _LOGGER.debug("%s after %s attempts. Cancel request", e, request.max_retries) + _LOGGER.debug("%s, cancel because timeout is expected for NodePingRequests", e) + elif request.resend: + _LOGGER.info("%s, retrying", e) else: - _LOGGER.info("%s after %s attempts, cancel request", e, request.max_retries) + _LOGGER.warning("%s, cancel request", e) except StickError as exception: _LOGGER.error(exception) raise StickError( @@ -122,6 +122,7 @@ async def submit( async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue.""" + _LOGGER.debug("Add request to queue: %s", request) await self._submit_queue.put(request) if self._submit_worker_task is None or self._submit_worker_task.done(): self._submit_worker_task = self._loop.create_task( @@ -131,10 +132,14 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: async def _send_queue_worker(self) -> None: """Send messages from queue at the order of priority.""" + _LOGGER.debug("Send_queue_worker started") while self._running: request = await self._submit_queue.get() + _LOGGER.debug("Send from queue %s", request) if request.priority == Priority.CANCEL: self._submit_queue.task_done() return await self._stick.write_to_stick(request) self._submit_queue.task_done() + _LOGGER.debug("Sent from queue %s", request) + _LOGGER.debug("Send_queue_worker finished") diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index c14494e4c..22a544bbe 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -104,9 +104,9 @@ def __init__( def connection_lost(self, exc: Exception | None = None) -> None: """Call when port was closed expectedly or unexpectedly.""" - _LOGGER.debug("Connection lost") + _LOGGER.info("Connection lost") if exc is not None: - _LOGGER.warning("Connection lost %s", exc) + _LOGGER.warning("Connection to Plugwise USB-stick lost %s", exc) self._loop.create_task(self.close()) if len(self._stick_event_subscribers) > 0: self._loop.create_task( @@ -132,7 +132,7 @@ def reduce_logging(self, reduce_logging: bool) -> None: def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" - _LOGGER.debug("Connection made") + _LOGGER.info("Connection made") self._transport = transport if ( self._connected_future is not None @@ -168,6 +168,7 @@ def data_received(self, data: bytes) -> None: This function is called by inherited asyncio.Protocol class """ + _LOGGER.debug("Received data from USB-Stick: %s", data) self._buffer += data if MESSAGE_FOOTER in self._buffer: msgs = self._buffer.split(MESSAGE_FOOTER) @@ -182,6 +183,7 @@ def data_received(self, data: bytes) -> None: def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: """Put message in queue.""" + _LOGGER.debug("Add response to queue: %s", response) self._receive_queue.put_nowait(response) if self._msg_processing_task is None or self._msg_processing_task.done(): self._msg_processing_task = self._loop.create_task( @@ -194,6 +196,7 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: # Lookup header of message, there are stray \x83 if (_header_index := msg.find(MESSAGE_HEADER)) == -1: return False + _LOGGER.debug("Extract message from data: %s", msg) msg = msg[_header_index:] # Detect response message type identifier = msg[4:8] @@ -211,23 +214,25 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: _LOGGER.warning(err) return None - _LOGGER.debug("Reading '%s' from USB-Stick", response) + _LOGGER.debug("Data %s converted into %s", msg, response) return response async def _receive_queue_worker(self): """Process queue items.""" + _LOGGER.debug("Receive_queue_worker started") while self.is_connected: response: PlugwiseResponse | None = await self._receive_queue.get() - _LOGGER.debug("Processing started for %s", response) + _LOGGER.debug("Process from queue: %s", response) if isinstance(response, StickResponse): + _LOGGER.debug("Received %s", response) await self._notify_stick_response_subscribers(response) elif response is None: self._receive_queue.task_done() return else: await self._notify_node_response_subscribers(response) - _LOGGER.debug("Processing finished for %s", response) self._receive_queue.task_done() + _LOGGER.debug("Receive_queue_worker finished") def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -295,6 +300,7 @@ async def _notify_stick_response_subscribers( continue if response_type is not None and response_type != stick_response.response_type: continue + _LOGGER.debug("Notify stick response subscriber for %s", stick_response) await callback(stick_response) def subscribe_to_node_responses( @@ -320,7 +326,7 @@ def remove_listener() -> None: async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse) -> None: """Call callback for all node response message subscribers.""" if node_response.seq_id in self._last_processed_messages: - _LOGGER.debug("Drop duplicate already processed %s", node_response) + _LOGGER.debug("Drop previously processed duplicate %s", node_response) return notify_tasks: list[Callable] = [] @@ -336,6 +342,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons notify_tasks.append(callback(node_response)) if len(notify_tasks) > 0: + _LOGGER.debug("Notify node response subscribers (%s) about %s", len(notify_tasks), node_response) if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) if node_response.seq_id in self._delayed_processing_tasks: diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 7bdd67ed2..af02472dd 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -56,6 +56,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: raise StickError("USB-Stick transport missing.") await self._stick_lock.acquire() + _LOGGER.debug("Send %s", request) self._current_request = request self._stick_response: Future[bytes] = self._loop.create_future() @@ -65,9 +66,9 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._receiver.subscribe_to_node_responses, ) - _LOGGER.debug("Writing '%s' to USB-Stick", request) # Write message to serial port buffer serialized_data = request.serialize() + _LOGGER.debug("Write %s to port: %s", request, serialized_data) self._transport.write(serialized_data) request.start_response_timeout() @@ -85,12 +86,14 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: ) ) except BaseException as exc: # pylint: disable=broad-exception-caught + _LOGGER.warning("Exception for %s: %s", request, exc) request.assign_error(exc) else: - _LOGGER.debug("Request '%s' was accepted by USB-stick with seq_id %s", request, str(request.seq_id)) if response.response_type == StickResponseType.ACCEPT: request.seq_id = response.seq_id + _LOGGER.info("Sent %s", request) elif response.response_type == StickResponseType.TIMEOUT: + _LOGGER.warning("USB-Stick responded with communication timeout for %s", request) request.assign_error( BaseException( StickError( @@ -99,6 +102,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: ) ) elif response.response_type == StickResponseType.FAILED: + _LOGGER.warning("USB-Stick failed communication for %s", request) request.assign_error( BaseException( StickError( diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 9d0b64fdc..6f3a5c61c 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -96,8 +96,8 @@ async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None encoding=UTF8, ) as file_data: await file_data.writelines(data_to_write) - _LOGGER.info( - "Saved %s lines to network cache file %s", + _LOGGER.debug( + "Saved %s lines to cache file %s", str(len(data)), self._cache_file ) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 3ee02cec7..89c595da3 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -102,6 +102,7 @@ def seq_id(self) -> bytes | None: def seq_id(self, seq_id: bytes) -> None: """Assign sequence id.""" if self._seq_id is not None: + _LOGGER.warning("Unable to change seq_id into %s for request %s", seq_id, self) raise MessageError(f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}") self._seq_id = seq_id # Subscribe to receive the response messages @@ -159,12 +160,14 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: self._unsubscribe_from_stick() self._unsubscribe_from_node() if stick_timeout: + _LOGGER.warning("USB-stick responded with time out to %s", self) self._response_future.set_exception( StickTimeout( f"USB-stick responded with time out to {self}" ) ) else: + _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, self._response, NODE_TIME_OUT) self._response_future.set_exception( NodeTimeout( f"No response to {self} within {NODE_TIME_OUT} seconds" diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 7b41694ec..caccac08e 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -232,7 +232,7 @@ async def update_missing_registrations( _LOGGER.debug("Full network registration finished, pre") await self.save_registry_to_cache() _LOGGER.debug("Full network registration finished, post") - _LOGGER.info("Full network registration discovery completed") + _LOGGER.info("Full network discovery completed") if self._full_scan_finished is not None: await self._full_scan_finished() self._full_scan_finished = None From 284d6f8d3e8cadecc02472bffb1f46f748444b2b Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:05:02 +0200 Subject: [PATCH 351/979] Improve NodeTimout error message --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 89c595da3..fc93b507b 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -170,7 +170,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, self._response, NODE_TIME_OUT) self._response_future.set_exception( NodeTimeout( - f"No response to {self} within {NODE_TIME_OUT} seconds" + f"No device response to {self} within {NODE_TIME_OUT} seconds" ) ) From 44eaf594ac45e1d6eaaaeafd3cac4abd85300bfb Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:05:47 +0200 Subject: [PATCH 352/979] Add extra info message --- plugwise_usb/connection/receiver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 22a544bbe..142e339b6 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -375,6 +375,8 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons ) return node_response.retries += 1 + if node_response.retries > 2: + _LOGGER.info("No subscription for %s, retry later", node_response) self._delayed_processing_tasks[node_response.seq_id] = self._loop.call_later( 0.1 * node_response.retries, self._put_message_in_receiver_queue, From 94d09fda8d2e762acc37ce9925eba9d95df79dfc Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:15:28 +0200 Subject: [PATCH 353/979] Remove obsolete else --- plugwise_usb/nodes/circle.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d02d8047f..f1c370dd7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -551,12 +551,11 @@ async def _energy_log_record_update_state( ) return True return False - else: - _LOGGER.debug( - "No existing energy collection log cached for %s", - self.mac - ) - self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) + _LOGGER.debug( + "No existing energy collection log cached for %s", + self.mac + ) + self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) return True async def switch_relay(self, state: bool) -> bool | None: From 50e8e0bf888035194e749a85852647e40fe3ade3 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:37:47 +0200 Subject: [PATCH 354/979] Sync pyproject.toml to main branch --- pyproject.toml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b17963e6e..347bd83f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,11 @@ authors = [ { name = "Plugwise device owners"} ] maintainers = [ + { name = "arnoutd_77" }, { name = "bouwew"}, { name = "brefra"}, - { name = "CoMPaTech" } + { name = "CoMPaTech" }, + { name = "dirixmjm" } ] requires-python = ">=3.13.0" dependencies = [ @@ -50,7 +52,6 @@ include = ["plugwise*"] [tool.black] target-version = ["py313"] exclude = 'generated' -line-length = 79 [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings @@ -188,7 +189,7 @@ norecursedirs = [ [tool.mypy] python_version = "3.13" show_error_codes = true -follow_imports = "skip" +follow_imports = "silent" ignore_missing_imports = true strict_equality = true warn_incomplete_stub = true @@ -206,9 +207,7 @@ no_implicit_optional = true strict = true warn_return_any = true warn_unreachable = true -exclude = [ - "tests/test_usb.py" -] +exclude = [] [tool.coverage.run] source = [ "plugwise" ] @@ -217,16 +216,16 @@ omit= [ "setup.py", ] - [tool.ruff] target-version = "py313" -select = [ +lint.select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B904", # Use raise from err or None to specify exception cause "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings @@ -287,7 +286,7 @@ select = [ "W", # pycodestyle ] -ignore = [ +lint.ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line @@ -311,7 +310,7 @@ ignore = [ exclude = [] -[tool.ruff.flake8-import-conventions.extend-aliases] +[tool.ruff.lint.flake8-import-conventions.extend-aliases] voluptuous = "vol" "homeassistant.helpers.area_registry" = "ar" "homeassistant.helpers.config_validation" = "cv" @@ -319,16 +318,16 @@ voluptuous = "vol" "homeassistant.helpers.entity_registry" = "er" "homeassistant.helpers.issue_registry" = "ir" -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] max-complexity = 25 -[tool.ruff.flake8-tidy-imports.banned-api] +[tool.ruff.lint.flake8-tidy-imports.banned-api] "pytz".msg = "use zoneinfo instead" -[tool.ruff.isort] +[tool.ruff.lint.isort] force-sort-within-sections = true section-order = ["future", "standard-library", "first-party", "third-party", "local-folder"] known-third-party = [ From 6e907b48e640f50ae840032126fccc008a54c0b2 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:38:10 +0200 Subject: [PATCH 355/979] Fix logging parameters --- plugwise_usb/messages/requests.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fc93b507b..2f705962e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -167,7 +167,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) ) else: - _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, self._response, NODE_TIME_OUT) + _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, NODE_TIME_OUT) self._response_future.set_exception( NodeTimeout( f"No device response to {self} within {NODE_TIME_OUT} seconds" @@ -216,7 +216,6 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: return if self._seq_id is None or self._seq_id != stick_response.seq_id: return - _LOGGER.warning("%s for request %s id %d", stick_response, self, self._id) if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) elif stick_response.ack_id == StickResponseType.FAILED: @@ -231,11 +230,10 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: pass else: _LOGGER.debug( - "Unknown StickResponseType %s at %s for request %s id %d", + "Unknown StickResponseType %s at %s for request %s", str(stick_response.ack_id), stick_response, - self, - self._id + self ) @property From 339a47806a068d7fc668bbb808d40e8a0edcfd35 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:43:55 +0200 Subject: [PATCH 356/979] Raise for unsupported version --- plugwise_usb/messages/requests.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 2f705962e..1cdca2433 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -534,17 +534,17 @@ def __init__( super().__init__(b"0016", mac) self._reply_identifier = b"0000" self.priority = Priority.HIGH - if protocol_version == 1.0: - pass + if protocol_version < 2.0: # FIXME: Define "absoluteHour" variable - elif protocol_version >= 2.0: - passed_days = dt.day - 1 - month_minutes = ( - (passed_days * DAY_IN_MINUTES) - + (dt.hour * HOUR_IN_MINUTES) - + dt.minute - ) - this_date = DateTime(dt.year, dt.month, month_minutes) + raise MessageError("Unsupported version of CircleClockSetRequest") + + passed_days = dt.day - 1 + month_minutes = ( + (passed_days * DAY_IN_MINUTES) + + (dt.hour * HOUR_IN_MINUTES) + + dt.minute + ) + this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) if reset: From dfacab7d0d95197a3d226e00b22f3d84151a140a Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 23 Aug 2024 19:54:01 +0200 Subject: [PATCH 357/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 347bd83f8..d61ea260a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a11" +version = "v0.40.0a12" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 26ea144d68b84fc7a4a1066d929c39ba2814a33b Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 10:35:27 +0200 Subject: [PATCH 358/979] Remove used constants --- plugwise_usb/constants.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 98350716c..b47c3a412 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -31,9 +31,7 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_ACCEPT_TIME_OUT: Final = 6 # Stick accept respond. STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. -QUEUE_TIME_OUT: Final = 45 # Total seconds to wait for queue NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. MAX_RETRIES: Final = 3 From a915b4acf980806def28074c0f9c5a1dc49fdf35 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 10:35:51 +0200 Subject: [PATCH 359/979] Reduce stick timeout duration --- plugwise_usb/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index b47c3a412..25e19c7dc 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -31,7 +31,7 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 15 # Stick responds with timeout messages after 10s. +STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. MAX_RETRIES: Final = 3 From 74e9dd79189b7a0dbc640ff5dbcea9b49fbc634c Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 10:38:33 +0200 Subject: [PATCH 360/979] Apply priority to response messages too --- plugwise_usb/messages/__init__.py | 42 ++++++++++++++++++++++++++++ plugwise_usb/messages/requests.py | 45 +----------------------------- plugwise_usb/messages/responses.py | 5 +++- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 1a933ff04..2c4bd40c6 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -2,15 +2,25 @@ from __future__ import annotations +from enum import Enum from typing import Any from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..helpers.util import crc_fun +class Priority(int, Enum): + """Message priority levels for USB-stick message requests.""" + + CANCEL = 0 + HIGH = 1 + MEDIUM = 2 + LOW = 3 class PlugwiseMessage: """Plugwise message base class.""" + priority: Priority = Priority.MEDIUM + def __init__(self, identifier: bytes) -> None: """Initialize a plugwise message.""" self._identifier = identifier @@ -59,3 +69,35 @@ def serialize(self) -> bytes: def calculate_checksum(data: bytes) -> bytes: """Calculate crc checksum.""" return bytes("%04X" % crc_fun(data), UTF8) + + def __gt__(self, other: PlugwiseMessage) -> bool: + """Greater than.""" + if self.priority.value == other.priority.value: + return self.timestamp > other.timestamp + if self.priority.value < other.priority.value: + return True + return False + + def __lt__(self, other: PlugwiseMessage) -> bool: + """Less than.""" + if self.priority.value == other.priority.value: + return self.timestamp < other.timestamp + if self.priority.value > other.priority.value: + return True + return False + + def __ge__(self, other: PlugwiseMessage) -> bool: + """Greater than or equal.""" + if self.priority.value == other.priority.value: + return self.timestamp >= other.timestamp + if self.priority.value < other.priority.value: + return True + return False + + def __le__(self, other: PlugwiseMessage) -> bool: + """Less than or equal.""" + if self.priority.value == other.priority.value: + return self.timestamp <= other.timestamp + if self.priority.value > other.priority.value: + return True + return False diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 1cdca2433..7678c76c2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -5,7 +5,6 @@ from collections.abc import Callable from copy import copy from datetime import UTC, datetime -from enum import Enum import logging from ..constants import ( @@ -19,7 +18,7 @@ ) from ..exceptions import MessageError, NodeError, NodeTimeout, StickError, StickTimeout from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType -from . import PlugwiseMessage +from . import PlugwiseMessage, Priority from .properties import ( DateTime, Int, @@ -34,20 +33,10 @@ _LOGGER = logging.getLogger(__name__) -class Priority(int, Enum): - """Message priority levels for USB-stick message requests.""" - - CANCEL = 0 - HIGH = 1 - MEDIUM = 2 - LOW = 3 - - class PlugwiseRequest(PlugwiseMessage): """Base class for request messages to be send from by USB-Stick.""" arguments: list = [] - priority: Priority = Priority.MEDIUM def __init__( self, @@ -260,38 +249,6 @@ def add_send_attempt(self): """Increase the number of retries.""" self._send_counter += 1 - def __gt__(self, other: PlugwiseRequest) -> bool: - """Greater than.""" - if self.priority.value == other.priority.value: - return self.timestamp > other.timestamp - if self.priority.value < other.priority.value: - return True - return False - - def __lt__(self, other: PlugwiseRequest) -> bool: - """Less than.""" - if self.priority.value == other.priority.value: - return self.timestamp < other.timestamp - if self.priority.value > other.priority.value: - return True - return False - - def __ge__(self, other: PlugwiseRequest) -> bool: - """Greater than or equal.""" - if self.priority.value == other.priority.value: - return self.timestamp >= other.timestamp - if self.priority.value < other.priority.value: - return True - return False - - def __le__(self, other: PlugwiseRequest) -> bool: - """Less than or equal.""" - if self.priority.value == other.priority.value: - return self.timestamp <= other.timestamp - if self.priority.value > other.priority.value: - return True - return False - class StickNetworkInfoRequest(PlugwiseRequest): """Request network information. diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 96273b6df..06bac3152 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -8,7 +8,7 @@ from ..api import NodeType from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 from ..exceptions import MessageError -from . import PlugwiseMessage +from . import PlugwiseMessage, Priority from .properties import ( BaseType, DateTime, @@ -227,6 +227,7 @@ class StickResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize StickResponse message object.""" super().__init__(b"0000", decode_ack=True, decode_mac=False) + self.priority = Priority.HIGH def __repr__(self) -> str: """Convert request into writable str.""" @@ -787,6 +788,7 @@ def __init__(self) -> None: super().__init__(NODE_AWAKE_RESPONSE_ID) self._awake_type = Int(0, 2, False) self._params += [self._awake_type] + self.priority = Priority.HIGH @property def awake_type(self) -> NodeAwakeResponseType: @@ -863,6 +865,7 @@ def __init__(self) -> None: super().__init__(b"0100") self._node_ack_type = BaseType(0, length=4) self._params += [self._node_ack_type] + self.priority = Priority.HIGH @property def node_ack_type(self) -> NodeAckResponseType: From 1a459e81cd9c2cbd384aad310bcd48cf7a8f5aab Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 10:39:07 +0200 Subject: [PATCH 361/979] Improve logging --- plugwise_usb/connection/queue.py | 4 ++-- plugwise_usb/connection/sender.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index d503dc6f7..135375eba 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -135,11 +135,11 @@ async def _send_queue_worker(self) -> None: _LOGGER.debug("Send_queue_worker started") while self._running: request = await self._submit_queue.get() - _LOGGER.debug("Send from queue %s", request) + _LOGGER.debug("Send from send queue %s", request) if request.priority == Priority.CANCEL: self._submit_queue.task_done() return await self._stick.write_to_stick(request) self._submit_queue.task_done() _LOGGER.debug("Sent from queue %s", request) - _LOGGER.debug("Send_queue_worker finished") + _LOGGER.debug("Send_queue_worker stopped") diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index af02472dd..a7b9691a3 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -68,7 +68,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: # Write message to serial port buffer serialized_data = request.serialize() - _LOGGER.debug("Write %s to port: %s", request, serialized_data) + _LOGGER.debug("Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) request.start_response_timeout() From 7ed9aa6a229d71479f29f893605b9e96b471daf7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:00:35 +0200 Subject: [PATCH 362/979] Create new response message objects on demand to prevent reuse of objects --- plugwise_usb/messages/responses.py | 70 +++++++++++++++++++----------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 06bac3152..b2a053e68 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -909,31 +909,6 @@ def __init__(self) -> None: self._params += [self.is_get, self.relay] -ID_TO_MESSAGE = { - b"0002": StickNetworkInfoResponse(), - b"0003": NodeSpecificResponse(), - b"0005": CirclePlusConnectResponse(), - NODE_JOIN_ID: NodeJoinAvailableResponse(), - b"000E": NodePingResponse(), - b"0010": NodeImageValidationResponse(), - b"0011": StickInitResponse(), - b"0013": CirclePowerUsageResponse(), - b"0015": CircleLogDataResponse(), - b"0019": CirclePlusScanResponse(), - b"001D": NodeRemoveResponse(), - b"0024": NodeInfoResponse(), - b"0027": EnergyCalibrationResponse(), - b"003A": CirclePlusRealTimeClockResponse(), - b"003F": CircleClockResponse(), - b"0049": CircleEnergyLogsResponse(), - NODE_SWITCH_GROUP_ID: NodeSwitchGroupResponse(), - b"0060": NodeFeaturesResponse(), - b"0100": NodeAckResponse(), - SENSE_REPORT_ID: SenseReportResponse(), - b"0139": CircleRelayInitStateResponse(), -} - - def get_message_object( identifier: bytes, length: int, seq_id: bytes ) -> PlugwiseResponse | None: @@ -956,4 +931,47 @@ def get_message_object( if length == 36: return NodeResponse() return None - return ID_TO_MESSAGE.get(identifier, None) + + # Regular response ID's + if identifier == b"0002": + return StickNetworkInfoResponse() + if identifier == b"0003": + return NodeSpecificResponse() + if identifier == b"0005": + return CirclePlusConnectResponse() + if identifier == NODE_JOIN_ID: + return NodeJoinAvailableResponse() + if identifier == b"000E": + return NodePingResponse() + if identifier == b"0010": + return NodeImageValidationResponse() + if identifier == b"0011": + return StickInitResponse() + if identifier == b"0013": + return CirclePowerUsageResponse() + if identifier == b"0015": + return CircleLogDataResponse() + if identifier == b"0019": + return CirclePlusScanResponse() + if identifier == b"001D": + return NodeRemoveResponse() + if identifier == b"0024": + return NodeInfoResponse() + if identifier == b"0027": + return EnergyCalibrationResponse() + if identifier == b"003A": + return CirclePlusRealTimeClockResponse() + if identifier == b"003F": + return CircleClockResponse() + if identifier == b"0049": + return CircleEnergyLogsResponse() + if identifier == NODE_SWITCH_GROUP_ID: + return NodeSwitchGroupResponse() + if identifier == b"0060": + return NodeFeaturesResponse() + if identifier == b"0100": + return NodeAckResponse() + if identifier == SENSE_REPORT_ID: + return SenseReportResponse() + if identifier == b"0139": + return CircleRelayInitStateResponse() From 605f068ce67a5b5ca1c67a5ce1af74db857f8a2d Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:02:00 +0200 Subject: [PATCH 363/979] Improve logging --- plugwise_usb/connection/receiver.py | 7 ++++--- plugwise_usb/connection/sender.py | 4 ++-- plugwise_usb/messages/requests.py | 10 ++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 142e339b6..12ea459e4 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -222,7 +222,7 @@ async def _receive_queue_worker(self): _LOGGER.debug("Receive_queue_worker started") while self.is_connected: response: PlugwiseResponse | None = await self._receive_queue.get() - _LOGGER.debug("Process from queue: %s", response) + _LOGGER.debug("Process from receive queue: %s", response) if isinstance(response, StickResponse): _LOGGER.debug("Received %s", response) await self._notify_stick_response_subscribers(response) @@ -232,7 +232,7 @@ async def _receive_queue_worker(self): else: await self._notify_node_response_subscribers(response) self._receive_queue.task_done() - _LOGGER.debug("Receive_queue_worker finished") + _LOGGER.debug("Receive_queue_worker stopped") def _reset_buffer(self, new_buffer: bytes) -> None: if new_buffer[:2] == MESSAGE_FOOTER: @@ -342,7 +342,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons notify_tasks.append(callback(node_response)) if len(notify_tasks) > 0: - _LOGGER.debug("Notify node response subscribers (%s) about %s", len(notify_tasks), node_response) + _LOGGER.info("Received %s", node_response) if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) if node_response.seq_id in self._delayed_processing_tasks: @@ -351,6 +351,7 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] # execute callbacks + _LOGGER.debug("Notify node response subscribers (%s) about %s", len(notify_tasks), node_response) task_result = await gather(*notify_tasks) # Log execution result for special cases diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index a7b9691a3..504b556d0 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -56,11 +56,11 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: raise StickError("USB-Stick transport missing.") await self._stick_lock.acquire() - _LOGGER.debug("Send %s", request) self._current_request = request self._stick_response: Future[bytes] = self._loop.create_future() request.add_send_attempt() + _LOGGER.debug("Send %s", request) request.subscribe_to_responses( self._receiver.subscribe_to_stick_responses, self._receiver.subscribe_to_node_responses, @@ -91,7 +91,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: else: if response.response_type == StickResponseType.ACCEPT: request.seq_id = response.seq_id - _LOGGER.info("Sent %s", request) + _LOGGER.debug("USB-Stick accepted %s with seq_id=%s", request, response.seq_id) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning("USB-Stick responded with communication timeout for %s", request) request.assign_error( diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 7678c76c2..7be8af55e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -66,8 +66,8 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempts={self._send_counter})" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempts={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempt={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempt={self._send_counter})" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" @@ -145,18 +145,20 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): return + if stick_timeout: + _LOGGER.info("USB-stick responded with time out to %s", self) + else: + _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, NODE_TIME_OUT) self._seq_id = None self._unsubscribe_from_stick() self._unsubscribe_from_node() if stick_timeout: - _LOGGER.warning("USB-stick responded with time out to %s", self) self._response_future.set_exception( StickTimeout( f"USB-stick responded with time out to {self}" ) ) else: - _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, NODE_TIME_OUT) self._response_future.set_exception( NodeTimeout( f"No device response to {self} within {NODE_TIME_OUT} seconds" From 6a0cf82c0b1099e5346ebf54413d2ae271bce83e Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:05:01 +0200 Subject: [PATCH 364/979] Wait for submit worker to finish --- plugwise_usb/connection/queue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 135375eba..bed239a64 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -74,6 +74,7 @@ async def stop(self) -> None: cancel_request = PlugwiseRequest(b"0000", None) cancel_request.priority = Priority.CANCEL await self._submit_queue.put(cancel_request) + await self._submit_worker_task self._submit_worker_task = None self._stick = None _LOGGER.debug("queue stopped") From 8613f2129a249c9142fdd20d88057c04a05fc092 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:07:28 +0200 Subject: [PATCH 365/979] Rename task --- plugwise_usb/connection/receiver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 12ea459e4..0984fe3de 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -78,7 +78,7 @@ def __init__( self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._stick_response_future: futures.Future | None = None - self._msg_processing_task: Task | None = None + self._receive_worker_task: Task | None = None self._delayed_processing_tasks: dict[bytes, TimerHandle] = {} # Subscribers self._stick_event_subscribers: dict[ @@ -185,8 +185,8 @@ def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: """Put message in queue.""" _LOGGER.debug("Add response to queue: %s", response) self._receive_queue.put_nowait(response) - if self._msg_processing_task is None or self._msg_processing_task.done(): - self._msg_processing_task = self._loop.create_task( + if self._receive_worker_task is None or self._receive_worker_task.done(): + self._receive_worker_task = self._loop.create_task( self._receive_queue_worker(), name="Receive queue worker" ) From be3450b708c2f6d4c7ab0b58719eb9afc5cb53a6 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:08:08 +0200 Subject: [PATCH 366/979] Convert receiver into priority queue --- plugwise_usb/connection/receiver.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 0984fe3de..2c201a439 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -19,7 +19,7 @@ from asyncio import ( Future, Protocol, - Queue, + PriorityQueue, Task, TimerHandle, gather, @@ -36,6 +36,7 @@ from ..api import StickEvent from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError +from ..messages import Priority from ..messages.responses import ( BROADCAST_IDS, PlugwiseResponse, @@ -73,7 +74,7 @@ def __init__( self._buffer: bytes = bytes([]) self._connection_state = False self._reduce_logging = True - self._receive_queue: Queue[PlugwiseResponse | None] = Queue() + self._receive_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} @@ -158,10 +159,10 @@ async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" for task in self._delayed_processing_tasks.values(): task.cancel() - await self._receive_queue.put(None) - if self._msg_processing_task is not None and not self._msg_processing_task.done(): - self._receive_queue.put_nowait(None) - await self._msg_processing_task + cancel_response = StickResponse() + cancel_response.priority = Priority.CANCEL + await self._receive_queue.put(cancel_response) + await self._receive_worker_task def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. @@ -221,14 +222,14 @@ async def _receive_queue_worker(self): """Process queue items.""" _LOGGER.debug("Receive_queue_worker started") while self.is_connected: - response: PlugwiseResponse | None = await self._receive_queue.get() + response: PlugwiseResponse = await self._receive_queue.get() + if response.priority == Priority.CANCEL: + self._receive_queue.task_done() + return _LOGGER.debug("Process from receive queue: %s", response) if isinstance(response, StickResponse): _LOGGER.debug("Received %s", response) await self._notify_stick_response_subscribers(response) - elif response is None: - self._receive_queue.task_done() - return else: await self._notify_node_response_subscribers(response) self._receive_queue.task_done() From 5cf6f9236e192e216a9839c46b357ad997058fd3 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:12:17 +0200 Subject: [PATCH 367/979] Update logging --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 7be8af55e..55c8846f9 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -148,7 +148,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: if stick_timeout: _LOGGER.info("USB-stick responded with time out to %s", self) else: - _LOGGER.warning("No response received for %s within timeout (%s seconds)", self, NODE_TIME_OUT) + _LOGGER.info("No response received for %s within %s seconds", self, NODE_TIME_OUT) self._seq_id = None self._unsubscribe_from_stick() self._unsubscribe_from_node() From e1a567ad79780ba299f135ca4096b168e290c55e Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:14:12 +0200 Subject: [PATCH 368/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d61ea260a..4e33c3be5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a12" +version = "v0.40.0a13" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From dc9aa5de3ac7fe8b3556eb56f1d8d3f2d23691cf Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:24:39 +0200 Subject: [PATCH 369/979] Move timestamp to base message class --- plugwise_usb/messages/__init__.py | 2 ++ plugwise_usb/messages/requests.py | 3 +-- plugwise_usb/messages/responses.py | 3 --- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 2c4bd40c6..7f72a95f6 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +from datetime import UTC, datetime from typing import Any from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 @@ -28,6 +29,7 @@ def __init__(self, identifier: bytes) -> None: self._checksum: bytes | None = None self._args: list[Any] = [] self._seq_id: bytes | None = None + self.timestamp = datetime.now(UTC) @property def seq_id(self) -> bytes | None: diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 55c8846f9..f8f04850a 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -4,7 +4,7 @@ from asyncio import Future, TimerHandle, get_running_loop from collections.abc import Callable from copy import copy -from datetime import UTC, datetime +from datetime import datetime import logging from ..constants import ( @@ -50,7 +50,6 @@ def __init__( self._mac = mac self._send_counter: int = 0 self._max_retries: int = MAX_RETRIES - self.timestamp = datetime.now(UTC) self._loop = get_running_loop() self._reply_identifier: bytes = b"0000" self._response: PlugwiseResponse | None = None diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index b2a053e68..4be5cc5f2 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -96,8 +96,6 @@ class NodeAwakeResponseType(int, Enum): class PlugwiseResponse(PlugwiseMessage): """Base class for response messages received by USB-Stick.""" - timestamp: datetime | None = None - def __init__( self, identifier: bytes, @@ -139,7 +137,6 @@ def seq_id(self) -> bytes: def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Deserialize bytes to actual message properties.""" - self.timestamp = datetime.now(UTC) # Header if response[:4] != MESSAGE_HEADER: raise MessageError( From 814de8812416f848c07f2e843d5a34355d0d25d7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:28:45 +0200 Subject: [PATCH 370/979] Sort imports --- plugwise_usb/messages/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 7f72a95f6..8c542ec1d 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from enum import Enum from datetime import UTC, datetime +from enum import Enum from typing import Any from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 From ffb46c4c8d62385eed5b2310e9c08eb813e56e5f Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:28:55 +0200 Subject: [PATCH 371/979] Remove unused import --- plugwise_usb/messages/responses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 4be5cc5f2..4c81ef670 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,7 +1,7 @@ """All known response messages to be received from plugwise devices.""" from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime from enum import Enum from typing import Any, Final From e3f60957f19537666bc17297e2d7f71a09076ea7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 21:37:39 +0200 Subject: [PATCH 372/979] Correctly shutdown receiver worker task --- plugwise_usb/connection/receiver.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2c201a439..a988408d3 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -159,10 +159,12 @@ async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" for task in self._delayed_processing_tasks.values(): task.cancel() - cancel_response = StickResponse() - cancel_response.priority = Priority.CANCEL - await self._receive_queue.put(cancel_response) - await self._receive_worker_task + if self._receive_worker_task is not None and not self._receive_worker_task.done(): + cancel_response = StickResponse() + cancel_response.priority = Priority.CANCEL + await self._receive_queue.put(cancel_response) + await self._receive_worker_task + self._receive_worker_task = None def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. From 87fb2b2da5f1778e69adb1af3c465ec2a70889a0 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 23:08:08 +0200 Subject: [PATCH 373/979] Fix creating cache folder during initialization --- plugwise_usb/__init__.py | 4 ++++ plugwise_usb/network/__init__.py | 8 +++++++- plugwise_usb/network/registry.py | 19 +++++++++---------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a0b55a56e..c69672aa0 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -276,6 +276,8 @@ async def initialize(self) -> None: self._network = StickNetwork(self._controller) self._network.cache_folder = self._cache_folder self._network.cache_enabled = self._cache_enabled + if self._cache_enabled: + await self._network.initialize_cache() @raise_not_connected @raise_not_initialized @@ -285,6 +287,8 @@ async def start_network(self) -> None: self._network = StickNetwork(self._controller) self._network.cache_folder = self._cache_folder self._network.cache_enabled = self._cache_enabled + if self._cache_enabled: + await self._network.initialize_cache() await self._network.start() @raise_not_connected diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 6b607e0f1..10e927d28 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -12,7 +12,7 @@ from ..api import NodeEvent, NodeType, StickEvent from ..connection import StickController from ..constants import UTF8 -from ..exceptions import MessageError, NodeError, StickError, StickTimeout +from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout from ..messages.requests import ( CirclePlusAllowJoiningRequest, NodeInfoRequest, @@ -103,6 +103,12 @@ def cache_folder(self, cache_folder: str) -> None: for node in self._nodes.values(): node.cache_folder = cache_folder + async def initialize_cache(self) -> None: + """Initialize the cache folder.""" + if not self._cache_enabled: + raise CacheError("Unable to initialize cache, enable cache first.") + await self._register.initialize_cache() + @property def controller_active(self) -> bool: """Return True if network controller (Circle+) is discovered and active.""" diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index caccac08e..d9ddf46e9 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -9,7 +9,7 @@ from ..api import NodeType from ..constants import UTF8 -from ..exceptions import NodeError +from ..exceptions import CacheError, NodeError from ..messages.requests import CirclePlusScanRequest, NodeAddRequest, NodeRemoveRequest from ..messages.responses import ( CirclePlusScanResponse, @@ -57,19 +57,18 @@ def cache_enabled(self) -> bool: def cache_enabled(self, enable: bool = True) -> None: """Enable or disable usage of cache.""" if enable and not self._cache_enabled: - _LOGGER.debug("Cache is enabled") + _LOGGER.debug("Enable cache") self._network_cache = NetworkRegistrationCache(self._cache_folder) - self._network_cache_file_task = create_task( - self._network_cache.initialize_cache() - ) elif not enable and self._cache_enabled: - if self._network_cache is not None: - self._network_cache_file_task = create_task( - self._network_cache.delete_cache() - ) - _LOGGER.debug("Cache is disabled") + _LOGGER.debug("Disable cache") self._cache_enabled = enable + async def initialize_cache(self) -> None: + """Initialize cache""" + if not self._cache_enabled: + raise CacheError("Unable to initialize cache, enable cache first.") + await self._network_cache.initialize_cache() + @property def cache_folder(self) -> str: """Path to folder to store cached data.""" From 205f3433821bf3ea826500f646bd545fe8ed9979 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 23:09:13 +0200 Subject: [PATCH 374/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4e33c3be5..478485826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a13" +version = "v0.40.0a14" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 63db92f00f9df4d84a8d751d775eac9315ff6319 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 23:48:48 +0200 Subject: [PATCH 375/979] Do not expect the cache folder to exist try to create it instead --- plugwise_usb/helpers/cache.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 6f3a5c61c..f156a6044 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -48,8 +48,6 @@ def cache_root_directory(self, cache_root_dir: str = "") -> None: async def initialize_cache(self) -> None: """Set (and create) the plugwise cache directory to store cache file.""" if self._root_dir != "": - if not await ospath.exists(self._root_dir): - raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") cache_dir = self._root_dir else: cache_dir = self._get_writable_os_dir() From d5eafa76a5334b6c3dbcbb8adcf6ca5077ae6d8f Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 23:49:32 +0200 Subject: [PATCH 376/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 478485826..2ec8def13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a14" +version = "v0.40.0a15" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From bdfb7648e4ea3c00bc64ba73f7bd61f47a1ec7c5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 24 Aug 2024 23:58:10 +0200 Subject: [PATCH 377/979] Revert "Do not expect the cache folder to exist" This reverts commit bdce6b3ec788c1e162b05d258db6e858356cb36f. --- plugwise_usb/helpers/cache.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index f156a6044..6f3a5c61c 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -48,6 +48,8 @@ def cache_root_directory(self, cache_root_dir: str = "") -> None: async def initialize_cache(self) -> None: """Set (and create) the plugwise cache directory to store cache file.""" if self._root_dir != "": + if not await ospath.exists(self._root_dir): + raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") cache_dir = self._root_dir else: cache_dir = self._get_writable_os_dir() From 83e5f59c1f8146c7aa13e0443d7b713ca3979a62 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 00:27:49 +0200 Subject: [PATCH 378/979] Add option to create root folder for cache --- plugwise_usb/__init__.py | 4 ++-- plugwise_usb/helpers/cache.py | 4 ++-- plugwise_usb/network/__init__.py | 4 ++-- plugwise_usb/network/registry.py | 4 ++-- tests/test_usb.py | 10 ++++++++++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index c69672aa0..208a0a339 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -269,7 +269,7 @@ async def connect(self, port: str | None = None) -> None: ) @raise_not_connected - async def initialize(self) -> None: + async def initialize(self, create_root_cache_folder: bool = False) -> None: """Initialize connection to USB-Stick.""" await self._controller.initialize_stick() if self._network is None: @@ -277,7 +277,7 @@ async def initialize(self) -> None: self._network.cache_folder = self._cache_folder self._network.cache_enabled = self._cache_enabled if self._cache_enabled: - await self._network.initialize_cache() + await self._network.initialize_cache(create_root_cache_folder) @raise_not_connected @raise_not_initialized diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 6f3a5c61c..8c2ea4efa 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -45,10 +45,10 @@ def cache_root_directory(self, cache_root_dir: str = "") -> None: self._initialized = False self._root_dir = cache_root_dir - async def initialize_cache(self) -> None: + async def initialize_cache(self, create_root_folder: bool = False) -> None: """Set (and create) the plugwise cache directory to store cache file.""" if self._root_dir != "": - if not await ospath.exists(self._root_dir): + if not create_root_folder and not await ospath.exists(self._root_dir): raise CacheError(f"Unable to initialize caching. Cache folder '{self._root_dir}' does not exists.") cache_dir = self._root_dir else: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 10e927d28..3729f1861 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -103,11 +103,11 @@ def cache_folder(self, cache_folder: str) -> None: for node in self._nodes.values(): node.cache_folder = cache_folder - async def initialize_cache(self) -> None: + async def initialize_cache(self, create_root_folder: bool = False) -> None: """Initialize the cache folder.""" if not self._cache_enabled: raise CacheError("Unable to initialize cache, enable cache first.") - await self._register.initialize_cache() + await self._register.initialize_cache(create_root_folder) @property def controller_active(self) -> bool: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index d9ddf46e9..05260325f 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -63,11 +63,11 @@ def cache_enabled(self, enable: bool = True) -> None: _LOGGER.debug("Disable cache") self._cache_enabled = enable - async def initialize_cache(self) -> None: + async def initialize_cache(self, create_root_folder: bool = False) -> None: """Initialize cache""" if not self._cache_enabled: raise CacheError("Unable to initialize cache, enable cache first.") - await self._network_cache.initialize_cache() + await self._network_cache.initialize_cache(create_root_folder) @property def cache_folder(self) -> str: diff --git a/tests/test_usb.py b/tests/test_usb.py index c35ab9fd8..dfa2f59f1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1350,7 +1350,12 @@ async def aiofiles_os_remove(file) -> None: return raise pw_exceptions.CacheError("Invalid file") + async def makedirs(cache_dir, exist_ok) -> None: + if cache_dir != "non_existing_folder": + raise pw_exceptions.CacheError("wrong folder to create") + monkeypatch.setattr(pw_helpers_cache, "aiofiles_os_remove", aiofiles_os_remove) + monkeypatch.setattr(pw_helpers_cache, "makedirs", makedirs) monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) pw_cache = pw_helpers_cache.PlugwiseCache("test-file", "non_existing_folder") @@ -1358,6 +1363,11 @@ async def aiofiles_os_remove(file) -> None: assert pw_cache.cache_root_directory == "non_existing_folder" with pytest.raises(pw_exceptions.CacheError): await pw_cache.initialize_cache() + assert not pw_cache.initialized + + # test create folder + await pw_cache.initialize_cache(create_root_folder=True) + assert pw_cache.initialized # Windows pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") From 724d395bc83e41731aac9f3dac6f0e4199528e03 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 00:35:59 +0200 Subject: [PATCH 379/979] Correct test --- tests/test_usb.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index dfa2f59f1..ec327fc05 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1351,8 +1351,11 @@ async def aiofiles_os_remove(file) -> None: raise pw_exceptions.CacheError("Invalid file") async def makedirs(cache_dir, exist_ok) -> None: - if cache_dir != "non_existing_folder": - raise pw_exceptions.CacheError("wrong folder to create") + if cache_dir == "mock_folder_that_exists": + return + if cache_dir == "non_existing_folder": + return + raise pw_exceptions.CacheError("wrong folder to create") monkeypatch.setattr(pw_helpers_cache, "aiofiles_os_remove", aiofiles_os_remove) monkeypatch.setattr(pw_helpers_cache, "makedirs", makedirs) From 1945ba0e34c4d9b29beadb474c7a0aba3967bf03 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 19:41:57 +0200 Subject: [PATCH 380/979] Catch cache writing errors and log warning message --- plugwise_usb/helpers/cache.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 8c2ea4efa..80a0219f1 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -90,17 +90,23 @@ async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None if _key not in processed_keys: data_to_write.append(f"{_key}{CACHE_KEY_SEPARATOR}{_value}\n") - async with aiofiles_open( - file=self._cache_file, - mode="w", - encoding=UTF8, - ) as file_data: - await file_data.writelines(data_to_write) - _LOGGER.debug( - "Saved %s lines to cache file %s", - str(len(data)), - self._cache_file - ) + try: + async with aiofiles_open( + file=self._cache_file, + mode="w", + encoding=UTF8, + ) as file_data: + await file_data.writelines(data_to_write) + except OSError as exc: + _LOGGER.warning( + "%s while writing data to cache file %s", exc, str(self._cache_file) + ) + else: + _LOGGER.debug( + "Saved %s lines to cache file %s", + str(len(data)), + self._cache_file + ) async def read_cache(self) -> dict[str, str]: """Return current data from cache file.""" From d3ef0b99c7482739aebf51eb19012a33b99cccc9 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 19:45:28 +0200 Subject: [PATCH 381/979] Do not log warning message when cache file is missing --- plugwise_usb/helpers/cache.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 80a0219f1..22032edfe 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -23,6 +23,7 @@ def __init__(self, file_name: str, root_dir: str = "") -> None: """Initialize class.""" self._root_dir = root_dir self._file_name = file_name + self._file_exists: bool = False self._cache_path: str | None = None self._cache_file: str | None = None self._initialized = False @@ -56,6 +57,7 @@ async def initialize_cache(self, create_root_folder: bool = False) -> None: await makedirs(cache_dir, exist_ok=True) self._cache_path = cache_dir self._cache_file = f"{cache_dir}/{self._file_name}" + self._file_exist = await ospath.exists(self._cache_file) self._initialized = True _LOGGER.debug("Start using network cache file: %s", self._cache_file) @@ -102,6 +104,8 @@ async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None "%s while writing data to cache file %s", exc, str(self._cache_file) ) else: + if not self._file_exist: + self._file_exist = True _LOGGER.debug( "Saved %s lines to cache file %s", str(len(data)), @@ -113,6 +117,11 @@ async def read_cache(self) -> dict[str, str]: if not self._initialized: raise CacheError(f"Unable to save cache. Initialize cache file '{self._file_name}' first.") current_data: dict[str, str] = {} + if not self._file_exist: + _LOGGER.debug( + "Cache file '%s' does not exists, return empty cache data", self._cache_file + ) + return current_data try: async with aiofiles_open( file=self._cache_file, From 4f1c87b84789aeb0d224194fab01dba16399a7d7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 19:45:57 +0200 Subject: [PATCH 382/979] Add name property --- plugwise_usb/nodes/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 88ff97125..1a1855fff 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -196,6 +196,13 @@ def loaded(self) -> bool: """Return load status.""" return self._loaded + @property + def name(self) -> str: + """Return name of node.""" + if self._node_info.name is not None: + return self._node_info.name + return self._mac_in_str + @property def mac(self) -> str: """Return mac address of node.""" From 1ecaafb848c4d8850f7450967baaa1430d1f02d0 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 19:47:29 +0200 Subject: [PATCH 383/979] Improve log message --- plugwise_usb/nodes/__init__.py | 4 +- plugwise_usb/nodes/circle.py | 131 +++++++++++++++++---------------- 2 files changed, 69 insertions(+), 66 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 1a1855fff..0a6408a56 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -430,13 +430,13 @@ async def _available_update_state(self, available: bool) -> None: if self._available == available: return if available: - _LOGGER.info("Mark node %s to be available", self.mac) + _LOGGER.info("Device %s detected to be available (on-line)", self.name) self._available = True await self.publish_feature_update_to_subscribers( NodeFeature.AVAILABLE, True ) return - _LOGGER.info("Mark node %s to be NOT available", self.mac) + _LOGGER.info("Device %s detected to be not available (off-line)", self.name) self._available = False await self.publish_feature_update_to_subscribers( NodeFeature.AVAILABLE, False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f1c370dd7..ba227ced1 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -132,16 +132,16 @@ def relay_init(self, state: bool) -> None: async def calibration_update(self) -> bool: """Retrieve and update calibration settings. Returns True if successful.""" _LOGGER.debug( - "Start updating energy calibration for node %s", - self._node_info.mac, + "Start updating energy calibration for %s", + self._mac_in_str, ) calibration_response: EnergyCalibrationResponse | None = ( await self._send(EnergyCalibrationRequest(self._mac_in_bytes)) ) if calibration_response is None: _LOGGER.warning( - "Updating energy calibration for node %s failed", - self._node_info.mac, + "Retrieving energy calibration information for %s failed", + self.name, ) await self._available_update_state(False) return False @@ -153,8 +153,8 @@ async def calibration_update(self) -> bool: calibration_response.off_tot, ) _LOGGER.debug( - "Updating energy calibration for node %s succeeded", - self._node_info.mac, + "Updating energy calibration for %s succeeded", + self._mac_in_str, ) return True @@ -182,13 +182,13 @@ async def _calibration_load_from_cache(self) -> bool: ) if result: _LOGGER.debug( - "Restore calibration settings from cache for node %s", - self.mac + "Restore calibration settings from cache for %s was successful", + self._mac_in_str ) return True _LOGGER.info( - "Failed to restore calibration settings from cache for node %s", - self.mac + "Failed to restore calibration settings from cache for %s", + self.name ) return False @@ -239,7 +239,7 @@ async def power_update(self) -> PowerStatistics | None: if response is None or response.timestamp is None: _LOGGER.debug( "No response for async_power_update() for %s", - self.mac + self._mac_in_str ) await self._available_update_state(False) return None @@ -275,15 +275,17 @@ async def energy_update( ) -> EnergyStatistics | None: """Return updated energy usage statistics.""" if self._current_log_address is None: - _LOGGER.info( + _LOGGER.debug( "Unable to update energy logs for node %s because last_log_address is unknown.", - self._node_info.mac, + self._mac_in_str, ) if await self.node_info_update() is None: + _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) return None # request node info update every 30 minutes. elif not self.skip_update(self._node_info, 1800): if await self.node_info_update() is None: + _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) return None # Always request last energy log records at initial startup @@ -293,13 +295,13 @@ async def energy_update( if self._energy_counters.log_rollover: if await self.node_info_update() is None: _LOGGER.debug( - "async_energy_update | %s | Log rollover | node_info_update failed", self._node_info.mac, + "async_energy_update | %s | Log rollover | node_info_update failed", self._mac_in_str, ) return None if not await self.energy_log_update(self._current_log_address): _LOGGER.debug( - "async_energy_update | %s | Log rollover | energy_log_update failed", self._node_info.mac, + "async_energy_update | %s | Log rollover | energy_log_update failed", self._mac_in_str, ) return None @@ -310,10 +312,10 @@ async def energy_update( if not await self.energy_log_update(_prev_log_address): _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", - self._node_info.mac, + self._mac_in_str, _prev_log_address, ) - return + return None if ( missing_addresses := self._energy_counters.log_addresses_missing @@ -322,7 +324,7 @@ async def energy_update( await self.power_update() _LOGGER.debug( "async_energy_update for %s | no missing log records", - self.mac, + self._mac_in_str, ) return self._energy_counters.energy_statistics if len(missing_addresses) == 1: @@ -330,7 +332,7 @@ async def energy_update( await self.power_update() _LOGGER.debug( "async_energy_update for %s | single energy log is missing | %s", - self.mac, + self._mac_in_str, missing_addresses, ) return self._energy_counters.energy_statistics @@ -342,14 +344,15 @@ async def energy_update( ): _LOGGER.debug( "Create task to update energy logs for node %s", - self._node_info.mac, + self._mac_in_str, ) self._retrieve_energy_logs_task = create_task(self.get_missing_energy_logs()) else: _LOGGER.debug( "Skip creating task to update energy logs for node %s", - self._node_info.mac, + self._mac_in_str, ) + _LOGGER.warning("Unable to return energy statistics for %s yet, still retrieving required data...", self.name) return None async def get_missing_energy_logs(self) -> None: @@ -359,7 +362,7 @@ async def get_missing_energy_logs(self) -> None: if self._energy_counters.log_addresses_missing is None: _LOGGER.debug( "Start with initial energy request for the last 10 log addresses for node %s.", - self._node_info.mac, + self._mac_in_str, ) total_addresses = 11 log_address = self._current_log_address @@ -369,8 +372,8 @@ async def get_missing_energy_logs(self) -> None: log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 - if not await gather(*log_update_tasks): - _LOGGER.warning( + if not all(await gather(*log_update_tasks)): + _LOGGER.info( "Failed to request one or more update energy log for %s", self._mac_in_str, ) @@ -379,14 +382,14 @@ async def get_missing_energy_logs(self) -> None: await self._energy_log_records_save_to_cache() return if self._energy_counters.log_addresses_missing is not None: - _LOGGER.info("Task created to get missing logs of %s", self._mac_in_str) + _LOGGER.debug("Task created to get missing logs of %s", self._mac_in_str) if ( missing_addresses := self._energy_counters.log_addresses_missing ) is not None: - _LOGGER.info( + _LOGGER.debug( "Task Request %s missing energy logs for node %s | %s", str(len(missing_addresses)), - self._node_info.mac, + self._mac_in_str, str(missing_addresses), ) @@ -403,7 +406,7 @@ async def energy_log_update(self, address: int) -> bool: _LOGGER.info( "Request of energy log at address %s for node %s", str(address), - self._mac_in_str, + self.name, ) request = CircleEnergyLogsRequest(self._mac_in_bytes, address) response: CircleEnergyLogsResponse | None = None @@ -447,7 +450,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: if self._get_cache(CACHE_ENERGY_COLLECTION) is None: _LOGGER.info( "Failed to restore energy log records from cache for node %s", - self.mac + self.name ) return False restored_logs: dict[int, list[int]] = {} @@ -491,7 +494,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: _LOGGER.debug( "Create task to request energy log %s for %s", address, - self._mac_in_bytes + self._mac_in_str ) create_task(self.energy_log_update(address)) return False @@ -544,7 +547,7 @@ async def _energy_log_record_update_state( "Add logrecord (%s, %s) to log cache of %s", str(address), str(slot), - self.mac + self._mac_in_str ) self._set_cache( CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record @@ -553,7 +556,7 @@ async def _energy_log_record_update_state( return False _LOGGER.debug( "No existing energy collection log cached for %s", - self.mac + self._mac_in_str ) self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) return True @@ -572,8 +575,8 @@ async def switch_relay(self, state: bool) -> bool | None: or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED ): _LOGGER.warning( - "Request to switch relay for node %s failed", - self._node_info.mac, + "Request to switch relay for %s failed", + self.name, ) return None @@ -590,7 +593,7 @@ async def switch_relay(self, state: bool) -> bool | None: _LOGGER.warning( "Unexpected NodeResponseType %s response for CircleRelaySwitchRequest at node %s...", str(response.ack_id), - self.mac, + self.name, ) return None @@ -602,16 +605,16 @@ async def _relay_load_from_cache(self) -> bool: if (cached_relay_data := self._get_cache(CACHE_RELAY)) is not None: _LOGGER.debug( "Restore relay state cache for node %s", - self.mac + self._mac_in_str ) relay_state = False if cached_relay_data == "True": relay_state = True await self._relay_update_state(relay_state) return True - _LOGGER.info( + _LOGGER.debug( "Failed to restore relay state from cache for node %s, try to request node info...", - self.mac + self._mac_in_str ) if await self.node_info_update() is None: return False @@ -661,7 +664,7 @@ async def clock_synchronize(self) -> bool: ): _LOGGER.info( "Reset clock of node %s because time has drifted %s sec", - self._node_info.mac, + self._mac_in_str, str(clock_offset.seconds), ) node_response: NodeResponse | None = await self._send( @@ -677,7 +680,7 @@ async def clock_synchronize(self) -> bool: ): _LOGGER.warning( "Failed to (re)set the internal clock of node %s", - self._node_info.mac, + self._mac_in_str, ) return False return True @@ -688,7 +691,7 @@ async def load(self) -> bool: return True if self._cache_enabled: _LOGGER.debug( - "Load Circle node %s from cache", self._node_info.mac + "Load Circle node %s from cache", self._mac_in_str ) if await self._load_from_cache(): self._loaded = True @@ -704,26 +707,26 @@ async def load(self) -> bool: if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) return True - _LOGGER.info( + _LOGGER.debug( "Load Circle node %s from cache failed", - self._node_info.mac, + self._mac_in_str, ) else: - _LOGGER.debug("Load Circle node %s", self._node_info.mac) + _LOGGER.debug("Load Circle node %s", self._mac_in_str) # Check if node is online if not self._available and not await self.is_online(): - _LOGGER.info( + _LOGGER.debug( "Failed to load Circle node %s because it is not online", - self._node_info.mac + self._mac_in_str ) return False # Get node info if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: - _LOGGER.info( + _LOGGER.debug( "Failed to load Circle node %s because it is not responding to information request", - self._node_info.mac + self._mac_in_str ) return False self._loaded = True @@ -749,20 +752,20 @@ async def _load_from_cache(self) -> bool: if not await self._calibration_load_from_cache(): _LOGGER.debug( "Node %s failed to load calibration from cache", - self.mac + self._mac_in_str ) return False # Energy collection if await self._energy_log_records_load_from_cache(): _LOGGER.debug( "Node %s failed to load energy_log_records from cache", - self.mac, + self._mac_in_str, ) # Relay if await self._relay_load_from_cache(): _LOGGER.debug( "Node %s failed to load relay state from cache", - self.mac, + self._mac_in_str, ) # Relay init config if feature is enabled if ( @@ -771,7 +774,7 @@ async def _load_from_cache(self) -> bool: if await self._relay_init_load_from_cache(): _LOGGER.debug( "Node %s failed to load relay_init state from cache", - self.mac, + self._mac_in_str, ) return True @@ -779,26 +782,26 @@ async def _load_from_cache(self) -> bool: async def initialize(self) -> bool: """Initialize node.""" if self._initialized: - _LOGGER.debug("Already initialized node %s", self.mac) + _LOGGER.debug("Already initialized node %s", self._mac_in_str) return True self._initialized = True if not self._calibration and not await self.calibration_update(): _LOGGER.debug( "Failed to initialized node %s, no calibration", - self.mac + self._mac_in_str ) self._initialized = False return False if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: _LOGGER.debug( "Failed to retrieve node info for %s", - self.mac + self._mac_in_str ) if not await self.clock_synchronize(): _LOGGER.debug( "Failed to initialized node %s, failed clock sync", - self.mac + self._mac_in_str ) self._initialized = False return False @@ -811,7 +814,7 @@ async def initialize(self) -> bool: else: _LOGGER.debug( "Failed to initialized node %s, relay init", - self.mac + self._mac_in_str ) self._initialized = False return False @@ -842,7 +845,7 @@ async def node_info_update( "Rollover log address from %s into %s for node %s", self._current_log_address, node_info.current_logaddress_pointer, - self.mac + self._mac_in_str ) if self._current_log_address != node_info.current_logaddress_pointer: self._current_log_address = node_info.current_logaddress_pointer @@ -970,7 +973,7 @@ def _calc_watts( _LOGGER.debug( "Correct negative power %s to 0.0 for %s", str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), - self.mac + self._mac_in_str ) return 0.0 @@ -985,7 +988,7 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: if pulses == -1: _LOGGER.debug( "Power pulse counter for node %s of value of -1, corrected to 0", - self._node_info.mac, + self._mac_in_str, ) return 0.0 if pulses != 0: @@ -1006,7 +1009,7 @@ async def get_state( if not await self.is_online(): _LOGGER.debug( "Node %s did not respond, unable to update state", - self.mac + self._mac_in_str ) for feature in features: states[feature] = None @@ -1022,14 +1025,14 @@ async def get_state( states[feature] = await self.energy_update() _LOGGER.debug( "async_get_state %s - energy: %s", - self.mac, + self._mac_in_str, states[feature], ) elif feature == NodeFeature.RELAY: states[feature] = self._relay_state _LOGGER.debug( "async_get_state %s - relay: %s", - self.mac, + self._mac_in_str, states[feature], ) elif feature == NodeFeature.RELAY_INIT: @@ -1038,7 +1041,7 @@ async def get_state( states[feature] = await self.power_update() _LOGGER.debug( "async_get_state %s - power: %s", - self.mac, + self._mac_in_str, states[feature], ) else: From ed63e867f397bdc5e03acef95f3e51e4bb9509e1 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 19:47:48 +0200 Subject: [PATCH 384/979] Improve error message --- plugwise_usb/nodes/circle.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index ba227ced1..94967f44a 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -114,8 +114,7 @@ def relay_init( """Request the relay states at startup/power-up.""" if NodeFeature.RELAY_INIT not in self._features: raise NodeError( - "Initial state of relay is not supported for device " - + self.mac + f"Initial state of relay is not supported for device {self.name}" ) return self._relay_init_state @@ -124,8 +123,8 @@ def relay_init(self, state: bool) -> None: """Request to configure relay states at startup/power-up.""" if NodeFeature.RELAY_INIT not in self._features: raise NodeError( - "Configuring initial state of relay" - + f"is not supported for device {self.mac}" + "Configuring initial state of relay " + + f"is not supported for device {self.name}" ) create_task(self._relay_init_set(state)) @@ -885,7 +884,7 @@ async def _relay_init_get(self) -> bool | None: if NodeFeature.RELAY_INIT not in self._features: raise NodeError( "Retrieval of initial state of relay is not " - + f"supported for device {self.mac}" + + f"supported for device {self.name}" ) response: CircleRelayInitStateResponse | None = await self._send( CircleRelayInitStateRequest(self._mac_in_bytes, False, False), @@ -900,7 +899,7 @@ async def _relay_init_set(self, state: bool) -> bool | None: if NodeFeature.RELAY_INIT not in self._features: raise NodeError( "Configuring of initial state of relay is not" - + f"supported for device {self.mac}" + + f"supported for device {self.name}" ) response: CircleRelayInitStateResponse | None = await self._send( CircleRelayInitStateRequest(self._mac_in_bytes, True, state), @@ -1019,7 +1018,7 @@ async def get_state( for feature in features: if feature not in self._features: raise NodeError( - f"Update of feature '{feature}' is not supported for {self.mac}" + f"Update of feature '{feature}' is not supported for {self.name}" ) if feature == NodeFeature.ENERGY: states[feature] = await self.energy_update() From ace117e8d6493e0873d06c9fcf916c2306c64286 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 20:11:00 +0200 Subject: [PATCH 385/979] Add some additional tests --- tests/test_usb.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index ec327fc05..6e4bf3f38 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -784,9 +784,15 @@ async def fake_get_missing_energy_logs(address) -> None: await stick.initialize() await stick.discover_nodes(load=False) + # Check calibration in unloaded state + assert not await stick.nodes["0098765432101234"].calibrated + # Manually load node assert await stick.nodes["0098765432101234"].load() + # Check calibration in loaded state + assert await stick.nodes["0098765432101234"].calibrated + # Test power state without request assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) pu = await stick.nodes["0098765432101234"].power_update() @@ -1376,6 +1382,12 @@ async def makedirs(cache_dir, exist_ok) -> None: pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") pw_cache.cache_root_directory = "mock_folder_that_exists" assert not pw_cache.initialized + + # Test raising CacheError when cache is not initialized yet + with pytest.raises(pw_exceptions.CacheError): + await pw_cache.read_cache() + await pw_cache.write_cache({"key1": "value z"}) + await pw_cache.initialize_cache() assert pw_cache.initialized @@ -1597,6 +1609,7 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.initialize() await stick.discover_nodes(load=True) + assert stick.nades["0098765432101234"].name == "Circle+ 01234" assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" @@ -1604,6 +1617,10 @@ async def test_node_discovery_and_load(self, monkeypatch): assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.battery_powered + # Check an unsupported state feature raises an error + with pytest.raises(pw_exceptions.NodeError): + missing_feature_state = await stick.nodes["0098765432101234"].get_state((pw_api.NodeFeature.MOTION, )) + # Get state get_state_timestamp = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["0098765432101234"].get_state((pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO)) From 02d0e2e868dd41f38d6a7e2cd71eb34737902197 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 20:15:00 +0200 Subject: [PATCH 386/979] Fix local variable name --- plugwise_usb/helpers/cache.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 22032edfe..89a5055d4 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -23,7 +23,7 @@ def __init__(self, file_name: str, root_dir: str = "") -> None: """Initialize class.""" self._root_dir = root_dir self._file_name = file_name - self._file_exists: bool = False + self._cache_file_exists: bool = False self._cache_path: str | None = None self._cache_file: str | None = None self._initialized = False @@ -57,7 +57,7 @@ async def initialize_cache(self, create_root_folder: bool = False) -> None: await makedirs(cache_dir, exist_ok=True) self._cache_path = cache_dir self._cache_file = f"{cache_dir}/{self._file_name}" - self._file_exist = await ospath.exists(self._cache_file) + self._cache_file_exists = await ospath.exists(self._cache_file) self._initialized = True _LOGGER.debug("Start using network cache file: %s", self._cache_file) @@ -104,8 +104,8 @@ async def write_cache(self, data: dict[str, str], rewrite: bool = False) -> None "%s while writing data to cache file %s", exc, str(self._cache_file) ) else: - if not self._file_exist: - self._file_exist = True + if not self._cache_file_exists: + self._cache_file_exists = True _LOGGER.debug( "Saved %s lines to cache file %s", str(len(data)), @@ -117,7 +117,7 @@ async def read_cache(self) -> dict[str, str]: if not self._initialized: raise CacheError(f"Unable to save cache. Initialize cache file '{self._file_name}' first.") current_data: dict[str, str] = {} - if not self._file_exist: + if not self._cache_file_exists: _LOGGER.debug( "Cache file '%s' does not exists, return empty cache data", self._cache_file ) From b89e516ded76773a6120a3cfbd5c4ce4ca446922 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 20:31:42 +0200 Subject: [PATCH 387/979] Fix tests --- tests/test_usb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 6e4bf3f38..d6421a2af 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -785,13 +785,13 @@ async def fake_get_missing_energy_logs(address) -> None: await stick.discover_nodes(load=False) # Check calibration in unloaded state - assert not await stick.nodes["0098765432101234"].calibrated + assert not stick.nodes["0098765432101234"].calibrated # Manually load node assert await stick.nodes["0098765432101234"].load() # Check calibration in loaded state - assert await stick.nodes["0098765432101234"].calibrated + assert stick.nodes["0098765432101234"].calibrated # Test power state without request assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) @@ -1523,7 +1523,7 @@ async def test_node_cache(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) - node_cache = pw_node_cache.NodeCache("0123456789ABCDEF", "mock_folder_that_exists") + node_cache = pw_node_cache.NodeCache("file_that_exists.ext", "mock_folder_that_exists") await node_cache.initialize_cache() # test with invalid data mock_read_data = [ @@ -1609,7 +1609,7 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.initialize() await stick.discover_nodes(load=True) - assert stick.nades["0098765432101234"].name == "Circle+ 01234" + assert stick.nodes["0098765432101234"].name == "Circle+ 01234" assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" From 9dcb694b084beda97c495c9408c26143f7cce3da Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 21:39:24 +0200 Subject: [PATCH 388/979] Fix tests --- tests/test_usb.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d6421a2af..02bb96641 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -178,6 +178,10 @@ async def exists(self, file_or_path: str) -> bool: """Exists folder.""" if file_or_path == "mock_folder_that_exists": return True + if file_or_path == f"mock_folder_that_exists/nodes.cache": + return True + if file_or_path == f"mock_folder_that_exists/0123456789ABCDEF.cache": + return True return file_or_path == "mock_folder_that_exists/file_that_exists.ext" async def mkdir(self, path: str) -> None: @@ -1523,7 +1527,7 @@ async def test_node_cache(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) - node_cache = pw_node_cache.NodeCache("file_that_exists.ext", "mock_folder_that_exists") + node_cache = pw_node_cache.NodeCache("0123456789ABCDEF", "mock_folder_that_exists") await node_cache.initialize_cache() # test with invalid data mock_read_data = [ From 30f4ae396e0764a414dfc565e8f462220e322538 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 21:59:44 +0200 Subject: [PATCH 389/979] Return subscription result --- plugwise_usb/nodes/scan.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 7ac60a78e..531ec43fc 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -84,15 +84,17 @@ async def unload(self) -> None: self._scan_subscription() await super().unload() - async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: + async def _switch_group(self, message: NodeSwitchGroupResponse) -> bool: """Switch group request from Scan.""" await self._available_update_state(True) if message.power_state.value == 0: # turn off => clear motion await self.motion_state_update(False, message.timestamp) + return True elif message.power_state.value == 1: # turn on => motion await self.motion_state_update(True, message.timestamp) + return True else: raise MessageError( f"Unknown power_state '{message.power_state.value}' " From c920e358f6b6fe1dbefd41ba9f123e1d62111078 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 22:00:26 +0200 Subject: [PATCH 390/979] Add tests for changing cache state and folder --- tests/test_usb.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 02bb96641..57543b244 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1680,6 +1680,16 @@ async def test_node_discovery_and_load(self, monkeypatch): ) assert state[pw_api.NodeFeature.RELAY].relay_state + # test disable cache + assert stick.cache_enabled + stick.cache_enabled = False + assert not stick.cache_enabled + + # test changing cache_folder + assert stick.cache_folder == "" + stick.cache_folder = "mock_folder_that_exists" + assert stick.cache_folder == "mock_folder_that_exists" + with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await stick.disconnect() await asyncio.sleep(1) From f4ce0213227b56cd924d555928a784021420f75b Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 22:09:31 +0200 Subject: [PATCH 391/979] Unnecessary elif after return --- plugwise_usb/nodes/scan.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 531ec43fc..5d3ee61fa 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -91,15 +91,14 @@ async def _switch_group(self, message: NodeSwitchGroupResponse) -> bool: # turn off => clear motion await self.motion_state_update(False, message.timestamp) return True - elif message.power_state.value == 1: + if message.power_state.value == 1: # turn on => motion await self.motion_state_update(True, message.timestamp) return True - else: - raise MessageError( - f"Unknown power_state '{message.power_state.value}' " - + f"received from {self.mac}" - ) + raise MessageError( + f"Unknown power_state '{message.power_state.value}' " + + f"received from {self.mac}" + ) async def motion_state_update( self, motion_state: bool, timestamp: datetime | None = None From 96f169fbd7ebd15a42c365956627500d0079590a Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 25 Aug 2024 22:16:39 +0200 Subject: [PATCH 392/979] Fix setting cache folder --- plugwise_usb/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 208a0a339..41e5d5ffa 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -76,7 +76,6 @@ def cache_folder(self, cache_folder: str) -> None: return if self._network is not None: self._network.cache_folder = cache_folder - return self._cache_folder = cache_folder @property From 25af01e546e2f88ca80352417e551ac81699edb3 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 08:56:59 +0200 Subject: [PATCH 393/979] Make cache_folder_create a local variable --- plugwise_usb/__init__.py | 3 +- plugwise_usb/network/__init__.py | 16 ++++++++-- plugwise_usb/nodes/__init__.py | 54 +++++++++----------------------- 3 files changed, 31 insertions(+), 42 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 41e5d5ffa..bdd39b14b 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -274,9 +274,10 @@ async def initialize(self, create_root_cache_folder: bool = False) -> None: if self._network is None: self._network = StickNetwork(self._controller) self._network.cache_folder = self._cache_folder + self._network.cache_folder_create = create_root_cache_folder self._network.cache_enabled = self._cache_enabled if self._cache_enabled: - await self._network.initialize_cache(create_root_cache_folder) + await self._network.initialize_cache() @raise_not_connected @raise_not_initialized diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 3729f1861..5c0a37085 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -63,6 +63,7 @@ def __init__( self._cache_folder: str = "" self._cache_enabled: bool = False + self._cache_folder_create = False self._discover: bool = False self._nodes: dict[str, PlugwiseNode] = {} @@ -103,11 +104,21 @@ def cache_folder(self, cache_folder: str) -> None: for node in self._nodes.values(): node.cache_folder = cache_folder - async def initialize_cache(self, create_root_folder: bool = False) -> None: + @property + def cache_folder_create(self) -> bool: + """Return if cache folder must be create when it does not exists.""" + return self._cache_folder_create + + @cache_folder_create.setter + def cache_folder_create(self, enable: bool = True) -> None: + """Enable or disable creation of cache folder.""" + self._cache_folder_create = enable + + async def initialize_cache(self) -> None: """Initialize the cache folder.""" if not self._cache_enabled: raise CacheError("Unable to initialize cache, enable cache first.") - await self._register.initialize_cache(create_root_folder) + await self._register.initialize_cache(self._cache_folder_create) @property def controller_active(self) -> bool: @@ -362,6 +373,7 @@ def _create_node_object( self._cache_folder, ) self._nodes[mac].cache_folder = self._cache_folder + self._nodes[mac].cache_folder_create = self._cache_folder_create self._nodes[mac].cache_enabled = True async def get_node_details( diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 0a6408a56..af741ca4d 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -65,10 +65,9 @@ def __init__( self._mac_in_bytes = bytes(mac, encoding=UTF8) self._mac_in_str = mac self._send = controller.send - self._node_cache: NodeCache | None = None self._cache_enabled: bool = False - self._cache_folder: str = "" self._cache_save_task: Task | None = None + self._node_cache = NodeCache("") # Sensors self._available: bool = False @@ -116,19 +115,22 @@ def network_address(self) -> int: @property def cache_folder(self) -> str: """Return path to cache folder.""" - return self._cache_folder + return self._node_cache.cache_root_directory @cache_folder.setter def cache_folder(self, cache_folder: str) -> None: """Set path to cache folder.""" - if cache_folder == self._cache_folder: - return - self._cache_folder = cache_folder - if self._cache_enabled: - if self._node_cache is None: - self._node_cache = NodeCache(self._cache_folder) - else: - self._node_cache.cache_root_directory = cache_folder + self._node_cache.cache_root_directory = cache_folder + + @property + def cache_folder_create(self) -> bool: + """Return if cache folder must be create when it does not exists.""" + return self._cache_folder_create + + @cache_folder_create.setter + def cache_folder_create(self, enable: bool = True) -> None: + """Enable or disable creation of cache folder.""" + self._cache_folder_create = enable @property def cache_enabled(self) -> bool: @@ -138,15 +140,6 @@ def cache_enabled(self) -> bool: @cache_enabled.setter def cache_enabled(self, enable: bool) -> None: """Enable or disable usage of cache.""" - if enable == self._cache_enabled: - return - if enable: - if self._node_cache is None: - self._node_cache = NodeCache(self.mac, self._cache_folder) - else: - self._node_cache.cache_root_directory = self._cache_folder - else: - self._node_cache = None self._cache_enabled = enable @property @@ -389,14 +382,8 @@ async def _load_cache_file(self) -> bool: self.mac, ) return False - if self._node_cache is None: - _LOGGER.warning( - "Unable to load node %s from cache because cache configuration is not loaded", - self.mac, - ) - return False if not self._node_cache.initialized: - await self._node_cache.initialize_cache() + await self._node_cache.initialize_cache(self._cache_folder_create) return await self._node_cache.restore_cache() async def clear_cache(self) -> None: @@ -623,7 +610,7 @@ async def unload(self) -> None: def _get_cache(self, setting: str) -> str | None: """Retrieve value of specified setting from cache memory.""" - if not self._cache_enabled or self._node_cache is None: + if not self._cache_enabled: return None return self._node_cache.get_state(setting) @@ -631,12 +618,6 @@ def _set_cache(self, setting: str, value: Any) -> None: """Store setting with value in cache memory.""" if not self._cache_enabled: return - if self._node_cache is None: - _LOGGER.warning( - "Failed to update '%s' in cache because cache is not initialized yet", - setting - ) - return if isinstance(value, datetime): self._node_cache.add_state( setting, @@ -652,11 +633,6 @@ async def save_cache(self, trigger_only: bool = True, full_write: bool = False) """Save current cache to cache file.""" if not self._cache_enabled or not self._loaded or not self._initialized: return - if self._node_cache is None: - _LOGGER.warning( - "Failed to save cache to disk because cache is not initialized yet" - ) - return _LOGGER.debug("Save cache file for node %s", self.mac) if self._cache_save_task is not None and not self._cache_save_task.done(): await self._cache_save_task From 4232ad4eb737cd335e57cc7232355254d3c19ff6 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 09:09:13 +0200 Subject: [PATCH 394/979] Do not save to cache file at unload when cache is disabled --- plugwise_usb/nodes/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index af741ca4d..465009778 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -604,6 +604,8 @@ async def get_state( async def unload(self) -> None: """Deactivate and unload node features.""" + if not self._cache_enabled: + return if self._cache_save_task is not None and not self._cache_save_task.done(): await self._cache_save_task await self.save_cache(trigger_only=False, full_write=True) From cd18fc5998d74191f7ffee72c76eafcfd5414f77 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 09:20:44 +0200 Subject: [PATCH 395/979] Propagate cache state change from network to each node --- plugwise_usb/network/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 5c0a37085..03cbb0525 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -89,6 +89,9 @@ def cache_enabled(self) -> bool: def cache_enabled(self, enable: bool = True) -> None: """Enable or disable usage of cache of network register.""" self._register.cache_enabled = enable + if self._cache_enabled != enable: + for node in self._nodes.values(): + node.cache_enabled = enable self._cache_enabled = enable @property From 0f1c9a006140eaaee06e99a2b564872a80d8095c Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 09:26:25 +0200 Subject: [PATCH 396/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2ec8def13..45237ddf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a15" +version = "v0.40.0a16" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 61fe1a5aec1711a6613cf48a16da7680fc9dc641 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 13:35:12 +0200 Subject: [PATCH 397/979] Add missing mac for cache file name --- plugwise_usb/nodes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 465009778..60af9b385 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -67,7 +67,7 @@ def __init__( self._send = controller.send self._cache_enabled: bool = False self._cache_save_task: Task | None = None - self._node_cache = NodeCache("") + self._node_cache = NodeCache(mac, "") # Sensors self._available: bool = False From 80cd371828883fb93259c252939b21c3c5bc2c9f Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 13:35:58 +0200 Subject: [PATCH 398/979] Add additional tests for node properties --- tests/test_usb.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 57543b244..4985afdd4 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1620,6 +1620,11 @@ async def test_node_discovery_and_load(self, monkeypatch): assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.battery_powered + assert not stick.nodes["0098765432101234"].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 # Check an unsupported state feature raises an error with pytest.raises(pw_exceptions.NodeError): From b0c402d28ac6c644b7f3baa2d6753e330f4f3f35 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 13:55:20 +0200 Subject: [PATCH 399/979] Improve log message text --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 94967f44a..87cc97da1 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -351,7 +351,7 @@ async def energy_update( "Skip creating task to update energy logs for node %s", self._mac_in_str, ) - _LOGGER.warning("Unable to return energy statistics for %s yet, still retrieving required data...", self.name) + _LOGGER.warning("Unable to return energy statistics for %s, collecting required data...", self.name) return None async def get_missing_energy_logs(self) -> None: From 81acfbc9567e2b7624120a46fb7f0bb5796a1fed Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 13:59:53 +0200 Subject: [PATCH 400/979] Optimize energy log retrieval --- plugwise_usb/nodes/circle.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 87cc97da1..01c20f1eb 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -393,9 +393,12 @@ async def get_missing_energy_logs(self) -> None: ) missing_addresses = sorted(missing_addresses, reverse=True) - for address in missing_addresses: - await self.energy_log_update(address) - await sleep(0.01) + await gather( + [ + self.energy_log_update(address) + for address in missing_addresses + ] + ) if self._cache_enabled: await self._energy_log_records_save_to_cache() From adb15a98264c9a534b1c0ef4cdce9eabc77e4ed6 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 14:03:48 +0200 Subject: [PATCH 401/979] Remove unused import --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 01c20f1eb..d154f63cf 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather, sleep +from asyncio import Task, create_task, gather from collections.abc import Callable from datetime import UTC, datetime from functools import wraps From 89290aed8df981089a076c13f967fa345374269e Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 14:04:13 +0200 Subject: [PATCH 402/979] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45237ddf4..8b0f7232a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a16" +version = "v0.40.0a17" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From d7ac035ebdd86f78ca84b892299062382991ace6 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 14:25:15 +0200 Subject: [PATCH 403/979] Add missing list expansion --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d154f63cf..41a9efc24 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -394,7 +394,7 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) await gather( - [ + *[ self.energy_log_update(address) for address in missing_addresses ] From c73f87a9286e6513cd719c0afff4a8cf81edc483 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 14:26:05 +0200 Subject: [PATCH 404/979] Bump to version v0.40.0a18 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8b0f7232a..f87bfa9fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a17" +version = "v0.40.0a18" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 16b5b142272b7040c25b52b989b502e48597507e Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 21:51:12 +0200 Subject: [PATCH 405/979] Move generic initialize code to base class --- plugwise_usb/nodes/__init__.py | 5 ++++- plugwise_usb/nodes/circle.py | 17 ++++++++--------- plugwise_usb/nodes/circle_plus.py | 23 +---------------------- plugwise_usb/nodes/scan.py | 7 +------ plugwise_usb/nodes/sed.py | 2 +- plugwise_usb/nodes/sense.py | 5 +---- plugwise_usb/nodes/switch.py | 5 +---- 7 files changed, 17 insertions(+), 47 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 60af9b385..113afdf90 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -410,7 +410,10 @@ async def _load_from_cache(self) -> bool: async def initialize(self) -> bool: """Initialize node.""" - raise NotImplementedError() + if self._initialized: + return True + self._initialized = True + return True async def _available_update_state(self, available: bool) -> None: """Update the node availability state.""" diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 41a9efc24..9ce3bcb83 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -786,8 +786,14 @@ async def initialize(self) -> bool: if self._initialized: _LOGGER.debug("Already initialized node %s", self._mac_in_str) return True - self._initialized = True + if isinstance(self, PlugwiseCircle) and not await self.clock_synchronize(): + _LOGGER.debug( + "Failed to initialized node %s, failed clock sync", + self._mac_in_str + ) + self._initialized = False + return False if not self._calibration and not await self.calibration_update(): _LOGGER.debug( "Failed to initialized node %s, no calibration", @@ -800,13 +806,6 @@ async def initialize(self) -> bool: "Failed to retrieve node info for %s", self._mac_in_str ) - if not await self.clock_synchronize(): - _LOGGER.debug( - "Failed to initialized node %s, failed clock sync", - self._mac_in_str - ) - self._initialized = False - return False if ( NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None @@ -820,7 +819,7 @@ async def initialize(self) -> bool: ) self._initialized = False return False - return True + return await super().initialize() async def node_info_update( self, node_info: NodeInfoResponse | None = None diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index c94903679..44b890d6b 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -90,31 +90,10 @@ async def initialize(self) -> bool: """Initialize node.""" if self._initialized: return True - self._initialized = True - if not self._available: - self._initialized = False - return False - if not self._calibration and not await self.calibration_update(): - self._initialized = False - return False if not await self.realtime_clock_synchronize(): self._initialized = False return False - if ( - NodeFeature.RELAY_INIT in self._features and - self._relay_init_state is None - ): - if (state := await self._relay_init_get()) is not None: - self._relay_init_state = state - else: - _LOGGER.debug( - "Failed to initialized node %s, relay init", - self.mac - ) - return False - self._initialized = True - await self._loaded_callback(NodeEvent.LOADED, self.mac) - return True + return await super().initialize() async def realtime_clock_synchronize(self) -> bool: """Synchronize realtime clock.""" diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 5d3ee61fa..2780fd3e2 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -66,17 +66,12 @@ async def initialize(self) -> bool: """Initialize Scan node.""" if self._initialized: return True - self._initialized = True - if not await super().initialize(): - self._initialized = False - return False self._scan_subscription = self._message_subscribe( self._switch_group, self._mac_in_bytes, (NODE_SWITCH_GROUP_ID,), ) - self._initialized = True - return True + return await super().initialize() async def unload(self) -> None: """Unload node.""" diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index a084bc664..2ff7b2d94 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -96,7 +96,7 @@ async def initialize(self) -> bool: self._mac_in_bytes, NODE_AWAKE_RESPONSE_ID, ) - return True + return await super().initialize() @property def maintenance_interval(self) -> int | None: diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 98f035b4c..bee2a5201 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -63,15 +63,12 @@ async def initialize(self) -> bool: """Initialize Sense node.""" if self._initialized: return True - if not await super().initialize(): - return False self._sense_subscription = self._message_subscribe( self._sense_report, self._mac_in_bytes, SENSE_REPORT_ID, ) - self._initialized = True - return True + return await super().initialize() async def unload(self) -> None: """Unload node.""" diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 0c0aadc07..d5167a0b6 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -47,16 +47,13 @@ async def initialize(self) -> bool: """Initialize Switch node.""" if self._initialized: return True - if not await super().initialize(): - return False self._switch_subscription = self._message_subscribe( b"0056", self._switch_group, self._mac_in_bytes, NODE_SWITCH_GROUP_ID, ) - self._initialized = True - return True + return await super().initialize() async def unload(self) -> None: """Unload node.""" From e43b2fcf0211e9f25bed3ecda6a49029792cd6a3 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:00:44 +0200 Subject: [PATCH 406/979] Add timestamp to indicate when initialization should be finished --- plugwise_usb/constants.py | 1 + plugwise_usb/nodes/__init__.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 25e19c7dc..762b01257 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -34,6 +34,7 @@ STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. MAX_RETRIES: Final = 3 +SUPPRESS_INITIALIZATION_WARNINGS: Final = 10 # Minutes to suppress (expected) communication warning messages after initialization # plugwise year information is offset from y2k PLUGWISE_EPOCH: Final = 2000 diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 113afdf90..e1edb2aa3 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -20,7 +20,7 @@ RelayState, ) from ..connection import StickController -from ..constants import UTF8, MotionSensitivity +from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8, MotionSensitivity from ..exceptions import NodeError from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse @@ -79,6 +79,7 @@ def __init__( self._connected: bool = False self._initialized: bool = False + self._initialization_delay_expired: datetime | None = None self._loaded: bool = False self._node_protocols: SupportedVersions | None = None self._node_last_online: datetime | None = None @@ -412,6 +413,7 @@ async def initialize(self) -> bool: """Initialize node.""" if self._initialized: return True + self._initialization_delay_expired = datetime.now(UTC) + timedelta(minutes=SUPPRESS_INITIALIZATION_WARNINGS) self._initialized = True return True From b0edf9e46cb0c14d7189b31b45071c6e30cfdf0c Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:03:17 +0200 Subject: [PATCH 407/979] Lower log level during initialization --- plugwise_usb/nodes/circle.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 9ce3bcb83..31a0e4acc 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -279,12 +279,18 @@ async def energy_update( self._mac_in_str, ) if await self.node_info_update() is None: - _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) + if datetime.now(UTC) < self._initialization_delay_expired: + _LOGGER.info("Unable to return energy statistics for %s, because it is not responding", self.name) + else: + _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) return None # request node info update every 30 minutes. elif not self.skip_update(self._node_info, 1800): if await self.node_info_update() is None: - _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) + if datetime.now(UTC) < self._initialization_delay_expired: + _LOGGER.info("Unable to return energy statistics for %s, because it is not responding", self.name) + else: + _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) return None # Always request last energy log records at initial startup @@ -351,7 +357,10 @@ async def energy_update( "Skip creating task to update energy logs for node %s", self._mac_in_str, ) - _LOGGER.warning("Unable to return energy statistics for %s, collecting required data...", self.name) + if datetime.now(UTC) < self._initialization_delay_expired: + _LOGGER.info("Unable to return energy statistics for %s, collecting required data...", self.name) + else: + _LOGGER.warning("Unable to return energy statistics for %s, collecting required data...", self.name) return None async def get_missing_energy_logs(self) -> None: From fedd620f05b0cb4a9e0f89c439296e5df3945fdd Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:05:21 +0200 Subject: [PATCH 408/979] Bump to version v0.40.0a19 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f87bfa9fe..409345f62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a18" +version = "v0.40.0a19" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 2be6291efae75a294c3c60f86b865c15acf4835d Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:24:48 +0200 Subject: [PATCH 409/979] Rewrite and standardize clock synchronization methods --- plugwise_usb/nodes/circle.py | 44 +++++++++++++++---------------- plugwise_usb/nodes/circle_plus.py | 8 ++++-- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 31a0e4acc..b4103a8b9 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -670,31 +670,31 @@ async def clock_synchronize(self) -> bool: clock_offset = ( clock_response.timestamp.replace(microsecond=0) - _dt_of_circle ) - if (clock_offset.seconds > MAX_TIME_DRIFT) or ( - clock_offset.seconds < -(MAX_TIME_DRIFT) + if (clock_offset.seconds < MAX_TIME_DRIFT) or ( + clock_offset.seconds > -(MAX_TIME_DRIFT) ): - _LOGGER.info( - "Reset clock of node %s because time has drifted %s sec", - self._mac_in_str, - str(clock_offset.seconds), + return True + _LOGGER.info( + "Reset clock of node %s because time has drifted %s sec", + self._mac_in_str, + str(clock_offset.seconds), + ) + node_response: NodeResponse | None = await self._send( + CircleClockSetRequest( + self._mac_in_bytes, + datetime.now(tz=UTC), + self._node_protocols.max ) - node_response: NodeResponse | None = await self._send( - CircleClockSetRequest( - self._mac_in_bytes, - datetime.now(tz=UTC), - self._node_protocols.max - ) + ) + if node_response is None: + _LOGGER.warning( + "Failed to (re)set the internal clock of %s", + self.name, ) - if ( - node_response is None - or node_response.ack_id != NodeResponseType.CLOCK_ACCEPTED - ): - _LOGGER.warning( - "Failed to (re)set the internal clock of node %s", - self._mac_in_str, - ) - return False - return True + return False + if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED: + return True + return False async def load(self) -> bool: """Load and activate Circle node features.""" diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 44b890d6b..979d7ffb5 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -95,8 +95,8 @@ async def initialize(self) -> bool: return False return await super().initialize() - async def realtime_clock_synchronize(self) -> bool: - """Synchronize realtime clock.""" + async def clock_synchronize(self) -> bool: + """Synchronize realtime clock. Returns true if successful.""" clock_response: CirclePlusRealTimeClockResponse | None = ( await self._send( CirclePlusRealTimeClockGetRequest(self._mac_in_bytes) @@ -138,6 +138,10 @@ async def realtime_clock_synchronize(self) -> bool: ), ) if node_response is None: + _LOGGER.warning( + "Failed to (re)set the internal realtime clock of %s", + self.name, + ) return False if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED: return True From be82fd7e5f59508e5f46c30556f54ee23cefa79f Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:25:22 +0200 Subject: [PATCH 410/979] No need to check instance --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index b4103a8b9..41de37ca2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -796,7 +796,7 @@ async def initialize(self) -> bool: _LOGGER.debug("Already initialized node %s", self._mac_in_str) return True - if isinstance(self, PlugwiseCircle) and not await self.clock_synchronize(): + if not await self.clock_synchronize(): _LOGGER.debug( "Failed to initialized node %s, failed clock sync", self._mac_in_str From 266da0558f78d6ded9503842a1b9d1d9404f7d1f Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:26:49 +0200 Subject: [PATCH 411/979] Remove duplicate subclass function use parent class function instead --- plugwise_usb/nodes/circle_plus.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 979d7ffb5..90c4dfbba 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -85,16 +85,6 @@ async def load(self) -> bool: await self._loaded_callback(NodeEvent.LOADED, self.mac) return True - @raise_not_loaded - async def initialize(self) -> bool: - """Initialize node.""" - if self._initialized: - return True - if not await self.realtime_clock_synchronize(): - self._initialized = False - return False - return await super().initialize() - async def clock_synchronize(self) -> bool: """Synchronize realtime clock. Returns true if successful.""" clock_response: CirclePlusRealTimeClockResponse | None = ( From 92662e6b447cfd4b326a872aa922c72f5f8c51b2 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 26 Aug 2024 22:34:45 +0200 Subject: [PATCH 412/979] Remove unused import --- plugwise_usb/nodes/circle_plus.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 90c4dfbba..056023f58 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -17,7 +17,6 @@ NodeResponseType, ) from .circle import PlugwiseCircle -from .helpers import raise_not_loaded from .helpers.firmware import CIRCLE_PLUS_FIRMWARE_SUPPORT _LOGGER = logging.getLogger(__name__) From 913fb5ff2ffe00b6db0366844bd06f2cbe1a3ce0 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:33:48 +0200 Subject: [PATCH 413/979] Make model generic and add specific model type --- plugwise_usb/nodes/__init__.py | 10 ++++++++-- tests/test_usb.py | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index e1edb2aa3..88e7148f7 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -520,15 +520,21 @@ async def _node_info_update_state( if self._node_info.version != hardware: self._node_info.version = hardware # Generate modelname based on hardware version - self._node_info.model = version_to_model(hardware) + model_info = version_to_model(hardware).split(' ') + self._node_info.model = model_info[0] if self._node_info.model == "Unknown": _LOGGER.warning( "Failed to detect hardware model for %s based on '%s'", self.mac, hardware, ) + if len(model_info) > 1: + self._node_info.model_type = " ".join(model_info[2:]) + else: + self._node_info.model_type = "" if self._node_info.model is not None: - self._node_info.name = f"{self._node_info.model.split(' ')[0]} {self._node_info.mac[-5:]}" + self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" + self._set_cache(CACHE_HARDWARE, hardware) if timestamp is None: complete = False diff --git a/tests/test_usb.py b/tests/test_usb.py index 4985afdd4..8be74d3aa 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -476,6 +476,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): ) assert stick.nodes["5555555555555555"].node_info.version == "000000070008" assert stick.nodes["5555555555555555"].node_info.model == "Scan" + assert stick.nodes["5555555555555555"].node_info.model_type == "" assert stick.nodes["5555555555555555"].available assert stick.nodes["5555555555555555"].node_info.battery_powered assert sorted(stick.nodes["5555555555555555"].features) == sorted( @@ -1616,7 +1617,8 @@ async def test_node_discovery_and_load(self, monkeypatch): assert stick.nodes["0098765432101234"].name == "Circle+ 01234" assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" - assert stick.nodes["0098765432101234"].node_info.model == "Circle+ type F" + assert stick.nodes["0098765432101234"].node_info.model == "Circle+" + assert stick.nodes["0098765432101234"].node_info.model_type == "type F" assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.battery_powered From 9e56560e47749b77d8dffd0ac48ab7ceaf142988 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:45:55 +0200 Subject: [PATCH 414/979] Do not log warning for awake messages during scan --- plugwise_usb/network/__init__.py | 2 ++ plugwise_usb/network/registry.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 03cbb0525..19b3306b7 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -225,6 +225,8 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: self._awake_discovery[mac] = response.timestamp return True if self._register.network_address(mac) is None: + if self._register.scan_completed: + return True _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 05260325f..86887a381 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -46,6 +46,7 @@ def __init__( self._network_cache_file_task: Task | None = None self._quick_scan_finished: Awaitable | None = None self._full_scan_finished: Awaitable | None = None + self._scan_completed = False # region Properties @property @@ -88,6 +89,11 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: """Return dictionary with all joined nodes.""" return deepcopy(self._registry) + @property + def scan_completed(self) -> bool: + """Indicate if scan is completed.""" + return self._scan_completed + def quick_scan_finished(self, callback: Awaitable) -> None: """Register method to be called when quick scan is finished.""" self._quick_scan_finished = callback @@ -227,6 +233,7 @@ async def update_missing_registrations( _LOGGER.info("Quick network registration discovery finished") else: _LOGGER.debug("Full network registration finished, save to cache") + self._scan_completed = True if self._cache_enabled: _LOGGER.debug("Full network registration finished, pre") await self.save_registry_to_cache() From 3a451499c99de39e38f15f1576e629a8e9cbdebf Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:46:14 +0200 Subject: [PATCH 415/979] Improve debug log message --- plugwise_usb/network/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 86887a381..0fb8938b2 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -232,10 +232,10 @@ async def update_missing_registrations( self._quick_scan_finished = None _LOGGER.info("Quick network registration discovery finished") else: - _LOGGER.debug("Full network registration finished, save to cache") + _LOGGER.debug("Full network registration finished") self._scan_completed = True if self._cache_enabled: - _LOGGER.debug("Full network registration finished, pre") + _LOGGER.debug("Full network registration finished, save to cache") await self.save_registry_to_cache() _LOGGER.debug("Full network registration finished, post") _LOGGER.info("Full network discovery completed") From 0402af8f209f58f02025ef1c39e09bb8e074110c Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:47:03 +0200 Subject: [PATCH 416/979] Return processing result for awake message --- plugwise_usb/nodes/sed.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 2ff7b2d94..9a5019cc2 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -111,7 +111,7 @@ async def node_info_update( return self._node_info return await super().node_info_update(node_info) - async def _awake_response(self, message: NodeAwakeResponse) -> None: + async def _awake_response(self, message: NodeAwakeResponse) -> bool: """Process awake message.""" self._node_last_online = message.timestamp await self._available_update_state(True) @@ -125,8 +125,7 @@ async def _awake_response(self, message: NodeAwakeResponse) -> None: if ping_response is not None: self._ping_at_awake = False await self.reset_maintenance_awake(message.timestamp) - return True - return False + return True async def reset_maintenance_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" From afe1687e07deb15ecd3083d5c3dc33e197cace13 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:52:15 +0200 Subject: [PATCH 417/979] Improve logging --- plugwise_usb/connection/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 504b556d0..15f18ddf1 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -60,7 +60,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._stick_response: Future[bytes] = self._loop.create_future() request.add_send_attempt() - _LOGGER.debug("Send %s", request) + _LOGGER.info("Send %s", request) request.subscribe_to_responses( self._receiver.subscribe_to_stick_responses, self._receiver.subscribe_to_node_responses, From fba25318457b9277afd2ae1c32e4a0d08b8246b4 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:55:02 +0200 Subject: [PATCH 418/979] Correct index --- plugwise_usb/nodes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 88e7148f7..cc80b09f9 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -529,7 +529,7 @@ async def _node_info_update_state( hardware, ) if len(model_info) > 1: - self._node_info.model_type = " ".join(model_info[2:]) + self._node_info.model_type = " ".join(model_info[1:]) else: self._node_info.model_type = "" if self._node_info.model is not None: From dd8d6685959732f3efea42a0be897c29f4622e97 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 09:59:53 +0200 Subject: [PATCH 419/979] Bump to version v0.40.0a20 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 409345f62..1662486a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a19" +version = "v0.40.0a20" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 4bc261c793098f969d50ff42aab7c67f0864adac Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:02:04 +0200 Subject: [PATCH 420/979] Correct test --- tests/test_usb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 8be74d3aa..70a2e0c89 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1658,7 +1658,8 @@ async def test_node_discovery_and_load(self, monkeypatch): ) assert state[pw_api.NodeFeature.INFO].firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" - assert state[pw_api.NodeFeature.INFO].model == "Circle+ type F" + assert state[pw_api.NodeFeature.INFO].model == "Circle+" + assert state[pw_api.NodeFeature.INFO].model_type == "type F" assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE_PLUS assert state[pw_api.NodeFeature.INFO].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp assert state[pw_api.NodeFeature.INFO].version == "000000730007" From d3c415b4b8271630bb134fbc73afd68f70b22047 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:23:27 +0200 Subject: [PATCH 421/979] Guard and log warning for receiver worker --- plugwise_usb/connection/receiver.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index a988408d3..375b6edd5 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -231,9 +231,15 @@ async def _receive_queue_worker(self): _LOGGER.debug("Process from receive queue: %s", response) if isinstance(response, StickResponse): _LOGGER.debug("Received %s", response) - await self._notify_stick_response_subscribers(response) + try: + await self._notify_stick_response_subscribers(response) + except Exception as exc: + _LOGGER.warning("Failed to process %s : %s", response, exc) else: - await self._notify_node_response_subscribers(response) + try: + await self._notify_node_response_subscribers(response) + except Exception as exc: + _LOGGER.warning("Failed to process %s : %s", response, exc) self._receive_queue.task_done() _LOGGER.debug("Receive_queue_worker stopped") From 8b53af987df5ac72f70bf29eeec991c10bcf005d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:24:17 +0200 Subject: [PATCH 422/979] Bump to v0.40.0a21 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1662486a6..21b6caf71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a20" +version = "v0.40.0a21" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 86b79a0e19d392ad6a42687f5463507f54a8b721 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:48:49 +0200 Subject: [PATCH 423/979] Fix monitoring awake state --- plugwise_usb/nodes/sed.py | 40 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 9a5019cc2..83882bfab 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import CancelledError, Future, get_event_loop, wait_for +from asyncio import CancelledError, Future, Task, get_event_loop, wait_for from collections.abc import Callable from datetime import datetime import logging @@ -61,8 +61,8 @@ class NodeSED(PlugwiseNode): # Maintenance _maintenance_interval: int | None = None _maintenance_last_awake: datetime | None = None - _maintenance_future: Future | None = None - + _awake_future: Future | None = None + _awake_timer_task: Task | None = None _ping_at_awake: bool = False _awake_subscription: Callable[[], None] | None = None @@ -80,8 +80,10 @@ def __init__( async def unload(self) -> None: """Deactivate and unload node features.""" - if self._maintenance_future is not None: - self._maintenance_future.cancel() + if self._awake_future is not None: + self._awake_future.set_result(True) + if self._awake_timer_task is not None or not self._awake_timer_task.done(): + await self._awake_timer_task if self._awake_subscription is not None: self._awake_subscription() await super().unload() @@ -124,10 +126,10 @@ async def _awake_response(self, message: NodeAwakeResponse) -> bool: ) if ping_response is not None: self._ping_at_awake = False - await self.reset_maintenance_awake(message.timestamp) + await self._reset_awake(message.timestamp) return True - async def reset_maintenance_awake(self, last_alive: datetime) -> None: + async def _reset_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" if self._maintenance_last_awake is None: self._maintenance_last_awake = last_alive @@ -136,17 +138,24 @@ async def reset_maintenance_awake(self, last_alive: datetime) -> None: last_alive - self._maintenance_last_awake ).seconds - # Finish previous maintenance timer - if self._maintenance_future is not None: - self._maintenance_future.set_result(True) + # Finish previous awake timer + if self._awake_future is not None: + self._awake_future.set_result(True) # Setup new maintenance timer - self._maintenance_future = get_event_loop().create_future() + current_loop = get_event_loop() + self._awake_future = current_loop.create_future() + self._awake_timer_task = current_loop.create_task( + self._awake_timer(), + name=f"Node awake timer for {self._mac_in_str}" + ) + async def _awake_timer(self) -> None: + """Task to monitor to get next awake in time. If not it sets device to be unavailable.""" # wait for next maintenance timer try: await wait_for( - self._maintenance_future, + self._awake_future, timeout=(self._maintenance_interval * 1.05), ) except TimeoutError: @@ -154,15 +163,14 @@ async def reset_maintenance_awake(self, last_alive: datetime) -> None: # Mark node as unavailable if self._available: _LOGGER.info( - "No maintenance awake message received for %s within expected %s seconds.", - self.mac, + "No awake message received from %s within expected %s seconds.", + self.name, str(self._maintenance_interval * 1.05), ) await self._available_update_state(False) except CancelledError: pass - - self._maintenance_future = None + self._awake_future = None async def sed_configure( self, From 97f3ea6e21d298bfed49324b0a46c07a30c94a04 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:49:17 +0200 Subject: [PATCH 424/979] Accept broad exception --- plugwise_usb/connection/receiver.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 375b6edd5..04663bf70 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -230,15 +230,14 @@ async def _receive_queue_worker(self): return _LOGGER.debug("Process from receive queue: %s", response) if isinstance(response, StickResponse): - _LOGGER.debug("Received %s", response) try: await self._notify_stick_response_subscribers(response) - except Exception as exc: + except Exception as exc: # [broad-exception-caught] _LOGGER.warning("Failed to process %s : %s", response, exc) else: try: await self._notify_node_response_subscribers(response) - except Exception as exc: + except Exception as exc: # [broad-exception-caught] _LOGGER.warning("Failed to process %s : %s", response, exc) self._receive_queue.task_done() _LOGGER.debug("Receive_queue_worker stopped") From b7eb8a1cd5e972c8109f92faf64d5cdd3e56aefb Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 10:58:07 +0200 Subject: [PATCH 425/979] Add model_type --- plugwise_usb/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 09a09da81..7dc58ad7f 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -77,6 +77,7 @@ class NodeInfo: firmware: datetime | None = None name: str | None = None model: str | None = None + model_type: str | None = None type: NodeType | None = None timestamp: datetime | None = None version: str | None = None From db64775534d70298965257c60d6c4c6fef23bd87 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 11:00:21 +0200 Subject: [PATCH 426/979] Remove try catch --- plugwise_usb/connection/receiver.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 04663bf70..323cf7b72 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -230,15 +230,9 @@ async def _receive_queue_worker(self): return _LOGGER.debug("Process from receive queue: %s", response) if isinstance(response, StickResponse): - try: - await self._notify_stick_response_subscribers(response) - except Exception as exc: # [broad-exception-caught] - _LOGGER.warning("Failed to process %s : %s", response, exc) + await self._notify_stick_response_subscribers(response) else: - try: - await self._notify_node_response_subscribers(response) - except Exception as exc: # [broad-exception-caught] - _LOGGER.warning("Failed to process %s : %s", response, exc) + await self._notify_node_response_subscribers(response) self._receive_queue.task_done() _LOGGER.debug("Receive_queue_worker stopped") From a81feadb78322f0d751e29c7a07724061d747f2d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 11:00:38 +0200 Subject: [PATCH 427/979] Remove comment --- plugwise_usb/connection/queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index bed239a64..ed25da589 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -107,7 +107,7 @@ async def submit( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" ) from exception - except BaseException as exception: # [broad-exception-caught] + except BaseException as exception: raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" From b2a00b457ac03a4aa938f0e6da39e8d275f3d796 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 27 Aug 2024 11:05:59 +0200 Subject: [PATCH 428/979] Fix operator --- plugwise_usb/nodes/sed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 83882bfab..c395418b0 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -82,7 +82,7 @@ async def unload(self) -> None: """Deactivate and unload node features.""" if self._awake_future is not None: self._awake_future.set_result(True) - if self._awake_timer_task is not None or not self._awake_timer_task.done(): + if self._awake_timer_task is not None and not self._awake_timer_task.done(): await self._awake_timer_task if self._awake_subscription is not None: self._awake_subscription() From a829dba4b40b28e6067b0dfa353db9484a0c93f8 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Sep 2024 21:24:38 +0200 Subject: [PATCH 429/979] Ignore .venv folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a45429225..11dac2da0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ tests/__pycache__ .coverage .vscode venv +.venv fixtures/* !fixtures/.keep *.sedbck From 36d7286f04837a12d3e3258f47423af38656d58f Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Sep 2024 21:52:49 +0200 Subject: [PATCH 430/979] Apply ruff and cleanup code --- plugwise_usb/__init__.py | 12 +- plugwise_usb/api.py | 33 + plugwise_usb/connection/__init__.py | 20 +- plugwise_usb/connection/manager.py | 67 +- plugwise_usb/connection/queue.py | 14 +- plugwise_usb/connection/receiver.py | 204 +++-- plugwise_usb/connection/sender.py | 4 +- plugwise_usb/constants.py | 9 - plugwise_usb/helpers/cache.py | 11 +- plugwise_usb/helpers/util.py | 4 +- plugwise_usb/messages/__init__.py | 12 +- plugwise_usb/messages/properties.py | 217 ++++- plugwise_usb/messages/requests.py | 924 +++++++++++++++++---- plugwise_usb/messages/responses.py | 123 ++- plugwise_usb/network/__init__.py | 256 +++--- plugwise_usb/network/cache.py | 4 +- plugwise_usb/network/registry.py | 129 ++- plugwise_usb/nodes/__init__.py | 303 ++++--- plugwise_usb/nodes/circle.py | 399 +++++---- plugwise_usb/nodes/circle_plus.py | 50 +- plugwise_usb/nodes/helpers/cache.py | 4 +- plugwise_usb/nodes/helpers/firmware.py | 319 ++----- plugwise_usb/nodes/helpers/pulses.py | 312 ++++--- plugwise_usb/nodes/helpers/subscription.py | 39 +- plugwise_usb/nodes/scan.py | 359 ++++++-- plugwise_usb/nodes/sed.py | 184 ++-- plugwise_usb/nodes/sense.py | 39 +- plugwise_usb/nodes/switch.py | 39 +- tests/test_usb.py | 654 +++++++++------ 29 files changed, 2904 insertions(+), 1840 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index bdd39b14b..cc5175c40 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -7,14 +7,14 @@ from __future__ import annotations from asyncio import get_running_loop -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from functools import wraps import logging from typing import Any, TypeVar, cast from .api import NodeEvent, StickEvent from .connection import StickController -from .exceptions import StickError +from .exceptions import StickError, SubscriptionError from .network import StickNetwork from .nodes import PlugwiseNode @@ -198,7 +198,7 @@ async def clear_cache(self) -> None: def subscribe_to_stick_events( self, - stick_event_callback: Callable[[StickEvent], Awaitable[None]], + stick_event_callback: Callable[[StickEvent], Coroutine[Any, Any, None]], events: tuple[StickEvent], ) -> Callable[[], None]: """Subscribe callback when specified StickEvent occurs. @@ -213,13 +213,15 @@ def subscribe_to_stick_events( @raise_not_initialized def subscribe_to_node_events( self, - node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], - events: tuple[NodeEvent], + node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]], + events: tuple[NodeEvent, ...], ) -> Callable[[], None]: """Subscribe callback to be called when specific NodeEvent occurs. Returns the function to be called to unsubscribe later. """ + if self._network is None: + raise SubscriptionError("Unable to subscribe to node events without network connection initialized") return self._network.subscribe_to_node_events( node_event_callback, events, diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 7dc58ad7f..4170cea37 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -15,6 +15,14 @@ class StickEvent(Enum): NETWORK_ONLINE = auto() +class MotionSensitivity(Enum): + """Motion sensitivity levels for Scan devices.""" + + HIGH = auto() + MEDIUM = auto() + OFF = auto() + + class NodeEvent(Enum): """Plugwise Node events for callback subscription.""" @@ -46,6 +54,7 @@ class NodeFeature(str, Enum): """USB Stick Node feature.""" AVAILABLE = "available" + BATTERY = "battery" ENERGY = "energy" HUMIDITY = "humidity" INFO = "info" @@ -66,6 +75,28 @@ class NodeFeature(str, Enum): ) +@dataclass +class BatteryConfig: + """Battery related configuration settings.""" + + # Duration in minutes the node synchronize its clock + clock_interval: int | None = None + + # Enable/disable clock sync + clock_sync: bool | None = None + + # Minimal interval in minutes the node will wake up + # and able to receive (maintenance) commands + maintenance_interval: int | None = None + + # Duration in seconds the SED will be awake for receiving commands + stay_active: int | None = None + + # Duration in minutes the SED will be in sleeping mode + # and not able to respond any command + sleep_for: int | None = None + + @dataclass class NodeInfo: """Node hardware information.""" @@ -116,6 +147,8 @@ class MotionState: motion: bool | None = None timestamp: datetime | None = None + reset_timer: int | None = None + daylight_mode: bool | None = None @dataclass diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index f7c61c153..5b2b38f02 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from concurrent import futures +from collections.abc import Awaitable, Callable, Coroutine import logging +from typing import Any from ..api import StickEvent from ..exceptions import NodeError, StickError @@ -26,7 +26,6 @@ def __init__(self) -> None: self._unsubscribe_stick_event: Callable[[], None] | None = None self._init_sequence_id: bytes | None = None - self._init_future: futures.Future | None = None self._is_initialized = False self._mac_stick: str | None = None @@ -115,7 +114,7 @@ async def connect_to_stick(self, serial_path: str) -> None: def subscribe_to_stick_events( self, stick_event_callback: Callable[[StickEvent], Awaitable[None]], - events: tuple[StickEvent], + events: tuple[StickEvent, ...], ) -> Callable[[], None]: """Subscribe callback when specified StickEvent occurs. @@ -130,7 +129,7 @@ def subscribe_to_stick_events( def subscribe_to_node_responses( self, - node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: @@ -165,15 +164,20 @@ async def initialize_stick(self) -> None: raise StickError("Cannot initialize, queue manager not running") try: - init_response: StickInitResponse = await self._queue.submit( - StickInitRequest() - ) + request = StickInitRequest(self.send) + init_response: StickInitResponse | None = await request.send() except StickError as err: raise StickError( "No response from USB-Stick to initialization request." + " Validate USB-stick is connected to port " + f"' {self._manager.serial_path}'" ) from err + if init_response is None: + raise StickError( + "No response from USB-Stick to initialization request." + + " Validate USB-stick is connected to port " + + f"' {self._manager.serial_path}'" + ) self._mac_stick = init_response.mac_decoded self._network_online = init_response.network_online diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 6496aab9b..633a2765e 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations from asyncio import Future, gather, get_event_loop, wait_for -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine import logging from typing import Any @@ -32,18 +32,26 @@ def __init__(self) -> None: self._connected: bool = False self._stick_event_subscribers: dict[ Callable[[], None], - tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + tuple[Callable[[StickEvent], Awaitable[None]], tuple[StickEvent, ...]], ] = {} self._unsubscribe_stick_events: Callable[[], None] | None = None @property def reduce_receive_logging(self) -> bool: """Return if logging must reduced.""" + if self._receiver is None: + raise StickError( + "Unable to return log settings when connection is not active." + ) return self._receiver.reduce_logging @reduce_receive_logging.setter def reduce_receive_logging(self, state: bool) -> None: """Reduce logging of unhandled received messages.""" + if self._receiver is None: + raise StickError( + "Unable to set log settings when connection is not active." + ) self._receiver.reduce_logging = state @property @@ -62,14 +70,12 @@ def is_connected(self) -> bool: def _subscribe_to_stick_events(self) -> None: """Subscribe to handle stick events by manager.""" - if not self.is_connected: + if not self.is_connected or self._receiver is None: raise StickError("Unable to subscribe to events") if self._unsubscribe_stick_events is None: - self._unsubscribe_stick_events = ( - self._receiver.subscribe_to_stick_events( - self._handle_stick_event, - (StickEvent.CONNECTED, StickEvent.DISCONNECTED) - ) + self._unsubscribe_stick_events = self._receiver.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED), ) async def _handle_stick_event( @@ -79,11 +85,9 @@ async def _handle_stick_event( """Call callback for stick event subscribers.""" if len(self._stick_event_subscribers) == 0: return - callback_list: list[Callable] = [] - for callback, filtered_events in list( - self._stick_event_subscribers.values() - ): - if event in filtered_events: + callback_list: list[Awaitable[None]] = [] + for callback, stick_events in self._stick_event_subscribers.values(): + if event in stick_events: callback_list.append(callback(event)) if len(callback_list) > 0: await gather(*callback_list) @@ -91,35 +95,37 @@ async def _handle_stick_event( def subscribe_to_stick_events( self, stick_event_callback: Callable[[StickEvent], Awaitable[None]], - events: tuple[StickEvent], + events: tuple[StickEvent, ...], ) -> Callable[[], None]: """Subscribe callback when specified StickEvent occurs. Returns the function to be called to unsubscribe later. """ + def remove_subscription() -> None: """Remove stick event subscription.""" self._stick_event_subscribers.pop(remove_subscription) - self._stick_event_subscribers[remove_subscription] = (stick_event_callback, events) + + self._stick_event_subscribers[remove_subscription] = ( + stick_event_callback, + events, + ) return remove_subscription def subscribe_to_stick_replies( self, - callback: Callable[ - [StickResponse], Awaitable[None] - ], + callback: Callable[[StickResponse], Coroutine[Any, Any, None]], ) -> Callable[[], None]: """Subscribe to response messages from stick.""" if self._receiver is None or not self._receiver.is_connected: raise StickError( - "Unable to subscribe to stick response when receiver " + - "is not loaded" + "Unable to subscribe to stick response when receiver " + "is not loaded" ) return self._receiver.subscribe_to_stick_responses(callback) def subscribe_to_node_responses( self, - node_response_callback: Callable[[PlugwiseResponse], Awaitable[None]], + node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, ) -> Callable[[], None]: @@ -129,21 +135,18 @@ def subscribe_to_node_responses( """ if self._receiver is None or not self._receiver.is_connected: raise StickError( - "Unable to subscribe to node response when receiver " + - "is not loaded" + "Unable to subscribe to node response when receiver " + "is not loaded" ) return self._receiver.subscribe_to_node_responses( node_response_callback, mac, message_ids ) - async def setup_connection_to_stick( - self, serial_path: str - ) -> None: + async def setup_connection_to_stick(self, serial_path: str) -> None: """Create serial connection to USB-stick.""" if self._connected: raise StickError("Cannot setup connection, already connected") loop = get_event_loop() - connected_future: Future[Any] = Future() + connected_future: Future[bool] = Future() self._receiver = StickReceiver(connected_future) self._port = serial_path @@ -187,14 +190,14 @@ async def write_to_stick(self, request: PlugwiseRequest) -> None: _LOGGER.debug("Write to USB-stick: %s", request) if not request.resend: raise StickError( - f"Failed to send {request.__class__.__name__} " + - f"to node {request.mac_decoded}, maximum number " + - f"of retries ({request.max_retries}) has been reached" + f"Failed to send {request.__class__.__name__} " + + f"to node {request.mac_decoded}, maximum number " + + f"of retries ({request.max_retries}) has been reached" ) if self._sender is None: raise StickError( - f"Failed to send {request.__class__.__name__}" + - "because USB-Stick connection is not setup" + f"Failed to send {request.__class__.__name__}" + + "because USB-Stick connection is not setup" ) await self._sender.write_request_to_port(request) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index ed25da589..43de49c74 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -8,7 +8,8 @@ from ..api import StickEvent from ..exceptions import NodeTimeout, StickError, StickTimeout -from ..messages.requests import NodePingRequest, PlugwiseRequest, Priority +from ..messages import Priority +from ..messages.requests import NodePingRequest, PlugwiseCancelRequest, PlugwiseRequest from ..messages.responses import PlugwiseResponse from .manager import StickConnectionManager @@ -31,7 +32,7 @@ def __init__(self) -> None: self._stick: StickConnectionManager | None = None self._loop = get_running_loop() self._submit_queue: PriorityQueue[PlugwiseRequest] = PriorityQueue() - self._submit_worker_task: Task | None = None + self._submit_worker_task: Task[None] | None = None self._unsubscribe_connection_events: Callable[[], None] | None = None self._running = False @@ -71,8 +72,7 @@ async def stop(self) -> None: self._unsubscribe_connection_events() self._running = False if self._submit_worker_task is not None and not self._submit_worker_task.done(): - cancel_request = PlugwiseRequest(b"0000", None) - cancel_request.priority = Priority.CANCEL + cancel_request = PlugwiseCancelRequest() await self._submit_queue.put(cancel_request) await self._submit_worker_task self._submit_worker_task = None @@ -97,10 +97,10 @@ async def submit( if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level _LOGGER.debug("%s, cancel because timeout is expected for NodePingRequests", e) - elif request.resend: + if request.resend: _LOGGER.info("%s, retrying", e) else: - _LOGGER.warning("%s, cancel request", e) + _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] except StickError as exception: _LOGGER.error(exception) raise StickError( @@ -134,7 +134,7 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: async def _send_queue_worker(self) -> None: """Send messages from queue at the order of priority.""" _LOGGER.debug("Send_queue_worker started") - while self._running: + while self._running and self._stick is not None: request = await self._submit_queue.get() _LOGGER.debug("Send from send queue %s", request) if request.priority == Priority.CANCEL: diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 323cf7b72..60f7a8dce 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -14,22 +14,22 @@ 1. Notify status subscribers to connection state changes """ + from __future__ import annotations from asyncio import ( Future, - Protocol, PriorityQueue, + Protocol, Task, TimerHandle, gather, get_running_loop, - sleep, ) -from collections.abc import Awaitable, Callable -from concurrent import futures +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass import logging -from typing import Final +from typing import Any, Final from serial_asyncio_fast import SerialTransport @@ -46,17 +46,35 @@ ) _LOGGER = logging.getLogger(__name__) -STICK_RECEIVER_EVENTS = ( - StickEvent.CONNECTED, - StickEvent.DISCONNECTED -) +STICK_RECEIVER_EVENTS = (StickEvent.CONNECTED, StickEvent.DISCONNECTED) CACHED_REQUESTS: Final = 50 -async def delayed_run(coroutine: Callable, seconds: float): - """Postpone a coroutine to be executed after given delay.""" - await sleep(seconds) - await coroutine +@dataclass +class StickEventSubscription: + """Subscription registration details for stick responses.""" + + callback_fn: Callable[[StickEvent], Coroutine[Any, Any, None]] + stick_events: tuple[StickEvent, ...] + + +@dataclass +class StickResponseSubscription: + """Subscription registration details for stick responses.""" + + callback_fn: Callable[[StickResponse], Coroutine[Any, Any, None]] + seq_id: bytes | None + stick_response_type: StickResponseType | None + + +@dataclass +class NodeResponseSubscription: + """Subscription registration details for node responses.""" + + callback_fn: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]] + mac: bytes | None + response_ids: tuple[bytes, ...] | None + seq_id: bytes | None class StickReceiver(Protocol): @@ -64,7 +82,7 @@ class StickReceiver(Protocol): def __init__( self, - connected_future: Future | None = None, + connected_future: Future[bool] | None = None, ) -> None: """Initialize instance of the USB Stick connection.""" super().__init__() @@ -76,31 +94,19 @@ def __init__( self._reduce_logging = True self._receive_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] - self._stick_future: futures.Future | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} - self._stick_response_future: futures.Future | None = None - self._receive_worker_task: Task | None = None + self._receive_worker_task: Task[None] | None = None self._delayed_processing_tasks: dict[bytes, TimerHandle] = {} # Subscribers self._stick_event_subscribers: dict[ - Callable[[], None], - tuple[Callable[[StickEvent], Awaitable[None]], StickEvent | None] + Callable[[], None], StickEventSubscription ] = {} - self._stick_response_subscribers: dict[ - Callable[[], None], - tuple[ - Callable[[StickResponse], Awaitable[None]], - bytes | None - ] + Callable[[], None], StickResponseSubscription ] = {} self._node_response_subscribers: dict[ - Callable[[], None], - tuple[ - Callable[[PlugwiseResponse], Awaitable[bool]], bytes | None, - tuple[bytes] | None, - ] + Callable[[], None], NodeResponseSubscription ] = {} def connection_lost(self, exc: Exception | None = None) -> None: @@ -135,10 +141,7 @@ def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.info("Connection made") self._transport = transport - if ( - self._connected_future is not None - and not self._connected_future.done() - ): + if self._connected_future is not None and not self._connected_future.done(): self._connected_future.set_result(True) self._connection_state = True if len(self._stick_event_subscribers) > 0: @@ -150,8 +153,6 @@ async def close(self) -> None: """Close connection.""" if self._transport is None: return - if self._stick_future is not None and not self._stick_future.done(): - self._stick_future.cancel() self._transport.close() await self._stop_running_tasks() @@ -159,7 +160,10 @@ async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" for task in self._delayed_processing_tasks.values(): task.cancel() - if self._receive_worker_task is not None and not self._receive_worker_task.done(): + if ( + self._receive_worker_task is not None + and not self._receive_worker_task.done() + ): cancel_response = StickResponse() cancel_response.priority = Priority.CANCEL await self._receive_queue.put(cancel_response) @@ -176,7 +180,7 @@ def data_received(self, data: bytes) -> None: if MESSAGE_FOOTER in self._buffer: msgs = self._buffer.split(MESSAGE_FOOTER) for msg in msgs[:-1]: - if (response := self.extract_message_from_line_buffer(msg)): + if (response := self.extract_message_from_line_buffer(msg)) is not None: self._put_message_in_receiver_queue(response) if len(msgs) > 4: _LOGGER.debug("Reading %d messages at once from USB-Stick", len(msgs)) @@ -190,15 +194,14 @@ def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: self._receive_queue.put_nowait(response) if self._receive_worker_task is None or self._receive_worker_task.done(): self._receive_worker_task = self._loop.create_task( - self._receive_queue_worker(), - name="Receive queue worker" + self._receive_queue_worker(), name="Receive queue worker" ) - def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: + def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse | None: """Extract message from buffer.""" # Lookup header of message, there are stray \x83 if (_header_index := msg.find(MESSAGE_HEADER)) == -1: - return False + return None _LOGGER.debug("Extract message from data: %s", msg) msg = msg[_header_index:] # Detect response message type @@ -220,7 +223,7 @@ def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse: _LOGGER.debug("Data %s converted into %s", msg, response) return response - async def _receive_queue_worker(self): + async def _receive_queue_worker(self) -> None: """Process queue items.""" _LOGGER.debug("Receive_queue_worker started") while self.is_connected: @@ -246,20 +249,21 @@ def _reset_buffer(self, new_buffer: bytes) -> None: def subscribe_to_stick_events( self, - stick_event_callback: Callable[[StickEvent], Awaitable[None]], - events: tuple[StickEvent], + stick_event_callback: Callable[[StickEvent], Coroutine[Any, Any, None]], + events: tuple[StickEvent, ...], ) -> Callable[[], None]: """Subscribe callback when specified StickEvent occurs. Returns the function to be called to unsubscribe later. """ + def remove_subscription() -> None: """Remove stick event subscription.""" self._stick_event_subscribers.pop(remove_subscription) - self._stick_event_subscribers[ - remove_subscription - ] = (stick_event_callback, events) + self._stick_event_subscribers[remove_subscription] = StickEventSubscription( + stick_event_callback, events + ) return remove_subscription async def _notify_stick_event_subscribers( @@ -267,81 +271,106 @@ async def _notify_stick_event_subscribers( event: StickEvent, ) -> None: """Call callback for stick event subscribers.""" - callback_list: list[Callable] = [] - for callback, filtered_events in ( - self._stick_event_subscribers.values() - ): - if event in filtered_events: - callback_list.append(callback(event)) + callback_list: list[Awaitable[None]] = [] + for subscription in self._stick_event_subscribers.values(): + if event in subscription.stick_events: + callback_list.append(subscription.callback_fn(event)) if len(callback_list) > 0: await gather(*callback_list) def subscribe_to_stick_responses( self, - callback: Callable[[StickResponse], Awaitable[None]], + callback: Callable[[StickResponse], Coroutine[Any, Any, None]], seq_id: bytes | None = None, - response_type: StickResponseType | None = None + response_type: StickResponseType | None = None, ) -> Callable[[], None]: """Subscribe to response messages from stick.""" + def remove_subscription() -> None: """Remove update listener.""" self._stick_response_subscribers.pop(remove_subscription) - self._stick_response_subscribers[ - remove_subscription - ] = callback, seq_id, response_type + self._stick_response_subscribers[remove_subscription] = ( + StickResponseSubscription(callback, seq_id, response_type) + ) return remove_subscription async def _notify_stick_response_subscribers( self, stick_response: StickResponse ) -> None: """Call callback for all stick response message subscribers.""" - for callback, seq_id, response_type in list(self._stick_response_subscribers.values()): - if seq_id is not None: - if seq_id != stick_response.seq_id: - continue - if response_type is not None and response_type != stick_response.response_type: + for subscription in list(self._stick_response_subscribers.values()): + if ( + subscription.seq_id is not None + and subscription.seq_id != stick_response.seq_id + ): + continue + if ( + subscription.stick_response_type is not None + and subscription.stick_response_type != stick_response.response_type + ): continue _LOGGER.debug("Notify stick response subscriber for %s", stick_response) - await callback(stick_response) + await subscription.callback_fn(stick_response) def subscribe_to_node_responses( self, - node_response_callback: Callable[[PlugwiseResponse], Awaitable[bool]], + node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, - message_ids: tuple[bytes] | None = None, + message_ids: tuple[bytes, ...] | None = None, seq_id: bytes | None = None, ) -> Callable[[], None]: """Subscribe a awaitable callback to be called when a specific message is received. Returns function to unsubscribe. """ + def remove_listener() -> None: """Remove update listener.""" self._node_response_subscribers.pop(remove_listener) - self._node_response_subscribers[ - remove_listener - ] = (node_response_callback, mac, message_ids, seq_id) + self._node_response_subscribers[remove_listener] = NodeResponseSubscription( + node_response_callback, + mac, + message_ids, + seq_id, + ) + _LOGGER.warning("node subscription created for %s - %s", mac, seq_id) return remove_listener - async def _notify_node_response_subscribers(self, node_response: PlugwiseResponse) -> None: + async def _notify_node_response_subscribers( + self, node_response: PlugwiseResponse + ) -> None: """Call callback for all node response message subscribers.""" + if node_response.seq_id is None: + return + if node_response.seq_id in self._last_processed_messages: _LOGGER.debug("Drop previously processed duplicate %s", node_response) return - notify_tasks: list[Callable] = [] - for callback, mac, message_ids, seq_id in list( - self._node_response_subscribers.values() - ): - if mac is not None and mac != node_response.mac: + _LOGGER.warning( + "total node subscriptions: %s", len(self._node_response_subscribers) + ) + + notify_tasks: list[Coroutine[Any, Any, bool]] = [] + for node_subscription in self._node_response_subscribers.values(): + if ( + node_subscription.mac is not None + and node_subscription.mac != node_response.mac + ): continue - if message_ids is not None and node_response.identifier not in message_ids: + if ( + node_subscription.response_ids is not None + and node_response.identifier not in node_subscription.response_ids + ): continue - if seq_id is not None and seq_id != node_response.seq_id: + if ( + node_subscription.seq_id is not None + and node_subscription.seq_id != node_response.seq_id + ): continue - notify_tasks.append(callback(node_response)) + notify_tasks.append(node_subscription.callback_fn(node_response)) if len(notify_tasks) > 0: _LOGGER.info("Received %s", node_response) @@ -350,15 +379,26 @@ async def _notify_node_response_subscribers(self, node_response: PlugwiseRespons if node_response.seq_id in self._delayed_processing_tasks: del self._delayed_processing_tasks[node_response.seq_id] # Limit tracking to only the last appended request (FIFO) - self._last_processed_messages = self._last_processed_messages[-CACHED_REQUESTS:] + self._last_processed_messages = self._last_processed_messages[ + -CACHED_REQUESTS: + ] # execute callbacks - _LOGGER.debug("Notify node response subscribers (%s) about %s", len(notify_tasks), node_response) + _LOGGER.debug( + "Notify node response subscribers (%s) about %s", + len(notify_tasks), + node_response, + ) task_result = await gather(*notify_tasks) # Log execution result for special cases if not all(task_result): - _LOGGER.warning("Executed %s tasks (result=%s) for %s", len(notify_tasks), task_result, node_response) + _LOGGER.warning( + "Executed %s tasks (result=%s) for %s", + len(notify_tasks), + task_result, + node_response, + ) return if node_response.retries > 10: diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 15f18ddf1..6a02aa85c 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -38,7 +38,7 @@ def __init__( self._loop = get_running_loop() self._receiver = stick_receiver self._transport = transport - self._stick_response: Future[bytes] | None = None + self._stick_response: Future[StickResponse] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None @@ -57,7 +57,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: await self._stick_lock.acquire() self._current_request = request - self._stick_response: Future[bytes] = self._loop.create_future() + self._stick_response = self._loop.create_future() request.add_send_attempt() _LOGGER.info("Send %s", request) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 762b01257..47aab62e4 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -2,7 +2,6 @@ from __future__ import annotations import datetime as dt -from enum import Enum, auto import logging from typing import Final @@ -86,11 +85,3 @@ "070051": "Switch", "080029": "Switch", } - - -class MotionSensitivity(Enum): - """Motion sensitivity levels for Scan devices.""" - - HIGH = auto() - MEDIUM = auto() - OFF = auto() diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 89a5055d4..60e6a3b3b 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -7,8 +7,11 @@ from os import getenv as os_getenv, name as os_name from os.path import expanduser as os_path_expand_user, join as os_path_join -from aiofiles import open as aiofiles_open, ospath -from aiofiles.os import makedirs, remove as aiofiles_os_remove +from aiofiles import open as aiofiles_open, ospath # type: ignore[import-untyped] +from aiofiles.os import ( # type: ignore[import-untyped] + makedirs, + remove as aiofiles_os_remove, +) from ..constants import CACHE_DIR, CACHE_KEY_SEPARATOR, UTF8 from ..exceptions import CacheError @@ -125,12 +128,12 @@ async def read_cache(self) -> dict[str, str]: try: async with aiofiles_open( file=self._cache_file, - mode="r", encoding=UTF8, ) as read_file_data: lines: list[str] = await read_file_data.readlines() except OSError as exc: - # suppress file errors + # suppress file errors as this is expected the first time + # when no cache file exists yet. _LOGGER.warning( "OS error %s while reading cache file %s", exc, str(self._cache_file) ) diff --git a/plugwise_usb/helpers/util.py b/plugwise_usb/helpers/util.py index 13d410f09..37e06458e 100644 --- a/plugwise_usb/helpers/util.py +++ b/plugwise_usb/helpers/util.py @@ -21,10 +21,10 @@ def validate_mac(mac: str) -> bool: return True -def version_to_model(version: str | None) -> str | None: +def version_to_model(version: str | None) -> str: """Translate hardware_version to device type.""" if version is None: - return None + return "Unknown" model = HW_MODELS.get(version) if model is None: diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 8c542ec1d..20b68c088 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -7,8 +7,10 @@ from typing import Any from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 +from ..exceptions import MessageError from ..helpers.util import crc_fun + class Priority(int, Enum): """Message priority levels for USB-stick message requests.""" @@ -20,15 +22,15 @@ class Priority(int, Enum): class PlugwiseMessage: """Plugwise message base class.""" - priority: Priority = Priority.MEDIUM + _identifier = b"FFFF" - def __init__(self, identifier: bytes) -> None: + def __init__(self) -> None: """Initialize a plugwise message.""" - self._identifier = identifier self._mac: bytes | None = None self._checksum: bytes | None = None self._args: list[Any] = [] self._seq_id: bytes | None = None + self.priority: Priority = Priority.MEDIUM self.timestamp = datetime.now(UTC) @property @@ -49,6 +51,8 @@ def identifier(self) -> bytes: @property def mac(self) -> bytes: """Return mac in bytes.""" + if self._mac is None: + raise MessageError("Mac not set") return self._mac @property @@ -70,7 +74,7 @@ def serialize(self) -> bytes: @staticmethod def calculate_checksum(data: bytes) -> bytes: """Calculate crc checksum.""" - return bytes("%04X" % crc_fun(data), UTF8) + return bytes(f"{crc_fun(data):04X}", UTF8) def __gt__(self, other: PlugwiseMessage) -> bool: """Greater than.""" diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 763ee710a..d94f66be9 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -6,24 +6,25 @@ from typing import Any from ..constants import LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8 +from ..exceptions import MessageError from ..helpers.util import int_to_uint class BaseType: """Generic single instance property.""" - def __init__(self, value: Any, length: int) -> None: + def __init__(self, raw_value: Any, length: int) -> None: """Initialize single instance property.""" - self.value = value + self._raw_value = raw_value self.length = length def serialize(self) -> bytes: """Return current value into an iterable list of bytes.""" - return bytes(self.value, UTF8) + return bytes(self._raw_value, UTF8) def deserialize(self, val: bytes) -> None: """Convert current value into single data object.""" - self.value = val + raise NotImplementedError() def __len__(self) -> int: """Return length of property object.""" @@ -35,7 +36,9 @@ class CompositeType: def __init__(self) -> None: """Initialize multi instance property.""" - self.contents: list = [] + self.contents: list[ + String | Int | SInt | UnixTimestamp | Year2k | IntDec | Float | LogAddr + ] = [] def serialize(self) -> bytes: """Return current value of all properties into an iterable list of bytes.""" @@ -46,38 +49,79 @@ def deserialize(self, val: bytes) -> None: for content in self.contents: _val = val[: len(content)] content.deserialize(_val) - val = val[len(_val):] + val = val[len(_val) :] def __len__(self) -> int: """Return length of property objects.""" return sum(len(x) for x in self.contents) +class Bytes(BaseType): + """Bytes based property.""" + + def __init__(self, value: bytes | None, length: int) -> None: + """Initialize bytes based property.""" + super().__init__(value, length) + self._value: bytes | None = None + + def deserialize(self, val: bytes) -> None: + """Set current value.""" + self._value = val + + @property + def value(self) -> bytes: + """Return bytes value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value + class String(BaseType): """String based property.""" + def __init__(self, value: str | None, length: int) -> None: + """Initialize string based property.""" + super().__init__(value, length) + self._value: str | None = None + + def deserialize(self, val: bytes) -> None: + """Convert current value into single string formatted object.""" + self._value = val.decode(UTF8) + + @property + def value(self) -> str: + """Return converted int value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value + class Int(BaseType): """Integer based property.""" - def __init__( - self, value: int, length: int = 2, negative: bool = True - ) -> None: + def __init__(self, value: int, length: int = 2, negative: bool = True) -> None: """Initialize integer based property.""" super().__init__(value, length) self.negative = negative + self._value: int | None = None def serialize(self) -> bytes: """Return current string formatted value into an iterable list of bytes.""" fmt = "%%0%dX" % self.length - return bytes(fmt % self.value, UTF8) + return bytes(fmt % self._raw_value, UTF8) def deserialize(self, val: bytes) -> None: """Convert current value into single string formatted object.""" - self.value = int(val, 16) + self._value = int(val, 16) if self.negative: mask = 1 << (self.length * 4 - 1) - self.value = -(self.value & mask) + (self.value & ~mask) + self._value = -(self._value & mask) + (self._value & ~mask) + + @property + def value(self) -> int: + """Return converted int value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class SInt(BaseType): @@ -86,6 +130,7 @@ class SInt(BaseType): def __init__(self, value: int, length: int = 2) -> None: """Initialize string formatted data with integer value property.""" super().__init__(value, length) + self._value: int | None = None @staticmethod def negative(val: int, octals: int) -> int: @@ -98,26 +143,44 @@ def negative(val: int, octals: int) -> int: def serialize(self) -> bytes: """Return current string formatted integer value into an iterable list of bytes.""" fmt = "%%0%dX" % self.length - return bytes(fmt % int_to_uint(self.value, self.length), UTF8) + return bytes(fmt % int_to_uint(self._raw_value, self.length), UTF8) def deserialize(self, val: bytes) -> None: """Convert current string formatted value into integer value.""" # TODO: negative is not initialized! 20220405 - self.value = self.negative(int(val, 16), self.length) + self._value = self.negative(int(val, 16), self.length) + + @property + def value(self) -> int: + """Return converted datetime value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value -class UnixTimestamp(Int): +class UnixTimestamp(BaseType): """Unix formatted timestamp property.""" def __init__(self, value: float, length: int = 8) -> None: """Initialize Unix formatted timestamp property.""" - Int.__init__(self, int(value), length, False) + super().__init__(value, length) + self._value: datetime.datetime | None = None + + def serialize(self) -> bytes: + """Return current string formatted value into an iterable list of bytes.""" + fmt = "%%0%dX" % self.length + return bytes(fmt % self._raw_value, UTF8) def deserialize(self, val: bytes) -> None: """Convert data into datetime based on Unix timestamp format.""" - self.value = datetime.datetime.fromtimestamp( - int(val, 16), datetime.UTC - ) + self._value = datetime.datetime.fromtimestamp(int(val, 16), datetime.UTC) + + @property + def value(self) -> datetime.datetime: + """Return converted datetime value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class Year2k(Int): @@ -128,8 +191,16 @@ class Year2k(Int): def deserialize(self, val: bytes) -> None: """Convert data into year valued based value with offset to Y2k.""" - Int.deserialize(self, val) - self.value += PLUGWISE_EPOCH + super().deserialize(val) + if self._value is not None: + self._value += PLUGWISE_EPOCH + + @property + def value(self) -> int: + """Return converted int value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class DateTime(CompositeType): @@ -140,49 +211,68 @@ class DateTime(CompositeType): and last four bytes are offset from the beginning of the month in minutes. """ - def __init__( - self, year: int = 0, month: int = 1, minutes: int = 0 - ) -> None: + def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: """Initialize Date time formatted property.""" CompositeType.__init__(self) self.year = Year2k(year - PLUGWISE_EPOCH, 2) self.month = Int(month, 2, False) self.minutes = Int(minutes, 4, False) self.contents += [self.year, self.month, self.minutes] - self.value: datetime.datetime | None = None + self._value: datetime.datetime | None = None + self._deserialized = False def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" if val == b"FFFFFFFF": - self.value = None + self._value = None else: CompositeType.deserialize(self, val) - self.value = datetime.datetime( + self._value = datetime.datetime( year=self.year.value, month=self.month.value, day=1 ) + datetime.timedelta(minutes=self.minutes.value) + self._deserialized = True + + @property + def value_set(self) -> bool: + """True when datetime is converted.""" + if not self._deserialized: + raise MessageError("Unable to return value. Deserialize data first") + return (self._value is not None) + + @property + def value(self) -> datetime.datetime: + """Return converted datetime value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class Time(CompositeType): """Time formatted property.""" - def __init__( - self, hour: int = 0, minute: int = 0, second: int = 0 - ) -> None: + def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: """Initialize time formatted property.""" CompositeType.__init__(self) self.hour = Int(hour, 2, False) self.minute = Int(minute, 2, False) self.second = Int(second, 2, False) self.contents += [self.hour, self.minute, self.second] - self.value: datetime.time | None = None + self._value: datetime.time | None = None def deserialize(self, val: bytes) -> None: """Convert data into time value.""" CompositeType.deserialize(self, val) - self.value = datetime.time( + self._value = datetime.time( self.hour.value, self.minute.value, self.second.value ) + @property + def value(self) -> datetime.time: + """Return converted time value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value + class IntDec(BaseType): """Integer as string formatted data with integer value property.""" @@ -190,62 +280,82 @@ class IntDec(BaseType): def __init__(self, value: int, length: int = 2) -> None: """Initialize integer based property.""" super().__init__(value, length) + self._value: str | None = None def serialize(self) -> bytes: """Return current string formatted integer value into an iterable list of bytes.""" fmt = "%%0%dd" % self.length - return bytes(fmt % self.value, UTF8) + return bytes(fmt % self._raw_value, UTF8) def deserialize(self, val: bytes) -> None: """Convert data into integer value based on string formatted data format.""" - self.value = val.decode(UTF8) + self._value = val.decode(UTF8) + + @property + def value(self) -> str: + """Return converted string value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class RealClockTime(CompositeType): """Time value property based on integer values.""" - def __init__( - self, hour: int = 0, minute: int = 0, second: int = 0 - ) -> None: + def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: """Initialize time formatted property.""" - CompositeType.__init__(self) + super().__init__() self.hour = IntDec(hour, 2) self.minute = IntDec(minute, 2) self.second = IntDec(second, 2) self.contents += [self.second, self.minute, self.hour] - self.value: datetime.time | None = None + self._value: datetime.time | None = None def deserialize(self, val: bytes) -> None: """Convert data into time value based on integer formatted data.""" CompositeType.deserialize(self, val) - self.value = datetime.time( + self._value = datetime.time( int(self.hour.value), int(self.minute.value), int(self.second.value), ) + @property + def value(self) -> datetime.time: + """Return converted time value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value + class RealClockDate(CompositeType): """Date value property based on integer values.""" def __init__(self, day: int = 0, month: int = 0, year: int = 0) -> None: """Initialize date formatted property.""" - CompositeType.__init__(self) + super().__init__() self.day = IntDec(day, 2) self.month = IntDec(month, 2) self.year = IntDec(year - PLUGWISE_EPOCH, 2) self.contents += [self.day, self.month, self.year] - self.value: datetime.date | None = None + self._value: datetime.date | None = None def deserialize(self, val: bytes) -> None: """Convert data into date value based on integer formatted data.""" CompositeType.deserialize(self, val) - self.value = datetime.date( + self._value = datetime.date( int(self.year.value) + PLUGWISE_EPOCH, int(self.month.value), int(self.day.value), ) + @property + def value(self) -> datetime.date: + """Return converted date value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value + class Float(BaseType): """Float value property.""" @@ -253,11 +363,19 @@ class Float(BaseType): def __init__(self, value: float, length: int = 4) -> None: """Initialize float value property.""" super().__init__(value, length) + self._value: float | None = None def deserialize(self, val: bytes) -> None: """Convert data into float value.""" hex_val = binascii.unhexlify(val) - self.value = float(struct.unpack("!f", hex_val)[0]) + self._value = float(struct.unpack("!f", hex_val)[0]) + + @property + def value(self) -> float: + """Return converted float value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value class LogAddr(Int): @@ -265,9 +383,16 @@ class LogAddr(Int): def serialize(self) -> bytes: """Return current log address formatted value into an iterable list of bytes.""" - return bytes("%08X" % ((self.value * 32) + LOGADDR_OFFSET), UTF8) + return bytes("%08X" % ((self._raw_value * 32) + LOGADDR_OFFSET), UTF8) def deserialize(self, val: bytes) -> None: """Convert data into integer value based on log address formatted data.""" Int.deserialize(self, val) - self.value = (self.value - LOGADDR_OFFSET) // 32 + self._value = (self.value - LOGADDR_OFFSET) // 32 + + @property + def value(self) -> int: + """Return converted time value.""" + if self._value is None: + raise MessageError("Unable to return value. Deserialize data first") + return self._value diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index f8f04850a..801846c1f 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1,11 +1,13 @@ """All known request messages to be send to plugwise devices.""" + from __future__ import annotations from asyncio import Future, TimerHandle, get_running_loop -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Coroutine from copy import copy from datetime import datetime import logging +from typing import Any from ..constants import ( DAY_IN_MINUTES, @@ -17,7 +19,30 @@ NODE_TIME_OUT, ) from ..exceptions import MessageError, NodeError, NodeTimeout, StickError, StickTimeout -from ..messages.responses import PlugwiseResponse, StickResponse, StickResponseType +from ..messages.responses import ( + CircleClockResponse, + CircleEnergyLogsResponse, + CircleLogDataResponse, + CirclePlusConnectResponse, + CirclePlusRealTimeClockResponse, + CirclePlusScanResponse, + CirclePowerUsageResponse, + CircleRelayInitStateResponse, + EnergyCalibrationResponse, + NodeAckResponse, + NodeFeaturesResponse, + NodeImageValidationResponse, + NodeInfoResponse, + NodePingResponse, + NodeRemoveResponse, + NodeResponse, + NodeSpecificResponse, + PlugwiseResponse, + StickInitResponse, + StickNetworkInfoResponse, + StickResponse, + StickResponseType, +) from . import PlugwiseMessage, Priority from .properties import ( DateTime, @@ -36,37 +61,56 @@ class PlugwiseRequest(PlugwiseMessage): """Base class for request messages to be send from by USB-Stick.""" - arguments: list = [] + _reply_identifier: bytes = b"0000" def __init__( self, - identifier: bytes, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]] + | None, mac: bytes | None, ) -> None: """Initialize request message.""" - super().__init__(identifier) - + super().__init__() self._args = [] self._mac = mac self._send_counter: int = 0 + self._send_fn = send_fn self._max_retries: int = MAX_RETRIES self._loop = get_running_loop() - self._reply_identifier: bytes = b"0000" self._response: PlugwiseResponse | None = None - self._stick_subscription_fn: Callable[[], None] | None = None - self._node_subscription_fn: Callable[[], None] | None = None + self._stick_subscription_fn: ( + Callable[ + [ + Callable[[StickResponse], Coroutine[Any, Any, None]], + bytes | None, + StickResponseType | None, + ], + Callable[[], None], + ] + | None + ) = None + self._node_subscription_fn: ( + Callable[ + [ + Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], + bytes | None, + tuple[bytes, ...] | None, + bytes | None, + ], + Callable[[], None], + ] + | None + ) = None self._unsubscribe_stick_response: Callable[[], None] | None = None self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None - self._response_future: Future[PlugwiseResponse] = ( - self._loop.create_future() - ) + self._response_future: Future[PlugwiseResponse] = self._loop.create_future() def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempt={self._send_counter})" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempt={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, attempt={self._send_counter})" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" @@ -90,22 +134,25 @@ def seq_id(self) -> bytes | None: def seq_id(self, seq_id: bytes) -> None: """Assign sequence id.""" if self._seq_id is not None: - _LOGGER.warning("Unable to change seq_id into %s for request %s", seq_id, self) - raise MessageError(f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}") + _LOGGER.warning( + "Unable to change seq_id into %s for request %s", seq_id, self + ) + raise MessageError( + f"Unable to set seq_id to {seq_id!r}. Already set to {self._seq_id!r}" + ) self._seq_id = seq_id # Subscribe to receive the response messages - self._unsubscribe_stick_response = self._stick_subscription_fn( - self._process_stick_response, - seq_id=self._seq_id - ) - self._unsubscribe_node_response = ( - self._node_subscription_fn( + if self._stick_subscription_fn is not None: + self._unsubscribe_stick_response = self._stick_subscription_fn( + self._process_stick_response, self._seq_id, None + ) + if self._node_subscription_fn is not None: + self._unsubscribe_node_response = self._node_subscription_fn( self._process_node_response, - mac=self._mac, - message_ids=(self._reply_identifier,), - seq_id=self._seq_id + self._mac, + (self._reply_identifier,), + self._seq_id, ) - ) def _unsubscribe_from_stick(self) -> None: """Unsubscribe from StickResponse messages.""" @@ -121,8 +168,25 @@ def _unsubscribe_from_node(self) -> None: def subscribe_to_responses( self, - stick_subscription_fn: Callable[[], None], - node_subscription_fn: Callable[[], None] + stick_subscription_fn: Callable[ + [ + Callable[[StickResponse], Coroutine[Any, Any, None]], + bytes | None, + StickResponseType | None, + ], + Callable[[], None], + ] + | None, + node_subscription_fn: Callable[ + [ + Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], + bytes | None, + tuple[bytes, ...] | None, + bytes | None, + ], + Callable[[], None], + ] + | None, ) -> None: """Register for response messages.""" self._node_subscription_fn = node_subscription_fn @@ -147,15 +211,15 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: if stick_timeout: _LOGGER.info("USB-stick responded with time out to %s", self) else: - _LOGGER.info("No response received for %s within %s seconds", self, NODE_TIME_OUT) + _LOGGER.info( + "No response received for %s within %s seconds", self, NODE_TIME_OUT + ) self._seq_id = None self._unsubscribe_from_stick() self._unsubscribe_from_node() if stick_timeout: self._response_future.set_exception( - StickTimeout( - f"USB-stick responded with time out to {self}" - ) + StickTimeout(f"USB-stick responded with time out to {self}") ) else: self._response_future.set_exception( @@ -176,14 +240,18 @@ def assign_error(self, error: BaseException) -> None: async def _process_node_response(self, response: PlugwiseResponse) -> bool: """Process incoming message from node.""" if self._seq_id is None: - _LOGGER.warning("Received %s as reply to %s without a seq_id assigned", self._response, self) + _LOGGER.warning( + "Received %s as reply to %s without a seq_id assigned", + self._response, + self, + ) return False if self._seq_id != response.seq_id: _LOGGER.warning( "Received %s as reply to %s which is not correct (expected seq_id=%s)", self._response, self, - str(self.seq_id) + str(self.seq_id), ) return False if self._response_future.done(): @@ -194,7 +262,12 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: self._unsubscribe_from_stick() self._unsubscribe_from_node() if self._send_counter > 1: - _LOGGER.info("Received %s after %s retries as reply to %s", self._response, self._send_counter, self) + _LOGGER.info( + "Received %s after %s retries as reply to %s", + self._response, + self._send_counter, + self, + ) else: _LOGGER.debug("Received %s as reply to %s", self._response, self) self._response_future.set_result(self._response) @@ -212,9 +285,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: self._unsubscribe_from_node() self._seq_id = None self._response_future.set_exception( - NodeError( - f"Stick failed request {self._seq_id}" - ) + NodeError(f"Stick failed request {self._seq_id}") ) elif stick_response.ack_id == StickResponseType.ACCEPT: pass @@ -223,9 +294,17 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: "Unknown StickResponseType %s at %s for request %s", str(stick_response.ack_id), stick_response, - self + self, ) + async def _send_request( + self, suppress_node_errors: bool = False + ) -> PlugwiseResponse | None: + """Send request.""" + if self._send_fn is None: + return None + return await self._send_fn(self, suppress_node_errors) + @property def max_retries(self) -> int: """Return the maximum retries.""" @@ -244,24 +323,44 @@ def retries_left(self) -> int: @property def resend(self) -> bool: """Return true if retry counter is not reached yet.""" - return self._max_retries > self._send_counter + return (self._max_retries > self._send_counter) - def add_send_attempt(self): + def add_send_attempt(self) -> None: """Increase the number of retries.""" self._send_counter += 1 +class PlugwiseCancelRequest(PlugwiseRequest): + """Cancel request for priority queue.""" + + def __init__(self) -> None: + """Initialize request message.""" + super().__init__(None, None) + self.priority = Priority.CANCEL + + class StickNetworkInfoRequest(PlugwiseRequest): """Request network information. Supported protocols : 1.0, 2.0 - Response message : NodeNetworkInfoResponse + Response message : StickNetworkInfoResponse """ - def __init__(self) -> None: - """Initialize StickNetworkInfoRequest message object.""" - self._reply_identifier = b"0002" - super().__init__(b"0001", None) + _identifier = b"0001" + _reply_identifier = b"0002" + + async def send( + self, suppress_node_errors: bool = False + ) -> StickNetworkInfoResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, StickNetworkInfoResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected StickNetworkInfoResponse" + ) class CirclePlusConnectRequest(PlugwiseRequest): @@ -271,10 +370,21 @@ class CirclePlusConnectRequest(PlugwiseRequest): Response message : CirclePlusConnectResponse """ - def __init__(self, mac: bytes) -> None: - """Initialize CirclePlusConnectRequest message object.""" - self._reply_identifier = b"0005" - super().__init__(b"0004", mac) + _identifier = b"0004" + _reply_identifier = b"0005" + + async def send( + self, suppress_node_errors: bool = False + ) -> CirclePlusConnectResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CirclePlusConnectResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CirclePlusConnectResponse" + ) # This message has an exceptional format and therefore # need to override the serialize method @@ -283,8 +393,8 @@ def serialize(self) -> bytes: # This command has # args: byte # key, byte - # networkinfo.index, ulong - # networkkey = 0 + # network info.index, ulong + # network key = 0 args = b"00000000000000000000" msg: bytes = self._identifier + args if self._mac is not None: @@ -293,16 +403,39 @@ def serialize(self) -> bytes: return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER -class NodeAddRequest(PlugwiseRequest): +class PlugwiseRequestWithNodeAckResponse(PlugwiseRequest): + """Base class of a plugwise request with a NodeAckResponse.""" + + async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) + + +class NodeAddRequest(PlugwiseRequestWithNodeAckResponse): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : TODO + Response message : TODO check if response is NodeAckResponse """ - def __init__(self, mac: bytes, accept: bool) -> None: + _identifier = b"0007" + _reply_identifier = b"0005" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + accept: bool, + ) -> None: """Initialize NodeAddRequest message object.""" - super().__init__(b"0007", mac) + super().__init__(send_fn, mac) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) @@ -317,10 +450,6 @@ def serialize(self) -> bytes: checksum = self.calculate_checksum(msg) return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER - def validate_reply(self, node_response: PlugwiseResponse) -> bool: - """"Validate node response.""" - return True - class CirclePlusAllowJoiningRequest(PlugwiseRequest): """Enable or disable receiving joining request of unjoined nodes. @@ -329,16 +458,33 @@ class CirclePlusAllowJoiningRequest(PlugwiseRequest): Supported protocols : 1.0, 2.0, 2.6 (has extra 'AllowThirdParty' field) - Response message : NodeAckResponse + Response message : NodeResponse """ - def __init__(self, enable: bool) -> None: + _identifier = b"0008" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + enable: bool, + ) -> None: """Initialize NodeAddRequest message object.""" - super().__init__(b"0008", None) - self._reply_identifier = b"0003" + super().__init__(send_fn, None) val = 1 if enable else 0 self._args.append(Int(val, length=2)) + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" + ) + class NodeResetRequest(PlugwiseRequest): """TODO:Some kind of reset request. @@ -347,14 +493,36 @@ class NodeResetRequest(PlugwiseRequest): Response message : """ - def __init__(self, mac: bytes, moduletype: int, timeout: int) -> None: + _identifier = b"0009" + _reply_identifier = b"0003" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + moduletype: int, + timeout: int, + ) -> None: """Initialize NodeResetRequest message object.""" - super().__init__(b"0009", mac) + super().__init__(send_fn, mac) self._args += [ Int(moduletype, length=2), Int(timeout, length=2), ] + async def send( + self, suppress_node_errors: bool = False + ) -> NodeSpecificResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeSpecificResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeSpecificResponse" + ) + class StickInitRequest(PlugwiseRequest): """Initialize USB-Stick. @@ -363,12 +531,32 @@ class StickInitRequest(PlugwiseRequest): Response message : StickInitResponse """ - def __init__(self) -> None: + _identifier = b"000A" + _reply_identifier = b"0011" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + ) -> None: """Initialize StickInitRequest message object.""" - super().__init__(b"000A", None) - self._reply_identifier = b"0011" + super().__init__(send_fn, None) self._max_retries = 1 + async def send( + self, suppress_node_errors: bool = False + ) -> StickInitResponse | None: + """Send request.""" + if self._send_fn is None: + raise MessageError("Send function missing") + result = await self._send_request(suppress_node_errors) + if isinstance(result, StickInitResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected StickInitResponse" + ) + class NodeImagePrepareRequest(PlugwiseRequest): """TODO: Some kind of request to prepare node for a firmware image. @@ -377,9 +565,21 @@ class NodeImagePrepareRequest(PlugwiseRequest): Response message : """ - def __init__(self) -> None: - """Initialize NodeImagePrepareRequest message object.""" - super().__init__(b"000B", None) + _identifier = b"000B" + _reply_identifier = b"0003" + + async def send( + self, suppress_node_errors: bool = False + ) -> NodeSpecificResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeSpecificResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeSpecificResponse" + ) class NodeImageValidateRequest(PlugwiseRequest): @@ -389,10 +589,21 @@ class NodeImageValidateRequest(PlugwiseRequest): Response message : NodeImageValidationResponse """ - def __init__(self) -> None: - """Initialize NodeImageValidateRequest message object.""" - super().__init__(b"000C", None) - self._reply_identifier = b"0010" + _identifier = b"000C" + _reply_identifier = b"0010" + + async def send( + self, suppress_node_errors: bool = False + ) -> NodeImageValidationResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeImageValidationResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeImageValidationResponse" + ) class NodePingRequest(PlugwiseRequest): @@ -402,12 +613,31 @@ class NodePingRequest(PlugwiseRequest): Response message : NodePingResponse """ - def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: + _identifier = b"000D" + _reply_identifier = b"000E" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + retries: int = MAX_RETRIES, + ) -> None: """Initialize NodePingRequest message object.""" - super().__init__(b"000D", mac) + super().__init__(send_fn, mac) self._reply_identifier = b"000E" self._max_retries = retries + async def send(self, suppress_node_errors: bool = False) -> NodePingResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodePingResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodePingResponse" + ) + class NodeImageActivateRequest(PlugwiseRequest): """TODO: Some kind of request to activate a firmware image for a node. @@ -416,11 +646,18 @@ class NodeImageActivateRequest(PlugwiseRequest): Response message : """ + _identifier = b"000F" + _reply_identifier = b"000E" + def __init__( - self, mac: bytes, request_type: int, reset_delay: int + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + request_type: int, + reset_delay: int, ) -> None: """Initialize NodeImageActivateRequest message object.""" - super().__init__(b"000F", mac) + super().__init__(send_fn, mac) _type = Int(request_type, 2) _reset_delay = Int(reset_delay, 2) self._args += [_type, _reset_delay] @@ -433,10 +670,21 @@ class CirclePowerUsageRequest(PlugwiseRequest): Response message : CirclePowerUsageResponse """ - def __init__(self, mac: bytes) -> None: - """Initialize CirclePowerUsageRequest message object.""" - super().__init__(b"0012", mac) - self._reply_identifier = b"0013" + _identifier = b"0012" + _reply_identifier = b"0013" + + async def send( + self, suppress_node_errors: bool = False + ) -> CirclePowerUsageResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CirclePowerUsageResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CirclePowerUsageResponse" + ) class CircleLogDataRequest(PlugwiseRequest): @@ -451,10 +699,18 @@ class CircleLogDataRequest(PlugwiseRequest): Response message : CircleLogDataResponse """ - def __init__(self, mac: bytes, start: datetime, end: datetime) -> None: + _identifier = b"0014" + _reply_identifier = b"0015" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + start: datetime, + end: datetime, + ) -> None: """Initialize CircleLogDataRequest message object.""" - super().__init__(b"0014", mac) - self._reply_identifier = b"0015" + super().__init__(send_fn, mac) passed_days_start = start.day - 1 month_minutes_start = ( (passed_days_start * DAY_IN_MINUTES) @@ -471,6 +727,19 @@ def __init__(self, mac: bytes, start: datetime, end: datetime) -> None: to_abs = DateTime(end.year, end.month, month_minutes_end) self._args += [from_abs, to_abs] + async def send( + self, suppress_node_errors: bool = False + ) -> CircleLogDataResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CircleLogDataResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CircleLogDataResponse" + ) + class CircleClockSetRequest(PlugwiseRequest): """Set internal clock of node and flash address. @@ -481,16 +750,19 @@ class CircleClockSetRequest(PlugwiseRequest): Response message : NodeResponse """ + _identifier = b"0016" + _reply_identifier = b"0000" + def __init__( self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, dt: datetime, protocol_version: float, reset: bool = False, ) -> None: """Initialize CircleLogDataRequest message object.""" - super().__init__(b"0016", mac) - self._reply_identifier = b"0000" + super().__init__(send_fn, mac) self.priority = Priority.HIGH if protocol_version < 2.0: # FIXME: Define "absoluteHour" variable @@ -498,18 +770,31 @@ def __init__( passed_days = dt.day - 1 month_minutes = ( - (passed_days * DAY_IN_MINUTES) - + (dt.hour * HOUR_IN_MINUTES) - + dt.minute + (passed_days * DAY_IN_MINUTES) + (dt.hour * HOUR_IN_MINUTES) + dt.minute ) this_date = DateTime(dt.year, dt.month, month_minutes) this_time = Time(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) if reset: - log_buf_addr = LogAddr(LOGADDR_OFFSET, 8, False) + self._args += [ + this_date, + LogAddr(LOGADDR_OFFSET, 8, False), + this_time, + day_of_week, + ] else: - log_buf_addr = String("FFFFFFFF", 8) - self._args += [this_date, log_buf_addr, this_time, day_of_week] + self._args += [this_date, String("FFFFFFFF", 8), this_time, day_of_week] + + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" + ) class CircleRelaySwitchRequest(PlugwiseRequest): @@ -519,14 +804,32 @@ class CircleRelaySwitchRequest(PlugwiseRequest): Response message : NodeResponse """ - def __init__(self, mac: bytes, on: bool) -> None: + _identifier = b"0017" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + on: bool, + ) -> None: """Initialize CircleRelaySwitchRequest message object.""" - super().__init__(b"0017", mac) - self._reply_identifier = b"0000" + super().__init__(send_fn, mac) self.priority = Priority.HIGH val = 1 if on else 0 self._args.append(Int(val, length=2)) + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" + ) + class CirclePlusScanRequest(PlugwiseRequest): """Request all linked Circle plugs from Circle+. @@ -538,10 +841,17 @@ class CirclePlusScanRequest(PlugwiseRequest): Response message : CirclePlusScanResponse """ - def __init__(self, mac: bytes, network_address: int) -> None: + _identifier = b"0018" + _reply_identifier = b"0019" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + network_address: int, + ) -> None: """Initialize CirclePlusScanRequest message object.""" - super().__init__(b"0018", mac) - self._reply_identifier = b"0019" + super().__init__(send_fn, mac) self._args.append(Int(network_address, length=2)) self.network_address = network_address @@ -549,6 +859,20 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, network_address={self.network_address})" + async def send( + self, suppress_node_errors: bool = False + ) -> CirclePlusScanResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CirclePlusScanResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CirclePlusScanResponse" + ) + + class NodeRemoveRequest(PlugwiseRequest): """Request node to be removed from Plugwise network by removing it from memory of Circle+ node. @@ -556,12 +880,32 @@ class NodeRemoveRequest(PlugwiseRequest): Response message : NodeRemoveResponse """ - def __init__(self, mac_circle_plus: bytes, mac_to_unjoined: str) -> None: + _identifier = b"001C" + _reply_identifier = b"001D" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac_circle_plus: bytes, + mac_to_unjoined: str, + ) -> None: """Initialize NodeRemoveRequest message object.""" - super().__init__(b"001C", mac_circle_plus) - self._reply_identifier = b"001D" + super().__init__(send_fn, mac_circle_plus) self._args.append(String(mac_to_unjoined, length=16)) + async def send( + self, suppress_node_errors: bool = False + ) -> NodeRemoveResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeRemoveResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeRemoveResponse" + ) + class NodeInfoRequest(PlugwiseRequest): """Request status info of node. @@ -570,12 +914,30 @@ class NodeInfoRequest(PlugwiseRequest): Response message : NodeInfoResponse """ - def __init__(self, mac: bytes, retries: int = MAX_RETRIES) -> None: + _identifier = b"0023" + _reply_identifier = b"0024" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + retries: int = MAX_RETRIES, + ) -> None: """Initialize NodeInfoRequest message object.""" - super().__init__(b"0023", mac) - self._reply_identifier = b"0024" + super().__init__(send_fn, mac) self._max_retries = retries + async def send(self, suppress_node_errors: bool = False) -> NodeInfoResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeInfoResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeInfoResponse" + ) + class EnergyCalibrationRequest(PlugwiseRequest): """Request power calibration settings of node. @@ -584,10 +946,21 @@ class EnergyCalibrationRequest(PlugwiseRequest): Response message : EnergyCalibrationResponse """ - def __init__(self, mac: bytes) -> None: - """Initialize EnergyCalibrationRequest message object.""" - super().__init__(b"0026", mac) - self._reply_identifier = b"0027" + _identifier = b"0026" + _reply_identifier = b"0027" + + async def send( + self, suppress_node_errors: bool = False + ) -> EnergyCalibrationResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, EnergyCalibrationResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected EnergyCalibrationResponse" + ) class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): @@ -597,16 +970,34 @@ class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): Response message : NodeResponse """ - def __init__(self, mac: bytes, dt: datetime): + _identifier = b"0028" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + dt: datetime, + ): """Initialize CirclePlusRealTimeClockSetRequest message object.""" - super().__init__(b"0028", mac) - self._reply_identifier = b"0000" + super().__init__(send_fn, mac) self.priority = Priority.HIGH this_time = RealClockTime(dt.hour, dt.minute, dt.second) day_of_week = Int(dt.weekday(), 2) this_date = RealClockDate(dt.day, dt.month, dt.year) self._args += [this_time, day_of_week, this_date] + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" + ) + class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): """Request current real time clock of CirclePlus. @@ -615,10 +1006,22 @@ class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): Response message : CirclePlusRealTimeClockResponse """ - def __init__(self, mac: bytes): - """Initialize CirclePlusRealTimeClockGetRequest message object.""" - super().__init__(b"0029", mac) - self._reply_identifier = b"003A" + _identifier = b"0029" + _reply_identifier = b"003A" + + async def send( + self, suppress_node_errors: bool = False + ) -> CirclePlusRealTimeClockResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CirclePlusRealTimeClockResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CirclePlusRealTimeClockResponse" + ) + # TODO : Insert # @@ -633,10 +1036,21 @@ class CircleClockGetRequest(PlugwiseRequest): Response message : CircleClockResponse """ - def __init__(self, mac: bytes): - """Initialize CircleClockGetRequest message object.""" - super().__init__(b"003E", mac) - self._reply_identifier = b"003F" + _identifier = b"003E" + _reply_identifier = b"003F" + + async def send( + self, suppress_node_errors: bool = False + ) -> CircleClockResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CircleClockResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CircleClockResponse" + ) class CircleActivateScheduleRequest(PlugwiseRequest): @@ -646,9 +1060,17 @@ class CircleActivateScheduleRequest(PlugwiseRequest): Response message : TODO: """ - def __init__(self, mac: bytes, on: bool) -> None: + _identifier = b"0040" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + on: bool, + ) -> None: """Initialize CircleActivateScheduleRequest message object.""" - super().__init__(b"0040", mac) + super().__init__(send_fn, mac) val = 1 if on else 0 self._args.append(Int(val, length=2)) # the second parameter is always 0x01 @@ -661,11 +1083,19 @@ class NodeAddToGroupRequest(PlugwiseRequest): Response message: TODO: """ + _identifier = b"0045" + _reply_identifier = b"0000" + def __init__( - self, mac: bytes, group_mac: bytes, task_id: str, port_mask: str + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + group_mac: str, + task_id: str, + port_mask: str, ) -> None: """Initialize NodeAddToGroupRequest message object.""" - super().__init__(b"0045", mac) + super().__init__(send_fn, mac) group_mac_val = String(group_mac, length=16) task_id_val = String(task_id, length=16) port_mask_val = String(port_mask, length=16) @@ -678,9 +1108,17 @@ class NodeRemoveFromGroupRequest(PlugwiseRequest): Response message: TODO: """ - def __init__(self, mac: bytes, group_mac: bytes) -> None: + _identifier = b"0046" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + group_mac: str, + ) -> None: """Initialize NodeRemoveFromGroupRequest message object.""" - super().__init__(b"0046", mac) + super().__init__(send_fn, mac) group_mac_val = String(group_mac, length=16) self._args += [group_mac_val] @@ -691,9 +1129,17 @@ class NodeBroadcastGroupSwitchRequest(PlugwiseRequest): Response message: TODO: """ - def __init__(self, group_mac: bytes, switch_state: bool) -> None: + _identifier = b"0047" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + group_mac: bytes, + switch_state: bool, + ) -> None: """Initialize NodeBroadcastGroupSwitchRequest message object.""" - super().__init__(b"0047", group_mac) + super().__init__(send_fn, group_mac) val = 1 if switch_state else 0 self._args.append(Int(val, length=2)) @@ -704,10 +1150,17 @@ class CircleEnergyLogsRequest(PlugwiseRequest): Response message: CircleEnergyLogsResponse """ - def __init__(self, mac: bytes, log_address: int) -> None: + _identifier = b"0048" + _reply_identifier = b"0049" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + log_address: int, + ) -> None: """Initialize CircleEnergyLogsRequest message object.""" - super().__init__(b"0048", mac) - self._reply_identifier = b"0049" + super().__init__(send_fn, mac) self._log_address = log_address self.priority = Priority.LOW self._args.append(LogAddr(log_address, 8)) @@ -716,16 +1169,28 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, log_address={self._log_address})" + async def send( + self, suppress_node_errors: bool = False + ) -> CircleEnergyLogsResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CircleEnergyLogsResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CircleEnergyLogsResponse" + ) + class CircleHandlesOffRequest(PlugwiseRequest): """?PWSetHandlesOffRequestV1_0. - Response message: ? + Response message: TODO """ - def __init__(self, mac: bytes) -> None: - """Initialize CircleHandlesOffRequest message object.""" - super().__init__(b"004D", mac) + _identifier = b"004D" + _reply_identifier = b"0000" class CircleHandlesOnRequest(PlugwiseRequest): @@ -734,9 +1199,8 @@ class CircleHandlesOnRequest(PlugwiseRequest): Response message: ? """ - def __init__(self, mac: bytes) -> None: - """Initialize CircleHandlesOnRequest message object.""" - super().__init__(b"004E", mac) + _identifier = b"004E" + _reply_identifier = b"0000" class NodeSleepConfigRequest(PlugwiseRequest): @@ -753,11 +1217,15 @@ class NodeSleepConfigRequest(PlugwiseRequest): clock_interval : Duration in minutes the node synchronize its clock - Response message: Ack message with SLEEP_SET + Response message: NodeAckResponse with SLEEP_SET """ + _identifier = b"0050" + _reply_identifier = b"0100" + def __init__( self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, stay_active: int, maintenance_interval: int, @@ -766,8 +1234,7 @@ def __init__( clock_interval: int, ): """Initialize NodeSleepConfigRequest message object.""" - super().__init__(b"0050", mac) - self._reply_identifier = b"0100" + super().__init__(send_fn, mac) stay_active_val = Int(stay_active, length=2) sleep_for_val = Int(sleep_for, length=4) maintenance_interval_val = Int(maintenance_interval, length=4) @@ -782,6 +1249,17 @@ def __init__( clock_interval_val, ] + async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) + class NodeSelfRemoveRequest(PlugwiseRequest): """TODO: Remove node?. @@ -795,9 +1273,8 @@ class NodeSelfRemoveRequest(PlugwiseRequest): """ - def __init__(self, mac: bytes) -> None: - """Initialize NodeSelfRemoveRequest message object.""" - super().__init__(b"0051", mac) + _identifier = b"0051" + _reply_identifier = b"0000" class CircleMeasureIntervalRequest(PlugwiseRequest): @@ -808,9 +1285,18 @@ class CircleMeasureIntervalRequest(PlugwiseRequest): Response message: Ack message with ??? TODO: """ - def __init__(self, mac: bytes, consumption: int, production: int): + _identifier = b"0057" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + consumption: int, + production: int, + ): """Initialize CircleMeasureIntervalRequest message object.""" - super().__init__(b"0057", mac) + super().__init__(send_fn, mac) self._args.append(Int(consumption, length=4)) self._args.append(Int(production, length=4)) @@ -818,12 +1304,20 @@ def __init__(self, mac: bytes, consumption: int, production: int): class NodeClearGroupMacRequest(PlugwiseRequest): """TODO: usage?. - Response message: ???? + Response message: TODO """ - def __init__(self, mac: bytes, taskId: int) -> None: + _identifier = b"0058" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + taskId: int, + ) -> None: """Initialize NodeClearGroupMacRequest message object.""" - super().__init__(b"0058", mac) + super().__init__(send_fn, mac) self._args.append(Int(taskId, length=2)) @@ -833,9 +1327,17 @@ class CircleSetScheduleValueRequest(PlugwiseRequest): Response message: TODO: """ - def __init__(self, mac: bytes, val: int) -> None: + _identifier = b"0059" + _reply_identifier = b"0000" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + val: int, + ) -> None: """Initialize CircleSetScheduleValueRequest message object.""" - super().__init__(b"0059", mac) + super().__init__(send_fn, mac) self._args.append(SInt(val, length=4)) @@ -845,12 +1347,32 @@ class NodeFeaturesRequest(PlugwiseRequest): Response message: NodeFeaturesResponse """ - def __init__(self, mac: bytes, val: int) -> None: + _identifier = b"005F" + _reply_identifier = b"0060" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + val: int, + ) -> None: """Initialize NodeFeaturesRequest message object.""" - super().__init__(b"005F", mac) - self._reply_identifier = b"0060" + super().__init__(send_fn, mac) self._args.append(SInt(val, length=4)) + async def send( + self, suppress_node_errors: bool = False + ) -> NodeFeaturesResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeFeaturesResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeFeaturesResponse" + ) + class ScanConfigureRequest(PlugwiseRequest): """Configure a Scan node. @@ -865,12 +1387,19 @@ class ScanConfigureRequest(PlugwiseRequest): Response message: NodeAckResponse """ + _identifier = b"0101" + _reply_identifier = b"0100" + def __init__( - self, mac: bytes, reset_timer: int, sensitivity: int, light: bool + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + reset_timer: int, + sensitivity: int, + light: bool, ): """Initialize ScanConfigureRequest message object.""" - super().__init__(b"0101", mac) - self._reply_identifier = b"0100" + super().__init__(send_fn, mac) reset_timer_value = Int(reset_timer, length=2) # Sensitivity: HIGH(0x14), MEDIUM(0x1E), OFF(0xFF) sensitivity_value = Int(sensitivity, length=2) @@ -882,6 +1411,17 @@ def __init__( reset_timer_value, ] + async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) + class ScanLightCalibrateRequest(PlugwiseRequest): """Calibrate light sensitivity. @@ -889,10 +1429,19 @@ class ScanLightCalibrateRequest(PlugwiseRequest): Response message: NodeAckResponse """ - def __init__(self, mac: bytes): - """Initialize ScanLightCalibrateRequest message object.""" - super().__init__(b"0102", mac) - self._reply_identifier = b"0100" + _identifier = b"0102" + _reply_identifier = b"0100" + + async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) class SenseReportIntervalRequest(PlugwiseRequest): @@ -903,12 +1452,30 @@ class SenseReportIntervalRequest(PlugwiseRequest): Response message: NodeAckResponse """ - def __init__(self, mac: bytes, interval: int): + _identifier = b"0103" + _reply_identifier = b"0100" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + interval: int, + ): """Initialize ScanLightCalibrateRequest message object.""" - super().__init__(b"0103", mac) - self._reply_identifier = b"0100" + super().__init__(send_fn, mac) self._args.append(Int(interval, length=2)) + async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeAckResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + ) + class CircleRelayInitStateRequest(PlugwiseRequest): """Get or set initial relay state after power-up of Circle. @@ -917,11 +1484,32 @@ class CircleRelayInitStateRequest(PlugwiseRequest): Response message : CircleInitRelayStateResponse """ - def __init__(self, mac: bytes, configure: bool, relay_state: bool) -> None: + _identifier = b"0138" + _reply_identifier = b"0139" + + def __init__( + self, + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], + mac: bytes, + configure: bool, + relay_state: bool, + ) -> None: """Initialize CircleRelayInitStateRequest message object.""" - super().__init__(b"0138", mac) - self._reply_identifier = b"0139" + super().__init__(send_fn, mac) self.priority = Priority.LOW self.set_or_get = Int(1 if configure else 0, length=2) self.relay = Int(1 if relay_state else 0, length=2) self._args += [self.set_or_get, self.relay] + + async def send( + self, suppress_node_errors: bool = False + ) -> CircleRelayInitStateResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, CircleRelayInitStateResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected CircleRelayInitStateResponse" + ) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 4c81ef670..f1ff516db 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1,4 +1,5 @@ """All known response messages to be received from plugwise devices.""" + from __future__ import annotations from datetime import datetime @@ -6,11 +7,11 @@ from typing import Any, Final from ..api import NodeType -from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER, UTF8 +from ..constants import MESSAGE_FOOTER, MESSAGE_HEADER from ..exceptions import MessageError from . import PlugwiseMessage, Priority from .properties import ( - BaseType, + Bytes, DateTime, Float, Int, @@ -103,7 +104,9 @@ def __init__( decode_mac: bool = True, ) -> None: """Initialize a response message.""" - super().__init__(identifier) + super().__init__() + self._identifier = identifier + self._mac: bytes | None = None self._ack_id: bytes | None = None self._decode_ack = decode_ack self._decode_mac = decode_mac @@ -113,7 +116,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, retries={self._retries})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, retries={self._retries})" @property def retries(self) -> int: @@ -130,11 +133,6 @@ def ack_id(self) -> bytes | None: """Return the acknowledge id.""" return self._ack_id - @property - def seq_id(self) -> bytes: - """Sequence ID.""" - return self._seq_id - def deserialize(self, response: bytes, has_footer: bool = True) -> None: """Deserialize bytes to actual message properties.""" # Header @@ -162,7 +160,7 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if (check := self.calculate_checksum(response[:-4])) != response[-4:]: raise MessageError( f"Invalid checksum for {self.__class__.__name__}, " - + f"expected {check} got " + + f"expected {check!r} got " + str(response[-4:]), ) response = response[:-4] @@ -171,8 +169,8 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if self._identifier != response[:4]: raise MessageError( "Invalid message identifier received " - + f"expected {self._identifier} " - + f"got {response[:4]}" + + f"expected {self._identifier!r} " + + f"got {response[:4]!r}" ) self._seq_id = response[4:8] response = response[8:] @@ -205,7 +203,7 @@ def _parse_params(self, response: bytes) -> bytes: for param in self._params: my_val = response[: len(param)] param.deserialize(my_val) - response = response[len(my_val):] + response = response[len(my_val) :] return response def __len__(self) -> int: @@ -228,11 +226,15 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" + if self.ack_id is None: + return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack=UNKNOWN)" + return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" @property def response_type(self) -> StickResponseType: """Return acknowledge response type.""" + if self.ack_id is None: + raise MessageError("Acknowledge ID is unknown") return StickResponseType(self.ack_id) @@ -252,7 +254,18 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" - return f"{super().__repr__()[:-1]}, ack={str(NodeResponseType(self.ack_id).name)})" + if self.ack_id is None: + return f"{super().__repr__()[:-1]}, ack=UNKNOWN)" + return ( + f"{super().__repr__()[:-1]}, ack={str(NodeResponseType(self.ack_id).name)})" + ) + + @property + def response_type(self) -> NodeResponseType: + """Return acknowledge response type.""" + if self.ack_id is None: + raise MessageError("Acknowledge ID is unknown") + return NodeResponseType(self.ack_id) class StickNetworkInfoResponse(PlugwiseResponse): @@ -265,29 +278,34 @@ class StickNetworkInfoResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeNetworkInfoResponse message object.""" super().__init__(b"0002") - self.channel = String(None, length=2) - self.source_mac_id = String(None, length=16) + self._channel = Int(0, length=2) + self._source_mac_id = String(None, length=16) self.extended_pan_id = String(None, length=16) self.unique_network_id = String(None, length=16) - self.new_node_mac_id = String(None, length=16) + self._new_node_mac_id = String(None, length=16) self.pan_id = String(None, length=4) self.idx = Int(0, length=2) self._params += [ - self.channel, - self.source_mac_id, + self._channel, + self._source_mac_id, self.extended_pan_id, self.unique_network_id, - self.new_node_mac_id, + self._new_node_mac_id, self.pan_id, self.idx, ] - def deserialize(self, response: bytes, has_footer: bool = True) -> None: - """Extract data from bytes.""" - super().deserialize(response, has_footer) + @property + def channel(self) -> int: + """Return zigbee channel.""" + return self._channel.value + + @property + def new_node_mac_id(self) -> str: + """New node mac_id.""" # Clear first two characters of mac ID, as they contain # part of the short PAN-ID - self.new_node_mac_id.value = b"00" + self.new_node_mac_id.value[2:] + return "00" + self._new_node_mac_id.value[2:] class NodeSpecificResponse(PlugwiseResponse): @@ -403,24 +421,24 @@ class StickInitResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize StickInitResponse message object.""" super().__init__(b"0011") - self.unknown1 = Int(0, length=2) + self._unknown1 = Int(0, length=2) self._network_online = Int(0, length=2) self._mac_nc = String(None, length=16) self._network_id = Int(0, 4, False) - self.unknown2 = Int(0, length=2) + self._unknown2 = Int(0, length=2) self._params += [ - self.unknown1, + self._unknown1, self._network_online, self._mac_nc, self._network_id, - self.unknown2, + self._unknown2, ] @property def mac_network_controller(self) -> str: """Return the mac of the network controller (Circle+).""" # Replace first 2 characters by 00 for mac of circle+ node - return "00" + self._mac_nc.value[2:].decode(UTF8) + return "00" + self._mac_nc.value[2:] @property def network_id(self) -> int: @@ -436,6 +454,7 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, network_controller={self.mac_network_controller}, network_online={self.network_online})" + class CirclePowerUsageResponse(PlugwiseResponse): """Returns power usage as impulse counters for several different time frames. @@ -529,7 +548,7 @@ def __init__(self) -> None: @property def registered_mac(self) -> str: """Return the mac of the node.""" - return self._registered_mac.value.decode(UTF8) + return self._registered_mac.value @property def network_address(self) -> int: @@ -540,6 +559,7 @@ def __repr__(self) -> str: """Convert response into writable str.""" return f"{super().__repr__()[:-1]}, network_address={self.network_address}, registered_mac={self.registered_mac})" + class NodeRemoveResponse(PlugwiseResponse): """Confirmation (or not) if node is removed from the Plugwise network. @@ -608,7 +628,7 @@ def __init__(self, protocol_version: str = "2.0") -> None: @property def hardware(self) -> str: """Return hardware id.""" - return self._hw_ver.value.decode(UTF8) + return str(self._hw_ver.value) @property def firmware(self) -> datetime: @@ -633,7 +653,7 @@ def relay_state(self) -> bool: @property def frequency(self) -> int: """Return frequency config of node.""" - return self._frequency + return self._frequency.value def __repr__(self) -> str: """Convert request into writable str.""" @@ -757,6 +777,28 @@ def log_address(self) -> int: """Return the gain A.""" return self._logaddr.value + @property + def log_data(self) -> dict[int, tuple[datetime | None, int | None]]: + """Return log data.""" + log_data: dict[int, tuple[datetime | None, int | None]] = {} + if self.logdate1.value_set: + log_data[1] = (self.logdate1.value, self.pulses1.value) + else: + log_data[1] = (None, None) + if self.logdate2.value_set: + log_data[2] = (self.logdate2.value, self.pulses2.value) + else: + log_data[2] = (None, None) + if self.logdate3.value_set: + log_data[3] = (self.logdate3.value, self.pulses3.value) + else: + log_data[3] = (None, None) + if self.logdate4.value_set: + log_data[4] = (self.logdate4.value, self.pulses4.value) + else: + log_data[4] = (None, None) + return log_data + def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, log_address={self._logaddr.value})" @@ -811,12 +853,16 @@ def __init__(self) -> None: """Initialize NodeSwitchGroupResponse message object.""" super().__init__(NODE_SWITCH_GROUP_ID) self.group = Int(0, 2, False) - self.power_state = Int(0, length=2) + self._power_state = Int(0, length=2) self._params += [ self.group, - self.power_state, + self._power_state, ] + @property + def switch_state(self) -> bool: + """Return state of switch (True = On, False = Off).""" + return (self._power_state.value != 0) class NodeFeaturesResponse(PlugwiseResponse): """Returns supported features of node. @@ -860,7 +906,7 @@ class NodeAckResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeAckResponse message object.""" super().__init__(b"0100") - self._node_ack_type = BaseType(0, length=4) + self._node_ack_type = Bytes(None, length=4) self._params += [self._node_ack_type] self.priority = Priority.HIGH @@ -906,7 +952,7 @@ def __init__(self) -> None: self._params += [self.is_get, self.relay] -def get_message_object( +def get_message_object( # noqa: C901 identifier: bytes, length: int, seq_id: bytes ) -> PlugwiseResponse | None: """Return message class based on sequence ID, Length of message or message ID.""" @@ -928,7 +974,7 @@ def get_message_object( if length == 36: return NodeResponse() return None - + # Regular response ID's if identifier == b"0002": return StickNetworkInfoResponse() @@ -972,3 +1018,4 @@ def get_message_object( return SenseReportResponse() if identifier == b"0139": return CircleRelayInitStateResponse() + raise MessageError(f"Unknown message for identifier {identifier!r}") diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 19b3306b7..260d91b3e 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -4,15 +4,17 @@ from __future__ import annotations -from asyncio import create_task, gather, sleep -from collections.abc import Awaitable, Callable +from asyncio import gather, sleep +from collections.abc import Callable, Coroutine from datetime import datetime, timedelta import logging +from typing import Any from ..api import NodeEvent, NodeType, StickEvent from ..connection import StickController from ..constants import UTF8 from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout +from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusAllowJoiningRequest, NodeInfoRequest, @@ -21,12 +23,12 @@ from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, NODE_JOIN_ID, - NodeAckResponse, NodeAwakeResponse, NodeInfoResponse, NodeJoinAvailableResponse, NodePingResponse, NodeResponseType, + PlugwiseResponse, ) from ..nodes import PlugwiseNode from ..nodes.circle import PlugwiseCircle @@ -35,7 +37,6 @@ from ..nodes.sense import PlugwiseSense from ..nodes.stealth import PlugwiseStealth from ..nodes.switch import PlugwiseSwitch -from ..helpers.util import validate_mac from .registry import StickNetworkRegister _LOGGER = logging.getLogger(__name__) @@ -46,7 +47,6 @@ class StickNetwork: """USB-Stick zigbee network class.""" accept_join_request = False - join_available: Callable | None = None _event_subscriptions: dict[StickEvent, int] = {} def __init__( @@ -71,14 +71,17 @@ def __init__( self._node_event_subscribers: dict[ Callable[[], None], - tuple[Callable[[NodeEvent], Awaitable[None]], NodeEvent | None] + tuple[ + Callable[[NodeEvent, str], Coroutine[Any, Any, None]], + tuple[NodeEvent, ...], + ], ] = {} self._unsubscribe_stick_event: Callable[[], None] | None = None self._unsubscribe_node_awake: Callable[[], None] | None = None self._unsubscribe_node_join: Callable[[], None] | None = None -# region - Properties + # region - Properties @property def cache_enabled(self) -> bool: @@ -146,14 +149,15 @@ def nodes( def registry(self) -> dict[int, tuple[str, NodeType | None]]: """Return dictionary with all registered (joined) nodes.""" return self._register.registry -# endregion - async def register_node(self, mac: str) -> None: + # endregion + + async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" if not validate_mac(mac): raise NodeError(f"Invalid mac '{mac}' to register") address = await self._register.register_node(mac) - self._discover_node(address, mac, None) + return await self._discover_node(address, mac, None) async def clear_cache(self) -> None: """Clear register cache.""" @@ -165,28 +169,22 @@ async def unregister_node(self, mac: str) -> None: await self._nodes[mac].unload() self._nodes.pop(mac) -# region - Handle stick connect/disconnect events + # region - Handle stick connect/disconnect events def _subscribe_to_protocol_events(self) -> None: """Subscribe to events from protocol.""" - self._unsubscribe_stick_event = ( - self._controller.subscribe_to_stick_events( - self._handle_stick_event, - (StickEvent.CONNECTED, StickEvent.DISCONNECTED), - ) + self._unsubscribe_stick_event = self._controller.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED), ) - self._unsubscribe_node_awake = ( - self._controller.subscribe_to_node_responses( - self.node_awake_message, - None, - (NODE_AWAKE_RESPONSE_ID,), - ) + self._unsubscribe_node_awake = self._controller.subscribe_to_node_responses( + self.node_awake_message, + None, + (NODE_AWAKE_RESPONSE_ID,), ) - self._unsubscribe_node_join = ( - self._controller.subscribe_to_node_responses( - self.node_join_available_message, - None, - (NODE_JOIN_ID,), - ) + self._unsubscribe_node_join = self._controller.subscribe_to_node_responses( + self.node_join_available_message, + None, + (NODE_JOIN_ID,), ) async def _handle_stick_event(self, event: StickEvent) -> None: @@ -202,21 +200,16 @@ async def _handle_stick_event(self, event: StickEvent) -> None: self._is_running = True await self.discover_nodes() elif event == StickEvent.DISCONNECTED: - await gather( - *[ - node.disconnect() - for node in self._nodes.values() - ] - ) + await gather(*[node.disconnect() for node in self._nodes.values()]) self._is_running = False - async def node_awake_message(self, response: NodeAwakeResponse) -> bool: + async def node_awake_message(self, response: PlugwiseResponse) -> bool: """Handle NodeAwakeResponse message.""" + if not isinstance(response, NodeAwakeResponse): + raise MessageError(f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse") mac = response.mac_decoded if self._awake_discovery.get(mac) is None: - self._awake_discovery[mac] = ( - response.timestamp - timedelta(seconds=15) - ) + self._awake_discovery[mac] = response.timestamp - timedelta(seconds=15) if mac in self._nodes: if self._awake_discovery[mac] < ( response.timestamp - timedelta(seconds=10) @@ -229,20 +222,21 @@ async def node_awake_message(self, response: NodeAwakeResponse) -> bool: return True _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", - mac + mac, ) return False - address: int | None = self._register.network_address(mac) - if self._nodes.get(mac) is None: - create_task( - self._discover_battery_powered_node(address, mac) - ) + address = self._register.network_address(mac) + if (address := self._register.network_address(mac)) is not None: + if self._nodes.get(mac) is None: + return await self._discover_battery_powered_node(address, mac) + else: + raise NodeError("Unknown network address for node {mac}") return True - async def node_join_available_message( - self, response: NodeJoinAvailableResponse - ) -> bool: + async def node_join_available_message(self, response: PlugwiseResponse) -> bool: """Handle NodeJoinAvailableResponse messages.""" + if not isinstance(response, NodeJoinAvailableResponse): + raise MessageError(f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse") mac = response.mac_decoded await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True @@ -256,12 +250,10 @@ def _unsubscribe_to_protocol_events(self) -> None: self._unsubscribe_stick_event() self._unsubscribe_stick_event = None -# endregion + # endregion -# region - Coordinator - async def discover_network_coordinator( - self, load: bool = False - ) -> bool: + # region - Coordinator + async def discover_network_coordinator(self, load: bool = False) -> bool: """Discover the Zigbee network coordinator (Circle+/Stealth+).""" if self._controller.mac_coordinator is None: raise NodeError("Unknown mac address for network coordinator.") @@ -270,35 +262,34 @@ async def discover_network_coordinator( # Validate the network controller is online # try to ping first and raise error at stick timeout - ping_response: NodePingResponse | None = None try: - ping_response = await self._controller.send( - NodePingRequest( - bytes(self._controller.mac_coordinator, UTF8), - retries=1 - ), - ) # type: ignore [assignment] + ping_request = NodePingRequest( + self._controller.send, + bytes(self._controller.mac_coordinator, UTF8), + retries=1, + ) + ping_response = await ping_request.send() except StickTimeout as err: raise StickError( - "The zigbee network coordinator (Circle+/Stealth+) with mac " + - "'%s' did not respond to ping request. Make " + - "sure the Circle+/Stealth+ is within reach of the USB-stick !", - self._controller.mac_coordinator + "The zigbee network coordinator (Circle+/Stealth+) with mac " + + "'%s' did not respond to ping request. Make " + + "sure the Circle+/Stealth+ is within reach of the USB-stick !", + self._controller.mac_coordinator, ) from err if ping_response is None: return False - address, node_type = self._register.network_controller() if await self._discover_node( - address, self._controller.mac_coordinator, node_type, ping_first=False + -1, self._controller.mac_coordinator, None, ping_first=False ): if load: return await self._load_node(self._controller.mac_coordinator) return True return False -# endregion -# region - Nodes + # endregion + + # region - Nodes def _create_node_object( self, mac: str, @@ -309,7 +300,7 @@ def _create_node_object( if self._nodes.get(mac) is not None: _LOGGER.debug( "Skip creating node object because node object for mac %s already exists", - mac + mac, ) return supported_type = True @@ -363,11 +354,7 @@ def _create_node_object( _LOGGER.debug("Stealth node %s added", mac) else: supported_type = False - _LOGGER.warning( - "Node %s of type %s is unsupported", - mac, - str(node_type) - ) + _LOGGER.warning("Node %s of type %s is unsupported", mac, str(node_type)) if supported_type: self._register.update_network_registration(address, mac, node_type) @@ -388,18 +375,16 @@ async def get_node_details( ping_response: NodePingResponse | None = None if ping_first: # Define ping request with one retry - ping_request = NodePingRequest(bytes(mac, UTF8), retries=1) - ping_response: NodePingResponse | None = ( - await self._controller.send( - ping_request - ) + ping_request = NodePingRequest( + self._controller.send, bytes(mac, UTF8), retries=1 ) + ping_response = await ping_request.send(suppress_node_errors=True) if ping_response is None: return (None, None) - - info_response: NodeInfoResponse | None = await self._controller.send( - NodeInfoRequest(bytes(mac, UTF8), retries=1) - ) # type: ignore [assignment] + info_request = NodeInfoRequest( + self._controller.send, bytes(mac, UTF8), retries=1 + ) + info_response = await info_request.send() return (info_response, ping_response) async def _discover_battery_powered_node( @@ -411,9 +396,14 @@ async def _discover_battery_powered_node( Return True if discovery succeeded. """ - await self._discover_node(address, mac, node_type=None, ping_first=False) - await self._load_node(mac) - await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + if not await self._discover_node( + address, mac, node_type=None, ping_first=False + ): + return False + if await self._load_node(mac): + await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) + return True + return False async def _discover_node( self, @@ -432,9 +422,7 @@ async def _discover_node( if node_type is not None: self._create_node_object(mac, address, node_type) - await self._notify_node_event_subscribers( - NodeEvent.DISCOVERED, mac - ) + await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) return True # Node type is unknown, so we need to discover it first @@ -449,6 +437,7 @@ async def _discover_node( if node_ping is not None: await self._nodes[mac].ping_update(node_ping) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) + return True async def _discover_registered_nodes(self) -> None: """Discover nodes.""" @@ -458,15 +447,10 @@ async def _discover_registered_nodes(self) -> None: mac, node_type = registration if mac != "": if self._nodes.get(mac) is None: - await self._discover_node( - address, mac, node_type - ) + await self._discover_node(address, mac, node_type) counter += 1 await sleep(0) - _LOGGER.debug( - "Total %s registered node(s)", - str(counter) - ) + _LOGGER.debug("Total %s registered node(s)", str(counter)) self._controller.reduce_receive_logging = False async def _load_node(self, mac: str) -> bool: @@ -480,46 +464,39 @@ async def _load_node(self, mac: str) -> bool: return True return False - async def _load_discovered_nodes(self) -> None: + async def _load_discovered_nodes(self) -> bool: """Load all nodes currently discovered.""" _LOGGER.debug("_load_discovered_nodes | START | %s", len(self._nodes)) for mac, node in self._nodes.items(): - _LOGGER.debug("_load_discovered_nodes | mac=%s | loaded=%s", mac, node.loaded) + _LOGGER.debug( + "_load_discovered_nodes | mac=%s | loaded=%s", mac, node.loaded + ) nodes_not_loaded = tuple( - mac - for mac, node in self._nodes.items() - if not node.loaded + mac for mac, node in self._nodes.items() if not node.loaded ) _LOGGER.debug("_load_discovered_nodes | nodes_not_loaded=%s", nodes_not_loaded) - load_result = await gather( - *[ - self._load_node(mac) - for mac in nodes_not_loaded - ] - ) + load_result = await gather(*[self._load_node(mac) for mac in nodes_not_loaded]) _LOGGER.debug("_load_discovered_nodes | load_result=%s", load_result) result_index = 0 for mac in nodes_not_loaded: if load_result[result_index]: await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) else: - _LOGGER.debug("_load_discovered_nodes | Load request for %s failed", mac) + _LOGGER.debug( + "_load_discovered_nodes | Load request for %s failed", mac + ) result_index += 1 _LOGGER.debug("_load_discovered_nodes | END") + return all(load_result) async def _unload_discovered_nodes(self) -> None: """Unload all nodes.""" - await gather( - *[ - node.unload() - for node in self._nodes.values() - ] - ) + await gather(*[node.unload() for node in self._nodes.values()]) -# endregion + # endregion -# region - Network instance + # region - Network instance async def start(self) -> None: """Start and activate network.""" self._register.quick_scan_finished(self._discover_registered_nodes) @@ -528,14 +505,16 @@ async def start(self) -> None: self._subscribe_to_protocol_events() self._is_running = True - async def discover_nodes(self, load: bool = True) -> None: + async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" if not self._is_running: await self.start() - await self.discover_network_coordinator() + if not await self.discover_network_coordinator(): + return False await self._discover_registered_nodes() if load: - await self._load_discovered_nodes() + return await self._load_discovered_nodes() + return True async def stop(self) -> None: """Stop network discovery.""" @@ -546,51 +525,44 @@ async def stop(self) -> None: await self._register.stop() _LOGGER.debug("Stopping finished") -# endregion + # endregion async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" - response: NodeAckResponse | None = await self._controller.send( - CirclePlusAllowJoiningRequest(state) - ) # type: ignore [assignment] + request = CirclePlusAllowJoiningRequest(self._controller.send, state) + response = await request.send() if response is None: - raise NodeError( - "No response to get notifications for join request." - ) - if response.node_ack_type != NodeResponseType.JOIN_ACCEPTED: + raise NodeError("No response to get notifications for join request.") + if response.response_type != NodeResponseType.JOIN_ACCEPTED: raise MessageError( - f"Unknown NodeResponseType '{response.ack_id!r}' received" + f"Unknown NodeResponseType '{response.response_type.name}' received" ) def subscribe_to_node_events( self, - node_event_callback: Callable[[NodeEvent, str], Awaitable[None]], - events: tuple[NodeEvent], + node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]], + events: tuple[NodeEvent, ...], ) -> Callable[[], None]: """Subscribe callback when specified NodeEvent occurs. Returns the function to be called to unsubscribe later. """ + def remove_subscription() -> None: """Remove stick event subscription.""" self._node_event_subscribers.pop(remove_subscription) - self._node_event_subscribers[ - remove_subscription - ] = (node_event_callback, events) + self._node_event_subscribers[remove_subscription] = ( + node_event_callback, + events, + ) return remove_subscription - async def _notify_node_event_subscribers( - self, - event: NodeEvent, - mac: str - ) -> None: + async def _notify_node_event_subscribers(self, event: NodeEvent, mac: str) -> None: """Call callback for node event subscribers.""" - callback_list: list[Callable] = [] - for callback, filtered_events in list( - self._node_event_subscribers.values() - ): - if event in filtered_events: + callback_list: list[Coroutine[Any, Any, None]] = [] + for callback, events in self._node_event_subscribers.values(): + if event in events: _LOGGER.debug("Publish %s for %s", event, mac) callback_list.append(callback(event, mac)) if len(callback_list) > 0: diff --git a/plugwise_usb/network/cache.py b/plugwise_usb/network/cache.py index 857b490cb..a9fb1eda2 100644 --- a/plugwise_usb/network/cache.py +++ b/plugwise_usb/network/cache.py @@ -21,7 +21,7 @@ def __init__(self, cache_root_dir: str = "") -> None: self._registrations: dict[int, tuple[str, NodeType | None]] = {} @property - def registrations(self) -> dict[int, tuple[str, NodeType]]: + def registrations(self) -> dict[int, tuple[str, NodeType | None]]: """Cached network information.""" return self._registrations @@ -34,7 +34,7 @@ async def save_cache(self) -> None: node_value = "" else: node_value = str(node_type) - cache_data_to_save[address] = f"{mac}{CACHE_DATA_SEPARATOR}{node_value}" + cache_data_to_save[str(address)] = f"{mac}{CACHE_DATA_SEPARATOR}{node_value}" await self.write_cache(cache_data_to_save) async def clear_cache(self) -> None: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 0fb8938b2..81439f53a 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -1,24 +1,27 @@ """Register of network configuration.""" + from __future__ import annotations from asyncio import Task, create_task, sleep -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable from copy import deepcopy import logging -from typing import Any from ..api import NodeType from ..constants import UTF8 from ..exceptions import CacheError, NodeError -from ..messages.requests import CirclePlusScanRequest, NodeAddRequest, NodeRemoveRequest +from ..helpers.util import validate_mac +from ..messages.requests import ( + CirclePlusScanRequest, + NodeAddRequest, + NodeRemoveRequest, + PlugwiseRequest, +) from ..messages.responses import ( CirclePlusScanResponse, - NodeRemoveResponse, - NodeResponse, NodeResponseType, PlugwiseResponse, ) -from ..helpers.util import validate_mac from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) @@ -30,7 +33,7 @@ class StickNetworkRegister: def __init__( self, mac_network_controller: bytes, - send_fn: Callable[[Any], Coroutine[Any, Any, PlugwiseResponse]] + send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], ) -> None: """Initialize network register.""" self._mac_nc = mac_network_controller @@ -42,12 +45,12 @@ def __init__( self._loaded: bool = False self._registry: dict[int, tuple[str, NodeType | None]] = {} self._first_free_address: int = 65 - self._registration_task: Task | None = None - self._network_cache_file_task: Task | None = None - self._quick_scan_finished: Awaitable | None = None - self._full_scan_finished: Awaitable | None = None + 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._scan_completed = False -# region Properties + + # region Properties @property def cache_enabled(self) -> bool: @@ -65,8 +68,8 @@ def cache_enabled(self, enable: bool = True) -> None: self._cache_enabled = enable async def initialize_cache(self, create_root_folder: bool = False) -> None: - """Initialize cache""" - if not self._cache_enabled: + """Initialize cache.""" + if not self._cache_enabled or self._network_cache is None: raise CacheError("Unable to initialize cache, enable cache first.") await self._network_cache.initialize_cache(create_root_folder) @@ -94,15 +97,15 @@ def scan_completed(self) -> bool: """Indicate if scan is completed.""" return self._scan_completed - def quick_scan_finished(self, callback: Awaitable) -> None: + 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 full_scan_finished(self, callback: Awaitable) -> None: + 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 -# endregion + # endregion async def start(self) -> None: """Initialize load the network registry.""" @@ -116,9 +119,7 @@ async def start(self) -> None: async def restore_network_cache(self) -> None: """Restore previously saved cached network and node information.""" if self._network_cache is None: - _LOGGER.error( - "Unable to restore cache when cache is not initialized" - ) + _LOGGER.error("Unable to restore cache when cache is not initialized") return if not self._cache_restored: if not self._network_cache.initialized: @@ -144,16 +145,11 @@ async def retrieve_network_registration( self, address: int, retry: bool = True ) -> tuple[int, str] | None: """Return the network mac registration of specified address.""" - response: CirclePlusScanResponse | None = ( - await self._send_to_controller( - CirclePlusScanRequest(self._mac_nc, address), - ) # type: ignore [assignment] - ) + request = CirclePlusScanRequest(self._send_to_controller, self._mac_nc, address) + response: CirclePlusScanResponse | None = await request.send() if response is None: if retry: - return await self.retrieve_network_registration( - address, retry=False - ) + return await self.retrieve_network_registration(address, retry=False) return None address = response.network_address mac_of_node = response.registered_mac @@ -169,11 +165,11 @@ def network_address(self, mac: str) -> int | None: return address return None - def network_controller(self) -> tuple[int, NodeType | None]: + def network_controller(self) -> tuple[str, NodeType | None]: """Return the registration for the network controller.""" - if self._registry.get(-1) is not None: - return self.registry[-1] - return (-1, None) + if self._registry.get(-1) is None: + raise NodeError("Unable to return network controller details") + return self.registry[-1] def update_network_registration( self, address: int, mac: str, node_type: NodeType | None @@ -187,27 +183,19 @@ def update_network_registration( if self._network_cache is not None: self._network_cache.update_registration(address, mac, node_type) - async def update_missing_registrations( - self, quick: bool = False - ) -> None: + async def update_missing_registrations(self, quick: bool = False) -> None: """Retrieve all unknown network registrations from network controller.""" for address in range(0, 64): if self._registry.get(address) is not None and not quick: mac, _ = self._registry[address] if mac == "": - self._first_free_address = min( - self._first_free_address, address - ) + self._first_free_address = min(self._first_free_address, address) continue - registration = await self.retrieve_network_registration( - address, False - ) + registration = await self.retrieve_network_registration(address, False) if registration is not None: address, mac = registration if mac == "": - self._first_free_address = min( - self._first_free_address, address - ) + self._first_free_address = min(self._first_free_address, address) if quick: break _LOGGER.debug( @@ -220,10 +208,7 @@ async def update_missing_registrations( if not quick: await sleep(10) if quick: - if ( - self._registration_task is None or - self._registration_task.done() - ): + if self._registration_task is None or self._registration_task.done(): self._registration_task = create_task( self.update_missing_registrations(quick=False) ) @@ -257,29 +242,22 @@ async def save_registry_to_cache(self) -> None: ) return _LOGGER.debug( - "save_registry_to_cache starting for %s items", - str(len(self._registry)) + "save_registry_to_cache starting for %s items", str(len(self._registry)) ) for address, registration in self._registry.items(): mac, node_type = registration self._network_cache.update_registration(address, mac, node_type) await self._network_cache.save_cache() - _LOGGER.debug( - "save_registry_to_cache finished" - ) + _LOGGER.debug("save_registry_to_cache finished") async def register_node(self, mac: str) -> int: """Register node to Plugwise network and return network address.""" if not validate_mac(mac): raise NodeError(f"Invalid mac '{mac}' to register") - response: NodeResponse | None = await self._send_to_controller( - NodeAddRequest(bytes(mac, UTF8), True) - ) # type: ignore [assignment] - if ( - response is None or - response.ack_id != NodeResponseType.JOIN_ACCEPTED - ): + request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) + response = await request.send() + if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: raise NodeError(f"Failed to register node {mac}") self.update_network_registration(self._first_free_address, mac, None) self._first_free_address += 1 @@ -289,23 +267,26 @@ async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): raise NodeError(f"Invalid mac '{mac}' to unregister") - if mac not in self._registry: - raise NodeError( - f"No existing registration '{mac}' found to unregister" - ) - response: NodeRemoveResponse | None = await self._send_to_controller( - NodeRemoveRequest(self._mac_nc, mac) - ) # type: ignore [assignment] + 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") + + request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) + response = await request.send() if response is None: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc}'" + - f" did not respond to unregister node '{mac}'" + f"The Zigbee network coordinator '{self._mac_nc!r}'" + + f" did not respond to unregister node '{mac}'" ) if response.status.value != 1: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc}'" + - f" failed to unregister node '{mac}'" + f"The Zigbee network coordinator '{self._mac_nc!r}'" + + f" failed to unregister node '{mac}'" ) if (address := self.network_address(mac)) is not None: self.update_network_registration(address, mac, None) @@ -319,5 +300,9 @@ async def clear_register_cache(self) -> None: async def stop(self) -> None: """Unload the network registry.""" self._stop_registration_task() - if self._cache_enabled and self._network_cache.initialized: + if ( + self._cache_enabled + and self._network_cache is not None + and self._network_cache.initialized + ): await self.save_registry_to_cache() diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index cc80b09f9..3656fce92 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -4,15 +4,18 @@ from abc import ABC from asyncio import Task, create_task -from collections.abc import Callable +from collections.abc import Awaitable, Callable from datetime import UTC, datetime, timedelta import logging from typing import Any from ..api import ( + BatteryConfig, EnergyStatistics, + MotionSensitivity, MotionState, NetworkStatistics, + NodeEvent, NodeFeature, NodeInfo, NodeType, @@ -20,14 +23,14 @@ RelayState, ) from ..connection import StickController -from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8, MotionSensitivity +from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8 from ..exceptions import NodeError +from ..helpers.util import version_to_model from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse -from ..helpers.util import version_to_model -from .helpers import raise_not_loaded +from .helpers import EnergyCalibration, raise_not_loaded from .helpers.cache import NodeCache -from .helpers.counter import EnergyCalibration, EnergyCounters +from .helpers.counter import EnergyCounters from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions from .helpers.subscription import FeaturePublisher @@ -51,12 +54,12 @@ def __init__( mac: str, address: int, controller: StickController, - loaded_callback: Callable, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Plugwise base node class.""" self._loaded_callback = loaded_callback self._message_subscribe = controller.subscribe_to_node_responses - self._features = NODE_FEATURES + self._features: tuple[NodeFeature, ...] = NODE_FEATURES self._last_update = datetime.now(UTC) self._node_info = NodeInfo(mac, address) self._ping = NetworkStatistics() @@ -66,7 +69,7 @@ def __init__( self._mac_in_str = mac self._send = controller.send self._cache_enabled: bool = False - self._cache_save_task: Task | None = None + self._cache_save_task: Task[None] | None = None self._node_cache = NodeCache(mac, "") # Sensors @@ -84,17 +87,14 @@ def __init__( self._node_protocols: SupportedVersions | None = None self._node_last_online: datetime | None = None + # Battery + self._battery_config = BatteryConfig() + # Motion self._motion = False self._motion_state = MotionState() - self._motion_reset_timer: int | None = None self._scan_subscription: Callable[[], None] | None = None - self._motion_reset_timer = None - self._daylight_mode: bool | None = None self._sensitivity_level: MotionSensitivity | None = None - self._new_motion_reset_timer: int | None = None - self._new_daylight_mode: bool | None = None - self._new_sensitivity: MotionSensitivity | None = None # Node info self._current_log_address: int | None = None @@ -108,6 +108,8 @@ def __init__( self._calibration: EnergyCalibration | None = None self._energy_counters = EnergyCounters(mac) + # region Properties + @property def network_address(self) -> int: """Network (zigbee based) registration address of this node.""" @@ -148,41 +150,68 @@ def available(self) -> bool: """Return network availability state.""" return self._available + @property + def battery_config(self) -> BatteryConfig: + """Return battery configuration settings.""" + if NodeFeature.BATTERY not in self._features: + raise NodeError( + f"Battery configuration settings are not supported for node {self.mac}" + ) + return self._battery_config + @property def battery_powered(self) -> bool: """Return if node is battery powered.""" return self._node_info.battery_powered + @property + def daylight_mode(self) -> bool: + """Daylight mode of motion sensor.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Daylight mode is not supported for node {self.mac}") + raise NotImplementedError() + @property def energy(self) -> EnergyStatistics | None: - """"Return energy statistics.""" + """Energy statistics.""" if NodeFeature.POWER not in self._features: - raise NodeError( - f"Energy state is not supported for node {self.mac}" - ) + raise NodeError(f"Energy state is not supported for node {self.mac}") + raise NotImplementedError() + + @property + def energy_consumption_interval(self) -> int | None: + """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.consumption_interval + + @property + def energy_production_interval(self) -> int | None: + """Interval (minutes) energy production counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.production_interval @property def features(self) -> tuple[NodeFeature, ...]: - """"Return tuple with all supported feature types.""" + """Supported feature types of node.""" return self._features @property def node_info(self) -> NodeInfo: - """"Return node information.""" + """Node information.""" return self._node_info @property def humidity(self) -> float | None: - """"Return humidity state.""" + """Humidity state.""" if NodeFeature.HUMIDITY not in self._features: - raise NodeError( - f"Humidity state is not supported for node {self.mac}" - ) + raise NodeError(f"Humidity state is not supported for node {self.mac}") return self._humidity @property def last_update(self) -> datetime: - """"Return timestamp of last update.""" + """Timestamp of last update.""" return self._last_update @property @@ -202,24 +231,32 @@ def mac(self) -> str: """Return mac address of node.""" return self._mac_in_str + @property + def maintenance_interval(self) -> int | None: + """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" + raise NotImplementedError() + @property def motion(self) -> bool | None: """Motion detection value.""" if NodeFeature.MOTION not in self._features: - raise NodeError( - f"Motion state is not supported for node {self.mac}" - ) + raise NodeError(f"Motion state is not supported for node {self.mac}") return self._motion @property def motion_state(self) -> MotionState: """Motion detection state.""" if NodeFeature.MOTION not in self._features: - raise NodeError( - f"Motion state is not supported for node {self.mac}" - ) + raise NodeError(f"Motion state is not supported for node {self.mac}") return self._motion_state + @property + def motion_reset_timer(self) -> int: + """Total minutes without motion before no motion is reported.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Motion reset timer is not supported for node {self.mac}") + raise NotImplementedError() + @property def ping(self) -> NetworkStatistics: """Ping statistics.""" @@ -229,54 +266,25 @@ def ping(self) -> NetworkStatistics: def power(self) -> PowerStatistics: """Power statistics.""" if NodeFeature.POWER not in self._features: - raise NodeError( - f"Power state is not supported for node {self.mac}" - ) + raise NodeError(f"Power state is not supported for node {self.mac}") return self._power - @property - def switch(self) -> bool | None: - """Switch button value.""" - if NodeFeature.SWITCH not in self._features: - raise NodeError( - f"Switch value is not supported for node {self.mac}" - ) - return self._switch - @property def relay_state(self) -> RelayState: """State of relay.""" if NodeFeature.RELAY not in self._features: - raise NodeError( - f"Relay state is not supported for node {self.mac}" - ) + raise NodeError(f"Relay state is not supported for node {self.mac}") return self._relay_state @property def relay(self) -> bool: """Relay value.""" if NodeFeature.RELAY not in self._features: - raise NodeError( - f"Relay value is not supported for node {self.mac}" - ) + raise NodeError(f"Relay value is not supported for node {self.mac}") if self._relay is None: raise NodeError(f"Relay value is unknown for node {self.mac}") return self._relay - @relay.setter - def relay(self, state: bool) -> None: - """Change relay to state value.""" - raise NotImplementedError() - - @property - def temperature(self) -> float | None: - """Temperature value.""" - if NodeFeature.TEMPERATURE not in self._features: - raise NodeError( - f"Temperature state is not supported for node {self.mac}" - ) - return self._temperature - @property def relay_init( self, @@ -284,15 +292,33 @@ def relay_init( """Request the relay states at startup/power-up.""" raise NotImplementedError() - @relay_init.setter - def relay_init(self, state: bool) -> None: - """Request to configure relay states at startup/power-up.""" + @property + def sensitivity_level(self) -> MotionSensitivity: + """Sensitivity level of motion sensor.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Sensitivity level is not supported for node {self.mac}") raise NotImplementedError() + @property + def switch(self) -> bool | None: + """Switch button value.""" + if NodeFeature.SWITCH not in self._features: + raise NodeError(f"Switch value is not supported for node {self.mac}") + return self._switch + + @property + def temperature(self) -> float | None: + """Temperature value.""" + if NodeFeature.TEMPERATURE not in self._features: + raise NodeError(f"Temperature state is not supported for node {self.mac}") + return self._temperature + + # endregion + def _setup_protocol( self, firmware: dict[datetime, SupportedVersions], - node_features: tuple[NodeFeature], + node_features: tuple[NodeFeature, ...], ) -> None: """Determine protocol version based on firmware version and enable supported additional supported features.""" if self._node_info.firmware is None: @@ -307,7 +333,7 @@ def _setup_protocol( str(firmware.keys()), ) return - new_feature_list = list(self._features) + # new_feature_list = list(self._features) for feature in node_features: if ( required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) @@ -316,10 +342,9 @@ def _setup_protocol( self._node_protocols.min <= required_version <= self._node_protocols.max - and feature not in new_feature_list + and feature not in self._features ): - new_feature_list.append(feature) - self._features = tuple(new_feature_list) + self._features += (feature,) self._node_info.features = self._features async def reconnect(self) -> None: @@ -333,27 +358,8 @@ async def disconnect(self) -> None: self._connected = False await self._available_update_state(False) - @property - def energy_consumption_interval(self) -> int | None: - """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" - if NodeFeature.ENERGY not in self._features: - raise NodeError( - f"Energy log interval is not supported for node {self.mac}" - ) - return self._energy_counters.consumption_interval - - @property - def energy_production_interval(self) -> int | None: - """Interval (minutes) energy production counters are locally logged at Circle devices.""" - if NodeFeature.ENERGY not in self._features: - raise NodeError( - f"Energy log interval is not supported for node {self.mac}" - ) - return self._energy_counters.production_interval - - @property - def maintenance_interval(self) -> int | None: - """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" + async def configure_motion_reset(self, delay: int) -> bool: + """Configure the duration to reset motion state.""" raise NotImplementedError() async def scan_calibrate_light(self) -> bool: @@ -402,10 +408,7 @@ async def _load_from_cache(self) -> bool: # Node Info if not await self._node_info_load_from_cache(): - _LOGGER.debug( - "Node %s failed to load node_info from cache", - self.mac - ) + _LOGGER.debug("Node %s failed to load node_info from cache", self.mac) return False return True @@ -413,7 +416,9 @@ async def initialize(self) -> bool: """Initialize node.""" if self._initialized: return True - self._initialization_delay_expired = datetime.now(UTC) + timedelta(minutes=SUPPRESS_INITIALIZATION_WARNINGS) + self._initialization_delay_expired = datetime.now(UTC) + timedelta( + minutes=SUPPRESS_INITIALIZATION_WARNINGS + ) self._initialized = True return True @@ -430,23 +435,17 @@ async def _available_update_state(self, available: bool) -> None: return _LOGGER.info("Device %s detected to be not available (off-line)", self.name) self._available = False - await self.publish_feature_update_to_subscribers( - NodeFeature.AVAILABLE, False - ) + await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, False) async def node_info_update( self, node_info: NodeInfoResponse | None = None ) -> NodeInfo | None: """Update Node hardware information.""" if node_info is None: - node_info = await self._send( - NodeInfoRequest(self._mac_in_bytes) - ) + request = NodeInfoRequest(self._send, self._mac_in_bytes) + node_info = await request.send() if node_info is None: - _LOGGER.debug( - "No response for node_info_update() for %s", - self.mac - ) + _LOGGER.debug("No response for node_info_update() for %s", self.mac) await self._available_update_state(False) return self._node_info @@ -461,38 +460,12 @@ async def node_info_update( async def _node_info_load_from_cache(self) -> bool: """Load node info settings from cache.""" - firmware: datetime | None = None + firmware = self._get_cache_as_datetime(CACHE_FIRMWARE) + hardware = self._get_cache(CACHE_HARDWARE) + timestamp = self._get_cache_as_datetime(CACHE_NODE_INFO_TIMESTAMP) node_type: NodeType | None = None - hardware: str | None = self._get_cache(CACHE_HARDWARE) - timestamp: datetime | None = None - if (firmware_str := self._get_cache(CACHE_FIRMWARE)) is not None: - data = firmware_str.split("-") - if len(data) == 6: - firmware = datetime( - year=int(data[0]), - month=int(data[1]), - day=int(data[2]), - hour=int(data[3]), - minute=int(data[4]), - second=int(data[5]), - tzinfo=UTC - ) if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: node_type = NodeType(int(node_type_str)) - if ( - timestamp_str := self._get_cache(CACHE_NODE_INFO_TIMESTAMP) - ) is not None: - data = timestamp_str.split("-") - if len(data) == 6: - timestamp = datetime( - year=int(data[0]), - month=int(data[1]), - day=int(data[2]), - hour=int(data[3]), - minute=int(data[4]), - second=int(data[5]), - tzinfo=UTC - ) return await self._node_info_update_state( firmware=firmware, hardware=hardware, @@ -520,7 +493,7 @@ async def _node_info_update_state( if self._node_info.version != hardware: self._node_info.version = hardware # Generate modelname based on hardware version - model_info = version_to_model(hardware).split(' ') + model_info = version_to_model(hardware).split(" ") self._node_info.model = model_info[0] if self._node_info.model == "Unknown": _LOGGER.warning( @@ -534,7 +507,7 @@ async def _node_info_update_state( self._node_info.model_type = "" if self._node_info.model is not None: self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" - + self._set_cache(CACHE_HARDWARE, hardware) if timestamp is None: complete = False @@ -552,10 +525,7 @@ async def _node_info_update_state( async def is_online(self) -> bool: """Check if node is currently online.""" if await self.ping_update() is None: - _LOGGER.debug( - "No response to ping for %s", - self.mac - ) + _LOGGER.debug("No response to ping for %s", self.mac) return False return True @@ -564,11 +534,8 @@ async def ping_update( ) -> NetworkStatistics | None: """Update ping statistics.""" if ping_response is None: - ping_response = await self._send( - NodePingRequest( - self._mac_in_bytes, retries - ) - ) + request = NodePingRequest(self._send, self._mac_in_bytes, retries) + ping_response = await request.send() if ping_response is None: await self._available_update_state(False) return None @@ -579,9 +546,7 @@ async def ping_update( self._ping.rssi_out = ping_response.rssi_out self._ping.rtt = ping_response.rtt - await self.publish_feature_update_to_subscribers( - NodeFeature.PING, self._ping - ) + await self.publish_feature_update_to_subscribers(NodeFeature.PING, self._ping) return self._ping async def switch_relay(self, state: bool) -> bool | None: @@ -589,9 +554,7 @@ async def switch_relay(self, state: bool) -> bool | None: raise NodeError(f"Relay control is not supported for node {self.mac}") @raise_not_loaded - async def get_state( - self, features: tuple[NodeFeature] - ) -> dict[NodeFeature, Any]: + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: @@ -627,22 +590,40 @@ def _get_cache(self, setting: str) -> str | None: return None return self._node_cache.get_state(setting) + 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: + data = timestamp_str.split("-") + if len(data) == 6: + return datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC, + ) + return None + def _set_cache(self, setting: str, value: Any) -> None: """Store setting with value in cache memory.""" if not self._cache_enabled: return if isinstance(value, datetime): - self._node_cache.add_state( + self._node_cache.update_state( setting, - f"{value.year}-{value.month}-{value.day}-{value.hour}" + - f"-{value.minute}-{value.second}" + f"{value.year}-{value.month}-{value.day}-{value.hour}" + + f"-{value.minute}-{value.second}", ) elif isinstance(value, str): - self._node_cache.add_state(setting, value) + self._node_cache.update_state(setting, value) else: - self._node_cache.add_state(setting, str(value)) + self._node_cache.update_state(setting, str(value)) - async def save_cache(self, trigger_only: bool = True, full_write: bool = False) -> None: + async def save_cache( + self, trigger_only: bool = True, full_write: bool = False + ) -> None: """Save current cache to cache file.""" if not self._cache_enabled or not self._loaded or not self._initialized: return @@ -663,8 +644,6 @@ def skip_update(data_class: Any, seconds: int) -> bool: return False if data_class.timestamp is None: return False - if data_class.timestamp + timedelta( - seconds=seconds - ) > datetime.now(UTC): + if data_class.timestamp + timedelta(seconds=seconds) > datetime.now(UTC): return True return False diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 41de37ca2..109f716b2 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -9,7 +9,7 @@ import logging from typing import Any, TypeVar, cast -from ..api import NodeEvent, NodeFeature, NodeInfo +from ..api import EnergyStatistics, NodeEvent, NodeFeature, NodeInfo, PowerStatistics from ..constants import ( MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, @@ -27,17 +27,8 @@ EnergyCalibrationRequest, NodeInfoRequest, ) -from ..messages.responses import ( - CircleClockResponse, - CircleEnergyLogsResponse, - CirclePowerUsageResponse, - CircleRelayInitStateResponse, - EnergyCalibrationResponse, - NodeInfoResponse, - NodeResponse, - NodeResponseType, -) -from ..nodes import EnergyStatistics, PlugwiseNode, PowerStatistics +from ..messages.responses import NodeInfoResponse, NodeResponse, NodeResponseType +from ..nodes import PlugwiseNode from .helpers import EnergyCalibration, raise_not_loaded from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord, calc_log_address @@ -70,9 +61,11 @@ def decorated(*args: Any, **kwargs: Any) -> Any: class PlugwiseCircle(PlugwiseNode): """Plugwise Circle node.""" - _retrieve_energy_logs_task: None | Task = None + _retrieve_energy_logs_task: None | Task[None] = None _last_energy_log_requested: bool = False + # region Properties + @property def calibrated(self) -> bool: """State of calibration.""" @@ -82,7 +75,7 @@ def calibrated(self) -> bool: @property def energy(self) -> EnergyStatistics | None: - """"Return energy statistics.""" + """Energy statistics.""" return self._energy_counters.energy_statistics @property @@ -91,12 +84,6 @@ def relay(self) -> bool: """Current value of relay.""" return bool(self._relay) - @relay.setter - @raise_not_loaded - def relay(self, state: bool) -> None: - """Request to change relay state.""" - create_task(self.switch_relay(state)) - @raise_not_loaded async def relay_off(self) -> None: """Switch relay off.""" @@ -118,15 +105,17 @@ def relay_init( ) return self._relay_init_state - @relay_init.setter - def relay_init(self, state: bool) -> None: - """Request to configure relay states at startup/power-up.""" - if NodeFeature.RELAY_INIT not in self._features: - raise NodeError( - "Configuring initial state of relay " - + f"is not supported for device {self.name}" - ) - create_task(self._relay_init_set(state)) + @raise_not_loaded + async def relay_init_off(self) -> None: + """Switch relay off.""" + await self._relay_init_set(False) + + @raise_not_loaded + async def relay_init_on(self) -> None: + """Switch relay on.""" + await self._relay_init_set(True) + + # endregion async def calibration_update(self) -> bool: """Retrieve and update calibration settings. Returns True if successful.""" @@ -134,9 +123,8 @@ async def calibration_update(self) -> bool: "Start updating energy calibration for %s", self._mac_in_str, ) - calibration_response: EnergyCalibrationResponse | None = ( - await self._send(EnergyCalibrationRequest(self._mac_in_bytes)) - ) + request = EnergyCalibrationRequest(self._send, self._mac_in_bytes) + calibration_response = await request.send() if calibration_response is None: _LOGGER.warning( "Retrieving energy calibration information for %s failed", @@ -182,12 +170,11 @@ async def _calibration_load_from_cache(self) -> bool: if result: _LOGGER.debug( "Restore calibration settings from cache for %s was successful", - self._mac_in_str + self._mac_in_str, ) return True _LOGGER.info( - "Failed to restore calibration settings from cache for %s", - self.name + "Failed to restore calibration settings from cache for %s", self.name ) return False @@ -199,18 +186,10 @@ async def _calibration_update_state( off_tot: float | None, ) -> bool: """Process new energy calibration settings. Returns True if successful.""" - if ( - gain_a is None or - gain_b is None or - off_noise is None or - off_tot is None - ): + if gain_a is None or gain_b is None or off_noise is None or off_tot is None: return False self._calibration = EnergyCalibration( - gain_a=gain_a, - gain_b=gain_b, - off_noise=off_noise, - off_tot=off_tot + gain_a=gain_a, gain_b=gain_b, off_noise=off_noise, off_tot=off_tot ) # Forward calibration config to energy collection self._energy_counters.calibration = self._calibration @@ -233,12 +212,11 @@ async def power_update(self) -> PowerStatistics | None: if self.skip_update(self._power, MINIMAL_POWER_UPDATE): return self._power - request = CirclePowerUsageRequest(self._mac_in_bytes) - response: CirclePowerUsageResponse | None = await self._send(request) + request = CirclePowerUsageRequest(self._send, self._mac_in_bytes) + response = await request.send() if response is None or response.timestamp is None: _LOGGER.debug( - "No response for async_power_update() for %s", - self._mac_in_str + "No response for async_power_update() for %s", self._mac_in_str ) await self._available_update_state(False) return None @@ -252,9 +230,7 @@ async def power_update(self) -> PowerStatistics | None: response.pulse_8s, 8, response.offset ) self._power.timestamp = response.timestamp - await self.publish_feature_update_to_subscribers( - NodeFeature.POWER, self._power - ) + await self.publish_feature_update_to_subscribers(NodeFeature.POWER, self._power) # Forward pulse interval counters to pulse Collection self._energy_counters.add_pulse_stats( @@ -269,9 +245,7 @@ async def power_update(self) -> PowerStatistics | None: @raise_not_loaded @raise_calibration_missing - async def energy_update( - self - ) -> EnergyStatistics | None: + async def energy_update(self) -> EnergyStatistics | None: """Return updated energy usage statistics.""" if self._current_log_address is None: _LOGGER.debug( @@ -279,41 +253,68 @@ async def energy_update( self._mac_in_str, ) if await self.node_info_update() is None: - if datetime.now(UTC) < self._initialization_delay_expired: - _LOGGER.info("Unable to return energy statistics for %s, because it is not responding", self.name) + if ( + self._initialization_delay_expired is not None + and datetime.now(UTC) < self._initialization_delay_expired + ): + _LOGGER.info( + "Unable to return energy statistics for %s, because it is not responding", + self.name, + ) else: - _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) + _LOGGER.warning( + "Unable to return energy statistics for %s, because it is not responding", + self.name, + ) return None # request node info update every 30 minutes. elif not self.skip_update(self._node_info, 1800): if await self.node_info_update() is None: - if datetime.now(UTC) < self._initialization_delay_expired: - _LOGGER.info("Unable to return energy statistics for %s, because it is not responding", self.name) + if ( + self._initialization_delay_expired is not None + and datetime.now(UTC) < self._initialization_delay_expired + ): + _LOGGER.info( + "Unable to return energy statistics for %s, because it is not responding", + self.name, + ) else: - _LOGGER.warning("Unable to return energy statistics for %s, because it is not responding", self.name) + _LOGGER.warning( + "Unable to return energy statistics for %s, because it is not responding", + self.name, + ) return None # Always request last energy log records at initial startup if not self._last_energy_log_requested: - self._last_energy_log_requested = await self.energy_log_update(self._current_log_address) + self._last_energy_log_requested = await self.energy_log_update( + self._current_log_address + ) if self._energy_counters.log_rollover: if await self.node_info_update() is None: _LOGGER.debug( - "async_energy_update | %s | Log rollover | node_info_update failed", self._mac_in_str, + "async_energy_update | %s | Log rollover | node_info_update failed", + self._mac_in_str, ) return None if not await self.energy_log_update(self._current_log_address): _LOGGER.debug( - "async_energy_update | %s | Log rollover | energy_log_update failed", self._mac_in_str, + "async_energy_update | %s | Log rollover | energy_log_update failed", + self._mac_in_str, ) return None - if self._energy_counters.log_rollover: + if ( + self._energy_counters.log_rollover + and self._current_log_address is not None + ): # Retry with previous log address as Circle node pointer to self._current_log_address # could be rolled over while the last log is at previous address/slot - _prev_log_address, _ = calc_log_address(self._current_log_address, 1, -4) + _prev_log_address, _ = calc_log_address( + self._current_log_address, 1, -4 + ) if not await self.energy_log_update(_prev_log_address): _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", @@ -351,22 +352,35 @@ async def energy_update( "Create task to update energy logs for node %s", self._mac_in_str, ) - self._retrieve_energy_logs_task = create_task(self.get_missing_energy_logs()) + self._retrieve_energy_logs_task = create_task( + self.get_missing_energy_logs() + ) else: _LOGGER.debug( "Skip creating task to update energy logs for node %s", self._mac_in_str, ) - if datetime.now(UTC) < self._initialization_delay_expired: - _LOGGER.info("Unable to return energy statistics for %s, collecting required data...", self.name) + if ( + self._initialization_delay_expired is not None + and datetime.now(UTC) < self._initialization_delay_expired + ): + _LOGGER.info( + "Unable to return energy statistics for %s, collecting required data...", + self.name, + ) else: - _LOGGER.warning("Unable to return energy statistics for %s, collecting required data...", self.name) + _LOGGER.warning( + "Unable to return energy statistics for %s, collecting required data...", + self.name, + ) return None async def get_missing_energy_logs(self) -> None: """Task to retrieve missing energy logs.""" self._energy_counters.update() + if self._current_log_address is None: + return None if self._energy_counters.log_addresses_missing is None: _LOGGER.debug( "Start with initial energy request for the last 10 log addresses for node %s.", @@ -403,25 +417,24 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) await gather( - *[ - self.energy_log_update(address) - for address in missing_addresses - ] + *[self.energy_log_update(address) for address in missing_addresses] ) if self._cache_enabled: await self._energy_log_records_save_to_cache() - async def energy_log_update(self, address: int) -> bool: + async def energy_log_update(self, address: int | None) -> bool: """Request energy log statistics from node. Returns true if successful.""" + if address is None: + return False _LOGGER.info( "Request of energy log at address %s for node %s", str(address), self.name, ) - request = CircleEnergyLogsRequest(self._mac_in_bytes, address) - response: CircleEnergyLogsResponse | None = None - if (response := await self._send(request)) is None: + request = CircleEnergyLogsRequest(self._send, self._mac_in_bytes, address) + response = await request.send() + if response is None: _LOGGER.debug( "Retrieving of energy log at address %s for node %s failed", str(address), @@ -436,36 +449,33 @@ async def energy_log_update(self, address: int) -> bool: # Each response message contains 4 log counters (slots) of the # energy pulses collected during the previous hour of given timestamp for _slot in range(4, 0, -1): - _log_timestamp: datetime | None = getattr( - response, "logdate%d" % (_slot,) - ).value - _log_pulses: int = getattr(response, "pulses%d" % (_slot,)).value - if _log_timestamp is None: + log_timestamp, log_pulses = response.log_data[_slot] + + if log_timestamp is None or log_pulses is None: self._energy_counters.add_empty_log(response.log_address, _slot) - else: - if await self._energy_log_record_update_state( - response.log_address, - _slot, - _log_timestamp.replace(tzinfo=UTC), - _log_pulses, - import_only=True - ): - energy_record_update = True + elif await self._energy_log_record_update_state( + response.log_address, + _slot, + log_timestamp.replace(tzinfo=UTC), + log_pulses, + import_only=True, + ): + energy_record_update = True self._energy_counters.update() - if energy_record_update: + if energy_record_update: await self.save_cache() return True async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" - if self._get_cache(CACHE_ENERGY_COLLECTION) is None: + cache_data = self._get_cache(CACHE_ENERGY_COLLECTION) + if cache_data is None: _LOGGER.info( - "Failed to restore energy log records from cache for node %s", - self.name + "Failed to restore energy log records from cache for node %s", self.name ) return False restored_logs: dict[int, list[int]] = {} - log_data = self._get_cache(CACHE_ENERGY_COLLECTION).split("|") + log_data = cache_data.split("|") for log_record in log_data: log_fields = log_record.split(":") if len(log_fields) == 4: @@ -483,7 +493,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: hour=int(timestamp_energy_log[3]), minute=int(timestamp_energy_log[4]), second=int(timestamp_energy_log[5]), - tzinfo=UTC + tzinfo=UTC, ), pulses=int(log_fields[3]), import_only=True, @@ -498,16 +508,12 @@ async def _energy_log_records_load_from_cache(self) -> bool: if self._energy_counters.log_addresses_missing is None: return False if len(self._energy_counters.log_addresses_missing) > 0: - missing_addresses = sorted( - self._energy_counters.log_addresses_missing, reverse=True - )[:5] - for address in missing_addresses: - _LOGGER.debug( - "Create task to request energy log %s for %s", - address, - self._mac_in_str - ) - create_task(self.energy_log_update(address)) + if self._retrieve_energy_logs_task is not None: + if not self._retrieve_energy_logs_task.done(): + await self._retrieve_energy_logs_task + self._retrieve_energy_logs_task = create_task( + self.get_missing_energy_logs() + ) return False return True @@ -540,11 +546,7 @@ async def _energy_log_record_update_state( ) -> bool: """Process new energy log record. Returns true if record is new or changed.""" self._energy_counters.add_pulse_log( - address, - slot, - timestamp, - pulses, - import_only=import_only + address, slot, timestamp, pulses, import_only=import_only ) if not self._cache_enabled: return False @@ -558,7 +560,7 @@ async def _energy_log_record_update_state( "Add logrecord (%s, %s) to log cache of %s", str(address), str(slot), - self._mac_in_str + self._mac_in_str, ) self._set_cache( CACHE_ENERGY_COLLECTION, cached_logs + "|" + log_cache_record @@ -566,8 +568,7 @@ async def _energy_log_record_update_state( return True return False _LOGGER.debug( - "No existing energy collection log cached for %s", - self._mac_in_str + "No existing energy collection log cached for %s", self._mac_in_str ) self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) return True @@ -578,13 +579,10 @@ async def switch_relay(self, state: bool) -> bool | None: Return new state of relay """ _LOGGER.debug("switch_relay() start") - response: NodeResponse | None = await self._send( - CircleRelaySwitchRequest(self._mac_in_bytes, state), - ) - if ( - response is None - or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED - ): + request = CircleRelaySwitchRequest(self._send, self._mac_in_bytes, state) + response = await request.send() + + if response is None or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED: _LOGGER.warning( "Request to switch relay for %s failed", self.name, @@ -592,14 +590,10 @@ async def switch_relay(self, state: bool) -> bool | None: return None if response.ack_id == NodeResponseType.RELAY_SWITCHED_OFF: - await self._relay_update_state( - state=False, timestamp=response.timestamp - ) + await self._relay_update_state(state=False, timestamp=response.timestamp) return False if response.ack_id == NodeResponseType.RELAY_SWITCHED_ON: - await self._relay_update_state( - state=True, timestamp=response.timestamp - ) + await self._relay_update_state(state=True, timestamp=response.timestamp) return True _LOGGER.warning( "Unexpected NodeResponseType %s response for CircleRelaySwitchRequest at node %s...", @@ -614,10 +608,7 @@ async def _relay_load_from_cache(self) -> bool: # State already known, no need to load from cache return True if (cached_relay_data := self._get_cache(CACHE_RELAY)) is not None: - _LOGGER.debug( - "Restore relay state cache for node %s", - self._mac_in_str - ) + _LOGGER.debug("Restore relay state cache for node %s", self._mac_in_str) relay_state = False if cached_relay_data == "True": relay_state = True @@ -625,7 +616,7 @@ async def _relay_load_from_cache(self) -> bool: return True _LOGGER.debug( "Failed to restore relay state from cache for node %s, try to request node info...", - self._mac_in_str + self._mac_in_str, ) if await self.node_info_update() is None: return False @@ -640,11 +631,11 @@ async def _relay_update_state( state_update = False if state: self._set_cache(CACHE_RELAY, "True") - if (self._relay is None or not self._relay): + if self._relay is None or not self._relay: state_update = True if not state: self._set_cache(CACHE_RELAY, "False") - if (self._relay is None or self._relay): + if self._relay is None or self._relay: state_update = True self._relay = state if state_update: @@ -655,9 +646,8 @@ async def _relay_update_state( async def clock_synchronize(self) -> bool: """Synchronize clock. Returns true if successful.""" - clock_response: CircleClockResponse | None = await self._send( - CircleClockGetRequest(self._mac_in_bytes) - ) + get_clock_request = CircleClockGetRequest(self._send, self._mac_in_bytes) + clock_response = await get_clock_request.send() if clock_response is None or clock_response.timestamp is None: return False _dt_of_circle = datetime.now(tz=UTC).replace( @@ -667,9 +657,7 @@ async def clock_synchronize(self) -> bool: microsecond=0, tzinfo=UTC, ) - clock_offset = ( - clock_response.timestamp.replace(microsecond=0) - _dt_of_circle - ) + clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle if (clock_offset.seconds < MAX_TIME_DRIFT) or ( clock_offset.seconds > -(MAX_TIME_DRIFT) ): @@ -679,13 +667,18 @@ async def clock_synchronize(self) -> bool: self._mac_in_str, str(clock_offset.seconds), ) - node_response: NodeResponse | None = await self._send( - CircleClockSetRequest( - self._mac_in_bytes, - datetime.now(tz=UTC), - self._node_protocols.max + if self._node_protocols is None: + raise NodeError( + "Unable to synchronize clock en when protocol version is unknown" ) + set_clock_request = CircleClockSetRequest( + self._send, + self._mac_in_bytes, + datetime.now(tz=UTC), + self._node_protocols.max, ) + node_response: NodeResponse | None = await set_clock_request.send() + if node_response is None: _LOGGER.warning( "Failed to (re)set the internal clock of %s", @@ -701,9 +694,7 @@ async def load(self) -> bool: if self._loaded: return True if self._cache_enabled: - _LOGGER.debug( - "Load Circle node %s from cache", self._mac_in_str - ) + _LOGGER.debug("Load Circle node %s from cache", self._mac_in_str) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -729,25 +720,29 @@ async def load(self) -> bool: if not self._available and not await self.is_online(): _LOGGER.debug( "Failed to load Circle node %s because it is not online", - self._mac_in_str + self._mac_in_str, ) return False # Get node info - if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: + if ( + self.skip_update(self._node_info, 30) + and await self.node_info_update() is None + ): _LOGGER.debug( "Failed to load Circle node %s because it is not responding to information request", - self._mac_in_str + self._mac_in_str, ) return False self._loaded = True self._setup_protocol( - CIRCLE_FIRMWARE_SUPPORT, ( + CIRCLE_FIRMWARE_SUPPORT, + ( NodeFeature.RELAY, NodeFeature.RELAY_INIT, NodeFeature.ENERGY, NodeFeature.POWER, - ) + ), ) if not await self.initialize(): return False @@ -762,8 +757,7 @@ async def _load_from_cache(self) -> bool: # Calibration settings if not await self._calibration_load_from_cache(): _LOGGER.debug( - "Node %s failed to load calibration from cache", - self._mac_in_str + "Node %s failed to load calibration from cache", self._mac_in_str ) return False # Energy collection @@ -779,9 +773,7 @@ async def _load_from_cache(self) -> bool: self._mac_in_str, ) # Relay init config if feature is enabled - if ( - NodeFeature.RELAY_INIT in self._features - ): + if NodeFeature.RELAY_INIT in self._features: if await self._relay_init_load_from_cache(): _LOGGER.debug( "Node %s failed to load relay_init state from cache", @@ -798,33 +790,27 @@ async def initialize(self) -> bool: if not await self.clock_synchronize(): _LOGGER.debug( - "Failed to initialized node %s, failed clock sync", - self._mac_in_str + "Failed to initialized node %s, failed clock sync", self._mac_in_str ) self._initialized = False return False if not self._calibration and not await self.calibration_update(): _LOGGER.debug( - "Failed to initialized node %s, no calibration", - self._mac_in_str + "Failed to initialized node %s, no calibration", self._mac_in_str ) self._initialized = False return False - if self.skip_update(self._node_info, 30) and await self.node_info_update() is None: - _LOGGER.debug( - "Failed to retrieve node info for %s", - self._mac_in_str - ) if ( - NodeFeature.RELAY_INIT in self._features and - self._relay_init_state is None + self.skip_update(self._node_info, 30) + and await self.node_info_update() is None ): + _LOGGER.debug("Failed to retrieve node info for %s", self._mac_in_str) + if NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None: if (state := await self._relay_init_get()) is not None: self._relay_init_state = state else: _LOGGER.debug( - "Failed to initialized node %s, relay init", - self._mac_in_str + "Failed to initialized node %s, relay init", self._mac_in_str ) self._initialized = False return False @@ -837,25 +823,24 @@ async def node_info_update( if node_info is None: if self.skip_update(self._node_info, 30): return self._node_info - node_info: NodeInfoResponse = await self._send( - NodeInfoRequest(self._mac_in_bytes) - ) + node_request = NodeInfoRequest(self._send, self._mac_in_bytes) + node_info = await node_request.send() if node_info is None: return None await super().node_info_update(node_info) await self._relay_update_state( node_info.relay_state, timestamp=node_info.timestamp ) - if ( - self._current_log_address is not None - and (self._current_log_address > node_info.current_logaddress_pointer or self._current_log_address == 1) + if self._current_log_address is not None and ( + self._current_log_address > node_info.current_logaddress_pointer + or self._current_log_address == 1 ): # Rollover of log address _LOGGER.debug( "Rollover log address from %s into %s for node %s", self._current_log_address, node_info.current_logaddress_pointer, - self._mac_in_str + self._mac_in_str, ) if self._current_log_address != node_info.current_logaddress_pointer: self._current_log_address = node_info.current_logaddress_pointer @@ -878,16 +863,21 @@ async def _node_info_load_from_cache(self) -> bool: async def unload(self) -> None: """Deactivate and unload node features.""" self._loaded = False - if self._retrieve_energy_logs_task is not None and not self._retrieve_energy_logs_task.done(): + if ( + self._retrieve_energy_logs_task is not None + and not self._retrieve_energy_logs_task.done() + ): self._retrieve_energy_logs_task.cancel() await self._retrieve_energy_logs_task if self._cache_enabled: await self._energy_log_records_save_to_cache() await super().unload() - async def switch_init_relay(self, state: bool) -> bool: + async def switch_relay_init(self, state: bool) -> bool: """Switch state of initial power-up relay state. Returns new state of relay.""" await self._relay_init_set(state) + if self._relay_init_state is None: + raise NodeError("Unknown relay init setting") return self._relay_init_state async def _relay_init_get(self) -> bool | None: @@ -897,13 +887,11 @@ async def _relay_init_get(self) -> bool | None: "Retrieval of initial state of relay is not " + f"supported for device {self.name}" ) - response: CircleRelayInitStateResponse | None = await self._send( - CircleRelayInitStateRequest(self._mac_in_bytes, False, False), - ) - if response is None: - return None - await self._relay_init_update_state(response.relay.value == 1) - return self._relay_init_state + request = CircleRelayInitStateRequest(self._send, self._mac_in_bytes, False, False) + if (response := await request.send()) is not None: + await self._relay_init_update_state(response.relay.value == 1) + return self._relay_init_state + return None async def _relay_init_set(self, state: bool) -> bool | None: """Configure relay init state.""" @@ -912,13 +900,11 @@ async def _relay_init_set(self, state: bool) -> bool | None: "Configuring of initial state of relay is not" + f"supported for device {self.name}" ) - response: CircleRelayInitStateResponse | None = await self._send( - CircleRelayInitStateRequest(self._mac_in_bytes, True, state), - ) - if response is None: - return None - await self._relay_init_update_state(response.relay.value == 1) - return self._relay_init_state + request = CircleRelayInitStateRequest(self._send, self._mac_in_bytes, True, state) + if (response := await request.send()) is not None: + await self._relay_init_update_state(response.relay.value == 1) + return self._relay_init_state + return None async def _relay_init_load_from_cache(self) -> bool: """Load relay init state from cache. Returns True if retrieval was successful.""" @@ -949,16 +935,12 @@ async def _relay_init_update_state(self, state: bool) -> None: await self.save_cache() @raise_calibration_missing - def _calc_watts( - self, pulses: int, seconds: int, nano_offset: int - ) -> float | None: + def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | None: """Calculate watts based on energy usages.""" if self._calibration is None: return None - pulses_per_s = self._correct_power_pulses(pulses, nano_offset) / float( - seconds - ) + pulses_per_s = self._correct_power_pulses(pulses, nano_offset) / float(seconds) corrected_pulses = seconds * ( ( ( @@ -975,15 +957,13 @@ def _calc_watts( # Fix minor miscalculations if ( - calc_value := corrected_pulses / PULSES_PER_KW_SECOND / seconds * ( - 1000 - ) + calc_value := corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) ) >= 0.0: return calc_value _LOGGER.debug( "Correct negative power %s to 0.0 for %s", str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), - self._mac_in_str + self._mac_in_str, ) return 0.0 @@ -1010,16 +990,13 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: return 0.0 @raise_not_loaded - async def get_state( - self, features: tuple[NodeFeature] - ) -> dict[NodeFeature, Any]: + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} if not self._available: if not await self.is_online(): _LOGGER.debug( - "Node %s did not respond, unable to update state", - self._mac_in_str + "Node %s did not respond, unable to update state", self._mac_in_str ) for feature in features: states[feature] = None diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 056023f58..55bf6c656 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -11,11 +11,7 @@ CirclePlusRealTimeClockGetRequest, CirclePlusRealTimeClockSetRequest, ) -from ..messages.responses import ( - CirclePlusRealTimeClockResponse, - NodeResponse, - NodeResponseType, -) +from ..messages.responses import NodeResponseType from .circle import PlugwiseCircle from .helpers.firmware import CIRCLE_PLUS_FIRMWARE_SUPPORT @@ -30,9 +26,7 @@ async def load(self) -> bool: if self._loaded: return True if self._cache_enabled: - _LOGGER.debug( - "Load Circle node %s from cache", self._node_info.mac - ) + _LOGGER.debug("Load Circle node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -58,7 +52,7 @@ async def load(self) -> bool: if not self._available and not await self.is_online(): _LOGGER.warning( "Failed to load Circle+ node %s because it is not online", - self._node_info.mac + self._node_info.mac, ) return False @@ -66,7 +60,7 @@ async def load(self) -> bool: if await self.node_info_update() is None: _LOGGER.warning( "Failed to load Circle+ node %s because it is not responding to information request", - self._node_info.mac + self._node_info.mac, ) return False self._loaded = True @@ -86,15 +80,13 @@ async def load(self) -> bool: async def clock_synchronize(self) -> bool: """Synchronize realtime clock. Returns true if successful.""" - clock_response: CirclePlusRealTimeClockResponse | None = ( - await self._send( - CirclePlusRealTimeClockGetRequest(self._mac_in_bytes) - ) + clock_request = CirclePlusRealTimeClockGetRequest( + self._send, self._mac_in_bytes ) + clock_response = await clock_request.send() if clock_response is None: _LOGGER.debug( - "No response for async_realtime_clock_synchronize() for %s", - self.mac + "No response for async_realtime_clock_synchronize() for %s", self.mac ) await self._available_update_state(False) return False @@ -107,9 +99,7 @@ async def clock_synchronize(self) -> bool: microsecond=0, tzinfo=UTC, ) - clock_offset = ( - clock_response.timestamp.replace(microsecond=0) - _dt_of_circle - ) + clock_offset = clock_response.timestamp.replace(microsecond=0) - _dt_of_circle if (clock_offset.seconds < MAX_TIME_DRIFT) or ( clock_offset.seconds > -(MAX_TIME_DRIFT) ): @@ -120,18 +110,14 @@ async def clock_synchronize(self) -> bool: str(clock_offset.seconds), str(MAX_TIME_DRIFT), ) - node_response: NodeResponse | None = await self._send( - CirclePlusRealTimeClockSetRequest( - self._mac_in_bytes, - datetime.now(tz=UTC) - ), + clock_set_request = CirclePlusRealTimeClockSetRequest( + self._send, self._mac_in_bytes, datetime.now(tz=UTC) + ) + node_response = await clock_set_request.send() + if (node_response := await clock_set_request.send()) is not None: + return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED + _LOGGER.warning( + "Failed to (re)set the internal realtime clock of %s", + self.name, ) - if node_response is None: - _LOGGER.warning( - "Failed to (re)set the internal realtime clock of %s", - self.name, - ) - return False - if node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED: - return True return False diff --git a/plugwise_usb/nodes/helpers/cache.py b/plugwise_usb/nodes/helpers/cache.py index 209c89b7a..b234638e7 100644 --- a/plugwise_usb/nodes/helpers/cache.py +++ b/plugwise_usb/nodes/helpers/cache.py @@ -24,11 +24,9 @@ def states(self) -> dict[str, str]: """Cached node state information.""" return self._states - def add_state(self, state: str, value: str, save: bool = False) -> None: + def update_state(self, state: str, value: str) -> None: """Add configuration state to cache.""" self._states[state] = value - if save: - self.write_cache({state: value}) def remove_state(self, state: str) -> None: """Remove configuration state from cache.""" diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 7d6555007..bf5c37b08 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -7,6 +7,7 @@ the Plugwise source installation. """ + from __future__ import annotations from datetime import UTC, datetime @@ -24,288 +25,127 @@ class SupportedVersions(NamedTuple): # region - node firmware versions CIRCLE_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( - min=1.0, max=1.1, - ), - datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions(min=1.0, max=1.1), + datetime(2009, 9, 8, 13, 50, 31, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 4, 27, 11, 56, 23, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 4, 14, 9, 6, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 17, 7, 40, 37, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 31, 5, 55, 19, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2010, 8, 31, 10, 21, 2, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 10, 7, 14, 46, 38, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 11, 1, 13, 29, 38, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2011, 3, 25, 17, 40, 20, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 5, 13, 7, 19, 23, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 6, 27, 8, 52, 18, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Legrand - datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 11, 3, 12, 57, 57, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Radio Test - datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2012, 4, 19, 14, 0, 42, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Beta release - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Proto release - datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), - datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # New Flash Update - datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } CIRCLE_PLUS_FIRMWARE_SUPPORT: Final = { - datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions( - min=1.0, max=1.1 - ), - datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2008, 8, 26, 15, 46, tzinfo=UTC): SupportedVersions(min=1.0, max=1.1), + datetime(2009, 9, 8, 14, 0, 32, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 4, 27, 11, 54, 15, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 4, 12, 56, 59, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 17, 7, 37, 57, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 31, 10, 9, 18, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 10, 7, 14, 49, 29, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 11, 1, 13, 24, 49, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2011, 3, 25, 17, 37, 55, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 5, 13, 7, 17, 7, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 6, 27, 8, 47, 37, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Legrand - datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 11, 3, 12, 55, 23, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Radio Test - datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2012, 4, 19, 14, 3, 55, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # SMA firmware 2015-06-16 - datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2015, 6, 18, 14, 42, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # New Flash Update - datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2017, 7, 11, 16, 5, 57, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } SCAN_FIRMWARE_SUPPORT: Final = { - datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Beta Scan Release - datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5, - ), + datetime(2011, 1, 12, 8, 32, 56, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Beta Scan Release - datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2011, 3, 4, 14, 43, 31, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Scan RC1 - datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 3, 28, 9, 0, 24, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 5, 13, 7, 21, 55, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 11, 3, 13, 0, 56, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Legrand - datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 6, 27, 8, 55, 44, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2017, 7, 11, 16, 8, 3, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # New Flash Update } SENSE_FIRMWARE_SUPPORT: Final = { # pre - internal test release - fixed version - datetime(2010, 12, 3, 10, 17, 7): ( - "2.0, max=2.5", - ), + datetime(2010, 12, 3, 10, 17, 7, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Proto release, with reset and join bug fixed - datetime(2011, 1, 11, 14, 19, 36): ( - "2.0, max=2.5", - ), - datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2011, 1, 11, 14, 19, 36, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 3, 4, 14, 52, 30, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 3, 25, 17, 43, 2, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 5, 13, 7, 24, 26, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 6, 27, 8, 58, 19, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Legrand - datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 11, 3, 13, 7, 33, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5, - ), + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # New Flash Update - datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2017, 7, 11, 16, 9, 5, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } SWITCH_FIRMWARE_SUPPORT: Final = { - datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.4 - ), - datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), - datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5 - ), + datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 1, 16, 14, 7, 13, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 4, 27, 11, 59, 31, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 4, 14, 15, 25, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 17, 7, 44, 24, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 8, 31, 10, 23, 32, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 10, 7, 14, 29, 55, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2010, 11, 1, 13, 41, 30, tzinfo=UTC): SupportedVersions(min=2.0, max=2.4), + datetime(2011, 3, 25, 17, 46, 41, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 5, 13, 7, 26, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), + datetime(2011, 6, 27, 9, 4, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # Legrand - datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6 - ), + datetime(2011, 11, 3, 13, 10, 18, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # Radio Test - datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.5, - ), + datetime(2012, 4, 19, 14, 10, 48, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # New Flash Update - datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2017, 7, 11, 16, 11, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } CELSIUS_FIRMWARE_SUPPORT: Final = { # Celsius Proto - datetime(2013, 9, 25, 15, 9, 44, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - - datetime(2013, 10, 11, 15, 15, 58, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2013, 10, 17, 10, 13, 12, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2013, 11, 19, 17, 35, 48, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2013, 12, 5, 16, 25, 33, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2013, 12, 11, 10, 53, 55, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2014, 1, 30, 8, 56, 21, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2014, 2, 3, 10, 9, 27, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2014, 3, 7, 16, 7, 42, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - datetime(2014, 3, 24, 11, 12, 23, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - + datetime(2013, 9, 25, 15, 9, 44, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2013, 10, 11, 15, 15, 58, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2013, 10, 17, 10, 13, 12, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2013, 11, 19, 17, 35, 48, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2013, 12, 5, 16, 25, 33, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2013, 12, 11, 10, 53, 55, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2014, 1, 30, 8, 56, 21, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2014, 2, 3, 10, 9, 27, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2014, 3, 7, 16, 7, 42, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2014, 3, 24, 11, 12, 23, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # MSPBootloader Image - Required to allow # a MSPBootload image for OTA update - datetime(2014, 4, 14, 15, 45, 26, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - + datetime(2014, 4, 14, 15, 45, 26, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # CelsiusV Image - datetime(2014, 7, 23, 19, 24, 18, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - + datetime(2014, 7, 23, 19, 24, 18, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # CelsiusV Image - datetime(2014, 9, 12, 11, 36, 40, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), - + datetime(2014, 9, 12, 11, 36, 40, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # New Flash Update - datetime(2017, 7, 11, 16, 2, 50, tzinfo=UTC): SupportedVersions( - min=2.0, max=2.6, - ), + datetime(2017, 7, 11, 16, 2, 50, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } # endregion @@ -313,6 +153,7 @@ class SupportedVersions(NamedTuple): # region - node firmware based features FEATURE_SUPPORTED_AT_FIRMWARE: Final = { + NodeFeature.BATTERY: 2.0, NodeFeature.INFO: 2.0, NodeFeature.TEMPERATURE: 2.0, NodeFeature.HUMIDITY: 2.0, diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 5eebf81f1..ea05b0dcb 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -1,4 +1,5 @@ """Energy pulse helper.""" + from __future__ import annotations from dataclasses import dataclass @@ -7,6 +8,7 @@ from typing import Final from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, WEEK_IN_HOURS +from ...exceptions import EnergyError _LOGGER = logging.getLogger(__name__) CONSUMED: Final = True @@ -121,6 +123,11 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: @property def last_log(self) -> tuple[int, int] | None: """Return address and slot of last imported log.""" + if ( + self._last_log_consumption_address is None + or self._last_log_consumption_slot is None + ): + return None return (self._last_log_consumption_address, self._last_log_consumption_slot) @property @@ -141,7 +148,7 @@ def log_interval_production(self) -> int | None: @property def log_rollover(self) -> bool: """Indicate if new log is required.""" - return (self._rollover_consumption or self._rollover_production) + return self._rollover_consumption or self._rollover_production @property def last_update(self) -> datetime | None: @@ -166,7 +173,9 @@ def collected_pulses( _LOGGER.debug("collected_pulses | %s | _rollover_production", self._mac) return (None, None) - if (log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption)) is None: + if ( + log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) + ) is None: _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) return (None, None) @@ -181,7 +190,11 @@ def collected_pulses( # _LOGGER.debug("collected_pulses | %s | pulses=%s", self._mac, pulses) if pulses is None: - _LOGGER.debug("collected_pulses | %s | is_consumption=%s, pulses=None", self._mac, is_consumption) + _LOGGER.debug( + "collected_pulses | %s | is_consumption=%s, pulses=None", + self._mac, + is_consumption, + ) return (None, None) return (pulses + log_pulses, timestamp) @@ -194,19 +207,29 @@ def _collect_pulses_from_logs( return None if is_consumption: if self._last_log_consumption_timestamp is None: - _LOGGER.debug("_collect_pulses_from_logs | %s | self._last_log_consumption_timestamp=None", self._mac) + _LOGGER.debug( + "_collect_pulses_from_logs | %s | self._last_log_consumption_timestamp=None", + self._mac, + ) return None if from_timestamp > self._last_log_consumption_timestamp: return 0 else: if self._last_log_production_timestamp is None: - _LOGGER.debug("_collect_pulses_from_logs | %s | self._last_log_production_timestamp=None", self._mac) + _LOGGER.debug( + "_collect_pulses_from_logs | %s | self._last_log_production_timestamp=None", + self._mac, + ) return None if from_timestamp > self._last_log_production_timestamp: return 0 missing_logs = self._logs_missing(from_timestamp) if missing_logs is None or missing_logs: - _LOGGER.debug("_collect_pulses_from_logs | %s | missing_logs=%s", self._mac, missing_logs) + _LOGGER.debug( + "_collect_pulses_from_logs | %s | missing_logs=%s", + self._mac, + missing_logs, + ) return None log_pulses = 0 @@ -229,9 +252,15 @@ def update_pulse_counter( if not (self._rollover_consumption or self._rollover_production): # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been power off for several days - if self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed: + if ( + self._pulses_consumption is not None + and self._pulses_consumption > pulses_consumed + ): self._rollover_consumption = True - if self._pulses_production is not None and self._pulses_production > pulses_produced: + if ( + self._pulses_production is not None + and self._pulses_production > pulses_produced + ): self._rollover_production = True self._pulses_consumption = pulses_consumed self._pulses_production = pulses_produced @@ -249,11 +278,21 @@ def _update_rollover(self) -> None: return if self._pulses_timestamp > self._next_log_consumption_timestamp: self._rollover_consumption = True - _LOGGER.debug("_update_rollover | %s | set consumption rollover => pulses newer", self._mac) + _LOGGER.debug( + "_update_rollover | %s | set consumption rollover => pulses newer", + self._mac, + ) elif self._pulses_timestamp < self._last_log_consumption_timestamp: self._rollover_consumption = True - _LOGGER.debug("_update_rollover | %s | set consumption rollover => log newer", self._mac) - elif self._last_log_consumption_timestamp < self._pulses_timestamp < self._next_log_consumption_timestamp: + _LOGGER.debug( + "_update_rollover | %s | set consumption rollover => log newer", + self._mac, + ) + elif ( + self._last_log_consumption_timestamp + < self._pulses_timestamp + < self._next_log_consumption_timestamp + ): if self._rollover_consumption: _LOGGER.debug("_update_rollover | %s | reset consumption", self._mac) self._rollover_consumption = False @@ -262,16 +301,29 @@ def _update_rollover(self) -> None: if not self._log_production: return - if self._last_log_production_timestamp is None or self._next_log_production_timestamp is None: + if ( + self._last_log_production_timestamp is None + or self._next_log_production_timestamp is None + ): # Unable to determine rollover return if self._pulses_timestamp > self._next_log_production_timestamp: self._rollover_production = True - _LOGGER.debug("_update_rollover | %s | set production rollover => pulses newer", self._mac) + _LOGGER.debug( + "_update_rollover | %s | set production rollover => pulses newer", + self._mac, + ) elif self._pulses_timestamp < self._last_log_production_timestamp: self._rollover_production = True - _LOGGER.debug("_update_rollover | %s | reset production rollover => log newer", self._mac) - elif self._last_log_production_timestamp < self._pulses_timestamp < self._next_log_production_timestamp: + _LOGGER.debug( + "_update_rollover | %s | reset production rollover => log newer", + self._mac, + ) + elif ( + self._last_log_production_timestamp + < self._pulses_timestamp + < self._next_log_production_timestamp + ): if self._rollover_production: _LOGGER.debug("_update_rollover | %s | reset production", self._mac) self._rollover_production = False @@ -280,34 +332,45 @@ def _update_rollover(self) -> None: def add_empty_log(self, address: int, slot: int) -> None: """Add empty energy log record to mark any start of beginning of energy log collection.""" - recalc = False + recalculate = False if self._first_log_address is None or address <= self._first_log_address: - if self._first_empty_log_address is None or self._first_empty_log_address < address: + if ( + self._first_empty_log_address is None + or self._first_empty_log_address < address + ): self._first_empty_log_address = address self._first_empty_log_slot = slot - recalc = True - elif ( - self._first_empty_log_address == address - and (self._first_empty_log_slot is None or self._first_empty_log_slot < slot) + recalculate = True + elif self._first_empty_log_address == address and ( + self._first_empty_log_slot is None or self._first_empty_log_slot < slot ): self._first_empty_log_slot = slot - recalc = True + recalculate = True if self._last_log_address is None or address >= self._last_log_address: - if self._last_empty_log_address is None or self._last_empty_log_address > address: + if ( + self._last_empty_log_address is None + or self._last_empty_log_address > address + ): self._last_empty_log_address = address self._last_empty_log_slot = slot - recalc = True - elif ( - self._last_empty_log_address == address - and (self._last_empty_log_slot is None or self._last_empty_log_slot > slot) + recalculate = True + elif self._last_empty_log_address == address and ( + self._last_empty_log_slot is None or self._last_empty_log_slot > slot ): self._last_empty_log_slot = slot - recalc = True - if recalc: + recalculate = True + if recalculate: self.recalculate_missing_log_addresses() - def add_log(self, address: int, slot: int, timestamp: datetime, pulses: int, import_only: bool = False) -> bool: + def add_log( + self, + address: int, + slot: int, + timestamp: datetime, + pulses: int, + import_only: bool = False, + ) -> bool: """Store pulse log.""" log_record = PulseLogRecord(timestamp, pulses, CONSUMED) if not self._add_log_record(address, slot, log_record): @@ -349,10 +412,16 @@ def _add_log_record( if self._logs.get(address) is None: self._logs[address] = {slot: log_record} self._logs[address][slot] = log_record - if address == self._first_empty_log_address and slot == self._first_empty_log_slot: + if ( + address == self._first_empty_log_address + and slot == self._first_empty_log_slot + ): self._first_empty_log_address = None self._first_empty_log_slot = None - if address == self._last_empty_log_address and slot == self._last_empty_log_slot: + if ( + address == self._last_empty_log_address + and slot == self._last_empty_log_slot + ): self._last_empty_log_address = None self._last_empty_log_slot = None return True @@ -407,10 +476,12 @@ def _update_log_interval(self) -> None: "_update_log_interval | %s | _logs=%s, _log_production=%s", self._mac, self._logs, - self._log_production + self._log_production, ) return - last_cons_address, last_cons_slot = self._last_log_reference(is_consumption=True) + last_cons_address, last_cons_slot = self._last_log_reference( + is_consumption=True + ) if last_cons_address is None or last_cons_slot is None: return @@ -429,15 +500,21 @@ def _update_log_interval(self) -> None: if not self._log_production: return address, slot = calc_log_address(address, slot, -1) - if self._log_interval_consumption is not None: + if ( + self._log_interval_consumption is not None + and self._last_log_consumption_timestamp is not None + ): self._next_log_consumption_timestamp = ( - self._last_log_consumption_timestamp + timedelta(minutes=self._log_interval_consumption) + self._last_log_consumption_timestamp + + timedelta(minutes=self._log_interval_consumption) ) if not self._log_production: return # Update interval of production - last_prod_address, last_prod_slot = self._last_log_reference(is_consumption=False) + last_prod_address, last_prod_slot = self._last_log_reference( + is_consumption=False + ) if last_prod_address is None or last_prod_slot is None: return last_prod_timestamp = self._logs[last_prod_address][last_prod_slot].timestamp @@ -452,9 +529,13 @@ def _update_log_interval(self) -> None: ) break address, slot = calc_log_address(address, slot, -1) - if self._log_interval_production is not None: + if ( + self._log_interval_production is not None + and self._last_log_production_timestamp is not None + ): self._next_log_production_timestamp = ( - self._last_log_production_timestamp + timedelta(minutes=self._log_interval_production) + self._last_log_production_timestamp + + timedelta(minutes=self._log_interval_production) ) def _log_exists(self, address: int, slot: int) -> bool: @@ -467,7 +548,7 @@ def _log_exists(self, address: int, slot: int) -> bool: return True def _update_last_log_reference( - self, address: int, slot: int, timestamp, is_consumption: bool + self, address: int, slot: int, timestamp: datetime, is_consumption: bool ) -> None: """Update references to last (most recent) log record.""" if self._last_log_timestamp is None or self._last_log_timestamp < timestamp: @@ -480,10 +561,13 @@ def _update_last_log_reference( self._last_log_timestamp = timestamp def _update_last_consumption_log_reference( - self, address: int, slot: int, timestamp: datetime + self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log consumption record.""" - if self._last_log_consumption_timestamp is None or self._last_log_consumption_timestamp <= timestamp: + if ( + self._last_log_consumption_timestamp is None + or self._last_log_consumption_timestamp <= timestamp + ): self._last_log_consumption_timestamp = timestamp self._last_log_consumption_address = address self._last_log_consumption_slot = slot @@ -502,35 +586,34 @@ def _reset_log_references(self) -> None: self._first_log_production_address = None self._first_log_production_slot = None self._first_log_production_timestamp = None + if self._logs is None: + return for address in self._logs: for slot, log_record in self._logs[address].items(): if log_record.is_consumption: - if ( - self._last_log_consumption_timestamp is None - or self._last_log_consumption_timestamp < log_record.timestamp - ): + if self._last_log_consumption_timestamp is None: + self._last_log_consumption_timestamp = log_record.timestamp + if self._last_log_consumption_timestamp <= log_record.timestamp: self._last_log_consumption_timestamp = log_record.timestamp self._last_log_consumption_address = address self._last_log_consumption_slot = slot - if ( - self._first_log_consumption_timestamp is None - or self._first_log_consumption_timestamp > log_record.timestamp - ): + + if self._first_log_consumption_timestamp is None: + self._first_log_consumption_timestamp = log_record.timestamp + if self._first_log_consumption_timestamp >= log_record.timestamp: self._first_log_consumption_timestamp = log_record.timestamp self._first_log_consumption_address = address self._first_log_consumption_slot = slot else: - if ( - self._last_log_production_timestamp is None - or self._last_log_production_timestamp < log_record.timestamp - ): + if self._last_log_production_timestamp is None: self._last_log_production_timestamp = log_record.timestamp + if self._last_log_production_timestamp <= log_record.timestamp: self._last_log_production_address = address self._last_log_production_slot = slot - if ( - self._first_log_production_timestamp is None - or self._first_log_production_timestamp > log_record.timestamp - ): + + if self._first_log_production_timestamp is None: + self._first_log_production_timestamp = log_record.timestamp + if self._first_log_production_timestamp > log_record.timestamp: self._first_log_production_timestamp = log_record.timestamp self._first_log_production_address = address self._first_log_production_slot = slot @@ -539,7 +622,10 @@ def _update_last_production_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to last (most recent) log production record.""" - if self._last_log_production_timestamp is None or self._last_log_production_timestamp <= timestamp: + if ( + self._last_log_production_timestamp is None + or self._last_log_production_timestamp <= timestamp + ): self._last_log_production_timestamp = timestamp self._last_log_production_address = address self._last_log_production_slot = slot @@ -561,7 +647,10 @@ def _update_first_consumption_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log consumption record.""" - if self._first_log_consumption_timestamp is None or self._first_log_consumption_timestamp >= timestamp: + if ( + self._first_log_consumption_timestamp is None + or self._first_log_consumption_timestamp >= timestamp + ): self._first_log_consumption_timestamp = timestamp self._first_log_consumption_address = address self._first_log_consumption_slot = slot @@ -570,13 +659,18 @@ def _update_first_production_log_reference( self, address: int, slot: int, timestamp: datetime ) -> None: """Update references to first (oldest) log production record.""" - if self._first_log_production_timestamp is None or self._first_log_production_timestamp >= timestamp: + if ( + self._first_log_production_timestamp is None + or self._first_log_production_timestamp >= timestamp + ): self._first_log_production_timestamp = timestamp self._first_log_production_address = address self._first_log_production_slot = slot def _update_log_references(self, address: int, slot: int) -> None: """Update next expected log timestamps.""" + if self._logs is None: + return log_time_stamp = self._logs[address][slot].timestamp is_consumption = self._logs[address][slot].is_consumption @@ -602,38 +696,23 @@ def _last_log_reference( ) -> tuple[int | None, int | None]: """Address and slot of last log.""" if is_consumption is None: - return ( - self._last_log_address, - self._last_log_slot - ) + return (self._last_log_address, self._last_log_slot) if is_consumption: - return ( - self._last_log_consumption_address, - self._last_log_consumption_slot - ) - return ( - self._last_log_production_address, - self._last_log_production_slot - ) + return (self._last_log_consumption_address, self._last_log_consumption_slot) + return (self._last_log_production_address, self._last_log_production_slot) def _first_log_reference( self, is_consumption: bool | None = None ) -> tuple[int | None, int | None]: """Address and slot of first log.""" if is_consumption is None: - return ( - self._first_log_address, - self._first_log_slot - ) + return (self._first_log_address, self._first_log_slot) if is_consumption: return ( self._first_log_consumption_address, - self._first_log_consumption_slot + self._first_log_consumption_slot, ) - return ( - self._first_log_production_address, - self._first_log_production_slot - ) + return (self._first_log_production_address, self._first_log_production_slot) def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: """Calculate list of missing log addresses.""" @@ -644,21 +723,37 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: return None last_address, last_slot = self._last_log_reference() if last_address is None or last_slot is None: - _LOGGER.debug("_logs_missing | %s | last_address=%s, last_slot=%s", self._mac, last_address, last_slot) + _LOGGER.debug( + "_logs_missing | %s | last_address=%s, last_slot=%s", + self._mac, + last_address, + last_slot, + ) return None first_address, first_slot = self._first_log_reference() if first_address is None or first_slot is None: - _LOGGER.debug("_logs_missing | %s | first_address=%s, first_slot=%s", self._mac, first_address, first_slot) + _LOGGER.debug( + "_logs_missing | %s | first_address=%s, first_slot=%s", + self._mac, + first_address, + first_slot, + ) return None missing = [] - _LOGGER.debug("_logs_missing | %s | first_address=%s, last_address=%s", self._mac, first_address, last_address) + _LOGGER.debug( + "_logs_missing | %s | first_address=%s, last_address=%s", + self._mac, + first_address, + last_address, + ) if ( last_address == first_address and last_slot == first_slot - and self._logs[first_address][first_slot].timestamp == self._logs[last_address][last_slot].timestamp + and self._logs[first_address][first_slot].timestamp + == self._logs[last_address][last_slot].timestamp ): # Power consumption logging, so we need at least 4 logs. return None @@ -678,7 +773,9 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: # return missing logs in range first if len(missing) > 0: - _LOGGER.debug("_logs_missing | %s | missing in range=%s", self._mac, missing) + _LOGGER.debug( + "_logs_missing | %s | missing in range=%s", self._mac, missing + ) return missing if first_address not in self._logs: @@ -707,9 +804,14 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: return None # We have an suspected interval, so try to calculate missing log addresses prior to first collected log - calculated_timestamp = self._logs[first_address][first_slot].timestamp - timedelta(minutes=log_interval) + calculated_timestamp = self._logs[first_address][ + first_slot + ].timestamp - timedelta(minutes=log_interval) while from_timestamp < calculated_timestamp: - if address == self._first_empty_log_address and slot == self._first_empty_log_slot: + if ( + address == self._first_empty_log_address + and slot == self._first_empty_log_slot + ): break if address not in missing: missing.append(address) @@ -722,14 +824,18 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: def _last_known_duration(self) -> timedelta: """Duration for last known logs.""" + if self._logs is None: + raise EnergyError("Unable to return last known duration without any logs") if len(self._logs) < 2: return timedelta(hours=1) address, slot = self._last_log_reference() + if address is None or slot is None: + raise EnergyError("Unable to return last known duration without any logs") last_known_timestamp = self._logs[address][slot].timestamp address, slot = calc_log_address(address, slot, -1) while ( - self._log_exists(address, slot) or - self._logs[address][slot].timestamp == last_known_timestamp + self._log_exists(address, slot) + or self._logs[address][slot].timestamp == last_known_timestamp ): address, slot = calc_log_address(address, slot, -1) return self._logs[address][slot].timestamp - last_known_timestamp @@ -749,9 +855,7 @@ def _missing_addresses_before( and self._log_interval_consumption > 0 ): # Use consumption interval - calc_interval_cons = timedelta( - minutes=self._log_interval_consumption - ) + calc_interval_cons = timedelta(minutes=self._log_interval_consumption) if self._log_interval_consumption == 0: pass @@ -772,9 +876,7 @@ def _missing_addresses_before( self._log_interval_production is not None and self._log_interval_production > 0 ): - calc_interval_prod = timedelta( - minutes=self._log_interval_production - ) + calc_interval_prod = timedelta(minutes=self._log_interval_production) expected_timestamp_cons = ( self._logs[address][slot].timestamp - calc_interval_cons @@ -785,8 +887,7 @@ def _missing_addresses_before( address, slot = calc_log_address(address, slot, -1) while ( - expected_timestamp_cons > target - or expected_timestamp_prod > target + expected_timestamp_cons > target or expected_timestamp_prod > target ) and address > 0: if address not in addresses: addresses.append(address) @@ -814,9 +915,7 @@ def _missing_addresses_after( and self._log_interval_consumption > 0 ): # Use consumption interval - calc_interval_cons = timedelta( - minutes=self._log_interval_consumption - ) + calc_interval_cons = timedelta(minutes=self._log_interval_consumption) if self._log_production is not True: expected_timestamp = ( @@ -836,9 +935,7 @@ def _missing_addresses_after( self._log_interval_production is not None and self._log_interval_production > 0 ): - calc_interval_prod = timedelta( - minutes=self._log_interval_production - ) + calc_interval_prod = timedelta(minutes=self._log_interval_production) expected_timestamp_cons = ( self._logs[address][slot].timestamp + calc_interval_cons @@ -847,10 +944,7 @@ def _missing_addresses_after( self._logs[address][slot].timestamp + calc_interval_prod ) address, slot = calc_log_address(address, slot, 1) - while ( - expected_timestamp_cons < target - or expected_timestamp_prod < target - ): + while expected_timestamp_cons < target or expected_timestamp_prod < target: if address not in addresses: addresses.append(address) if expected_timestamp_prod < expected_timestamp_cons: diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index c662a5eb2..da91c656c 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -3,38 +3,47 @@ from __future__ import annotations from asyncio import gather -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine +from dataclasses import dataclass from typing import Any from ...api import NodeFeature +@dataclass +class NodeFeatureSubscription: + """Subscription registration details for node feature.""" + + callback_fn: Callable[[NodeFeature, Any], Coroutine[Any, Any, None]] + features: tuple[NodeFeature, ...] + + class FeaturePublisher: """Base Class to call awaitable of subscription when event happens.""" _feature_update_subscribers: dict[ - Callable[[], None], - tuple[Callable[[NodeFeature], Awaitable[None]], NodeFeature | None] - ] = {} + Callable[[], None], + NodeFeatureSubscription, + ] = {} def subscribe_to_feature_update( self, - node_feature_callback: Callable[ - [NodeFeature, Any], Awaitable[None] - ], - features: tuple[NodeFeature], + node_feature_callback: Callable[[NodeFeature, Any], Coroutine[Any, Any, None]], + features: tuple[NodeFeature, ...], ) -> Callable[[], None]: """Subscribe callback when specified NodeFeature state updates. Returns the function to be called to unsubscribe later. """ + def remove_subscription() -> None: """Remove stick feature subscription.""" self._feature_update_subscribers.pop(remove_subscription) - self._feature_update_subscribers[ - remove_subscription - ] = (node_feature_callback, features) + self._feature_update_subscribers[remove_subscription] = NodeFeatureSubscription( + node_feature_callback, + features, + ) return remove_subscription async def publish_feature_update_to_subscribers( @@ -43,11 +52,11 @@ async def publish_feature_update_to_subscribers( state: Any, ) -> None: """Publish feature to applicable subscribers.""" - callback_list: list[Callable] = [] - for callback, filtered_features in list( + callback_list: list[Coroutine[Any, Any, None]] = [] + for node_feature_subscription in list( self._feature_update_subscribers.values() ): - if feature in filtered_features: - callback_list.append(callback(feature, state)) + if feature in node_feature_subscription.features: + callback_list.append(node_feature_subscription.callback_fn(feature, state)) if len(callback_list) > 0: await gather(*callback_list) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 2780fd3e2..9afae62a5 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -2,19 +2,21 @@ from __future__ import annotations +from asyncio import Task +from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime import logging from typing import Any, Final -from ..api import NodeEvent, NodeFeature -from ..constants import MotionSensitivity +from ..api import MotionSensitivity, NodeEvent, NodeFeature +from ..connection import StickController from ..exceptions import MessageError, NodeError, NodeTimeout from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest from ..messages.responses import ( NODE_SWITCH_GROUP_ID, - NodeAckResponse, NodeAckResponseType, NodeSwitchGroupResponse, + PlugwiseResponse, ) from ..nodes.sed import NodeSED from .helpers import raise_not_loaded @@ -22,38 +24,63 @@ _LOGGER = logging.getLogger(__name__) -CACHE_MOTION = "motion" +CACHE_MOTION_STATE = "motion_state" +CACHE_MOTION_TIMESTAMP = "motion_timestamp" +CACHE_MOTION_RESET_TIMER = "motion_reset_timer" -# Defaults for Scan Devices +CACHE_SCAN_SENSITIVITY = "scan_sensitivity_level" +CACHE_SCAN_DAYLIGHT_MODE = "scan_daylight_mode" + + +# region Defaults for Scan Devices # Time in minutes the motion sensor should not sense motion to # report "no motion" state -SCAN_MOTION_RESET_TIMER: Final = 5 +SCAN_DEFAULT_MOTION_RESET_TIMER: Final = 5 # Default sensitivity of the motion sensors -SCAN_SENSITIVITY = MotionSensitivity.MEDIUM +SCAN_DEFAULT_SENSITIVITY: Final = MotionSensitivity.MEDIUM # Light override -SCAN_DAYLIGHT_MODE: Final = False +SCAN_DEFAULT_DAYLIGHT_MODE: Final = False + +# endregion class PlugwiseScan(NodeSED): """Plugwise Scan node.""" + def __init__( + self, + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ): + """Initialize Scan Device.""" + super().__init__(mac, address, controller, loaded_callback) + self._config_task_scheduled = False + self._new_motion_reset_timer: int | None = None + self._new_daylight_mode: bool | None = None + self._configure_daylight_mode_task: Task[Coroutine[Any, Any, None]] | None = ( + None + ) + self._new_sensitivity_level: MotionSensitivity | None = None + + # region Load & Initialize + async def load(self) -> bool: """Load and activate Scan node features.""" if self._loaded: return True if self._cache_enabled: - _LOGGER.debug( - "Load Scan node %s from cache", self._node_info.mac - ) + _LOGGER.debug("Load Scan node %s from cache", self._node_info.mac) await self._load_from_cache() self._loaded = True self._setup_protocol( SCAN_FIRMWARE_SUPPORT, - (NodeFeature.INFO, NodeFeature.MOTION), + (NodeFeature.BATTERY, NodeFeature.INFO, NodeFeature.MOTION), ) if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) @@ -79,23 +106,163 @@ async def unload(self) -> None: self._scan_subscription() await super().unload() - async def _switch_group(self, message: NodeSwitchGroupResponse) -> bool: - """Switch group request from Scan.""" + # region Caching + async def _load_from_cache(self) -> bool: + """Load states from previous cached information. Returns True if successful.""" + if not await super()._load_from_cache(): + return False + if not await self.motion_from_cache() or not self.config_from_cache(): + return False + return True + + async def motion_from_cache(self) -> bool: + """Load motion state and timestamp from cache.""" + if ( + cached_motion_timestamp := self._get_cache_as_datetime( + CACHE_MOTION_TIMESTAMP + ) + ) is not None and ( + cached_motion_state := self._get_cache(CACHE_MOTION_STATE) + ) is not None: + motion_state = False + if cached_motion_state == "True": + motion_state = True + await self._motion_state_update(motion_state, cached_motion_timestamp) + _LOGGER.debug( + "Restore motion state (%s) and timestamp (%s) cache for node %s", + cached_motion_state, + cached_motion_timestamp, + self._mac_in_str, + ) + return True + + def config_from_cache(self) -> bool: + """Load motion state and timestamp from cache.""" + if ( + cached_reset_timer := self._get_cache(CACHE_MOTION_RESET_TIMER) + ) is not None: + self._motion_state.reset_timer = int(cached_reset_timer) + else: + self._motion_state.reset_timer = SCAN_DEFAULT_MOTION_RESET_TIMER + + if ( + cached_sensitivity_level := self._get_cache(CACHE_SCAN_SENSITIVITY) + ) is not None: + self._sensitivity_level = MotionSensitivity[cached_sensitivity_level] + else: + self._sensitivity_level = SCAN_DEFAULT_SENSITIVITY + + if ( + cached_daylight_mode := self._get_cache(CACHE_SCAN_DAYLIGHT_MODE) + ) is not None: + self._motion_state.daylight_mode = False + if cached_daylight_mode == "True": + self._motion_state.daylight_mode = True + else: + self._motion_state.daylight_mode = SCAN_DEFAULT_DAYLIGHT_MODE + return True + + # endregion + + # region Properties + + @property + @raise_not_loaded + def daylight_mode(self) -> bool: + """Daylight mode of motion sensor.""" + if self._config_task_scheduled and self._new_daylight_mode is not None: + _LOGGER.debug( + "Return the new (scheduled to be changed) daylight_mode for %s", + self.mac, + ) + return self._new_daylight_mode + if self._motion_state.daylight_mode is None: + raise NodeError(f"Daylight mode is unknown for node {self.mac}") + return self._motion_state.daylight_mode + + @raise_not_loaded + async def update_daylight_mode(self, state: bool) -> None: + """Reconfigure daylight mode of motion sensor. + + Configuration will be applied next time when node is online. + """ + if state == self._motion_state.daylight_mode: + if self._new_daylight_mode is not None: + self._new_daylight_mode = None + return + self._new_daylight_mode = state + if self._config_task_scheduled: + return + await self.schedule_task_when_awake(self._configure_scan_task()) + + @property + @raise_not_loaded + def motion_reset_timer(self) -> int: + """Total minutes without motion before no motion is reported.""" + if self._config_task_scheduled and self._new_motion_reset_timer is not None: + _LOGGER.debug( + "Return the new (scheduled to be changed) motion reset timer for %s", + self.mac, + ) + return self._new_motion_reset_timer + if self._motion_state.reset_timer is None: + raise NodeError(f"Motion reset timer is unknown for node {self.mac}") + return self._motion_state.reset_timer + + @raise_not_loaded + async def update_motion_reset_timer(self, reset_timer: int) -> None: + """Reconfigure minutes without motion before no motion is reported. + + Configuration will be applied next time when node is online. + """ + if reset_timer == self._motion_state.reset_timer: + return + self._new_motion_reset_timer = reset_timer + if self._config_task_scheduled: + return + await self.schedule_task_when_awake(self._configure_scan_task()) + + @property + @raise_not_loaded + def sensitivity_level(self) -> MotionSensitivity: + """Sensitivity level of motion sensor.""" + if self._config_task_scheduled and self._new_sensitivity_level is not None: + return self._new_sensitivity_level + if self._sensitivity_level is None: + raise NodeError(f"Sensitivity value is unknown for node {self.mac}") + return self._sensitivity_level + + @raise_not_loaded + async def update_sensitivity_level( + self, sensitivity_level: MotionSensitivity + ) -> None: + """Reconfigure the sensitivity level for motion sensor. + + Configuration will be applied next time when node is awake. + """ + if sensitivity_level == self._sensitivity_level: + return + self._new_sensitivity_level = sensitivity_level + if self._config_task_scheduled: + return + await self.schedule_task_when_awake(self._configure_scan_task()) + + # endregion + + async def _switch_group(self, response: PlugwiseResponse) -> bool: + """Switch group request from Scan. + + turn on => motion, turn off => clear motion + """ + if not isinstance(response, NodeSwitchGroupResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" + ) await self._available_update_state(True) - if message.power_state.value == 0: - # turn off => clear motion - await self.motion_state_update(False, message.timestamp) - return True - if message.power_state.value == 1: - # turn on => motion - await self.motion_state_update(True, message.timestamp) - return True - raise MessageError( - f"Unknown power_state '{message.power_state.value}' " - + f"received from {self.mac}" - ) + await self._motion_state_update(response.switch_state, response.timestamp) + return True - async def motion_state_update( + async def _motion_state_update( self, motion_state: bool, timestamp: datetime | None = None ) -> None: """Process motion state update.""" @@ -103,25 +270,74 @@ async def motion_state_update( self._motion_state.timestamp = timestamp state_update = False if motion_state: - self._set_cache(CACHE_MOTION, "True") + self._set_cache(CACHE_MOTION_STATE, "True") if self._motion is None or not self._motion: state_update = True if not motion_state: - self._set_cache(CACHE_MOTION, "False") + self._set_cache(CACHE_MOTION_STATE, "False") if self._motion is None or self._motion: state_update = True + self._set_cache(CACHE_MOTION_TIMESTAMP, timestamp) if state_update: self._motion = motion_state await self.publish_feature_update_to_subscribers( - NodeFeature.MOTION, self._motion_state, + NodeFeature.MOTION, + self._motion_state, ) await self.save_cache() + async def _configure_scan_task(self) -> bool: + """Configure Scan device settings. Returns True if successful.""" + change_required = False + if self._new_motion_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: + return True + + if not await self.scan_configure( + motion_reset_timer=self.motion_reset_timer, + sensitivity_level=self.sensitivity_level, + daylight_mode=self.daylight_mode, + ): + return False + if self._new_motion_reset_timer is not None: + _LOGGER.info( + "Change of motion reset timer from %s to %s minutes has been accepted by %s", + self._motion_state.reset_timer, + self._new_motion_reset_timer, + self.name, + ) + self._new_motion_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._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_state.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 = SCAN_MOTION_RESET_TIMER, - sensitivity_level: MotionSensitivity = MotionSensitivity.MEDIUM, - daylight_mode: bool = SCAN_DAYLIGHT_MODE, + motion_reset_timer: int, + sensitivity_level: MotionSensitivity, + daylight_mode: bool, ) -> bool: """Configure Scan device settings. Returns True if successful.""" # Default to medium: @@ -131,48 +347,63 @@ async def scan_configure( if sensitivity_level == MotionSensitivity.OFF: sensitivity_value = 255 # b'FF' - response: NodeAckResponse | None = await self._send( - ScanConfigureRequest( - self._mac_in_bytes, - motion_reset_timer, - sensitivity_value, - daylight_mode, - ) + request = ScanConfigureRequest( + self._send, + self._mac_in_bytes, + motion_reset_timer, + sensitivity_value, + daylight_mode, ) - if response is None: + if (response := await request.send()) is not None: + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: + raise NodeError(f"Scan {self.mac} failed to configure scan settings") + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: + await self._scan_configure_update( + motion_reset_timer, sensitivity_level, daylight_mode + ) + return True + else: raise NodeTimeout( f"No response from Scan device {self.mac} " + "for configuration request." ) - if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: - raise NodeError( - f"Scan {self.mac} failed to configure scan settings" - ) - if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: - self._motion_reset_timer = motion_reset_timer - self._sensitivity_level = sensitivity_level - self._daylight_mode = daylight_mode - return True 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_state.reset_timer = motion_reset_timer + self._set_cache(CACHE_MOTION_RESET_TIMER, str(motion_reset_timer)) + self._sensitivity_level = sensitivity_level + self._set_cache(CACHE_SCAN_SENSITIVITY, sensitivity_level.value) + self._motion_state.daylight_mode = daylight_mode + if daylight_mode: + self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "True") + else: + self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "False") + await self.save_cache() + async def scan_calibrate_light(self) -> bool: """Request to calibration light sensitivity of Scan device.""" - response: NodeAckResponse | None = await self._send( - ScanLightCalibrateRequest(self._mac_in_bytes) + request = ScanLightCalibrateRequest(self._send, self._mac_in_bytes) + if (response := await request.send()) is not None: + if ( + response.node_ack_type + == NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED + ): + return True + return False + raise NodeTimeout( + f"No response from Scan device {self.mac} " + + "to light calibration request." ) - if response is None: - raise NodeTimeout( - f"No response from Scan device {self.mac} " - + "to light calibration request." - ) - if response.node_ack_type == NodeAckResponseType.SCAN_LIGHT_CALIBRATION_ACCEPTED: - return True - return False @raise_not_loaded - async def get_state( - self, features: tuple[NodeFeature] - ) -> dict[NodeFeature, Any]: + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: @@ -191,6 +422,4 @@ async def get_state( else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] - - states[NodeFeature.AVAILABLE] = self._available return states diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index c395418b0..7b4f74c08 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -2,24 +2,31 @@ from __future__ import annotations -from asyncio import CancelledError, Future, Task, get_event_loop, wait_for -from collections.abc import Callable +from asyncio import ( + CancelledError, + Future, + Lock, + Task, + gather, + get_running_loop, + wait_for, +) +from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime import logging from typing import Any, Final -from ..api import NodeFeature, NodeInfo +from ..api import NodeEvent, NodeFeature, NodeInfo from ..connection import StickController -from ..exceptions import NodeError, NodeTimeout +from ..exceptions import MessageError, NodeError from ..messages.requests import NodeSleepConfigRequest from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, NodeAwakeResponse, NodeAwakeResponseType, NodeInfoResponse, - NodePingResponse, - NodeResponse, NodeResponseType, + PlugwiseResponse, ) from ..nodes import PlugwiseNode from .helpers import raise_not_loaded @@ -44,6 +51,8 @@ SED_CLOCK_INTERVAL: Final = 25200 +CACHE_MAINTENANCE_INTERVAL = "maintenance_interval" + _LOGGER = logging.getLogger(__name__) @@ -59,10 +68,9 @@ class NodeSED(PlugwiseNode): _sed_config_clock_interval: int | None = None # Maintenance - _maintenance_interval: int | None = None _maintenance_last_awake: datetime | None = None - _awake_future: Future | None = None - _awake_timer_task: Task | None = None + _awake_future: Future[bool] | None = None + _awake_timer_task: Task[None] | None = None _ping_at_awake: bool = False _awake_subscription: Callable[[], None] | None = None @@ -72,11 +80,15 @@ def __init__( mac: str, address: int, controller: StickController, - loaded_callback: Callable, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize base class for Sleeping End Device.""" super().__init__(mac, address, controller, loaded_callback) + self._loop = get_running_loop() self._node_info.battery_powered = True + self._maintenance_interval = 86400 # Assume standard interval of 24h + self._send_task_queue: list[Coroutine[Any, Any, bool]] = [] + self._send_task_lock = Lock() async def unload(self) -> None: """Deactivate and unload node features.""" @@ -86,6 +98,12 @@ async def unload(self) -> None: await self._awake_timer_task if self._awake_subscription is not None: self._awake_subscription() + 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 @@ -96,10 +114,28 @@ async def initialize(self) -> bool: self._awake_subscription = self._message_subscribe( self._awake_response, self._mac_in_bytes, - NODE_AWAKE_RESPONSE_ID, + (NODE_AWAKE_RESPONSE_ID,), ) return await super().initialize() + async def _load_from_cache(self) -> bool: + """Load states from previous cached information. Returns True if successful.""" + if not await super()._load_from_cache(): + return False + self.maintenance_interval_from_cache() + return True + + def maintenance_interval_from_cache(self) -> bool: + """Load maintenance interval from cache.""" + if ( + cached_maintenance_interval := self._get_cache(CACHE_MAINTENANCE_INTERVAL) + ) is not None: + _LOGGER.debug( + "Restore maintenance interval cache for node %s", self._mac_in_str + ) + self._maintenance_interval = int(cached_maintenance_interval) + return True + @property def maintenance_interval(self) -> int | None: """Heartbeat maintenance interval (seconds).""" @@ -113,46 +149,49 @@ async def node_info_update( return self._node_info return await super().node_info_update(node_info) - async def _awake_response(self, message: NodeAwakeResponse) -> bool: + async def _awake_response(self, response: PlugwiseResponse) -> bool: """Process awake message.""" - self._node_last_online = message.timestamp + if not isinstance(response, NodeAwakeResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" + ) + self._node_last_online = response.timestamp await self._available_update_state(True) - if message.timestamp is None: - return False - if message.awake_type == NodeAwakeResponseType.MAINTENANCE: + if response.awake_type == NodeAwakeResponseType.MAINTENANCE: + if self._maintenance_last_awake is None: + self._maintenance_last_awake = response.timestamp + self._maintenance_interval = ( + response.timestamp - self._maintenance_last_awake + ).seconds if self._ping_at_awake: - ping_response: NodePingResponse | None = ( - await self.ping_update() # type: ignore [assignment] - ) - if ping_response is not None: - self._ping_at_awake = False - await self._reset_awake(message.timestamp) + await self.ping_update() + elif response.awake_type == NodeAwakeResponseType.FIRST: + _LOGGER.info("Device %s is turned on for first time", self.name) + elif response.awake_type == NodeAwakeResponseType.STARTUP: + _LOGGER.info("Device %s is restarted", self.name) + elif response.awake_type == NodeAwakeResponseType.STATE: + _LOGGER.info("Device %s is awake to send status update", self.name) + elif response.awake_type == NodeAwakeResponseType.BUTTON: + _LOGGER.info("Button is pressed at device %s", self.name) + await self._reset_awake(response.timestamp) return True async def _reset_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" - if self._maintenance_last_awake is None: - self._maintenance_last_awake = last_alive - return - self._maintenance_interval = ( - last_alive - self._maintenance_last_awake - ).seconds - - # Finish previous awake timer if self._awake_future is not None: self._awake_future.set_result(True) # Setup new maintenance timer - current_loop = get_event_loop() - self._awake_future = current_loop.create_future() - self._awake_timer_task = current_loop.create_task( - self._awake_timer(), - name=f"Node awake timer for {self._mac_in_str}" + self._awake_future = self._loop.create_future() + self._awake_timer_task = self._loop.create_task( + self._awake_timer(), name=f"Node awake timer for {self._mac_in_str}" ) async def _awake_timer(self) -> None: """Task to monitor to get next awake in time. If not it sets device to be unavailable.""" # wait for next maintenance timer + if self._awake_future is None: + return try: await wait_for( self._awake_future, @@ -172,6 +211,33 @@ 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 + + await self._send_task_lock.acquire() + 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, + ) + else: + self._send_task_queue = [] + self._send_task_lock.release() + + 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.""" + await self._send_task_lock.acquire() + self._send_task_queue.append(task_fn) + self._send_task_lock.release() + async def sed_configure( self, stay_active: int = SED_STAY_ACTIVE, @@ -179,40 +245,27 @@ async def sed_configure( maintenance_interval: int = SED_MAINTENANCE_INTERVAL, clock_sync: bool = SED_CLOCK_SYNC, clock_interval: int = SED_CLOCK_INTERVAL, - awake: bool = False, - ) -> None: + ) -> bool: """Reconfigure the sleep/awake settings for a SED send at next awake of SED.""" - if not awake: - self._sed_configure_at_awake = True - self._sed_config_stay_active = stay_active - self._sed_config_sleep_for = sleep_for - self._sed_config_maintenance_interval = maintenance_interval - self._sed_config_clock_sync = clock_sync - self._sed_config_clock_interval = clock_interval - return - response: NodeResponse | None = await self._send( - NodeSleepConfigRequest( - self._mac_in_bytes, - stay_active, - maintenance_interval, - sleep_for, - clock_sync, - clock_interval, - ) + request = NodeSleepConfigRequest( + self._send, + self._mac_in_bytes, + stay_active, + maintenance_interval, + sleep_for, + clock_sync, + clock_interval, ) - if response is None: - raise NodeTimeout( - "No response to 'NodeSleepConfigRequest' from node " + self.mac - ) - if response.ack_id == NodeResponseType.SLEEP_CONFIG_FAILED: - raise NodeError("SED failed to configure sleep settings") - if response.ack_id == NodeResponseType.SLEEP_CONFIG_ACCEPTED: - self._maintenance_interval = maintenance_interval + if (response := await request.send()) is not None: + if response.ack_id == NodeResponseType.SLEEP_CONFIG_FAILED: + raise NodeError("SED failed to configure sleep settings") + if response.ack_id == NodeResponseType.SLEEP_CONFIG_ACCEPTED: + self._maintenance_interval = maintenance_interval + return True + return False @raise_not_loaded - async def get_state( - self, features: tuple[NodeFeature] - ) -> dict[NodeFeature, Any]: + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: @@ -226,3 +279,4 @@ async def get_state( else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] + return states diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index bee2a5201..0627c5ff7 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -1,4 +1,5 @@ """Plugwise Sense node object.""" + from __future__ import annotations from collections.abc import Callable @@ -6,8 +7,8 @@ from typing import Any, Final from ..api import NodeEvent, NodeFeature -from ..exceptions import NodeError -from ..messages.responses import SENSE_REPORT_ID, SenseReportResponse +from ..exceptions import MessageError, NodeError +from ..messages.responses import SENSE_REPORT_ID, PlugwiseResponse, SenseReportResponse from ..nodes.sed import NodeSED from .helpers import raise_not_loaded from .helpers.firmware import SENSE_FIRMWARE_SUPPORT @@ -39,18 +40,12 @@ async def load(self) -> bool: return True self._node_info.battery_powered = True if self._cache_enabled: - _LOGGER.debug( - "Load Sense node %s from cache", self._node_info.mac - ) + _LOGGER.debug("Load Sense node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( SENSE_FIRMWARE_SUPPORT, - ( - NodeFeature.INFO, - NodeFeature.TEMPERATURE, - NodeFeature.HUMIDITY - ), + (NodeFeature.INFO, NodeFeature.TEMPERATURE, NodeFeature.HUMIDITY), ) if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) @@ -66,7 +61,7 @@ async def initialize(self) -> bool: self._sense_subscription = self._message_subscribe( self._sense_report, self._mac_in_bytes, - SENSE_REPORT_ID, + (SENSE_REPORT_ID,), ) return await super().initialize() @@ -77,32 +72,34 @@ async def unload(self) -> None: self._sense_subscription() await super().unload() - async def _sense_report(self, message: SenseReportResponse) -> None: + async def _sense_report(self, response: PlugwiseResponse) -> bool: """Process sense report message to extract current temperature and humidity values.""" + if not isinstance(response, SenseReportResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected SenseReportResponse" + ) await self._available_update_state(True) - if message.temperature.value != 65535: + if response.temperature.value != 65535: self._temperature = int( - SENSE_TEMPERATURE_MULTIPLIER * ( - message.temperature.value / 65536 - ) + SENSE_TEMPERATURE_MULTIPLIER * (response.temperature.value / 65536) - SENSE_TEMPERATURE_OFFSET ) await self.publish_feature_update_to_subscribers( NodeFeature.TEMPERATURE, self._temperature ) - if message.humidity.value != 65535: + if response.humidity.value != 65535: self._humidity = int( - SENSE_HUMIDITY_MULTIPLIER * (message.humidity.value / 65536) + SENSE_HUMIDITY_MULTIPLIER * (response.humidity.value / 65536) - SENSE_HUMIDITY_OFFSET ) await self.publish_feature_update_to_subscribers( NodeFeature.HUMIDITY, self._humidity ) + return True + return False @raise_not_loaded - async def get_state( - self, features: tuple[NodeFeature] - ) -> dict[NodeFeature, Any]: + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} for feature in features: diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index d5167a0b6..81f36bd46 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -7,7 +7,11 @@ from ..api import NodeEvent, NodeFeature from ..exceptions import MessageError -from ..messages.responses import NODE_SWITCH_GROUP_ID, NodeSwitchGroupResponse +from ..messages.responses import ( + NODE_SWITCH_GROUP_ID, + NodeSwitchGroupResponse, + PlugwiseResponse, +) from ..nodes.sed import NodeSED from .helpers import raise_not_loaded from .helpers.firmware import SWITCH_FIRMWARE_SUPPORT @@ -27,9 +31,7 @@ async def load(self) -> bool: return True self._node_info.battery_powered = True if self._cache_enabled: - _LOGGER.debug( - "Load Switch node %s from cache", self._node_info.mac - ) + _LOGGER.debug("Load Switch node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -48,10 +50,9 @@ async def initialize(self) -> bool: if self._initialized: return True self._switch_subscription = self._message_subscribe( - b"0056", self._switch_group, self._mac_in_bytes, - NODE_SWITCH_GROUP_ID, + (NODE_SWITCH_GROUP_ID,), ) return await super().initialize() @@ -62,22 +63,24 @@ async def unload(self) -> None: self._switch_subscription() await super().unload() - async def _switch_group(self, message: NodeSwitchGroupResponse) -> None: + async def _switch_group(self, response: PlugwiseResponse) -> bool: """Switch group request from Switch.""" - if message.power_state.value == 0: - if self._switch is None or self._switch: - self._switch = False - await self.publish_feature_update_to_subscribers( - NodeFeature.SWITCH, False - ) - elif message.power_state.value == 1: + if not isinstance(response, NodeSwitchGroupResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" + ) + # Switch on + if response.switch_state: if self._switch_state is None or not self._switch: self._switch_state = True await self.publish_feature_update_to_subscribers( NodeFeature.SWITCH, True ) - else: - raise MessageError( - f"Unknown power_state '{message.power_state.value}' " + - f"received from {self.mac}" + return True + # Switch off + if self._switch is None or self._switch: + self._switch = False + await self.publish_feature_update_to_subscribers( + NodeFeature.SWITCH, False ) + return True diff --git a/tests/test_usb.py b/tests/test_usb.py index 70a2e0c89..39ed80222 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,15 +1,17 @@ """Test plugwise USB Stick.""" import asyncio -from datetime import UTC, datetime as dt, timedelta as td, timezone as tz +from collections.abc import Callable +from datetime import UTC, datetime as dt, timedelta as td import importlib import logging import random +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest -import aiofiles +import aiofiles # type: ignore[import-untyped] import crcmod from freezegun import freeze_time @@ -19,9 +21,7 @@ pw_api = importlib.import_module("plugwise_usb.api") pw_exceptions = importlib.import_module("plugwise_usb.exceptions") pw_connection = importlib.import_module("plugwise_usb.connection") -pw_connection_manager = importlib.import_module( - "plugwise_usb.connection.manager" -) +pw_connection_manager = importlib.import_module("plugwise_usb.connection.manager") pw_constants = importlib.import_module("plugwise_usb.constants") pw_helpers_cache = importlib.import_module("plugwise_usb.helpers.cache") pw_network_cache = importlib.import_module("plugwise_usb.network.cache") @@ -32,19 +32,15 @@ pw_responses = importlib.import_module("plugwise_usb.messages.responses") pw_userdata = importlib.import_module("stick_test_data") pw_circle = importlib.import_module("plugwise_usb.nodes.circle") -pw_energy_counter = importlib.import_module( - "plugwise_usb.nodes.helpers.counter" -) -pw_energy_calibration = importlib.import_module( - "plugwise_usb.nodes.helpers" -) +pw_energy_counter = importlib.import_module("plugwise_usb.nodes.helpers.counter") +pw_energy_calibration = importlib.import_module("plugwise_usb.nodes.helpers") pw_energy_pulses = importlib.import_module("plugwise_usb.nodes.helpers.pulses") _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -def inc_seq_id(seq_id: bytes) -> bytes: +def inc_seq_id(seq_id: bytes | None) -> bytes: """Increment sequence id.""" if seq_id is None: return b"0000" @@ -60,21 +56,29 @@ def inc_seq_id(seq_id: bytes) -> bytes: def construct_message(data: bytes, seq_id: bytes = b"0000") -> bytes: """Construct plugwise message.""" body = data[:4] + seq_id + data[4:] - return ( + return bytes( pw_constants.MESSAGE_HEADER + body - + bytes("%04X" % crc_fun(body), pw_constants.UTF8) + + bytes(f"{crc_fun(body):04X}", pw_constants.UTF8) + pw_constants.MESSAGE_FOOTER ) class DummyTransport: - def __init__(self, loop, test_data=None) -> None: + """Dummy transport class.""" + + protocol_data_received: Callable[[bytes], None] + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + test_data: dict[bytes, tuple[str, bytes, bytes]] | None = None, + ) -> None: + """Initialize dummy transport class.""" self._loop = loop self._msg = 0 self._seq_id = b"1233" - self.protocol_data_received = None - self._processed = [] + self._processed: list[bytes] = [] self._first_response = test_data self._second_response = test_data if test_data is None: @@ -84,29 +88,27 @@ def __init__(self, loop, test_data=None) -> None: self._closing = False def is_closing(self) -> bool: + """Close connection.""" return self._closing def write(self, data: bytes) -> None: + """Write data back to system.""" log = None - if data in self._processed: - log, ack, response = self._second_response.get( - data, (None, None, None) - ) + if data in self._processed and self._second_response is not None: + log, ack, response = self._second_response.get(data, (None, None, None)) + if log is None and self._first_response is not None: + log, ack, response = self._first_response.get(data, (None, None, None)) if log is None: - log, ack, response = self._first_response.get( - data, (None, None, None) + resp = pw_userdata.PARTLY_RESPONSE_MESSAGES.get( + data[:24], (None, None, None) ) - if log is None: - resp = pw_userdata.PARTLY_RESPONSE_MESSAGES.get( - data[:24], (None, None, None) - ) - if resp is None: - _LOGGER.debug("No msg response for %s", str(data)) - return - log, ack, response = resp - if ack is None: - _LOGGER.debug("No ack response for %s", str(data)) + if resp is None: + _LOGGER.debug("No msg response for %s", str(data)) return + log, ack, response = resp + if ack is None: + _LOGGER.debug("No ack response for %s", str(data)) + return self._seq_id = inc_seq_id(self._seq_id) if response and self._msg == 0: @@ -117,57 +119,79 @@ def write(self, data: bytes) -> None: self._processed.append(data) if response is None or self._closing: return - self._loop.create_task( - # 0.5, - self._delayed_response(response, self._seq_id) - ) + self._loop.create_task(self._delayed_response(response, self._seq_id)) self._msg += 1 async def _delayed_response(self, data: bytes, seq_id: bytes) -> None: - delay = random.uniform(0.05, 0.25) + delay = random.uniform(0.005, 0.025) await asyncio.sleep(delay) self.message_response(data, seq_id) def message_response(self, data: bytes, seq_id: bytes) -> None: + """Handle message response.""" self.random_extra_byte += 1 if self.random_extra_byte > 25: self.protocol_data_received(b"\x83") self.random_extra_byte = 0 - self.protocol_data_received( - construct_message(data, seq_id) + b"\x83" - ) + self.protocol_data_received(construct_message(data, seq_id) + b"\x83") else: self.protocol_data_received(construct_message(data, seq_id)) def message_response_at_once(self, ack: bytes, data: bytes, seq_id: bytes) -> None: + """Full message.""" self.random_extra_byte += 1 if self.random_extra_byte > 25: self.protocol_data_received(b"\x83") self.random_extra_byte = 0 self.protocol_data_received( - construct_message(ack, seq_id) + construct_message(data, seq_id) + b"\x83" + construct_message(ack, seq_id) + + construct_message(data, seq_id) + + b"\x83" ) else: - self.protocol_data_received(construct_message(ack, seq_id) + construct_message(data, seq_id)) + self.protocol_data_received( + construct_message(ack, seq_id) + construct_message(data, seq_id) + ) def close(self) -> None: + """Close connection.""" self._closing = True class MockSerial: - def __init__(self, custom_response) -> None: - self.custom_response = custom_response - self._protocol = None - self._transport = None + """Mock serial connection.""" - async def mock_connection(self, loop, protocol_factory, **kwargs): + def __init__( + self, custom_response: dict[bytes, tuple[str, bytes, bytes]] | None + ) -> None: + """Init mocked serial connection.""" + self.custom_response = custom_response + self._protocol: pw_receiver.StickReceiver | None = None # type: ignore[name-defined] + self._transport: DummyTransport | None = None + + def inject_message(self, data: bytes, seq_id: bytes) -> None: + """Inject message to be received from stick.""" + if self._transport is None: + return + self._transport.message_response(data, seq_id) + + def trigger_connection_lost(self) -> None: + """Trigger connection lost.""" + if self._protocol is None: + return + self._protocol.connection_lost() + + async def mock_connection( + self, + loop: asyncio.AbstractEventLoop, + protocol_factory: Callable[[], pw_receiver.StickReceiver], # type: ignore[name-defined] + **kwargs: dict[str, Any], + ) -> tuple[DummyTransport, pw_receiver.StickReceiver]: # type: ignore[name-defined] """Mock connection with dummy connection.""" self._protocol = protocol_factory() self._transport = DummyTransport(loop, self.custom_response) self._transport.protocol_data_received = self._protocol.data_received - loop.call_soon_threadsafe( - self._protocol.connection_made, self._transport - ) + loop.call_soon_threadsafe(self._protocol.connection_made, self._transport) return self._transport, self._protocol @@ -178,9 +202,9 @@ async def exists(self, file_or_path: str) -> bool: """Exists folder.""" if file_or_path == "mock_folder_that_exists": return True - if file_or_path == f"mock_folder_that_exists/nodes.cache": + if file_or_path == "mock_folder_that_exists/nodes.cache": return True - if file_or_path == f"mock_folder_that_exists/0123456789ABCDEF.cache": + if file_or_path == "mock_folder_that_exists/0123456789ABCDEF.cache": return True return file_or_path == "mock_folder_that_exists/file_that_exists.ext" @@ -190,26 +214,42 @@ async def mkdir(self, path: str) -> None: aiofiles.threadpool.wrap.register(MagicMock)( - lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase(*args, **kwargs) + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase(*args, **kwargs) # pylint: disable=unnecessary-lambda ) class TestStick: + """Test USB Stick.""" + + test_node_awake: asyncio.Future[str] + test_node_join: asyncio.Future[str] + test_connected: asyncio.Future[bool] + test_disconnected: asyncio.Future[bool] + test_relay_state_on: asyncio.Future[bool] + test_relay_state_off: asyncio.Future[bool] + test_motion_on: asyncio.Future[bool] + test_motion_off: asyncio.Future[bool] + test_init_relay_state_off: asyncio.Future[bool] + test_init_relay_state_on: asyncio.Future[bool] + + async def dummy_fn(self, request: pw_requests.PlugwiseRequest, test: bool) -> None: # type: ignore[name-defined] + """Callable dummy routine.""" + return @pytest.mark.asyncio - async def test_sorting_request_messages(self): + async def test_sorting_request_messages(self) -> None: """Test request message priority sorting.""" node_add_request = pw_requests.NodeAddRequest( - b"1111222233334444", True + self.dummy_fn, b"1111222233334444", True ) await asyncio.sleep(0.001) relay_switch_request = pw_requests.CircleRelaySwitchRequest( - b"1234ABCD12341234", True + self.dummy_fn, b"1234ABCD12341234", True ) await asyncio.sleep(0.001) circle_plus_allow_joining_request = pw_requests.CirclePlusAllowJoiningRequest( - True + self.dummy_fn, True ) # validate sorting based on timestamp with same priority level @@ -241,7 +281,7 @@ async def test_sorting_request_messages(self): assert circle_plus_allow_joining_request >= node_add_request @pytest.mark.asyncio - async def test_stick_connect_without_port(self): + async def test_stick_connect_without_port(self) -> None: """Test connecting to stick without port config.""" stick = pw_stick.Stick() assert stick.accept_join_request is None @@ -264,7 +304,7 @@ async def test_stick_connect_without_port(self): await stick.disconnect() @pytest.mark.asyncio - async def test_stick_reconnect(self, monkeypatch): + async def test_stick_reconnect(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test connecting to stick while already connected.""" monkeypatch.setattr( pw_connection_manager, @@ -281,17 +321,19 @@ async def test_stick_reconnect(self, monkeypatch): await stick.disconnect() @pytest.mark.asyncio - async def test_stick_connect_without_response(self, monkeypatch): + async def test_stick_connect_without_response( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Test connecting to stick without response.""" monkeypatch.setattr( pw_connection_manager, "create_serial_connection", MockSerial( { - b"dummy": ( + b"FFFF": ( "no response", b"0000", - None, + b"", ), } ).mock_connection, @@ -309,7 +351,7 @@ async def test_stick_connect_without_response(self, monkeypatch): await stick.disconnect() @pytest.mark.asyncio - async def test_stick_connect_timeout(self, monkeypatch): + async def test_stick_connect_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test connecting to stick.""" monkeypatch.setattr( pw_connection_manager, @@ -319,7 +361,7 @@ async def test_stick_connect_timeout(self, monkeypatch): b"\x05\x05\x03\x03000AB43C\r\n": ( "STICK INIT timeout", b"000000E1", # Timeout ack - None, # + b"", ), } ).mock_connection, @@ -331,7 +373,7 @@ async def test_stick_connect_timeout(self, monkeypatch): await stick.initialize() await stick.disconnect() - async def connected(self, event): + async def connected(self, event: pw_api.StickEvent) -> None: # type: ignore[name-defined] """Set connected state helper.""" if event is pw_api.StickEvent.CONNECTED: self.test_connected.set_result(True) @@ -339,7 +381,7 @@ async def connected(self, event): self.test_connected.set_exception(BaseException("Incorrect event")) @pytest.mark.asyncio - async def test_stick_connect(self, monkeypatch): + async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test connecting to stick.""" monkeypatch.setattr( pw_connection_manager, @@ -348,12 +390,11 @@ async def test_stick_connect(self, monkeypatch): ) stick = pw_stick.Stick(port="test_port", cache_enabled=False) - self.test_connected = asyncio.Future() unsub_connect = stick.subscribe_to_stick_events( stick_event_callback=self.connected, events=(pw_api.StickEvent.CONNECTED,), ) - + self.test_connected = asyncio.Future() await stick.connect("test_port") assert await self.test_connected await stick.initialize() @@ -372,7 +413,7 @@ async def test_stick_connect(self, monkeypatch): with pytest.raises(pw_exceptions.StickError): assert stick.mac_stick - async def disconnected(self, event): + async def disconnected(self, event: pw_api.StickEvent) -> None: # type: ignore[name-defined] """Handle disconnect event callback.""" if event is pw_api.StickEvent.DISCONNECTED: self.test_disconnected.set_result(True) @@ -380,7 +421,7 @@ async def disconnected(self, event): self.test_disconnected.set_exception(BaseException("Incorrect event")) @pytest.mark.asyncio - async def test_stick_connection_lost(self, monkeypatch): + async def test_stick_connection_lost(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test connecting to stick.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -398,52 +439,53 @@ async def test_stick_connection_lost(self, monkeypatch): events=(pw_api.StickEvent.DISCONNECTED,), ) # Trigger disconnect - mock_serial._protocol.connection_lost() + mock_serial.trigger_connection_lost() assert await self.test_disconnected assert not stick.network_state unsub_disconnect() await stick.disconnect() - async def node_awake(self, event: pw_api.NodeEvent, mac: str): + async def node_awake(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] """Handle awake event callback.""" if event == pw_api.NodeEvent.AWAKE: self.test_node_awake.set_result(mac) else: self.test_node_awake.set_exception( BaseException( - f"Invalid {event} event, expected " + - f"{pw_api.NodeEvent.AWAKE}" + f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.AWAKE}" ) ) async def node_motion_state( self, - feature: pw_api.NodeFeature, - state: pw_api.MotionState, - ): + feature: pw_api.NodeFeature, # type: ignore[name-defined] + state: pw_api.MotionState, # type: ignore[name-defined] + ) -> None: """Handle motion event callback.""" if feature == pw_api.NodeFeature.MOTION: if state.motion: - self.motion_on.set_result(state.motion) + self.test_motion_on.set_result(state.motion) else: - self.motion_off.set_result(state.motion) + self.test_motion_off.set_result(state.motion) elif state.motion: - self.motion_on.set_exception( + self.test_motion_on.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.MOTION}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" ) ) else: - self.motion_off.set_exception( + self.test_motion_off.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.MOTION}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.MOTION}" ) ) @pytest.mark.asyncio - async def test_stick_node_discovered_subscription(self, monkeypatch): + async def test_stick_node_discovered_subscription( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Testing "new_node" subscription for Scan.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -465,7 +507,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): ) # Inject NodeAwakeResponse message to trigger a 'node discovered' event - mock_serial._transport.message_response(b"004F555555555555555500", b"FFFE") + mock_serial.inject_message(b"004F555555555555555500", b"FFFE") mac_awake_node = await self.test_node_awake assert mac_awake_node == "5555555555555555" unsub_awake() @@ -482,6 +524,7 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): assert sorted(stick.nodes["5555555555555555"].features) == sorted( ( pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, pw_api.NodeFeature.INFO, pw_api.NodeFeature.PING, pw_api.NodeFeature.MOTION, @@ -505,43 +548,42 @@ async def test_stick_node_discovered_subscription(self, monkeypatch): assert stick.nodes["5555555555555555"].energy # Motion - self.motion_on = asyncio.Future() - self.motion_off = asyncio.Future() - unsub_motion = stick.nodes[ - "5555555555555555" - ].subscribe_to_feature_update( + self.test_motion_on = asyncio.Future() + self.test_motion_off = asyncio.Future() + unsub_motion = stick.nodes["5555555555555555"].subscribe_to_feature_update( node_feature_callback=self.node_motion_state, features=(pw_api.NodeFeature.MOTION,), ) # Inject motion message to trigger a 'motion on' event - mock_serial._transport.message_response(b"005655555555555555550001", b"FFFF") - motion_on = await self.motion_on + mock_serial.inject_message(b"005655555555555555550001", b"FFFF") + motion_on = await self.test_motion_on assert motion_on assert stick.nodes["5555555555555555"].motion # Inject motion message to trigger a 'motion off' event - mock_serial._transport.message_response(b"005655555555555555550000", b"FFFF") - motion_off = await self.motion_off + mock_serial.inject_message(b"005655555555555555550000", b"FFFF") + motion_off = await self.test_motion_off assert not motion_off assert not stick.nodes["5555555555555555"].motion unsub_motion() await stick.disconnect() - async def node_join(self, event: pw_api.NodeEvent, mac: str): + async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] """Handle join event callback.""" if event == pw_api.NodeEvent.JOIN: self.test_node_join.set_result(mac) else: self.test_node_join.set_exception( BaseException( - f"Invalid {event} event, expected " + - f"{pw_api.NodeEvent.JOIN}" + f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.JOIN}" ) ) @pytest.mark.asyncio - async def test_stick_node_join_subscription(self, monkeypatch): + async def test_stick_node_join_subscription( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Testing "new_node" subscription.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -562,14 +604,14 @@ async def test_stick_node_join_subscription(self, monkeypatch): ) # Inject node join request message - mock_serial._transport.message_response(b"00069999999999999999", b"FFFC") + mock_serial.inject_message(b"00069999999999999999", b"FFFC") mac_join_node = await self.test_node_join assert mac_join_node == "9999999999999999" unusb_join() await stick.disconnect() @pytest.mark.asyncio - async def test_node_discovery(self, monkeypatch): + async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing discovery of nodes.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -589,9 +631,9 @@ async def test_node_discovery(self, monkeypatch): async def node_relay_state( self, - feature: pw_api.NodeFeature, - state: pw_api.RelayState, - ): + feature: pw_api.NodeFeature, # type: ignore[name-defined] + state: pw_api.RelayState, # type: ignore[name-defined] + ) -> None: """Handle relay event callback.""" if feature == pw_api.NodeFeature.RELAY: if state.relay_state: @@ -601,23 +643,23 @@ async def node_relay_state( else: self.test_relay_state_on.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.RELAY}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY}" ) ) self.test_relay_state_off.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.RELAY}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY}" ) ) async def node_init_relay_state( self, - feature: pw_api.NodeFeature, + feature: pw_api.NodeFeature, # type: ignore[name-defined] state: bool, - ): - """Callback helper for relay event.""" + ) -> None: + """Relay Callback for event.""" if feature == pw_api.NodeFeature.RELAY_INIT: if state: self.test_init_relay_state_on.set_result(state) @@ -626,20 +668,20 @@ async def node_init_relay_state( else: self.test_init_relay_state_on.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.RELAY_INIT}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY_INIT}" ) ) self.test_init_relay_state_off.set_exception( BaseException( - f"Invalid {feature} feature, expected " + - f"{pw_api.NodeFeature.RELAY_INIT}" + f"Invalid {feature} feature, expected " + + f"{pw_api.NodeFeature.RELAY_INIT}" ) ) @pytest.mark.asyncio - async def test_node_relay_and_power(self, monkeypatch): - """Testing discovery of nodes""" + async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Testing discovery of nodes.""" mock_serial = MockSerial(None) monkeypatch.setattr( pw_connection_manager, @@ -656,26 +698,10 @@ async def test_node_relay_and_power(self, monkeypatch): # Manually load node assert await stick.nodes["0098765432101234"].load() - self.test_relay_state_on = asyncio.Future() - self.test_relay_state_off = asyncio.Future() - unsub_relay = stick.nodes[ - "0098765432101234" - ].subscribe_to_feature_update( + unsub_relay = stick.nodes["0098765432101234"].subscribe_to_feature_update( node_feature_callback=self.node_relay_state, features=(pw_api.NodeFeature.RELAY,), ) - # Test sync switching from on to off - assert stick.nodes["0098765432101234"].relay - stick.nodes["0098765432101234"].relay = False - assert not await self.test_relay_state_off - assert not stick.nodes["0098765432101234"].relay - assert not stick.nodes["0098765432101234"].relay_state.relay_state - - # Test sync switching back from off to on - stick.nodes["0098765432101234"].relay = True - assert await self.test_relay_state_on - assert stick.nodes["0098765432101234"].relay - assert stick.nodes["0098765432101234"].relay_state.relay_state # Test async switching back from on to off self.test_relay_state_off = asyncio.Future() @@ -689,14 +715,14 @@ async def test_node_relay_and_power(self, monkeypatch): assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay - # Test sync switching back from on to off + # Test async switching back from on to off self.test_relay_state_off = asyncio.Future() await stick.nodes["0098765432101234"].relay_off() assert not await self.test_relay_state_off assert not stick.nodes["0098765432101234"].relay assert not stick.nodes["0098765432101234"].relay_state.relay_state - # Test sync switching back from off to on + # Test async switching back from off to on self.test_relay_state_on = asyncio.Future() await stick.nodes["0098765432101234"].relay_on() assert await self.test_relay_state_on @@ -712,11 +738,9 @@ async def test_node_relay_and_power(self, monkeypatch): with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].relay_init with pytest.raises(pw_exceptions.NodeError): - stick.nodes["0098765432101234"].relay_init = True + await stick.nodes["0098765432101234"].switch_relay_init(True) with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].switch_init_relay(True) - with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].switch_init_relay(False) + await stick.nodes["0098765432101234"].switch_relay_init(False) # Check Circle is raising NodeError for unsupported features with pytest.raises(pw_exceptions.NodeError): @@ -734,32 +758,21 @@ async def test_node_relay_and_power(self, monkeypatch): assert await stick.nodes["2222222222222222"].load() self.test_init_relay_state_on = asyncio.Future() self.test_init_relay_state_off = asyncio.Future() - unsub_inti_relay = stick.nodes[ - "0098765432101234" - ].subscribe_to_feature_update( + unsub_inti_relay = stick.nodes["0098765432101234"].subscribe_to_feature_update( node_feature_callback=self.node_init_relay_state, features=(pw_api.NodeFeature.RELAY_INIT,), ) - # Test sync switching init_state from on to off - assert stick.nodes["2222222222222222"].relay_init - stick.nodes["2222222222222222"].relay_init = False - assert not await self.test_init_relay_state_off - assert not stick.nodes["2222222222222222"].relay_init - - # Test sync switching back init_state from off to on - stick.nodes["2222222222222222"].relay_init = True - assert await self.test_init_relay_state_on - assert stick.nodes["2222222222222222"].relay_init # Test async switching back init_state from on to off + assert stick.nodes["2222222222222222"].relay_init self.test_init_relay_state_off = asyncio.Future() - assert not await stick.nodes["2222222222222222"].switch_init_relay(False) + assert not await stick.nodes["2222222222222222"].switch_relay_init(False) assert not await self.test_init_relay_state_off assert not stick.nodes["2222222222222222"].relay_init # Test async switching back from off to on self.test_init_relay_state_on = asyncio.Future() - assert await stick.nodes["2222222222222222"].switch_init_relay(True) + assert await stick.nodes["2222222222222222"].switch_relay_init(True) assert await self.test_init_relay_state_on assert stick.nodes["2222222222222222"].relay_init @@ -768,7 +781,7 @@ async def test_node_relay_and_power(self, monkeypatch): await stick.disconnect() @pytest.mark.asyncio - async def test_energy_circle(self, monkeypatch): + async def test_energy_circle(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing energy retrieval.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -780,10 +793,14 @@ async def test_energy_circle(self, monkeypatch): monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.2) monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 2.0) - async def fake_get_missing_energy_logs(address) -> None: - pass + async def fake_get_missing_energy_logs(address: int) -> None: + """Mock missing energy logs.""" - monkeypatch.setattr(pw_circle.PlugwiseCircle, "get_missing_energy_logs", fake_get_missing_energy_logs) + monkeypatch.setattr( + pw_circle.PlugwiseCircle, + "get_missing_energy_logs", + fake_get_missing_energy_logs, + ) stick = pw_stick.Stick("test_port", cache_enabled=False) await stick.connect() await stick.initialize() @@ -799,7 +816,9 @@ async def fake_get_missing_energy_logs(address) -> None: assert stick.nodes["0098765432101234"].calibrated # Test power state without request - assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics(last_second=None, last_8_seconds=None, timestamp=None) + assert stick.nodes["0098765432101234"].power == pw_api.PowerStatistics( + last_second=None, last_8_seconds=None, timestamp=None + ) pu = await stick.nodes["0098765432101234"].power_update() assert pu.last_second == 21.2780505980402 assert pu.last_8_seconds == 27.150578775440106 @@ -822,7 +841,7 @@ async def fake_get_missing_energy_logs(address) -> None: week_production_reset=None, ) # energy_update is not complete and should return none - utc_now = dt.utcnow().replace(tzinfo=UTC) + utc_now = dt.now(UTC) assert await stick.nodes["0098765432101234"].energy_update() is None # Allow for background task to finish @@ -845,14 +864,14 @@ async def fake_get_missing_energy_logs(address) -> None: await stick.disconnect() @freeze_time(dt.now()) - def test_pulse_collection_consumption(self, monkeypatch): + def test_pulse_collection_consumption( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Testing pulse collection class.""" monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) - fixed_timestamp_utc = dt.now(tz.utc) - fixed_this_hour = fixed_timestamp_utc.replace( - minute=0, second=0, microsecond=0 - ) + fixed_timestamp_utc = dt.now(UTC) + fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") @@ -866,7 +885,9 @@ def test_pulse_collection_consumption(self, monkeypatch): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None) assert tst_consumption.log_addresses_missing is None # Test consumption - Log import #2, random log @@ -877,7 +898,9 @@ def test_pulse_collection_consumption(self, monkeypatch): assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert tst_consumption.production_logging is None - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #3 @@ -887,8 +910,10 @@ def test_pulse_collection_consumption(self, monkeypatch): tst_consumption.add_log(95, 3, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None - assert tst_consumption.production_logging is False - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert not tst_consumption.production_logging + assert (tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None)) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #4, no change @@ -896,8 +921,10 @@ def test_pulse_collection_consumption(self, monkeypatch): tst_consumption.add_log(95, 2, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None - assert tst_consumption.production_logging is False - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert not tst_consumption.production_logging + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #5 @@ -906,7 +933,7 @@ def test_pulse_collection_consumption(self, monkeypatch): tst_consumption.add_log(95, 1, test_timestamp, 1000) assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None - assert tst_consumption.production_logging is False + assert not tst_consumption.production_logging assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #6 @@ -915,46 +942,70 @@ def test_pulse_collection_consumption(self, monkeypatch): tst_consumption.add_log(99, 4, test_timestamp, 750) assert tst_consumption.log_interval_consumption == 60 assert tst_consumption.log_interval_production is None - assert tst_consumption.production_logging is False + assert not tst_consumption.production_logging assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (None, None) tst_consumption.add_log(99, 3, fixed_this_hour - td(hours=2), 1111) assert tst_consumption.log_interval_consumption == 60 assert tst_consumption.log_interval_production is None - assert tst_consumption.production_logging is False + assert not tst_consumption.production_logging assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (None, None) # Test consumption - pulse update #1 pulse_update_1 = fixed_this_hour + td(minutes=5) tst_consumption.update_pulse_counter(1234, 0, pulse_update_1) - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (1234, pulse_update_1) - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=False) == (None, None) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (1234, pulse_update_1) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=False + ) == (None, None) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - pulse update #2 pulse_update_2 = fixed_this_hour + td(minutes=7) test_timestamp = fixed_this_hour tst_consumption.update_pulse_counter(2345, 0, pulse_update_2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345, pulse_update_2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (2345, pulse_update_2) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=False + ) == (None, None) # Test consumption - pulses + log (address=100, slot=1) test_timestamp = fixed_this_hour - td(hours=1) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345 + 1000, pulse_update_2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (2345 + 1000, pulse_update_2) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=False + ) == (None, None) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - pulses + logs (address=100, slot=1 & address=99, slot=4) test_timestamp = fixed_this_hour - td(hours=2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (2345 + 1000 + 750, pulse_update_2) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (2345 + 1000 + 750, pulse_update_2) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=False + ) == (None, None) # Test consumption - pulses + missing logs test_timestamp = fixed_this_hour - td(hours=3) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=False) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=False + ) == (None, None) assert not tst_consumption.log_rollover # add missing logs @@ -989,30 +1040,40 @@ def test_pulse_collection_consumption(self, monkeypatch): tst_consumption.update_pulse_counter(45, 0, pulse_update_3) assert tst_consumption.log_rollover test_timestamp = fixed_this_hour + td(hours=1, seconds=5) - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (None, None) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover - assert tst_consumption.collected_pulses(test_timestamp, is_consumption=True) == (45, pulse_update_3) - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (45 + 2222, pulse_update_3) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (45, pulse_update_3) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (45 + 2222, pulse_update_3) # Test log rollover by updating log first before updating pulses tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) assert tst_consumption.log_rollover - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (None, None) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (None, None) pulse_update_4 = fixed_this_hour + td(hours=2, seconds=10) tst_consumption.update_pulse_counter(321, 0, pulse_update_4) assert not tst_consumption.log_rollover - assert tst_consumption.collected_pulses(fixed_this_hour, is_consumption=True) == (2222 + 3333 + 321, pulse_update_4) + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (2222 + 3333 + 321, pulse_update_4) @freeze_time(dt.now()) - def test_pulse_collection_consumption_empty(self, monkeypatch): + def test_pulse_collection_consumption_empty( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Testing pulse collection class.""" monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) - fixed_timestamp_utc = dt.now(tz.utc) - fixed_this_hour = fixed_timestamp_utc.replace( - minute=0, second=0, microsecond=0 - ) + fixed_timestamp_utc = dt.now(UTC) + fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) # Import consumption logs tst_pc = pw_energy_pulses.PulseCollection(mac="0098765432101234") @@ -1045,16 +1106,14 @@ def test_pulse_collection_consumption_empty(self, monkeypatch): assert tst_pc.log_addresses_missing == [100] @freeze_time(dt.now()) - def test_pulse_collection_production(self, monkeypatch): + def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing pulse collection class.""" # Set log hours to 1 week monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 168) - fixed_timestamp_utc = dt.now(tz.utc) - fixed_this_hour = fixed_timestamp_utc.replace( - minute=0, second=0, microsecond=0 - ) + fixed_timestamp_utc = dt.now(UTC) + fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) # Test consumption and production logs tst_production = pw_energy_pulses.PulseCollection(mac="0098765432101234") @@ -1080,7 +1139,7 @@ def test_pulse_collection_production(self, monkeypatch): # Test consumption & production - Log import #3 - production # Interval of consumption is not yet available - test_timestamp = fixed_this_hour - td(hours=2) + test_timestamp = fixed_this_hour - td(hours=2) # type: ignore[unreachable] tst_production.add_log(199, 4, test_timestamp, 4000) missing_check = list(range(199, 157, -1)) assert tst_production.log_addresses_missing == missing_check @@ -1099,26 +1158,36 @@ def test_pulse_collection_production(self, monkeypatch): pulse_update_1 = fixed_this_hour + td(minutes=5) tst_production.update_pulse_counter(100, 50, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour, is_consumption=True) == (100, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour, is_consumption=False) == (50, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=True) == (100, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=2), is_consumption=True) == (1000 + 100, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=1), is_consumption=False) == (50, pulse_update_1) - assert tst_production.collected_pulses(fixed_this_hour - td(hours=2), is_consumption=False) == (2000 + 50, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (100, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour, is_consumption=False + ) == (50, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour - td(hours=1), is_consumption=True + ) == (100, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour - td(hours=2), is_consumption=True + ) == (1000 + 100, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour - td(hours=1), is_consumption=False + ) == (50, pulse_update_1) + assert tst_production.collected_pulses( + fixed_this_hour - td(hours=2), is_consumption=False + ) == (2000 + 50, pulse_update_1) _pulse_update = 0 @freeze_time(dt.now()) - def test_log_address_rollover(self, monkeypatch): + def test_log_address_rollover(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test log address rollover.""" # Set log hours to 25 monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) - fixed_timestamp_utc = dt.now(tz.utc) - fixed_this_hour = fixed_timestamp_utc.replace( - minute=0, second=0, microsecond=0 - ) + fixed_timestamp_utc = dt.now(UTC) + fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) tst_pc = pw_energy_pulses.PulseCollection(mac="0098765432101234") tst_pc.add_log(2, 1, fixed_this_hour - td(hours=1), 3000) tst_pc.add_log(1, 4, fixed_this_hour - td(hours=2), 3000) @@ -1138,7 +1207,9 @@ def test_log_address_rollover(self, monkeypatch): tst_pc.add_log(6015, 1, fixed_this_hour - td(hours=8), 10382) assert tst_pc.log_addresses_missing == [1, 0] - def pulse_update(self, timestamp: dt, is_consumption: bool): + def pulse_update( + self, timestamp: dt, is_consumption: bool + ) -> tuple[int | None, dt | None]: """Update pulse helper for energy counter.""" self._pulse_update += 1 if self._pulse_update == 1: @@ -1152,7 +1223,7 @@ def pulse_update(self, timestamp: dt, is_consumption: bool): return (3333, timestamp + td(minutes=15, seconds=10)) @freeze_time(dt.now()) - def test_energy_counter(self): + def test_energy_counter(self) -> None: """Testing energy counter class.""" pulse_col_mock = Mock() pulse_col_mock.collected_pulses.side_effect = self.pulse_update @@ -1224,35 +1295,42 @@ def test_energy_counter(self): assert not energy_counter_p_h.is_consumption @pytest.mark.asyncio - async def test_creating_request_messages(self): + async def test_creating_request_messages(self) -> None: """Test create request message.""" - node_network_info_request = pw_requests.StickNetworkInfoRequest() + node_network_info_request = pw_requests.StickNetworkInfoRequest( + self.dummy_fn, None + ) assert node_network_info_request.serialize() == b"\x05\x05\x03\x030001CAAB\r\n" circle_plus_connect_request = pw_requests.CirclePlusConnectRequest( - b"1111222233334444" + self.dummy_fn, b"1111222233334444" ) assert ( circle_plus_connect_request.serialize() == b"\x05\x05\x03\x030004000000000000000000001111222233334444BDEC\r\n" ) - node_add_request = pw_requests.NodeAddRequest(b"1111222233334444", True) + node_add_request = pw_requests.NodeAddRequest( + self.dummy_fn, b"1111222233334444", True + ) assert ( node_add_request.serialize() == b"\x05\x05\x03\x0300070111112222333344445578\r\n" ) - node_reset_request = pw_requests.NodeResetRequest(b"1111222233334444", 2, 5) + node_reset_request = pw_requests.NodeResetRequest( + self.dummy_fn, b"1111222233334444", 2, 5 + ) assert ( node_reset_request.serialize() == b"\x05\x05\x03\x030009111122223333444402053D5C\r\n" ) node_image_activate_request = pw_requests.NodeImageActivateRequest( - b"1111222233334444", 2, 5 + self.dummy_fn, b"1111222233334444", 2, 5 ) assert ( node_image_activate_request.serialize() == b"\x05\x05\x03\x03000F1111222233334444020563AA\r\n" ) circle_log_data_request = pw_requests.CircleLogDataRequest( + self.dummy_fn, b"1111222233334444", dt(2022, 5, 3, 0, 0, 0), dt(2022, 5, 10, 23, 0, 0), @@ -1262,7 +1340,7 @@ async def test_creating_request_messages(self): == b"\x05\x05\x03\x030014111122223333444416050B4016053804AD3A\r\n" ) node_remove_request = pw_requests.NodeRemoveRequest( - b"1111222233334444", "5555666677778888" + self.dummy_fn, b"1111222233334444", "5555666677778888" ) assert ( node_remove_request.serialize() @@ -1271,7 +1349,7 @@ async def test_creating_request_messages(self): circle_plus_realtimeclock_request = ( pw_requests.CirclePlusRealTimeClockSetRequest( - b"1111222233334444", dt(2022, 5, 4, 3, 1, 0) + self.dummy_fn, b"1111222233334444", dt(2022, 5, 4, 3, 1, 0) ) ) assert ( @@ -1280,6 +1358,7 @@ async def test_creating_request_messages(self): ) node_sleep_config_request = pw_requests.NodeSleepConfigRequest( + self.dummy_fn, b"1111222233334444", 5, # Duration in seconds the SED will be awake for receiving commands 360, # Duration in minutes the SED will be in sleeping mode and not able to respond any command @@ -1293,6 +1372,7 @@ async def test_creating_request_messages(self): ) scan_configure_request = pw_requests.ScanConfigureRequest( + self.dummy_fn, b"1111222233334444", 5, # Delay in minutes when signal is send when no motion is detected 30, # Sensitivity of Motion sensor (High, Medium, Off) @@ -1304,7 +1384,7 @@ async def test_creating_request_messages(self): ) @pytest.mark.asyncio - async def test_stick_network_down(self, monkeypatch): + async def test_stick_network_down(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing timeout circle+ discovery.""" mock_serial = MockSerial( { @@ -1335,24 +1415,26 @@ async def test_stick_network_down(self, monkeypatch): await stick.disconnect() def fake_env(self, env: str) -> str | None: + """Fake environment.""" if env == "APPDATA": return "c:\\user\\tst\\appdata" if env == "~": return "/home/usr" return None - def os_path_join(self, strA: str, strB: str) -> str: - return f"{strA}/{strB}" + def os_path_join(self, str_a: str, str_b: str) -> str: + """Join path.""" + return f"{str_a}/{str_b}" @pytest.mark.asyncio - async def test_cache(self, monkeypatch): + async def test_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test PlugwiseCache class.""" monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) - async def aiofiles_os_remove(file) -> None: + async def aiofiles_os_remove(file: str) -> None: if file == "mock_folder_that_exists/file_that_exists.ext": return if file == "mock_folder_that_exists/nodes.cache": @@ -1361,7 +1443,7 @@ async def aiofiles_os_remove(file) -> None: return raise pw_exceptions.CacheError("Invalid file") - async def makedirs(cache_dir, exist_ok) -> None: + async def makedirs(cache_dir: str, exist_ok: bool) -> None: if cache_dir == "mock_folder_that_exists": return if cache_dir == "non_existing_folder": @@ -1384,7 +1466,9 @@ async def makedirs(cache_dir, exist_ok) -> None: assert pw_cache.initialized # Windows - pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") + pw_cache = pw_helpers_cache.PlugwiseCache( + "file_that_exists.ext", "mock_folder_that_exists" + ) pw_cache.cache_root_directory = "mock_folder_that_exists" assert not pw_cache.initialized @@ -1404,9 +1488,7 @@ async def makedirs(cache_dir, exist_ok) -> None: "key3;value d \r\n", ] file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): assert await pw_cache.read_cache() == { "key1": "value a", @@ -1414,23 +1496,15 @@ async def makedirs(cache_dir, exist_ok) -> None: "key3": "value d", } file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await pw_cache.write_cache({"key1": "value z"}) mock_file_stream.writelines.assert_called_with( - [ - "key1;value z\n", - "key2;value b|value c\n", - "key3;value d\n" - ] + ["key1;value z\n", "key2;value b|value c\n", "key3;value d\n"] ) file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await pw_cache.write_cache({"key4": "value e"}, rewrite=True) mock_file_stream.writelines.assert_called_with( @@ -1440,7 +1514,9 @@ async def makedirs(cache_dir, exist_ok) -> None: ) monkeypatch.setattr(pw_helpers_cache, "os_name", "linux") - pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_exists") + pw_cache = pw_helpers_cache.PlugwiseCache( + "file_that_exists.ext", "mock_folder_that_exists" + ) pw_cache.cache_root_directory = "mock_folder_that_exists" assert not pw_cache.initialized await pw_cache.initialize_cache() @@ -1448,11 +1524,13 @@ async def makedirs(cache_dir, exist_ok) -> None: await pw_cache.delete_cache() pw_cache.cache_root_directory = "mock_folder_that_does_not_exists" await pw_cache.delete_cache() - pw_cache = pw_helpers_cache.PlugwiseCache("file_that_exists.ext", "mock_folder_that_does_not_exists") + pw_cache = pw_helpers_cache.PlugwiseCache( + "file_that_exists.ext", "mock_folder_that_does_not_exists" + ) await pw_cache.delete_cache() @pytest.mark.asyncio - async def test_network_cache(self, monkeypatch): + async def test_network_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test NetworkRegistrationCache class.""" monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) @@ -1460,7 +1538,9 @@ async def test_network_cache(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) - pw_nw_cache = pw_network_cache.NetworkRegistrationCache("mock_folder_that_exists") + pw_nw_cache = pw_network_cache.NetworkRegistrationCache( + "mock_folder_that_exists" + ) await pw_nw_cache.initialize_cache() # test with invalid data mock_read_data = [ @@ -1469,9 +1549,7 @@ async def test_network_cache(self, monkeypatch): "invalid129834765AFBECD|NodeType.CIRCLE", ] file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await pw_nw_cache.restore_cache() assert pw_nw_cache.registrations == { @@ -1486,9 +1564,7 @@ async def test_network_cache(self, monkeypatch): "2;;", ] file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await pw_nw_cache.restore_cache() assert pw_nw_cache.registrations == { @@ -1509,7 +1585,8 @@ async def test_network_cache(self, monkeypatch): "2;|\n", "3;1234ABCD4321FEDC|NodeType.STEALTH\n", "4;|\n", - ] + [f"{address};|\n" for address in range(5, 64)] + ] + + [f"{address};|\n" for address in range(5, 64)] ) assert pw_nw_cache.registrations == { -1: ("0123456789ABCDEF", pw_api.NodeType.CIRCLE_PLUS), @@ -1520,7 +1597,7 @@ async def test_network_cache(self, monkeypatch): } @pytest.mark.asyncio - async def test_node_cache(self, monkeypatch): + async def test_node_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test NodeCache class.""" monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) monkeypatch.setattr(pw_helpers_cache, "os_name", "nt") @@ -1528,7 +1605,9 @@ async def test_node_cache(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) - node_cache = pw_node_cache.NodeCache("0123456789ABCDEF", "mock_folder_that_exists") + node_cache = pw_node_cache.NodeCache( + "0123456789ABCDEF", "mock_folder_that_exists" + ) await node_cache.initialize_cache() # test with invalid data mock_read_data = [ @@ -1545,9 +1624,7 @@ async def test_node_cache(self, monkeypatch): "energy_collection;102:4:2024-3-14-19-0-0:47|102:3:2024-3-14-18-0-0:48|102:2:2024-3-14-17-0-0:45", ] file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): await node_cache.restore_cache() assert node_cache.states == { @@ -1564,7 +1641,7 @@ async def test_node_cache(self, monkeypatch): "energy_collection": "102:4:2024-3-14-19-0-0:47|102:3:2024-3-14-18-0-0:48|102:2:2024-3-14-17-0-0:45", } assert node_cache.get_state("hardware") == "000004400107" - node_cache.add_state("current_log_address", "128") + node_cache.update_state("current_log_address", "128") assert node_cache.get_state("current_log_address") == "128" node_cache.remove_state("calibration_gain_a") assert node_cache.get_state("calibration_gain_a") is None @@ -1587,7 +1664,9 @@ async def test_node_cache(self, monkeypatch): ) @pytest.mark.asyncio - async def test_node_discovery_and_load(self, monkeypatch): + async def test_node_discovery_and_load( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """Testing discovery of nodes.""" mock_serial = MockSerial(None) monkeypatch.setattr( @@ -1604,9 +1683,7 @@ async def test_node_discovery_and_load(self, monkeypatch): monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) mock_read_data = [""] file_chunks_iter = iter(mock_read_data) - mock_file_stream = MagicMock( - readlines=lambda *args, **kwargs: file_chunks_iter - ) + mock_file_stream = MagicMock(readlines=lambda *args, **kwargs: file_chunks_iter) stick = pw_stick.Stick("test_port", cache_enabled=True) await stick.connect() @@ -1615,7 +1692,9 @@ async def test_node_discovery_and_load(self, monkeypatch): await stick.discover_nodes(load=True) assert stick.nodes["0098765432101234"].name == "Circle+ 01234" - assert stick.nodes["0098765432101234"].node_info.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) + assert stick.nodes["0098765432101234"].node_info.firmware == dt( + 2011, 6, 27, 8, 47, 37, tzinfo=UTC + ) assert stick.nodes["0098765432101234"].node_info.version == "000000730007" assert stick.nodes["0098765432101234"].node_info.model == "Circle+" assert stick.nodes["0098765432101234"].node_info.model_type == "type F" @@ -1630,17 +1709,26 @@ async def test_node_discovery_and_load(self, monkeypatch): # Check an unsupported state feature raises an error with pytest.raises(pw_exceptions.NodeError): - missing_feature_state = await stick.nodes["0098765432101234"].get_state((pw_api.NodeFeature.MOTION, )) + await stick.nodes["0098765432101234"].get_state( + (pw_api.NodeFeature.MOTION,) + ) # Get state - get_state_timestamp = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0) - state = await stick.nodes["0098765432101234"].get_state((pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO)) + get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) + state = await stick.nodes["0098765432101234"].get_state( + (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO) + ) # Check Ping assert state[pw_api.NodeFeature.PING].rssi_in == 69 assert state[pw_api.NodeFeature.PING].rssi_out == 70 assert state[pw_api.NodeFeature.PING].rtt == 1074 - assert state[pw_api.NodeFeature.PING].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert ( + state[pw_api.NodeFeature.PING].timestamp.replace( + minute=0, second=0, microsecond=0 + ) + == get_state_timestamp + ) # Check INFO assert state[pw_api.NodeFeature.INFO].mac == "0098765432101234" @@ -1656,16 +1744,23 @@ async def test_node_discovery_and_load(self, monkeypatch): pw_api.NodeFeature.POWER, ) ) - assert state[pw_api.NodeFeature.INFO].firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) + assert state[pw_api.NodeFeature.INFO].firmware == dt( + 2011, 6, 27, 8, 47, 37, tzinfo=UTC + ) assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" assert state[pw_api.NodeFeature.INFO].model == "Circle+" assert state[pw_api.NodeFeature.INFO].model_type == "type F" assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE_PLUS - assert state[pw_api.NodeFeature.INFO].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert ( + state[pw_api.NodeFeature.INFO].timestamp.replace( + minute=0, second=0, microsecond=0 + ) + == get_state_timestamp + ) assert state[pw_api.NodeFeature.INFO].version == "000000730007" # Check 1111111111111111 - get_state_timestamp = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0) + get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["1111111111111111"].get_state( (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) ) @@ -1675,7 +1770,12 @@ async def test_node_discovery_and_load(self, monkeypatch): assert not state[pw_api.NodeFeature.INFO].battery_powered assert state[pw_api.NodeFeature.INFO].version == "000000070140" assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE - assert state[pw_api.NodeFeature.INFO].timestamp.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert ( + state[pw_api.NodeFeature.INFO].timestamp.replace( + minute=0, second=0, microsecond=0 + ) + == get_state_timestamp + ) assert sorted(state[pw_api.NodeFeature.INFO].features) == sorted( ( pw_api.NodeFeature.AVAILABLE, From c08f1dbee3a47f67406009fb45d55f0006131e93 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 29 Sep 2024 12:27:21 +0200 Subject: [PATCH 431/979] Introduce protocol for plugwise node --- plugwise_usb/__init__.py | 3 +- plugwise_usb/api.py | 302 ++++++++++- plugwise_usb/network/__init__.py | 115 ++-- plugwise_usb/nodes/__init__.py | 705 +++---------------------- plugwise_usb/nodes/celsius.py | 3 +- plugwise_usb/nodes/circle.py | 45 +- plugwise_usb/nodes/helpers/__init__.py | 3 +- plugwise_usb/nodes/node.py | 660 +++++++++++++++++++++++ plugwise_usb/nodes/sed.py | 9 +- plugwise_usb/nodes/sense.py | 2 +- plugwise_usb/nodes/switch.py | 2 +- tests/test_usb.py | 84 ++- 12 files changed, 1167 insertions(+), 766 deletions(-) create mode 100644 plugwise_usb/nodes/node.py diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index cc5175c40..5eb1ac63d 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -12,11 +12,10 @@ import logging from typing import Any, TypeVar, cast -from .api import NodeEvent, StickEvent +from .api import NodeEvent, PlugwiseNode, StickEvent from .connection import StickController from .exceptions import StickError, SubscriptionError from .network import StickNetwork -from .nodes import PlugwiseNode FuncT = TypeVar("FuncT", bound=Callable[..., Any]) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 4170cea37..0774ab6dc 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -1,8 +1,13 @@ """Plugwise USB-Stick API.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime from enum import Enum, auto +import logging +from typing import Any, Protocol + +_LOGGER = logging.getLogger(__name__) class StickEvent(Enum): @@ -32,6 +37,23 @@ class NodeEvent(Enum): JOIN = auto() +class NodeFeature(str, Enum): + """USB Stick Node feature.""" + + AVAILABLE = "available" + BATTERY = "battery" + ENERGY = "energy" + HUMIDITY = "humidity" + INFO = "info" + MOTION = "motion" + PING = "ping" + POWER = "power" + RELAY = "relay" + RELAY_INIT = "relay_init" + SWITCH = "switch" + TEMPERATURE = "temperature" + + class NodeType(Enum): """USB Node types.""" @@ -50,28 +72,11 @@ class NodeType(Enum): # 11 AME_STAR -class NodeFeature(str, Enum): - """USB Stick Node feature.""" - - AVAILABLE = "available" - BATTERY = "battery" - ENERGY = "energy" - HUMIDITY = "humidity" - INFO = "info" - MOTION = "motion" - PING = "ping" - POWER = "power" - RELAY = "relay" - RELAY_INIT = "relay_init" - SWITCH = "switch" - TEMPERATURE = "temperature" - - PUSHING_FEATURES = ( NodeFeature.HUMIDITY, NodeFeature.MOTION, NodeFeature.TEMPERATURE, - NodeFeature.SWITCH + NodeFeature.SWITCH, ) @@ -103,13 +108,13 @@ class NodeInfo: mac: str zigbee_address: int - battery_powered: bool = False + is_battery_powered: bool = False features: tuple[NodeFeature, ...] = (NodeFeature.INFO,) firmware: datetime | None = None name: str | None = None model: str | None = None model_type: str | None = None - type: NodeType | None = None + node_type: NodeType | None = None timestamp: datetime | None = None version: str | None = None @@ -169,3 +174,260 @@ class EnergyStatistics: day_production_reset: datetime | None = None week_production: float | None = None week_production_reset: datetime | None = None + + +class PlugwiseNode(Protocol): + """Protocol definition of a Plugwise device node.""" + + def __init__( + self, + mac: str, + address: int, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ) -> None: + """Initialize plugwise node object.""" + + # region Generic node details + @property + def features(self) -> tuple[NodeFeature, ...]: + """Supported feature types of node.""" + + @property + def is_battery_powered(self) -> bool: + """Indicate if node is power by battery.""" + + @property + def is_loaded(self) -> bool: + """Indicate if node is loaded.""" + + @property + def last_update(self) -> datetime: + """Timestamp of last update.""" + + @property + def name(self) -> str: + """Return name of node.""" + + @property + def node_info(self) -> NodeInfo: + """Node information.""" + + async def load(self) -> bool: + """Load configuration and activate node features.""" + + async def update_node_details( + self, + firmware: datetime | None, + hardware: str | None, + node_type: NodeType | None, + timestamp: datetime | None, + relay_state: bool | None, + logaddress_pointer: int | None, + ) -> bool: + """Update node information.""" + + async def unload(self) -> None: + """Load configuration and activate node features.""" + + # endregion + + # region Network + @property + def available(self) -> bool: + """Last known network availability state.""" + + @property + def mac(self) -> str: + """Zigbee mac address.""" + + @property + def network_address(self) -> int: + """Zigbee network registration address.""" + + @property + def ping_stats(self) -> NetworkStatistics: + """Ping statistics.""" + + async def is_online(self) -> bool: + """Check network status.""" + + def update_ping_stats( + self, timestamp: datetime, rssi_in: int, rssi_out: int, rtt: int + ) -> None: + """Update ping statistics.""" + + # TODO: Move to node with subscription to stick event + async def reconnect(self) -> None: + """Reconnect node to Plugwise Zigbee network.""" + + # TODO: Move to node with subscription to stick event + async def disconnect(self) -> None: + """Disconnect from Plugwise Zigbee network.""" + + # endregion + + # region cache + + @property + def cache_folder(self) -> str: + """Path to cache folder.""" + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Path to cache folder.""" + + @property + def cache_folder_create(self) -> bool: + """Create cache folder when it does not exists.""" + + @cache_folder_create.setter + def cache_folder_create(self, enable: bool = True) -> None: + """Create cache folder when it does not exists.""" + + @property + def cache_enabled(self) -> bool: + """Activate caching of retrieved information.""" + + @cache_enabled.setter + def cache_enabled(self, enable: bool) -> None: + """Activate caching of retrieved information.""" + + async def clear_cache(self) -> None: + """Clear currently cached information.""" + + async def save_cache( + self, trigger_only: bool = True, full_write: bool = False + ) -> None: + """Write currently cached information to cache file.""" + + # endregion + + # region sensors + @property + def energy(self) -> EnergyStatistics | None: + """Energy statistics. + + Raises NodeError when energy feature is not present at device. + """ + + @property + def humidity(self) -> float | None: + """Last received humidity state. + + Raises NodeError when humidity feature is not present at device. + """ + + @property + def motion(self) -> bool | None: + """Current state of motion detection. + + Raises NodeError when motion feature is not present at device. + """ + + @property + def motion_state(self) -> MotionState: + """Last known motion state information. + + Raises NodeError when motion feature is not present at device. + """ + + @property + def power(self) -> PowerStatistics: + """Current power statistics. + + Raises NodeError when power feature is not present at device. + """ + + @property + def relay(self) -> bool: + """Current state of relay. + + Raises NodeError when relay feature is not present at device. + """ + + @property + def relay_state(self) -> RelayState: + """Last known relay state information. + + Raises NodeError when relay feature is not present at device. + """ + + @property + def switch(self) -> bool | None: + """Current state of the switch. + + Raises NodeError when switch feature is not present at device. + """ + + @property + def temperature(self) -> float | None: + """Last received temperature state. + + Raises NodeError when temperature feature is not present at device. + """ + + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: + """Request an updated state for given feature. + + Returns the state or statistics for each requested feature. + """ + + # endregion + + # region control & configure + @property + def battery_config(self) -> BatteryConfig: + """Battery configuration settings. + + Raises NodeError when battery configuration feature is not present at device. + """ + + @property + def relay_init(self) -> bool | None: + """Configured state at which the relay must be at initial power-up of device. + + Raises NodeError when relay configuration feature is not present at device. + """ + + async def switch_relay(self, state: bool) -> bool | None: + """Change the state of the relay and return the new state of relay. + + Raises NodeError when relay feature is not present at device. + """ + + async def switch_relay_init_off(self, state: bool) -> bool | None: + """Change the state of initial (power-up) state of the relay and return the new configured setting. + + Raises NodeError when the initial (power-up) relay configure feature is not present at device. + """ + + @property + def energy_consumption_interval(self) -> int | None: ... # noqa: D102 + + @property + def energy_production_interval(self) -> int | None: ... # noqa: D102 + + @property + def maintenance_interval(self) -> int | None: ... # noqa: D102 + + @property + def motion_reset_timer(self) -> int: ... # noqa: D102 + + @property + def daylight_mode(self) -> bool: ... # noqa: D102 + + @property + def sensitivity_level(self) -> MotionSensitivity: ... # noqa: D102 + + async def configure_motion_reset(self, delay: int) -> bool: ... # noqa: D102 + + async def scan_calibrate_light(self) -> bool: ... # noqa: D102 + + async def scan_configure( # noqa: D102 + self, + motion_reset_timer: int, + sensitivity_level: MotionSensitivity, + daylight_mode: bool, + ) -> bool: ... + + # endregion diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 260d91b3e..cc7b685f3 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -10,7 +10,7 @@ import logging from typing import Any -from ..api import NodeEvent, NodeType, StickEvent +from ..api import NodeEvent, NodeType, PlugwiseNode, StickEvent from ..connection import StickController from ..constants import UTF8 from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout @@ -30,13 +30,7 @@ NodeResponseType, PlugwiseResponse, ) -from ..nodes import PlugwiseNode -from ..nodes.circle import PlugwiseCircle -from ..nodes.circle_plus import PlugwiseCirclePlus -from ..nodes.scan import PlugwiseScan -from ..nodes.sense import PlugwiseSense -from ..nodes.stealth import PlugwiseStealth -from ..nodes.switch import PlugwiseSwitch +from ..nodes import get_plugwise_node from .registry import StickNetworkRegister _LOGGER = logging.getLogger(__name__) @@ -206,7 +200,9 @@ async def _handle_stick_event(self, event: StickEvent) -> None: async def node_awake_message(self, response: PlugwiseResponse) -> bool: """Handle NodeAwakeResponse message.""" if not isinstance(response, NodeAwakeResponse): - raise MessageError(f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse") + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse" + ) mac = response.mac_decoded if self._awake_discovery.get(mac) is None: self._awake_discovery[mac] = response.timestamp - timedelta(seconds=15) @@ -236,7 +232,9 @@ async def node_awake_message(self, response: PlugwiseResponse) -> bool: async def node_join_available_message(self, response: PlugwiseResponse) -> bool: """Handle NodeJoinAvailableResponse messages.""" if not isinstance(response, NodeJoinAvailableResponse): - raise MessageError(f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse") + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" + ) mac = response.mac_decoded await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True @@ -303,62 +301,21 @@ def _create_node_object( mac, ) return - supported_type = True - if node_type == NodeType.CIRCLE_PLUS: - self._nodes[mac] = PlugwiseCirclePlus( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Circle+ node %s added", mac) - elif node_type == NodeType.CIRCLE: - self._nodes[mac] = PlugwiseCircle( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Circle node %s added", mac) - elif node_type == NodeType.SWITCH: - self._nodes[mac] = PlugwiseSwitch( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Switch node %s added", mac) - elif node_type == NodeType.SENSE: - self._nodes[mac] = PlugwiseSense( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Sense node %s added", mac) - elif node_type == NodeType.SCAN: - self._nodes[mac] = PlugwiseScan( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Scan node %s added", mac) - elif node_type == NodeType.STEALTH: - self._nodes[mac] = PlugwiseStealth( - mac, - address, - self._controller, - self._notify_node_event_subscribers, - ) - _LOGGER.debug("Stealth node %s added", mac) - else: - supported_type = False + node = get_plugwise_node( + mac, + address, + self._controller, + self._notify_node_event_subscribers, + node_type, + ) + if node is None: _LOGGER.warning("Node %s of type %s is unsupported", mac, str(node_type)) - if supported_type: - self._register.update_network_registration(address, mac, node_type) + return + self._nodes[mac] = node + _LOGGER.debug("%s node %s added", node.__class__.__name__, mac) + self._register.update_network_registration(address, mac, node_type) - if self._cache_enabled and supported_type: + if self._cache_enabled: _LOGGER.debug( "Enable caching for node %s to folder '%s'", mac, @@ -416,6 +373,7 @@ async def _discover_node( Return True if discovery succeeded. """ + _LOGGER.debug("Start discovery of node %s ", mac) if self._nodes.get(mac) is not None: _LOGGER.debug("Skip discovery of already known node %s ", mac) return True @@ -432,10 +390,22 @@ async def _discover_node( return False self._create_node_object(mac, address, node_info.node_type) - # Forward received NodeInfoResponse message to node object - await self._nodes[mac].node_info_update(node_info) + # Forward received NodeInfoResponse message to node + await self._nodes[mac].update_node_details( + node_info.firmware, + node_info.hardware, + node_info.node_type, + node_info.timestamp, + node_info.relay_state, + node_info.current_logaddress_pointer, + ) if node_ping is not None: - await self._nodes[mac].ping_update(node_ping) + self._nodes[mac].update_ping_stats( + node_ping.timestamp, + node_ping.rssi_in, + node_ping.rssi_out, + node_ping.rtt, + ) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) return True @@ -457,7 +427,7 @@ async def _load_node(self, mac: str) -> bool: """Load node.""" if self._nodes.get(mac) is None: return False - if self._nodes[mac].loaded: + if self._nodes[mac].is_loaded: return True if await self._nodes[mac].load(): await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) @@ -469,11 +439,11 @@ async def _load_discovered_nodes(self) -> bool: _LOGGER.debug("_load_discovered_nodes | START | %s", len(self._nodes)) for mac, node in self._nodes.items(): _LOGGER.debug( - "_load_discovered_nodes | mac=%s | loaded=%s", mac, node.loaded + "_load_discovered_nodes | mac=%s | loaded=%s", mac, node.is_loaded ) nodes_not_loaded = tuple( - mac for mac, node in self._nodes.items() if not node.loaded + mac for mac, node in self._nodes.items() if not node.is_loaded ) _LOGGER.debug("_load_discovered_nodes | nodes_not_loaded=%s", nodes_not_loaded) load_result = await gather(*[self._load_node(mac) for mac in nodes_not_loaded]) @@ -499,6 +469,7 @@ async def _unload_discovered_nodes(self) -> None: # region - Network instance async def start(self) -> None: """Start and activate network.""" + self._register.quick_scan_finished(self._discover_registered_nodes) self._register.full_scan_finished(self._discover_registered_nodes) await self._register.start() @@ -507,10 +478,10 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" + if not await self.discover_network_coordinator(load=load): + return False if not self._is_running: await self.start() - if not await self.discover_network_coordinator(): - return False await self._discover_registered_nodes() if load: return await self._load_discovered_nodes() diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 3656fce92..e9953e5fa 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -1,649 +1,68 @@ -"""Plugwise devices linked to USB-stick.""" +"""Plugwise node devices.""" from __future__ import annotations -from abc import ABC -from asyncio import Task, create_task from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta -import logging -from typing import Any -from ..api import ( - BatteryConfig, - EnergyStatistics, - MotionSensitivity, - MotionState, - NetworkStatistics, - NodeEvent, - NodeFeature, - NodeInfo, - NodeType, - PowerStatistics, - RelayState, -) +from ..api import NodeEvent, NodeType, PlugwiseNode from ..connection import StickController -from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8 -from ..exceptions import NodeError -from ..helpers.util import version_to_model -from ..messages.requests import NodeInfoRequest, NodePingRequest -from ..messages.responses import NodeInfoResponse, NodePingResponse -from .helpers import EnergyCalibration, raise_not_loaded -from .helpers.cache import NodeCache -from .helpers.counter import EnergyCounters -from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions -from .helpers.subscription import FeaturePublisher - -_LOGGER = logging.getLogger(__name__) -NODE_FEATURES = ( - NodeFeature.AVAILABLE, - NodeFeature.INFO, - NodeFeature.PING, -) -CACHE_FIRMWARE = "firmware" -CACHE_NODE_TYPE = "node_type" -CACHE_HARDWARE = "hardware" -CACHE_NODE_INFO_TIMESTAMP = "node_info_timestamp" - - -class PlugwiseNode(FeaturePublisher, ABC): - """Abstract Base Class for a Plugwise node.""" - - def __init__( - self, - mac: str, - address: int, - controller: StickController, - loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], - ): - """Initialize Plugwise base node class.""" - self._loaded_callback = loaded_callback - self._message_subscribe = controller.subscribe_to_node_responses - self._features: tuple[NodeFeature, ...] = NODE_FEATURES - self._last_update = datetime.now(UTC) - self._node_info = NodeInfo(mac, address) - self._ping = NetworkStatistics() - self._power = PowerStatistics() - - self._mac_in_bytes = bytes(mac, encoding=UTF8) - self._mac_in_str = mac - self._send = controller.send - self._cache_enabled: bool = False - self._cache_save_task: Task[None] | None = None - self._node_cache = NodeCache(mac, "") - - # Sensors - self._available: bool = False - self._humidity: float | None = None - self._motion: bool | None = None - - self._switch: bool | None = None - self._temperature: float | None = None - - self._connected: bool = False - self._initialized: bool = False - self._initialization_delay_expired: datetime | None = None - self._loaded: bool = False - self._node_protocols: SupportedVersions | None = None - self._node_last_online: datetime | None = None - - # Battery - self._battery_config = BatteryConfig() - - # Motion - self._motion = False - self._motion_state = MotionState() - self._scan_subscription: Callable[[], None] | None = None - self._sensitivity_level: MotionSensitivity | None = None - - # Node info - self._current_log_address: int | None = None - - # Relay - self._relay: bool | None = None - self._relay_state = RelayState() - self._relay_init_state: bool | None = None - - # Power & energy - self._calibration: EnergyCalibration | None = None - self._energy_counters = EnergyCounters(mac) - - # region Properties - - @property - def network_address(self) -> int: - """Network (zigbee based) registration address of this node.""" - return self._node_info.zigbee_address - - @property - def cache_folder(self) -> str: - """Return path to cache folder.""" - return self._node_cache.cache_root_directory - - @cache_folder.setter - def cache_folder(self, cache_folder: str) -> None: - """Set path to cache folder.""" - self._node_cache.cache_root_directory = cache_folder - - @property - def cache_folder_create(self) -> bool: - """Return if cache folder must be create when it does not exists.""" - return self._cache_folder_create - - @cache_folder_create.setter - def cache_folder_create(self, enable: bool = True) -> None: - """Enable or disable creation of cache folder.""" - self._cache_folder_create = enable - - @property - def cache_enabled(self) -> bool: - """Return usage of cache.""" - return self._cache_enabled - - @cache_enabled.setter - def cache_enabled(self, enable: bool) -> None: - """Enable or disable usage of cache.""" - self._cache_enabled = enable - - @property - def available(self) -> bool: - """Return network availability state.""" - return self._available - - @property - def battery_config(self) -> BatteryConfig: - """Return battery configuration settings.""" - if NodeFeature.BATTERY not in self._features: - raise NodeError( - f"Battery configuration settings are not supported for node {self.mac}" - ) - return self._battery_config - - @property - def battery_powered(self) -> bool: - """Return if node is battery powered.""" - return self._node_info.battery_powered - - @property - def daylight_mode(self) -> bool: - """Daylight mode of motion sensor.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Daylight mode is not supported for node {self.mac}") - raise NotImplementedError() - - @property - def energy(self) -> EnergyStatistics | None: - """Energy statistics.""" - if NodeFeature.POWER not in self._features: - raise NodeError(f"Energy state is not supported for node {self.mac}") - raise NotImplementedError() - - @property - def energy_consumption_interval(self) -> int | None: - """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" - if NodeFeature.ENERGY not in self._features: - raise NodeError(f"Energy log interval is not supported for node {self.mac}") - return self._energy_counters.consumption_interval - - @property - def energy_production_interval(self) -> int | None: - """Interval (minutes) energy production counters are locally logged at Circle devices.""" - if NodeFeature.ENERGY not in self._features: - raise NodeError(f"Energy log interval is not supported for node {self.mac}") - return self._energy_counters.production_interval - - @property - def features(self) -> tuple[NodeFeature, ...]: - """Supported feature types of node.""" - return self._features - - @property - def node_info(self) -> NodeInfo: - """Node information.""" - return self._node_info - - @property - def humidity(self) -> float | None: - """Humidity state.""" - if NodeFeature.HUMIDITY not in self._features: - raise NodeError(f"Humidity state is not supported for node {self.mac}") - return self._humidity - - @property - def last_update(self) -> datetime: - """Timestamp of last update.""" - return self._last_update - - @property - def loaded(self) -> bool: - """Return load status.""" - return self._loaded - - @property - def name(self) -> str: - """Return name of node.""" - if self._node_info.name is not None: - return self._node_info.name - return self._mac_in_str - - @property - def mac(self) -> str: - """Return mac address of node.""" - return self._mac_in_str - - @property - def maintenance_interval(self) -> int | None: - """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" - raise NotImplementedError() - - @property - def motion(self) -> bool | None: - """Motion detection value.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion state is not supported for node {self.mac}") - return self._motion - - @property - def motion_state(self) -> MotionState: - """Motion detection state.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion state is not supported for node {self.mac}") - return self._motion_state - - @property - def motion_reset_timer(self) -> int: - """Total minutes without motion before no motion is reported.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion reset timer is not supported for node {self.mac}") - raise NotImplementedError() - - @property - def ping(self) -> NetworkStatistics: - """Ping statistics.""" - return self._ping - - @property - def power(self) -> PowerStatistics: - """Power statistics.""" - if NodeFeature.POWER not in self._features: - raise NodeError(f"Power state is not supported for node {self.mac}") - return self._power - - @property - def relay_state(self) -> RelayState: - """State of relay.""" - if NodeFeature.RELAY not in self._features: - raise NodeError(f"Relay state is not supported for node {self.mac}") - return self._relay_state - - @property - def relay(self) -> bool: - """Relay value.""" - if NodeFeature.RELAY not in self._features: - raise NodeError(f"Relay value is not supported for node {self.mac}") - if self._relay is None: - raise NodeError(f"Relay value is unknown for node {self.mac}") - return self._relay - - @property - def relay_init( - self, - ) -> bool | None: - """Request the relay states at startup/power-up.""" - raise NotImplementedError() - - @property - def sensitivity_level(self) -> MotionSensitivity: - """Sensitivity level of motion sensor.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Sensitivity level is not supported for node {self.mac}") - raise NotImplementedError() - - @property - def switch(self) -> bool | None: - """Switch button value.""" - if NodeFeature.SWITCH not in self._features: - raise NodeError(f"Switch value is not supported for node {self.mac}") - return self._switch - - @property - def temperature(self) -> float | None: - """Temperature value.""" - if NodeFeature.TEMPERATURE not in self._features: - raise NodeError(f"Temperature state is not supported for node {self.mac}") - return self._temperature - - # endregion - - def _setup_protocol( - self, - firmware: dict[datetime, SupportedVersions], - node_features: tuple[NodeFeature, ...], - ) -> None: - """Determine protocol version based on firmware version and enable supported additional supported features.""" - if self._node_info.firmware is None: - return - self._node_protocols = firmware.get(self._node_info.firmware, None) - if self._node_protocols is None: - _LOGGER.warning( - "Failed to determine the protocol version for node %s (%s) based on firmware version %s of list %s", - self._node_info.mac, - self.__class__.__name__, - self._node_info.firmware, - str(firmware.keys()), - ) - return - # new_feature_list = list(self._features) - for feature in node_features: - if ( - required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) - ) is not None: - if ( - self._node_protocols.min - <= required_version - <= self._node_protocols.max - and feature not in self._features - ): - self._features += (feature,) - self._node_info.features = self._features - - async def reconnect(self) -> None: - """Reconnect node to Plugwise Zigbee network.""" - if await self.ping_update() is not None: - self._connected = True - await self._available_update_state(True) - - async def disconnect(self) -> None: - """Disconnect node from Plugwise Zigbee network.""" - self._connected = False - await self._available_update_state(False) - - async def configure_motion_reset(self, delay: int) -> bool: - """Configure the duration to reset motion state.""" - raise NotImplementedError() - - async def scan_calibrate_light(self) -> bool: - """Request to calibration light sensitivity of Scan device. Returns True if successful.""" - raise NotImplementedError() - - async def scan_configure( - self, - motion_reset_timer: int, - sensitivity_level: MotionSensitivity, - daylight_mode: bool, - ) -> bool: - """Configure Scan device settings. Returns True if successful.""" - raise NotImplementedError() - - async def load(self) -> bool: - """Load and activate node features.""" - raise NotImplementedError() - - async def _load_cache_file(self) -> bool: - """Load states from previous cached information.""" - if self._loaded: - return True - if not self._cache_enabled: - _LOGGER.warning( - "Unable to load node %s from cache because caching is disabled", - self.mac, - ) - return False - if not self._node_cache.initialized: - await self._node_cache.initialize_cache(self._cache_folder_create) - return await self._node_cache.restore_cache() - - async def clear_cache(self) -> None: - """Clear current cache.""" - if self._node_cache is not None: - await self._node_cache.clear_cache() - - async def _load_from_cache(self) -> bool: - """Load states from previous cached information. Return True if successful.""" - if self._loaded: - return True - if not await self._load_cache_file(): - _LOGGER.debug("Node %s failed to load cache file", self.mac) - return False - - # Node Info - if not await self._node_info_load_from_cache(): - _LOGGER.debug("Node %s failed to load node_info from cache", self.mac) - return False - return True - - async def initialize(self) -> bool: - """Initialize node.""" - if self._initialized: - return True - self._initialization_delay_expired = datetime.now(UTC) + timedelta( - minutes=SUPPRESS_INITIALIZATION_WARNINGS - ) - self._initialized = True - return True - - async def _available_update_state(self, available: bool) -> None: - """Update the node availability state.""" - if self._available == available: - return - if available: - _LOGGER.info("Device %s detected to be available (on-line)", self.name) - self._available = True - await self.publish_feature_update_to_subscribers( - NodeFeature.AVAILABLE, True - ) - return - _LOGGER.info("Device %s detected to be not available (off-line)", self.name) - self._available = False - await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, False) - - async def node_info_update( - self, node_info: NodeInfoResponse | None = None - ) -> NodeInfo | None: - """Update Node hardware information.""" - if node_info is None: - request = NodeInfoRequest(self._send, self._mac_in_bytes) - node_info = await request.send() - if node_info is None: - _LOGGER.debug("No response for node_info_update() for %s", self.mac) - await self._available_update_state(False) - return self._node_info - - await self._available_update_state(True) - await self._node_info_update_state( - firmware=node_info.firmware, - node_type=node_info.node_type, - hardware=node_info.hardware, - timestamp=node_info.timestamp, - ) - return self._node_info - - async def _node_info_load_from_cache(self) -> bool: - """Load node info settings from cache.""" - firmware = self._get_cache_as_datetime(CACHE_FIRMWARE) - hardware = self._get_cache(CACHE_HARDWARE) - timestamp = self._get_cache_as_datetime(CACHE_NODE_INFO_TIMESTAMP) - node_type: NodeType | None = None - if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: - node_type = NodeType(int(node_type_str)) - return await self._node_info_update_state( - firmware=firmware, - hardware=hardware, - node_type=node_type, - timestamp=timestamp, - ) - - async def _node_info_update_state( - self, - firmware: datetime | None, - hardware: str | None, - node_type: NodeType | None, - timestamp: datetime | None, - ) -> bool: - """Process new node info and return true if all fields are updated.""" - complete = True - if firmware is None: - complete = False - else: - self._node_info.firmware = firmware - self._set_cache(CACHE_FIRMWARE, firmware) - if hardware is None: - complete = False - else: - if self._node_info.version != hardware: - self._node_info.version = hardware - # Generate modelname based on hardware version - model_info = version_to_model(hardware).split(" ") - self._node_info.model = model_info[0] - if self._node_info.model == "Unknown": - _LOGGER.warning( - "Failed to detect hardware model for %s based on '%s'", - self.mac, - hardware, - ) - if len(model_info) > 1: - self._node_info.model_type = " ".join(model_info[1:]) - else: - self._node_info.model_type = "" - if self._node_info.model is not None: - self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" - - self._set_cache(CACHE_HARDWARE, hardware) - if timestamp is None: - complete = False - else: - self._node_info.timestamp = timestamp - self._set_cache(CACHE_NODE_INFO_TIMESTAMP, timestamp) - if node_type is None: - complete = False - else: - self._node_info.type = NodeType(node_type) - self._set_cache(CACHE_NODE_TYPE, self._node_info.type.value) - await self.save_cache() - return complete - - async def is_online(self) -> bool: - """Check if node is currently online.""" - if await self.ping_update() is None: - _LOGGER.debug("No response to ping for %s", self.mac) - return False - return True - - async def ping_update( - self, ping_response: NodePingResponse | None = None, retries: int = 1 - ) -> NetworkStatistics | None: - """Update ping statistics.""" - if ping_response is None: - request = NodePingRequest(self._send, self._mac_in_bytes, retries) - ping_response = await request.send() - if ping_response is None: - await self._available_update_state(False) - return None - await self._available_update_state(True) - - self._ping.timestamp = ping_response.timestamp - self._ping.rssi_in = ping_response.rssi_in - self._ping.rssi_out = ping_response.rssi_out - self._ping.rtt = ping_response.rtt - - await self.publish_feature_update_to_subscribers(NodeFeature.PING, self._ping) - return self._ping - - async def switch_relay(self, state: bool) -> bool | None: - """Switch relay state.""" - raise NodeError(f"Relay control is not supported for node {self.mac}") - - @raise_not_loaded - async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: - """Update latest state for given feature.""" - states: dict[NodeFeature, Any] = {} - for feature in features: - if feature not in self._features: - raise NodeError( - f"Update of feature '{feature.name}' is " - + f"not supported for {self.mac}" - ) - if feature == NodeFeature.INFO: - states[NodeFeature.INFO] = await self.node_info_update() - elif feature == NodeFeature.AVAILABLE: - states[NodeFeature.AVAILABLE] = self._available - elif feature == NodeFeature.PING: - states[NodeFeature.PING] = await self.ping_update() - else: - raise NodeError( - f"Update of feature '{feature.name}' is " - + f"not supported for {self.mac}" - ) - return states - - async def unload(self) -> None: - """Deactivate and unload node features.""" - if not self._cache_enabled: - return - if self._cache_save_task is not None and not self._cache_save_task.done(): - await self._cache_save_task - await self.save_cache(trigger_only=False, full_write=True) - - def _get_cache(self, setting: str) -> str | None: - """Retrieve value of specified setting from cache memory.""" - if not self._cache_enabled: - return None - return self._node_cache.get_state(setting) - - 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: - data = timestamp_str.split("-") - if len(data) == 6: - return datetime( - year=int(data[0]), - month=int(data[1]), - day=int(data[2]), - hour=int(data[3]), - minute=int(data[4]), - second=int(data[5]), - tzinfo=UTC, - ) - return None - - def _set_cache(self, setting: str, value: Any) -> None: - """Store setting with value in cache memory.""" - if not self._cache_enabled: - return - if isinstance(value, datetime): - self._node_cache.update_state( - setting, - f"{value.year}-{value.month}-{value.day}-{value.hour}" - + f"-{value.minute}-{value.second}", - ) - elif isinstance(value, str): - self._node_cache.update_state(setting, value) - else: - self._node_cache.update_state(setting, str(value)) - - async def save_cache( - self, trigger_only: bool = True, full_write: bool = False - ) -> None: - """Save current cache to cache file.""" - if not self._cache_enabled or not self._loaded or not self._initialized: - return - _LOGGER.debug("Save cache file for node %s", self.mac) - if self._cache_save_task is not None and not self._cache_save_task.done(): - await self._cache_save_task - if trigger_only: - self._cache_save_task = create_task(self._node_cache.save_cache()) - else: - await self._node_cache.save_cache(rewrite=full_write) - - @staticmethod - def skip_update(data_class: Any, seconds: int) -> bool: - """Check if update can be skipped when timestamp of given dataclass is less than given seconds old.""" - if data_class is None: - return False - if not hasattr(data_class, "timestamp"): - return False - if data_class.timestamp is None: - return False - if data_class.timestamp + timedelta(seconds=seconds) > datetime.now(UTC): - return True - return False +from .circle import PlugwiseCircle +from .circle_plus import PlugwiseCirclePlus +from .scan import PlugwiseScan +from .sense import PlugwiseSense +from .stealth import PlugwiseStealth +from .switch import PlugwiseSwitch + + +def get_plugwise_node( + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + node_type: NodeType, +) -> PlugwiseNode | None: + """Return an initialized plugwise node class based on given the node type.""" + + if node_type == NodeType.CIRCLE_PLUS: + return PlugwiseCirclePlus( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + if node_type == NodeType.CIRCLE: + return PlugwiseCircle( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + if node_type == NodeType.SWITCH: + return PlugwiseSwitch( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + if node_type == NodeType.SENSE: + return PlugwiseSense( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + if node_type == NodeType.SCAN: + return PlugwiseScan( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + if node_type == NodeType.STEALTH: + return PlugwiseStealth( + mac, + address, + controller, + loaded_callback, + ) # type: ignore[return-value] + return None diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 7458d44db..815c0f059 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -27,7 +27,8 @@ async def load(self) -> bool: """Load and activate node features.""" if self._loaded: return True - self._node_info.battery_powered = True + self._node_info.is_battery_powered = True + if self._cache_enabled: _LOGGER.debug( "Load Celsius node %s from cache", self._node_info.mac diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 109f716b2..293c657ca 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -9,7 +9,14 @@ import logging from typing import Any, TypeVar, cast -from ..api import EnergyStatistics, NodeEvent, NodeFeature, NodeInfo, PowerStatistics +from ..api import ( + EnergyStatistics, + NodeEvent, + NodeFeature, + NodeInfo, + NodeType, + PowerStatistics, +) from ..constants import ( MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, @@ -28,10 +35,10 @@ NodeInfoRequest, ) from ..messages.responses import NodeInfoResponse, NodeResponse, NodeResponseType -from ..nodes import PlugwiseNode from .helpers import EnergyCalibration, raise_not_loaded from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord, calc_log_address +from .node import PlugwiseBaseNode CACHE_CURRENT_LOG_ADDRESS = "current_log_address" CACHE_CALIBRATION_GAIN_A = "calibration_gain_a" @@ -58,7 +65,7 @@ def decorated(*args: Any, **kwargs: Any) -> Any: return cast(FuncT, decorated) -class PlugwiseCircle(PlugwiseNode): +class PlugwiseCircle(PlugwiseBaseNode): """Plugwise Circle node.""" _retrieve_energy_logs_task: None | Task[None] = None @@ -860,6 +867,30 @@ async def _node_info_load_from_cache(self) -> bool: return result return False + async def update_node_details( + self, + firmware: datetime | None, + hardware: str | None, + node_type: NodeType | None, + timestamp: datetime | None, + relay_state: bool | None, + logaddress_pointer: int | None, + ) -> bool: + """Process new node info and return true if all fields are updated.""" + if relay_state is not None: + self._relay_state.relay_state = relay_state + self._relay_state.timestamp = timestamp + if logaddress_pointer is not None: + self._current_log_address = logaddress_pointer + return await super().update_node_details( + firmware, + hardware, + node_type, + timestamp, + relay_state, + logaddress_pointer, + ) + async def unload(self) -> None: """Deactivate and unload node features.""" self._loaded = False @@ -887,7 +918,9 @@ async def _relay_init_get(self) -> bool | None: "Retrieval of initial state of relay is not " + f"supported for device {self.name}" ) - request = CircleRelayInitStateRequest(self._send, self._mac_in_bytes, False, False) + request = CircleRelayInitStateRequest( + self._send, self._mac_in_bytes, False, False + ) if (response := await request.send()) is not None: await self._relay_init_update_state(response.relay.value == 1) return self._relay_init_state @@ -900,7 +933,9 @@ async def _relay_init_set(self, state: bool) -> bool | None: "Configuring of initial state of relay is not" + f"supported for device {self.name}" ) - request = CircleRelayInitStateRequest(self._send, self._mac_in_bytes, True, state) + request = CircleRelayInitStateRequest( + self._send, self._mac_in_bytes, True, state + ) if (response := await request.send()) is not None: await self._relay_init_update_state(response.relay.value == 1) return self._relay_init_state diff --git a/plugwise_usb/nodes/helpers/__init__.py b/plugwise_usb/nodes/helpers/__init__.py index 023343120..1ef0b8a86 100644 --- a/plugwise_usb/nodes/helpers/__init__.py +++ b/plugwise_usb/nodes/helpers/__init__.py @@ -27,8 +27,7 @@ def raise_not_loaded(func: FuncT) -> FuncT: """Raise NodeError when node is not loaded.""" @wraps(func) def decorated(*args: Any, **kwargs: Any) -> Any: - - if not args[0].loaded: + if not args[0].is_loaded: raise NodeError(f"Node {args[0].mac} is not loaded yet") return func(*args, **kwargs) return cast(FuncT, decorated) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py new file mode 100644 index 000000000..3920f50f9 --- /dev/null +++ b/plugwise_usb/nodes/node.py @@ -0,0 +1,660 @@ +"""Base class of Plugwise node device.""" + +from __future__ import annotations + +from abc import ABC +from asyncio import Task, create_task +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +import logging +from typing import Any + +from ..api import ( + BatteryConfig, + EnergyStatistics, + MotionSensitivity, + MotionState, + NetworkStatistics, + NodeEvent, + NodeFeature, + NodeInfo, + NodeType, + PowerStatistics, + RelayState, +) +from ..connection import StickController +from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8 +from ..exceptions import NodeError +from ..helpers.util import version_to_model +from ..messages.requests import NodeInfoRequest, NodePingRequest +from ..messages.responses import NodeInfoResponse, NodePingResponse +from .helpers import EnergyCalibration, raise_not_loaded +from .helpers.cache import NodeCache +from .helpers.counter import EnergyCounters +from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions +from .helpers.subscription import FeaturePublisher + +_LOGGER = logging.getLogger(__name__) + + +NODE_FEATURES = ( + NodeFeature.AVAILABLE, + NodeFeature.INFO, + NodeFeature.PING, +) + + +CACHE_FIRMWARE = "firmware" +CACHE_NODE_TYPE = "node_type" +CACHE_HARDWARE = "hardware" +CACHE_NODE_INFO_TIMESTAMP = "node_info_timestamp" + + +class PlugwiseBaseNode(FeaturePublisher, ABC): + """Abstract Base Class for a Plugwise node.""" + + def __init__( + self, + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ): + """Initialize Plugwise base node class.""" + self._loaded_callback = loaded_callback + self._message_subscribe = controller.subscribe_to_node_responses + self._features: tuple[NodeFeature, ...] = NODE_FEATURES + self._last_update = datetime.now(UTC) + self._node_info = NodeInfo(mac, address) + self._ping = NetworkStatistics() + self._power = PowerStatistics() + self._mac_in_bytes = bytes(mac, encoding=UTF8) + self._mac_in_str = mac + self._send = controller.send + self._cache_enabled: bool = False + self._cache_save_task: Task[None] | None = None + self._node_cache = NodeCache(mac, "") + # Sensors + self._available: bool = False + self._humidity: float | None = None + self._motion: bool | None = None + self._switch: bool | None = None + self._temperature: float | None = None + self._connected: bool = False + self._initialized: bool = False + self._initialization_delay_expired: datetime | None = None + self._loaded: bool = False + self._node_protocols: SupportedVersions | None = None + self._node_last_online: datetime | None = None + # Battery + self._battery_config = BatteryConfig() + # Motion + self._motion = False + self._motion_state = MotionState() + self._scan_subscription: Callable[[], None] | None = None + self._sensitivity_level: MotionSensitivity | None = None + # Node info + self._current_log_address: int | None = None + # Relay + self._relay: bool | None = None + self._relay_state: RelayState = RelayState() + self._relay_init_state: bool | None = None + # Power & energy + self._calibration: EnergyCalibration | None = None + self._energy_counters = EnergyCounters(mac) + + # region Properties + + @property + def network_address(self) -> int: + """Zigbee network registration address.""" + return self._node_info.zigbee_address + + @property + def cache_folder(self) -> str: + """Return path to cache folder.""" + return self._node_cache.cache_root_directory + + @cache_folder.setter + def cache_folder(self, cache_folder: str) -> None: + """Set path to cache folder.""" + self._node_cache.cache_root_directory = cache_folder + + @property + def cache_folder_create(self) -> bool: + """Return if cache folder must be create when it does not exists.""" + return self._cache_folder_create + + @cache_folder_create.setter + def cache_folder_create(self, enable: bool = True) -> None: + """Enable or disable creation of cache folder.""" + self._cache_folder_create = enable + + @property + def cache_enabled(self) -> bool: + """Return usage of cache.""" + return self._cache_enabled + + @cache_enabled.setter + def cache_enabled(self, enable: bool) -> None: + """Enable or disable usage of cache.""" + self._cache_enabled = enable + + @property + def available(self) -> bool: + """Return network availability state.""" + return self._available + + @property + def battery_config(self) -> BatteryConfig: + """Return battery configuration settings.""" + if NodeFeature.BATTERY not in self._features: + raise NodeError( + f"Battery configuration settings are not supported for node {self.mac}" + ) + return self._battery_config + + @property + def is_battery_powered(self) -> bool: + """Return if node is battery powered.""" + return self._node_info.is_battery_powered + + @property + def daylight_mode(self) -> bool: + """Daylight mode of motion sensor.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Daylight mode is not supported for node {self.mac}") + raise NotImplementedError() + + @property + def energy(self) -> EnergyStatistics | None: + """Energy statistics.""" + if NodeFeature.POWER not in self._features: + raise NodeError(f"Energy state is not supported for node {self.mac}") + raise NotImplementedError() + + @property + def energy_consumption_interval(self) -> int | None: + """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.consumption_interval + + @property + def energy_production_interval(self) -> int | None: + """Interval (minutes) energy production counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.production_interval + + @property + def features(self) -> tuple[NodeFeature, ...]: + """Supported feature types of node.""" + return self._features + + @property + def node_info(self) -> NodeInfo: + """Node information.""" + return self._node_info + + @property + def humidity(self) -> float | None: + """Humidity state.""" + if NodeFeature.HUMIDITY not in self._features: + raise NodeError(f"Humidity state is not supported for node {self.mac}") + return self._humidity + + @property + def last_update(self) -> datetime: + """Timestamp of last update.""" + return self._last_update + + @property + def is_loaded(self) -> bool: + """Return load status.""" + return self._loaded + + @property + def name(self) -> str: + """Return name of node.""" + if self._node_info.name is not None: + return self._node_info.name + return self._mac_in_str + + @property + def mac(self) -> str: + """Zigbee mac address of node.""" + return self._mac_in_str + + @property + def maintenance_interval(self) -> int | None: + """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" + raise NotImplementedError() + + @property + def motion(self) -> bool | None: + """Motion detection value.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Motion state is not supported for node {self.mac}") + return self._motion + + @property + def motion_state(self) -> MotionState: + """Motion detection state.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Motion state is not supported for node {self.mac}") + return self._motion_state + + @property + def motion_reset_timer(self) -> int: + """Total minutes without motion before no motion is reported.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Motion reset timer is not supported for node {self.mac}") + raise NotImplementedError() + + @property + def ping_stats(self) -> NetworkStatistics: + """Ping statistics.""" + return self._ping + + @property + def power(self) -> PowerStatistics: + """Power statistics.""" + if NodeFeature.POWER not in self._features: + raise NodeError(f"Power state is not supported for node {self.mac}") + return self._power + + @property + def relay_state(self) -> RelayState: + """State of relay.""" + if NodeFeature.RELAY not in self._features: + raise NodeError(f"Relay state is not supported for node {self.mac}") + return self._relay_state + + @property + def relay(self) -> bool: + """Relay value.""" + if NodeFeature.RELAY not in self._features: + raise NodeError(f"Relay value is not supported for node {self.mac}") + if self._relay is None: + raise NodeError(f"Relay value is unknown for node {self.mac}") + return self._relay + + @property + def relay_init( + self, + ) -> bool | None: + """Request the relay states at startup/power-up.""" + raise NotImplementedError() + + @property + def sensitivity_level(self) -> MotionSensitivity: + """Sensitivity level of motion sensor.""" + if NodeFeature.MOTION not in self._features: + raise NodeError(f"Sensitivity level is not supported for node {self.mac}") + raise NotImplementedError() + + @property + def switch(self) -> bool | None: + """Switch button value.""" + if NodeFeature.SWITCH not in self._features: + raise NodeError(f"Switch value is not supported for node {self.mac}") + return self._switch + + @property + def temperature(self) -> float | None: + """Temperature value.""" + if NodeFeature.TEMPERATURE not in self._features: + raise NodeError(f"Temperature state is not supported for node {self.mac}") + return self._temperature + + # endregion + + def _setup_protocol( + self, + firmware: dict[datetime, SupportedVersions], + node_features: tuple[NodeFeature, ...], + ) -> None: + """Determine protocol version based on firmware version and enable supported additional supported features.""" + if self._node_info.firmware is None: + return + self._node_protocols = firmware.get(self._node_info.firmware, None) + if self._node_protocols is None: + _LOGGER.warning( + "Failed to determine the protocol version for node %s (%s) based on firmware version %s of list %s", + self._node_info.mac, + self.__class__.__name__, + self._node_info.firmware, + str(firmware.keys()), + ) + return + for feature in node_features: + if ( + required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) + ) is not None: + if ( + self._node_protocols.min + <= required_version + <= self._node_protocols.max + and feature not in self._features + ): + self._features += (feature,) + self._node_info.features = self._features + + async def reconnect(self) -> None: + """Reconnect node to Plugwise Zigbee network.""" + if await self.ping_update() is not None: + self._connected = True + await self._available_update_state(True) + + async def disconnect(self) -> None: + """Disconnect node from Plugwise Zigbee network.""" + self._connected = False + await self._available_update_state(False) + + async def configure_motion_reset(self, delay: int) -> bool: + """Configure the duration to reset motion state.""" + raise NotImplementedError() + + async def scan_calibrate_light(self) -> bool: + """Request to calibration light sensitivity of Scan device. Returns True if successful.""" + raise NotImplementedError() + + async def scan_configure( + self, + motion_reset_timer: int, + sensitivity_level: MotionSensitivity, + daylight_mode: bool, + ) -> bool: + """Configure Scan device settings. Returns True if successful.""" + raise NotImplementedError() + + async def load(self) -> bool: + """Load configuration and activate node features.""" + raise NotImplementedError() + + async def _load_cache_file(self) -> bool: + """Load states from previous cached information.""" + if self._loaded: + return True + if not self._cache_enabled: + _LOGGER.warning( + "Unable to load node %s from cache because caching is disabled", + self.mac, + ) + return False + if not self._node_cache.initialized: + await self._node_cache.initialize_cache(self._cache_folder_create) + return await self._node_cache.restore_cache() + + async def clear_cache(self) -> None: + """Clear current cache.""" + if self._node_cache is not None: + await self._node_cache.clear_cache() + + async def _load_from_cache(self) -> bool: + """Load states from previous cached information. Return True if successful.""" + if self._loaded: + return True + if not await self._load_cache_file(): + _LOGGER.debug("Node %s failed to load cache file", self.mac) + return False + # Node Info + if not await self._node_info_load_from_cache(): + _LOGGER.debug("Node %s failed to load node_info from cache", self.mac) + return False + return True + + async def initialize(self) -> bool: + """Initialize node configuration.""" + if self._initialized: + return True + self._initialization_delay_expired = datetime.now(UTC) + timedelta( + minutes=SUPPRESS_INITIALIZATION_WARNINGS + ) + self._initialized = True + return True + + async def _available_update_state(self, available: bool) -> None: + """Update the node availability state.""" + if self._available == available: + return + if available: + _LOGGER.info("Device %s detected to be available (on-line)", self.name) + self._available = True + await self.publish_feature_update_to_subscribers( + NodeFeature.AVAILABLE, True + ) + return + _LOGGER.info("Device %s detected to be not available (off-line)", self.name) + self._available = False + await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, False) + + async def node_info_update( + self, node_info: NodeInfoResponse | None = None + ) -> NodeInfo | None: + """Update Node hardware information.""" + if node_info is None: + request = NodeInfoRequest(self._send, self._mac_in_bytes) + node_info = await request.send() + if node_info is None: + _LOGGER.debug("No response for node_info_update() for %s", self.mac) + await self._available_update_state(False) + return self._node_info + await self._available_update_state(True) + await self.update_node_details( + firmware=node_info.firmware, + node_type=node_info.node_type, + hardware=node_info.hardware, + timestamp=node_info.timestamp, + relay_state=node_info.relay_state, + logaddress_pointer=node_info.current_logaddress_pointer, + ) + return self._node_info + + async def _node_info_load_from_cache(self) -> bool: + """Load node info settings from cache.""" + firmware = self._get_cache_as_datetime(CACHE_FIRMWARE) + hardware = self._get_cache(CACHE_HARDWARE) + timestamp = self._get_cache_as_datetime(CACHE_NODE_INFO_TIMESTAMP) + node_type: NodeType | None = None + if (node_type_str := self._get_cache(CACHE_NODE_TYPE)) is not None: + node_type = NodeType(int(node_type_str)) + return await self.update_node_details( + firmware=firmware, + hardware=hardware, + node_type=node_type, + timestamp=timestamp, + relay_state=None, + logaddress_pointer=None, + ) + + async def update_node_details( + self, + firmware: datetime | None, + hardware: str | None, + node_type: NodeType | None, + timestamp: datetime | None, + relay_state: bool | None, + logaddress_pointer: int | None, + ) -> bool: + """Process new node info and return true if all fields are updated.""" + complete = True + if firmware is None: + complete = False + else: + self._node_info.firmware = firmware + self._set_cache(CACHE_FIRMWARE, firmware) + if hardware is None: + complete = False + else: + if self._node_info.version != hardware: + self._node_info.version = hardware + # Generate modelname based on hardware version + model_info = version_to_model(hardware).split(" ") + self._node_info.model = model_info[0] + if self._node_info.model == "Unknown": + _LOGGER.warning( + "Failed to detect hardware model for %s based on '%s'", + self.mac, + hardware, + ) + if len(model_info) > 1: + self._node_info.model_type = " ".join(model_info[1:]) + else: + self._node_info.model_type = "" + if self._node_info.model is not None: + self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" + self._set_cache(CACHE_HARDWARE, hardware) + if timestamp is None: + complete = False + else: + self._node_info.timestamp = timestamp + self._set_cache(CACHE_NODE_INFO_TIMESTAMP, timestamp) + if node_type is None: + complete = False + else: + self._node_info.node_type = NodeType(node_type) + self._set_cache(CACHE_NODE_TYPE, self._node_info.node_type.value) + await self.save_cache() + return complete + + async def is_online(self) -> bool: + """Check if node is currently online.""" + if await self.ping_update() is None: + _LOGGER.debug("No response to ping for %s", self.mac) + return False + return True + + async def ping_update( + self, ping_response: NodePingResponse | None = None, retries: int = 1 + ) -> NetworkStatistics | None: + """Update ping statistics.""" + if ping_response is None: + request = NodePingRequest(self._send, self._mac_in_bytes, retries) + ping_response = await request.send() + if ping_response is None: + await self._available_update_state(False) + return None + await self._available_update_state(True) + self.update_ping_stats( + ping_response.timestamp, + ping_response.rssi_in, + ping_response.rssi_out, + ping_response.rtt, + ) + await self.publish_feature_update_to_subscribers(NodeFeature.PING, self._ping) + return self._ping + + def update_ping_stats( + self, timestamp: datetime, rssi_in: int, rssi_out: int, rtt: int + ) -> None: + """Update ping statistics.""" + self._ping.timestamp = timestamp + self._ping.rssi_in = rssi_in + self._ping.rssi_out = rssi_out + self._ping.rtt = rtt + self._available = True + + async def switch_relay(self, state: bool) -> bool | None: + """Switch relay state.""" + raise NodeError(f"Relay control is not supported for node {self.mac}") + + async def switch_relay_init(self, state: bool) -> bool: + """Switch state of initial power-up relay state. Returns new state of relay.""" + raise NodeError(f"Control of initial (power-up) state of relay is not supported for node {self.mac}") + + @raise_not_loaded + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + states: dict[NodeFeature, Any] = {} + for feature in features: + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.INFO: + states[NodeFeature.INFO] = await self.node_info_update() + elif feature == NodeFeature.AVAILABLE: + states[NodeFeature.AVAILABLE] = self._available + elif feature == NodeFeature.PING: + states[NodeFeature.PING] = await self.ping_update() + else: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + return states + + async def unload(self) -> None: + """Deactivate and unload node features.""" + if not self._cache_enabled: + return + if self._cache_save_task is not None and not self._cache_save_task.done(): + await self._cache_save_task + await self.save_cache(trigger_only=False, full_write=True) + + def _get_cache(self, setting: str) -> str | None: + """Retrieve value of specified setting from cache memory.""" + if not self._cache_enabled: + return None + return self._node_cache.get_state(setting) + + 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: + data = timestamp_str.split("-") + if len(data) == 6: + return datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC, + ) + return None + + def _set_cache(self, setting: str, value: Any) -> None: + """Store setting with value in cache memory.""" + if not self._cache_enabled: + return + if isinstance(value, datetime): + self._node_cache.update_state( + setting, + f"{value.year}-{value.month}-{value.day}-{value.hour}" + + f"-{value.minute}-{value.second}", + ) + elif isinstance(value, str): + self._node_cache.update_state(setting, value) + else: + self._node_cache.update_state(setting, str(value)) + + async def save_cache( + self, trigger_only: bool = True, full_write: bool = False + ) -> None: + """Save cached data to cache file when cache is enabled.""" + if not self._cache_enabled or not self._loaded or not self._initialized: + return + _LOGGER.debug("Save cache file for node %s", self.mac) + if self._cache_save_task is not None and not self._cache_save_task.done(): + await self._cache_save_task + if trigger_only: + self._cache_save_task = create_task(self._node_cache.save_cache()) + else: + await self._node_cache.save_cache(rewrite=full_write) + + @staticmethod + def skip_update(data_class: Any, seconds: int) -> bool: + """Check if update can be skipped when timestamp of given dataclass is less than given seconds old.""" + if data_class is None: + return False + if not hasattr(data_class, "timestamp"): + return False + if data_class.timestamp is None: + return False + if data_class.timestamp + timedelta(seconds=seconds) > datetime.now(UTC): + return True + return False diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 7b4f74c08..d1dba62f8 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -28,8 +28,8 @@ NodeResponseType, PlugwiseResponse, ) -from ..nodes import PlugwiseNode from .helpers import raise_not_loaded +from .node import PlugwiseBaseNode # Defaults for 'Sleeping End Devices' @@ -56,7 +56,7 @@ _LOGGER = logging.getLogger(__name__) -class NodeSED(PlugwiseNode): +class NodeSED(PlugwiseBaseNode): """provides base class for SED based nodes like Scan, Sense & Switch.""" # SED configuration @@ -85,7 +85,7 @@ def __init__( """Initialize base class for Sleeping End Device.""" super().__init__(mac, address, controller, loaded_callback) self._loop = get_running_loop() - self._node_info.battery_powered = True + self._node_info.is_battery_powered = True self._maintenance_interval = 86400 # Assume standard interval of 24h self._send_task_queue: list[Coroutine[Any, Any, bool]] = [] self._send_task_lock = Lock() @@ -180,7 +180,6 @@ async def _reset_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" if self._awake_future is not None: self._awake_future.set_result(True) - # Setup new maintenance timer self._awake_future = self._loop.create_future() self._awake_timer_task = self._loop.create_task( @@ -215,10 +214,8 @@ async def _send_tasks(self) -> None: """Send all tasks in queue.""" if len(self._send_task_queue) == 0: return - await self._send_task_lock.acquire() task_result = await gather(*self._send_task_queue) - if not all(task_result): _LOGGER.warning( "Executed %s tasks (result=%s) for %s", diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 0627c5ff7..5e9634d1b 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -38,7 +38,7 @@ async def load(self) -> bool: """Load and activate Sense node features.""" if self._loaded: return True - self._node_info.battery_powered = True + self._node_info.is_battery_powered = True if self._cache_enabled: _LOGGER.debug("Load Sense node %s from cache", self._node_info.mac) if await self._load_from_cache(): diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 81f36bd46..4c63ef642 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -29,7 +29,7 @@ async def load(self) -> bool: """Load and activate Switch node features.""" if self._loaded: return True - self._node_info.battery_powered = True + self._node_info.is_battery_powered = True if self._cache_enabled: _LOGGER.debug("Load Switch node %s from cache", self._node_info.mac) if await self._load_from_cache(): diff --git a/tests/test_usb.py b/tests/test_usb.py index 39ed80222..8dc5d3342 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1,7 +1,7 @@ """Test plugwise USB Stick.""" import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine from datetime import UTC, datetime as dt, timedelta as td import importlib import logging @@ -32,6 +32,7 @@ pw_responses = importlib.import_module("plugwise_usb.messages.responses") pw_userdata = importlib.import_module("stick_test_data") pw_circle = importlib.import_module("plugwise_usb.nodes.circle") +pw_scan = importlib.import_module("plugwise_usb.nodes.scan") pw_energy_counter = importlib.import_module("plugwise_usb.nodes.helpers.counter") pw_energy_calibration = importlib.import_module("plugwise_usb.nodes.helpers") pw_energy_pulses = importlib.import_module("plugwise_usb.nodes.helpers.pulses") @@ -166,7 +167,7 @@ def __init__( ) -> None: """Init mocked serial connection.""" self.custom_response = custom_response - self._protocol: pw_receiver.StickReceiver | None = None # type: ignore[name-defined] + self._protocol: pw_receiver.StickReceiver | None = None # type: ignore[name-defined] self._transport: DummyTransport | None = None def inject_message(self, data: bytes, seq_id: bytes) -> None: @@ -213,6 +214,35 @@ async def mkdir(self, path: str) -> None: return +class MockStickController: + """Mock stick controller.""" + + def subscribe_to_node_responses( + self, + node_response_callback: Callable[ # type: ignore[name-defined] + [pw_responses.PlugwiseResponse], Coroutine[Any, Any, bool] + ], + mac: bytes | None = None, + message_ids: tuple[bytes] | None = None, + ) -> Callable[[], None]: + """Subscribe a awaitable callback to be called when a specific message is received. + + Returns function to unsubscribe. + """ + + def dummy_method() -> None: + """Fake method.""" + + return dummy_method + + async def send( + self, + request: pw_requests.PlugwiseRequest, # type: ignore[name-defined] + suppress_node_errors: bool = True, + ) -> pw_responses.PlugwiseResponse | None: # type: ignore[name-defined] + """Submit request to queue and return response.""" + + aiofiles.threadpool.wrap.register(MagicMock)( lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase(*args, **kwargs) # pylint: disable=unnecessary-lambda ) @@ -520,7 +550,7 @@ async def test_stick_node_discovered_subscription( assert stick.nodes["5555555555555555"].node_info.model == "Scan" assert stick.nodes["5555555555555555"].node_info.model_type == "" assert stick.nodes["5555555555555555"].available - assert stick.nodes["5555555555555555"].node_info.battery_powered + assert stick.nodes["5555555555555555"].node_info.is_battery_powered assert sorted(stick.nodes["5555555555555555"].features) == sorted( ( pw_api.NodeFeature.AVAILABLE, @@ -911,9 +941,9 @@ def test_pulse_collection_consumption( assert tst_consumption.log_interval_consumption is None assert tst_consumption.log_interval_production is None assert not tst_consumption.production_logging - assert (tst_consumption.collected_pulses( + assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True - ) == (None, None)) + ) == (None, None) assert tst_consumption.log_addresses_missing == [99, 98, 97, 96] # Test consumption - Log import #4, no change @@ -1139,7 +1169,7 @@ def test_pulse_collection_production(self, monkeypatch: pytest.MonkeyPatch) -> N # Test consumption & production - Log import #3 - production # Interval of consumption is not yet available - test_timestamp = fixed_this_hour - td(hours=2) # type: ignore[unreachable] + test_timestamp = fixed_this_hour - td(hours=2) # type: ignore[unreachable] tst_production.add_log(199, 4, test_timestamp, 4000) missing_check = list(range(199, 157, -1)) assert tst_production.log_addresses_missing == missing_check @@ -1663,6 +1693,20 @@ async def test_node_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: ] ) + @pytest.mark.asyncio + async def test_scan_node(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Testing properties of scan.""" + + mock_stick_controller = MockStickController() + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Load callback for event.""" + + test_scan = pw_scan.PlugwiseScan( + "1298347650AFBECD", 1, mock_stick_controller, load_callback + ) + assert await test_scan.load() + @pytest.mark.asyncio async def test_node_discovery_and_load( self, monkeypatch: pytest.MonkeyPatch @@ -1691,6 +1735,9 @@ async def test_node_discovery_and_load( await stick.initialize() await stick.discover_nodes(load=True) + assert len(stick.nodes) == 6 + + assert stick.nodes["0098765432101234"].is_loaded assert stick.nodes["0098765432101234"].name == "Circle+ 01234" assert stick.nodes["0098765432101234"].node_info.firmware == dt( 2011, 6, 27, 8, 47, 37, tzinfo=UTC @@ -1700,8 +1747,8 @@ async def test_node_discovery_and_load( assert stick.nodes["0098765432101234"].node_info.model_type == "type F" assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" assert stick.nodes["0098765432101234"].available - assert not stick.nodes["0098765432101234"].node_info.battery_powered - assert not stick.nodes["0098765432101234"].battery_powered + 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 @@ -1716,7 +1763,7 @@ async def test_node_discovery_and_load( # Get state get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["0098765432101234"].get_state( - (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO) + (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) ) # Check Ping @@ -1729,11 +1776,20 @@ async def test_node_discovery_and_load( ) == get_state_timestamp ) + assert stick.nodes["0098765432101234"].ping_stats.rssi_in == 69 + assert stick.nodes["0098765432101234"].ping_stats.rssi_out == 70 + assert stick.nodes["0098765432101234"].ping_stats.rtt == 1074 + assert ( + stick.nodes["0098765432101234"].ping_stats.timestamp.replace( + minute=0, second=0, microsecond=0 + ) + == get_state_timestamp + ) # 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].battery_powered + assert not state[pw_api.NodeFeature.INFO].is_battery_powered assert sorted(state[pw_api.NodeFeature.INFO].features) == sorted( ( pw_api.NodeFeature.AVAILABLE, @@ -1750,7 +1806,7 @@ async def test_node_discovery_and_load( assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" assert state[pw_api.NodeFeature.INFO].model == "Circle+" assert state[pw_api.NodeFeature.INFO].model_type == "type F" - assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE_PLUS + assert state[pw_api.NodeFeature.INFO].node_type == pw_api.NodeType.CIRCLE_PLUS assert ( state[pw_api.NodeFeature.INFO].timestamp.replace( minute=0, second=0, microsecond=0 @@ -1759,6 +1815,8 @@ async def test_node_discovery_and_load( ) assert state[pw_api.NodeFeature.INFO].version == "000000730007" + assert state[pw_api.NodeFeature.RELAY].relay_state + # Check 1111111111111111 get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["1111111111111111"].get_state( @@ -1767,9 +1825,9 @@ async def test_node_discovery_and_load( assert state[pw_api.NodeFeature.INFO].mac == "1111111111111111" assert state[pw_api.NodeFeature.INFO].zigbee_address == 0 - assert not state[pw_api.NodeFeature.INFO].battery_powered + assert not state[pw_api.NodeFeature.INFO].is_battery_powered assert state[pw_api.NodeFeature.INFO].version == "000000070140" - assert state[pw_api.NodeFeature.INFO].type == pw_api.NodeType.CIRCLE + assert state[pw_api.NodeFeature.INFO].node_type == pw_api.NodeType.CIRCLE assert ( state[pw_api.NodeFeature.INFO].timestamp.replace( minute=0, second=0, microsecond=0 From cf6d0e4af9930a714466ceb22a7e1d5d3633808f Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 29 Sep 2024 12:27:41 +0200 Subject: [PATCH 432/979] Cleanup code --- plugwise_usb/connection/receiver.py | 6 +----- plugwise_usb/network/registry.py | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 60f7a8dce..ead105dc6 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -335,7 +335,7 @@ def remove_listener() -> None: message_ids, seq_id, ) - _LOGGER.warning("node subscription created for %s - %s", mac, seq_id) + _LOGGER.debug("node subscription created for %s - %s", mac, seq_id) return remove_listener async def _notify_node_response_subscribers( @@ -349,10 +349,6 @@ async def _notify_node_response_subscribers( _LOGGER.debug("Drop previously processed duplicate %s", node_response) return - _LOGGER.warning( - "total node subscriptions: %s", len(self._node_response_subscribers) - ) - notify_tasks: list[Coroutine[Any, Any, bool]] = [] for node_subscription in self._node_response_subscribers.values(): if ( diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 81439f53a..f6201b51a 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -111,9 +111,7 @@ async def start(self) -> None: """Initialize load the network registry.""" if self._cache_enabled: await self.restore_network_cache() - await sleep(0) await self.load_registry_from_cache() - await sleep(0) await self.update_missing_registrations(quick=True) async def restore_network_cache(self) -> None: From 13601f472846d4f4ae6f9947a66b1be61d03d532 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 29 Dec 2024 16:51:19 +0100 Subject: [PATCH 433/979] Extent testing --- tests/stick_test_data.py | 15 +- tests/test_usb.py | 399 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 406 insertions(+), 8 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index c316d3d45..dcb8eb897 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -96,7 +96,7 @@ b"0019" + b"0098765432101234" + b"7777777777777777" + b"06", ), b"\x05\x05\x03\x030018009876543210123407CE1E\r\n": ( - "SCAN 07", + "SWITCH 01", b"000000C1", # Success ack b"0019" + b"0098765432101234" + b"8888888888888888" + b"07", ), @@ -658,6 +658,19 @@ + b"4E084590" # fw_ver + b"06", # node_type (Scan) ), + b"\x05\x05\x03\x03002388888888888888886C1F\r\n": ( + "Node info for 8888888888888888", + b"000000C1", # Success ack + b"0024" # msg_id + + b"8888888888888888" # mac + + b"22026A68" # datetime + + b"00000000" # log address + + b"00" # relay + + b"01" # hz + + b"000000070051" # hw_ver + + b"4E08478A" # fw_ver + + b"03", # node_type (Switch) + ), b"\x05\x05\x03\x03001200987654321012340A72\r\n": ( "Power usage for 0098765432101234", b"000000C1", # Success ack diff --git a/tests/test_usb.py b/tests/test_usb.py index 8dc5d3342..2af5a43bb 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -30,9 +30,13 @@ pw_sender = importlib.import_module("plugwise_usb.connection.sender") pw_requests = importlib.import_module("plugwise_usb.messages.requests") pw_responses = importlib.import_module("plugwise_usb.messages.responses") +pw_msg_properties = importlib.import_module("plugwise_usb.messages.properties") pw_userdata = importlib.import_module("stick_test_data") +pw_node = importlib.import_module("plugwise_usb.nodes.node") pw_circle = importlib.import_module("plugwise_usb.nodes.circle") +pw_sed = importlib.import_module("plugwise_usb.nodes.sed") pw_scan = importlib.import_module("plugwise_usb.nodes.scan") +pw_switch = importlib.import_module("plugwise_usb.nodes.switch") pw_energy_counter = importlib.import_module("plugwise_usb.nodes.helpers.counter") pw_energy_calibration = importlib.import_module("plugwise_usb.nodes.helpers") pw_energy_pulses = importlib.import_module("plugwise_usb.nodes.helpers.pulses") @@ -95,6 +99,8 @@ def is_closing(self) -> bool: def write(self, data: bytes) -> None: """Write data back to system.""" log = None + ack = None + response = None if data in self._processed and self._second_response is not None: log, ack, response = self._second_response.get(data, (None, None, None)) if log is None and self._first_response is not None: @@ -205,8 +211,14 @@ async def exists(self, file_or_path: str) -> bool: return True if file_or_path == "mock_folder_that_exists/nodes.cache": return True + if file_or_path == "mock_folder_that_exists\\nodes.cache": + return True if file_or_path == "mock_folder_that_exists/0123456789ABCDEF.cache": return True + if file_or_path == "mock_folder_that_exists\\0123456789ABCDEF.cache": + return True + if file_or_path == "mock_folder_that_exists\\file_that_exists.ext": + return True return file_or_path == "mock_folder_that_exists/file_that_exists.ext" async def mkdir(self, path: str) -> None: @@ -217,7 +229,7 @@ async def mkdir(self, path: str) -> None: class MockStickController: """Mock stick controller.""" - def subscribe_to_node_responses( + async def subscribe_to_messages( self, node_response_callback: Callable[ # type: ignore[name-defined] [pw_responses.PlugwiseResponse], Coroutine[Any, Any, bool] @@ -273,11 +285,9 @@ async def test_sorting_request_messages(self) -> None: node_add_request = pw_requests.NodeAddRequest( self.dummy_fn, b"1111222233334444", True ) - await asyncio.sleep(0.001) relay_switch_request = pw_requests.CircleRelaySwitchRequest( self.dummy_fn, b"1234ABCD12341234", True ) - await asyncio.sleep(0.001) circle_plus_allow_joining_request = pw_requests.CirclePlusAllowJoiningRequest( self.dummy_fn, True ) @@ -310,6 +320,20 @@ async def test_sorting_request_messages(self) -> None: assert circle_plus_allow_joining_request > node_add_request assert circle_plus_allow_joining_request >= node_add_request + @pytest.mark.asyncio + async def test_msg_properties(self) -> None: + """Test message properties.""" + + # UnixTimestamp + unix_timestamp = pw_msg_properties.UnixTimestamp( + dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC), 8 + ) + 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.deserialize(b"4E08478A") + assert unix_timestamp.value == dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC) + @pytest.mark.asyncio async def test_stick_connect_without_port(self) -> None: """Test connecting to stick without port config.""" @@ -768,9 +792,9 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No with pytest.raises(pw_exceptions.NodeError): assert stick.nodes["0098765432101234"].relay_init with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].switch_relay_init(True) + await stick.nodes["0098765432101234"].configure_relay_init_state(True) with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].switch_relay_init(False) + await stick.nodes["0098765432101234"].configure_relay_init_state(False) # Check Circle is raising NodeError for unsupported features with pytest.raises(pw_exceptions.NodeError): @@ -796,13 +820,15 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No # Test async switching back init_state from on to off assert stick.nodes["2222222222222222"].relay_init self.test_init_relay_state_off = asyncio.Future() - assert not await stick.nodes["2222222222222222"].switch_relay_init(False) + assert not await stick.nodes["2222222222222222"].configure_relay_init_state( + False + ) assert not await self.test_init_relay_state_off assert not stick.nodes["2222222222222222"].relay_init # Test async switching back from off to on self.test_init_relay_state_on = asyncio.Future() - assert await stick.nodes["2222222222222222"].switch_relay_init(True) + assert await stick.nodes["2222222222222222"].configure_relay_init_state(True) assert await self.test_init_relay_state_on assert stick.nodes["2222222222222222"].relay_init @@ -1566,6 +1592,25 @@ async def test_network_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(pw_helpers_cache, "os_getenv", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_expand_user", self.fake_env) monkeypatch.setattr(pw_helpers_cache, "os_path_join", self.os_path_join) + + async def aiofiles_os_remove(file: str) -> None: + if file == "mock_folder_that_exists/file_that_exists.ext": + return + if file == "mock_folder_that_exists/nodes.cache": + return + if file == "mock_folder_that_exists/0123456789ABCDEF.cache": + return + raise pw_exceptions.CacheError("Invalid file") + + async def makedirs(cache_dir: str, exist_ok: bool) -> None: + if cache_dir == "mock_folder_that_exists": + return + if cache_dir == "non_existing_folder": + return + raise pw_exceptions.CacheError("wrong folder to create") + + monkeypatch.setattr(pw_helpers_cache, "aiofiles_os_remove", aiofiles_os_remove) + monkeypatch.setattr(pw_helpers_cache, "makedirs", makedirs) monkeypatch.setattr(pw_helpers_cache, "ospath", MockOsPath()) pw_nw_cache = pw_network_cache.NetworkRegistrationCache( @@ -1697,6 +1742,39 @@ async def test_node_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_scan_node(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing properties of scan.""" + def fake_cache(dummy: object, setting: str) -> str | None: + """Fake cache retrieval.""" + if setting == pw_node.CACHE_FIRMWARE: + 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: + return "20" + if setting == pw_sed.CACHE_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: + return "2024-12-6-1-0-0" + if setting == pw_scan.CACHE_MOTION_RESET_TIMER: + return "10" + if setting == pw_scan.CACHE_SCAN_SENSITIVITY: + return "MEDIUM" + if setting == pw_scan.CACHE_SCAN_DAYLIGHT_MODE: + return "True" + return None + + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) mock_stick_controller = MockStickController() async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] @@ -1705,8 +1783,216 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign test_scan = pw_scan.PlugwiseScan( "1298347650AFBECD", 1, mock_stick_controller, load_callback ) + assert not test_scan.cache_enabled + with pytest.raises(pw_exceptions.NodeError): + battery_config = test_scan.battery_config + + assert sorted(test_scan.features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + ) + ) + await test_scan.update_node_details( + firmware=dt(2011, 6, 27, 8, 55, 44, tzinfo=UTC), + hardware="080007", + node_type=None, + timestamp=None, + relay_state=None, + logaddress_pointer=None, + ) assert await test_scan.load() + # test default SED config + assert test_scan.sed_awake_duration == 10 + assert test_scan.sed_clock_interval == 25200 + assert test_scan.sed_clock_sync is False + assert test_scan.sed_maintenance_interval == 600 + assert test_scan.sed_sleep_duration == 60 + assert test_scan.battery_config.awake_duration == 10 + assert test_scan.battery_config.clock_interval == 25200 + assert test_scan.battery_config.clock_sync is False + assert test_scan.battery_config.maintenance_interval == 600 + assert test_scan.battery_config.sleep_duration == 60 + + # Scan specific defaults + assert test_scan.daylight_mode is False + assert test_scan.motion_reset_timer == 10 + assert test_scan.sensitivity_level == pw_api.MotionSensitivity.MEDIUM + + # scan with cache enabled + test_scan = pw_scan.PlugwiseScan( + "1298347650AFBECD", 1, mock_stick_controller, load_callback + ) + await test_scan.update_node_details( + firmware=dt(2011, 6, 27, 8, 55, 44, tzinfo=UTC), + hardware="080007", + node_type=None, + timestamp=None, + relay_state=None, + logaddress_pointer=None, + ) + test_scan.cache_enabled = True + assert await test_scan.load() + assert sorted(test_scan.features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.PING, + ) + ) + # Validate if we get values from (faked) cache + assert test_scan.sed_awake_duration == 20 + assert test_scan.sed_clock_interval == 12600 + assert test_scan.sed_clock_sync + assert test_scan.sed_maintenance_interval == 43200 + assert test_scan.sed_sleep_duration == 120 + assert test_scan.battery_config.awake_duration == 20 + assert test_scan.battery_config.clock_interval == 12600 + assert test_scan.battery_config.clock_sync + assert test_scan.battery_config.maintenance_interval == 43200 + assert test_scan.battery_config.sleep_duration == 120 + + state = await test_scan.get_state( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.MOTION, + ) + ) + assert not state[pw_api.NodeFeature.AVAILABLE] + assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 43200 + assert state[pw_api.NodeFeature.BATTERY].awake_duration == 20 + assert state[pw_api.NodeFeature.BATTERY].clock_sync + assert state[pw_api.NodeFeature.BATTERY].clock_interval == 12600 + assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 120 + + @pytest.mark.asyncio + async def test_switch_node(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Testing properties of switch.""" + + def fake_cache(dummy: object, setting: str) -> str | None: + """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_TYPE: + return "3" + if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: + return "2024-12-7-1-0-0" + if setting == pw_sed.CACHE_AWAKE_DURATION: + return "15" + if setting == pw_sed.CACHE_CLOCK_INTERVAL: + return "14600" + if setting == pw_sed.CACHE_CLOCK_SYNC: + return "False" + if setting == pw_sed.CACHE_MAINTENANCE_INTERVAL: + return "900" + if setting == pw_sed.CACHE_SLEEP_DURATION: + return "180" + return None + + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + mock_stick_controller = MockStickController() + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Load callback for event.""" + + test_switch = pw_switch.PlugwiseSwitch( + "1298347650AFBECD", 1, mock_stick_controller, load_callback + ) + assert not test_switch.cache_enabled + with pytest.raises(pw_exceptions.NodeError): + battery_config = test_switch.battery_config + + assert sorted(test_switch.features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + ) + ) + await test_switch.update_node_details( + firmware=dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC), + hardware="070051", + node_type=None, + timestamp=None, + relay_state=None, + logaddress_pointer=None, + ) + assert await test_switch.load() + + # test default SED config + assert test_switch.sed_awake_duration == 10 + assert test_switch.sed_clock_interval == 25200 + assert test_switch.sed_clock_sync is False + assert test_switch.sed_maintenance_interval == 600 + assert test_switch.sed_sleep_duration == 60 + assert test_switch.battery_config.awake_duration == 10 + assert test_switch.battery_config.clock_interval == 25200 + assert test_switch.battery_config.clock_sync is False + assert test_switch.battery_config.maintenance_interval == 600 + assert test_switch.battery_config.sleep_duration == 60 + + # Switch specific defaults + assert test_switch.switch is False + + # switch with cache enabled + test_switch = pw_switch.PlugwiseSwitch( + "1298347650AFBECD", 1, mock_stick_controller, load_callback + ) + await test_switch.update_node_details( + firmware=dt(2011, 6, 27, 9, 4, 10, tzinfo=UTC), + hardware="070051", + node_type=None, + timestamp=None, + relay_state=None, + logaddress_pointer=None, + ) + test_switch.cache_enabled = True + assert test_switch.cache_enabled is True + assert await test_switch.load() + assert sorted(test_switch.features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.SWITCH, + ) + ) + # Validate if we get values from (faked) cache + assert test_switch.sed_awake_duration == 15 + assert test_switch.sed_clock_interval == 14600 + assert test_switch.sed_clock_sync is False + assert test_switch.sed_maintenance_interval == 900 + assert test_switch.sed_sleep_duration == 180 + assert test_switch.battery_config.awake_duration == 15 + assert test_switch.battery_config.clock_interval == 14600 + assert test_switch.battery_config.clock_sync is False + assert test_switch.battery_config.maintenance_interval == 900 + assert test_switch.battery_config.sleep_duration == 180 + + state = await test_switch.get_state( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.SWITCH, + ) + ) + assert not state[pw_api.NodeFeature.AVAILABLE] + assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 900 + assert state[pw_api.NodeFeature.BATTERY].awake_duration == 15 + assert state[pw_api.NodeFeature.BATTERY].clock_sync is False + assert state[pw_api.NodeFeature.BATTERY].clock_interval == 14600 + assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 180 + @pytest.mark.asyncio async def test_node_discovery_and_load( self, monkeypatch: pytest.MonkeyPatch @@ -1846,6 +2132,105 @@ async def test_node_discovery_and_load( ) assert state[pw_api.NodeFeature.RELAY].relay_state + # region Scan + self.test_node_awake = asyncio.Future() + unsub_awake = stick.subscribe_to_node_events( + node_event_callback=self.node_awake, + events=(pw_api.NodeEvent.AWAKE,), + ) + mock_serial.inject_message(b"004F555555555555555500", b"FFFE") + assert await self.test_node_awake + unsub_awake() + + assert stick.nodes["5555555555555555"].node_info.firmware == dt( + 2011, 6, 27, 8, 55, 44, tzinfo=UTC + ) + assert stick.nodes["5555555555555555"].node_info.version == "000000070008" + assert stick.nodes["5555555555555555"].node_info.model == "Scan" + assert stick.nodes["5555555555555555"].node_info.model_type == "" + assert stick.nodes["5555555555555555"].available + assert stick.nodes["5555555555555555"].node_info.is_battery_powered + assert sorted(stick.nodes["5555555555555555"].features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.MOTION, + ) + ) + state = await stick.nodes["5555555555555555"].get_state( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.MOTION, + ) + ) + assert state[pw_api.NodeFeature.AVAILABLE] + assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 600 + assert state[pw_api.NodeFeature.BATTERY].awake_duration == 10 + assert not state[pw_api.NodeFeature.BATTERY].clock_sync + assert state[pw_api.NodeFeature.BATTERY].clock_interval == 25200 + assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 60 + + # Motion + self.test_motion_on = asyncio.Future() + self.test_motion_off = asyncio.Future() + unsub_motion = stick.nodes["5555555555555555"].subscribe_to_feature_update( + node_feature_callback=self.node_motion_state, + features=(pw_api.NodeFeature.MOTION,), + ) + # Inject motion message to trigger a 'motion on' event + mock_serial.inject_message(b"005655555555555555550001", b"FFFF") + motion_on = await self.test_motion_on + assert motion_on + assert stick.nodes["5555555555555555"].motion + + # Inject motion message to trigger a 'motion off' event + mock_serial.inject_message(b"005655555555555555550000", b"FFFF") + motion_off = await self.test_motion_off + assert not motion_off + assert not stick.nodes["5555555555555555"].motion + unsub_motion() + # endregion + + # region Switch + self.test_node_awake = asyncio.Future() + unsub_awake = stick.subscribe_to_node_events( + node_event_callback=self.node_awake, + events=(pw_api.NodeEvent.AWAKE,), + ) + mock_serial.inject_message(b"004F888888888888888800", b"FFFE") + assert await self.test_node_awake + unsub_awake() + assert stick.nodes["8888888888888888"].node_info.firmware == dt( + 2011, 6, 27, 9, 4, 10, tzinfo=UTC + ) + assert stick.nodes["8888888888888888"].node_info.version == "000000070051" + assert stick.nodes["8888888888888888"].node_info.model == "Switch" + assert stick.nodes["8888888888888888"].node_info.model_type == "" + assert stick.nodes["8888888888888888"].available + assert stick.nodes["8888888888888888"].node_info.is_battery_powered + assert sorted(stick.nodes["8888888888888888"].features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.SWITCH, + ) + ) + state = await stick.nodes["8888888888888888"].get_state( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.SWITCH, + ) + ) + # endregion + # test disable cache assert stick.cache_enabled stick.cache_enabled = False From a82b11c54e3202d3be171c5baaa166249bdc1ec5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 29 Dec 2024 16:55:15 +0100 Subject: [PATCH 434/979] Remove reduced logging --- plugwise_usb/connection/__init__.py | 10 ---------- plugwise_usb/connection/manager.py | 18 ----------------- plugwise_usb/connection/receiver.py | 31 ++++++----------------------- plugwise_usb/network/__init__.py | 1 - 4 files changed, 6 insertions(+), 54 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 5b2b38f02..6cee960ca 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -33,16 +33,6 @@ def __init__(self) -> None: self._network_id: int | None = None self._network_online = False - @property - def reduce_receive_logging(self) -> bool: - """Return if logging must reduced.""" - return self._manager.reduce_receive_logging - - @reduce_receive_logging.setter - def reduce_receive_logging(self, state: bool) -> None: - """Reduce logging of unhandled received messages.""" - self._manager.reduce_receive_logging = state - @property def is_initialized(self) -> bool: """Returns True if UBS-Stick connection is active and initialized.""" diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 633a2765e..506af09a1 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -36,24 +36,6 @@ def __init__(self) -> None: ] = {} self._unsubscribe_stick_events: Callable[[], None] | None = None - @property - def reduce_receive_logging(self) -> bool: - """Return if logging must reduced.""" - if self._receiver is None: - raise StickError( - "Unable to return log settings when connection is not active." - ) - return self._receiver.reduce_logging - - @reduce_receive_logging.setter - def reduce_receive_logging(self, state: bool) -> None: - """Reduce logging of unhandled received messages.""" - if self._receiver is None: - raise StickError( - "Unable to set log settings when connection is not active." - ) - self._receiver.reduce_logging = state - @property def serial_path(self) -> str: """Return current port.""" diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index ead105dc6..88b8a6751 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -91,7 +91,6 @@ def __init__( self._transport: SerialTransport | None = None self._buffer: bytes = bytes([]) self._connection_state = False - self._reduce_logging = True self._receive_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} @@ -127,16 +126,6 @@ def is_connected(self) -> bool: """Return current connection state of the USB-Stick.""" return self._connection_state - @property - def reduce_logging(self) -> bool: - """Return if logging must reduced.""" - return self._reduce_logging - - @reduce_logging.setter - def reduce_logging(self, reduce_logging: bool) -> None: - """Reduce logging.""" - self._reduce_logging = reduce_logging - def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.info("Connection made") @@ -398,20 +387,12 @@ async def _notify_node_response_subscribers( return if node_response.retries > 10: - if self._reduce_logging: - _LOGGER.debug( - "No subscriber to handle %s, seq_id=%s from %s after 10 retries", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, - ) - else: - _LOGGER.warning( - "No subscriber to handle %s, seq_id=%s from %s after 10 retries", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, - ) + _LOGGER.warning( + "No subscriber to handle %s, seq_id=%s from %s after 10 retries", + node_response.__class__.__name__, + node_response.seq_id, + node_response.mac_decoded, + ) return node_response.retries += 1 if node_response.retries > 2: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index cc7b685f3..bfeaaa42a 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -421,7 +421,6 @@ async def _discover_registered_nodes(self) -> None: counter += 1 await sleep(0) _LOGGER.debug("Total %s registered node(s)", str(counter)) - self._controller.reduce_receive_logging = False async def _load_node(self, mac: str) -> bool: """Load node.""" From cc8ca19f4b618ac4f8998f7b86e45cafb4446756 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 2 Jan 2025 22:46:37 +0100 Subject: [PATCH 435/979] Add FeatureError --- plugwise_usb/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugwise_usb/exceptions.py b/plugwise_usb/exceptions.py index ff3710130..dd95c564b 100644 --- a/plugwise_usb/exceptions.py +++ b/plugwise_usb/exceptions.py @@ -13,6 +13,10 @@ class EnergyError(PlugwiseException): """Energy error.""" +class FeatureError(PlugwiseException): + """Feature error.""" + + class MessageError(PlugwiseException): """Message errors.""" From dd4f1a488de4fa35616e32663f3e8631862b2f34 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 2 Jan 2025 22:47:25 +0100 Subject: [PATCH 436/979] Add Windows support to cache --- plugwise_usb/helpers/cache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index 60e6a3b3b..ad00184cb 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -59,7 +59,10 @@ async def initialize_cache(self, create_root_folder: bool = False) -> None: cache_dir = self._get_writable_os_dir() await makedirs(cache_dir, exist_ok=True) self._cache_path = cache_dir - self._cache_file = f"{cache_dir}/{self._file_name}" + if os_name == "nt": + self._cache_file = f"{cache_dir}\\{self._file_name}" + else: + self._cache_file = f"{cache_dir}/{self._file_name}" self._cache_file_exists = await ospath.exists(self._cache_file) self._initialized = True _LOGGER.debug("Start using network cache file: %s", self._cache_file) From 32c26161a44c3b3d75008b7dfb9a8a9288224823 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 20:51:59 +0100 Subject: [PATCH 437/979] Specific import --- plugwise_usb/messages/properties.py | 38 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index d94f66be9..aa8d4ad54 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -1,7 +1,7 @@ """Message property types.""" import binascii -import datetime +from datetime import UTC, date, datetime, time, timedelta import struct from typing import Any @@ -161,10 +161,10 @@ def value(self) -> int: class UnixTimestamp(BaseType): """Unix formatted timestamp property.""" - def __init__(self, value: float, length: int = 8) -> None: + def __init__(self, value: datetime | None, length: int = 8) -> None: """Initialize Unix formatted timestamp property.""" super().__init__(value, length) - self._value: datetime.datetime | None = None + self._value: datetime | None = None def serialize(self) -> bytes: """Return current string formatted value into an iterable list of bytes.""" @@ -173,10 +173,10 @@ def serialize(self) -> bytes: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on Unix timestamp format.""" - self._value = datetime.datetime.fromtimestamp(int(val, 16), datetime.UTC) + self._value = datetime.fromtimestamp(int(val, 16), UTC) @property - def value(self) -> datetime.datetime: + def value(self) -> datetime: """Return converted datetime value.""" if self._value is None: raise MessageError("Unable to return value. Deserialize data first") @@ -218,7 +218,7 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: self.month = Int(month, 2, False) self.minutes = Int(minutes, 4, False) self.contents += [self.year, self.month, self.minutes] - self._value: datetime.datetime | None = None + self._value: datetime | None = None self._deserialized = False def deserialize(self, val: bytes) -> None: @@ -227,9 +227,9 @@ def deserialize(self, val: bytes) -> None: self._value = None else: CompositeType.deserialize(self, val) - self._value = datetime.datetime( + self._value = datetime( year=self.year.value, month=self.month.value, day=1 - ) + datetime.timedelta(minutes=self.minutes.value) + ) + timedelta(minutes=self.minutes.value) self._deserialized = True @property @@ -240,7 +240,7 @@ def value_set(self) -> bool: return (self._value is not None) @property - def value(self) -> datetime.datetime: + def value(self) -> datetime: """Return converted datetime value.""" if self._value is None: raise MessageError("Unable to return value. Deserialize data first") @@ -257,17 +257,15 @@ def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: self.minute = Int(minute, 2, False) self.second = Int(second, 2, False) self.contents += [self.hour, self.minute, self.second] - self._value: datetime.time | None = None + self._value: time | None = None def deserialize(self, val: bytes) -> None: """Convert data into time value.""" CompositeType.deserialize(self, val) - self._value = datetime.time( - self.hour.value, self.minute.value, self.second.value - ) + self._value = time(self.hour.value, self.minute.value, self.second.value) @property - def value(self) -> datetime.time: + def value(self) -> time: """Return converted time value.""" if self._value is None: raise MessageError("Unable to return value. Deserialize data first") @@ -309,19 +307,19 @@ def __init__(self, hour: int = 0, minute: int = 0, second: int = 0) -> None: self.minute = IntDec(minute, 2) self.second = IntDec(second, 2) self.contents += [self.second, self.minute, self.hour] - self._value: datetime.time | None = None + self._value: time | None = None def deserialize(self, val: bytes) -> None: """Convert data into time value based on integer formatted data.""" CompositeType.deserialize(self, val) - self._value = datetime.time( + self._value = time( int(self.hour.value), int(self.minute.value), int(self.second.value), ) @property - def value(self) -> datetime.time: + def value(self) -> time: """Return converted time value.""" if self._value is None: raise MessageError("Unable to return value. Deserialize data first") @@ -338,19 +336,19 @@ def __init__(self, day: int = 0, month: int = 0, year: int = 0) -> None: self.month = IntDec(month, 2) self.year = IntDec(year - PLUGWISE_EPOCH, 2) self.contents += [self.day, self.month, self.year] - self._value: datetime.date | None = None + self._value: date | None = None def deserialize(self, val: bytes) -> None: """Convert data into date value based on integer formatted data.""" CompositeType.deserialize(self, val) - self._value = datetime.date( + self._value = date( int(self.year.value) + PLUGWISE_EPOCH, int(self.month.value), int(self.day.value), ) @property - def value(self) -> datetime.date: + def value(self) -> date: """Return converted date value.""" if self._value is None: raise MessageError("Unable to return value. Deserialize data first") From 9c1724d667db5c3f07617998af57387adedec426 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 20:53:39 +0100 Subject: [PATCH 438/979] Update properties.py --- plugwise_usb/messages/properties.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index aa8d4ad54..40f76d4f8 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -75,6 +75,7 @@ def value(self) -> bytes: raise MessageError("Unable to return value. Deserialize data first") return self._value + class String(BaseType): """String based property.""" @@ -168,8 +169,11 @@ def __init__(self, value: datetime | None, length: int = 8) -> None: def serialize(self) -> bytes: """Return current string formatted value into an iterable list of bytes.""" + if not isinstance(self._raw_value, datetime): + raise MessageError("Unable to serialize. Value is not a datetime object") fmt = "%%0%dX" % self.length - return bytes(fmt % self._raw_value, UTF8) + date_in_float = self._raw_value.timestamp() + return bytes(fmt % int(date_in_float), UTF8) def deserialize(self, val: bytes) -> None: """Convert data into datetime based on Unix timestamp format.""" @@ -237,7 +241,7 @@ def value_set(self) -> bool: """True when datetime is converted.""" if not self._deserialized: raise MessageError("Unable to return value. Deserialize data first") - return (self._value is not None) + return self._value is not None @property def value(self) -> datetime: From 21392c2b1cac5ff37d10ce0f5a322bf38c06d344 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 20:57:50 +0100 Subject: [PATCH 439/979] Subscribe to messages --- plugwise_usb/connection/__init__.py | 10 ++++------ plugwise_usb/connection/manager.py | 20 +++++--------------- plugwise_usb/network/__init__.py | 15 ++++++++++----- plugwise_usb/nodes/node.py | 2 +- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 6cee960ca..74c2b63d3 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -117,21 +117,19 @@ def subscribe_to_stick_events( events, ) - def subscribe_to_node_responses( + async def subscribe_to_messages( self, node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, + seq_id: bytes | None = None, ) -> Callable[[], None]: """Subscribe a awaitable callback to be called when a specific message is received. Returns function to unsubscribe. """ - - return self._manager.subscribe_to_node_responses( - node_response_callback, - mac, - message_ids, + return await self._manager.subscribe_to_messages( + node_response_callback, mac, message_ids, seq_id ) async def _handle_stick_event(self, event: StickEvent) -> None: diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 506af09a1..bd8787be2 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -13,7 +13,7 @@ from ..api import StickEvent from ..exceptions import StickError from ..messages.requests import PlugwiseRequest -from ..messages.responses import PlugwiseResponse, StickResponse +from ..messages.responses import PlugwiseResponse from .receiver import StickReceiver from .sender import StickSender @@ -94,22 +94,12 @@ def remove_subscription() -> None: ) return remove_subscription - def subscribe_to_stick_replies( - self, - callback: Callable[[StickResponse], Coroutine[Any, Any, None]], - ) -> Callable[[], None]: - """Subscribe to response messages from stick.""" - if self._receiver is None or not self._receiver.is_connected: - raise StickError( - "Unable to subscribe to stick response when receiver " + "is not loaded" - ) - return self._receiver.subscribe_to_stick_responses(callback) - - def subscribe_to_node_responses( + async def subscribe_to_messages( self, node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, message_ids: tuple[bytes] | None = None, + seq_id: bytes | None = None, ) -> Callable[[], None]: """Subscribe a awaitable callback to be called when a specific message is received. @@ -119,8 +109,8 @@ def subscribe_to_node_responses( raise StickError( "Unable to subscribe to node response when receiver " + "is not loaded" ) - return self._receiver.subscribe_to_node_responses( - node_response_callback, mac, message_ids + return await self._receiver.subscribe_to_node_responses( + node_response_callback, mac, message_ids, seq_id ) async def setup_connection_to_stick(self, serial_path: str) -> None: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index bfeaaa42a..c3b0e570c 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -170,15 +170,20 @@ def _subscribe_to_protocol_events(self) -> None: self._handle_stick_event, (StickEvent.CONNECTED, StickEvent.DISCONNECTED), ) - self._unsubscribe_node_awake = self._controller.subscribe_to_node_responses( + + async def _subscribe_to_node_events(self) -> None: + """Subscribe to events from protocol.""" + self._unsubscribe_node_awake = await self._controller.subscribe_to_messages( self.node_awake_message, None, (NODE_AWAKE_RESPONSE_ID,), - ) - self._unsubscribe_node_join = self._controller.subscribe_to_node_responses( - self.node_join_available_message, None, - (NODE_JOIN_ID,), + ) + self._unsubscribe_node_join = await self._controller.subscribe_to_messages( + self.node_join_available_message, None, (NODE_JOIN_ID,), None + ) + self._unsubscribe_node_rejoin = await self._controller.subscribe_to_messages( + self.node_rejoin_message, None, (NODE_REJOIN_ID,), None ) async def _handle_stick_event(self, event: StickEvent) -> None: diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 3920f50f9..86fb8490c 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -62,7 +62,7 @@ def __init__( ): """Initialize Plugwise base node class.""" self._loaded_callback = loaded_callback - self._message_subscribe = controller.subscribe_to_node_responses + self._message_subscribe = controller.subscribe_to_messages self._features: tuple[NodeFeature, ...] = NODE_FEATURES self._last_update = datetime.now(UTC) self._node_info = NodeInfo(mac, address) From 7f1b689e8e981678951a10ebcb6999d50fd5b3d0 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:02:58 +0100 Subject: [PATCH 440/979] Improve and add extra tests --- tests/stick_test_data.py | 2 +- tests/test_usb.py | 628 +++++++++++++++++++++++++++++++-------- 2 files changed, 503 insertions(+), 127 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index dcb8eb897..ac32b151a 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -667,7 +667,7 @@ + b"00000000" # log address + b"00" # relay + b"01" # hz - + b"000000070051" # hw_ver + + b"000007005100" # hw_ver + b"4E08478A" # fw_ver + b"03", # node_type (Switch) ), diff --git a/tests/test_usb.py b/tests/test_usb.py index 2af5a43bb..363cb0796 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -229,6 +229,8 @@ async def mkdir(self, path: str) -> None: class MockStickController: """Mock stick controller.""" + send_response = None + async def subscribe_to_messages( self, node_response_callback: Callable[ # type: ignore[name-defined] @@ -253,6 +255,7 @@ async def send( suppress_node_errors: bool = True, ) -> pw_responses.PlugwiseResponse | None: # type: ignore[name-defined] """Submit request to queue and return response.""" + return self.send_response aiofiles.threadpool.wrap.register(MagicMock)( @@ -264,6 +267,7 @@ class TestStick: """Test USB Stick.""" test_node_awake: asyncio.Future[str] + test_node_loaded: asyncio.Future[str] test_node_join: asyncio.Future[str] test_connected: asyncio.Future[bool] test_disconnected: asyncio.Future[bool] @@ -285,9 +289,11 @@ async def test_sorting_request_messages(self) -> None: node_add_request = pw_requests.NodeAddRequest( self.dummy_fn, b"1111222233334444", True ) + await asyncio.sleep(0.001) # Ensure timestamp is different relay_switch_request = pw_requests.CircleRelaySwitchRequest( self.dummy_fn, b"1234ABCD12341234", True ) + await asyncio.sleep(0.001) # Ensure timestamp is different circle_plus_allow_joining_request = pw_requests.CirclePlusAllowJoiningRequest( self.dummy_fn, True ) @@ -510,25 +516,34 @@ async def node_awake(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ) ) + async def node_loaded(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Handle awake event callback.""" + if event == pw_api.NodeEvent.LOADED: + self.test_node_loaded.set_result(mac) + else: + self.test_node_loaded.set_exception( + BaseException( + f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.LOADED}" + ) + ) async def node_motion_state( self, feature: pw_api.NodeFeature, # type: ignore[name-defined] - state: pw_api.MotionState, # type: ignore[name-defined] + motion: pw_api.MotionState, # type: ignore[name-defined] ) -> None: """Handle motion event callback.""" if feature == pw_api.NodeFeature.MOTION: - if state.motion: - self.test_motion_on.set_result(state.motion) + if motion.state: + self.test_motion_on.set_result(motion.state) else: - self.test_motion_off.set_result(state.motion) - elif state.motion: + self.test_motion_off.set_result(motion.state) + else: self.test_motion_on.set_exception( BaseException( f"Invalid {feature} feature, expected " + f"{pw_api.NodeFeature.MOTION}" ) ) - else: self.test_motion_off.set_exception( BaseException( f"Invalid {feature} feature, expected " @@ -582,23 +597,24 @@ async def test_stick_node_discovered_subscription( pw_api.NodeFeature.INFO, pw_api.NodeFeature.PING, pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.MOTION_CONFIG, ) ) # Check Scan is raising NodeError for unsupported features - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].relay - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].relay_state - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].switch - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].power - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].humidity - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].temperature - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["5555555555555555"].energy # Motion @@ -690,10 +706,10 @@ async def node_relay_state( ) -> None: """Handle relay event callback.""" if feature == pw_api.NodeFeature.RELAY: - if state.relay_state: - self.test_relay_state_on.set_result(state.relay_state) + if state.state: + self.test_relay_state_on.set_result(state.state) else: - self.test_relay_state_off.set_result(state.relay_state) + self.test_relay_state_off.set_result(state.state) else: self.test_relay_state_on.set_exception( BaseException( @@ -711,14 +727,14 @@ async def node_relay_state( async def node_init_relay_state( self, feature: pw_api.NodeFeature, # type: ignore[name-defined] - state: bool, + config: pw_api.RelayConfig, # type: ignore[name-defined] ) -> None: """Relay Callback for event.""" if feature == pw_api.NodeFeature.RELAY_INIT: - if state: - self.test_init_relay_state_on.set_result(state) + if config.init_state: + self.test_init_relay_state_on.set_result(config.init_state) else: - self.test_init_relay_state_off.set_result(state) + self.test_init_relay_state_off.set_result(config.init_state) else: self.test_init_relay_state_on.set_exception( BaseException( @@ -749,6 +765,10 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No await stick.initialize() await stick.discover_nodes(load=False) + # Validate if NodeError is raised when device is not loaded + with pytest.raises(pw_exceptions.NodeError): + await stick.nodes["0098765432101234"].set_relay(True) + # Manually load node assert await stick.nodes["0098765432101234"].load() @@ -759,13 +779,13 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No # Test async switching back from on to off self.test_relay_state_off = asyncio.Future() - assert not await stick.nodes["0098765432101234"].switch_relay(False) + assert not await stick.nodes["0098765432101234"].set_relay(False) assert not await self.test_relay_state_off assert not stick.nodes["0098765432101234"].relay # Test async switching back from off to on self.test_relay_state_on = asyncio.Future() - assert await stick.nodes["0098765432101234"].switch_relay(True) + assert await stick.nodes["0098765432101234"].set_relay(True) assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay @@ -774,41 +794,46 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No await stick.nodes["0098765432101234"].relay_off() assert not await self.test_relay_state_off assert not stick.nodes["0098765432101234"].relay - assert not stick.nodes["0098765432101234"].relay_state.relay_state + assert not stick.nodes["0098765432101234"].relay_state.state # Test async switching back from off to on self.test_relay_state_on = asyncio.Future() await stick.nodes["0098765432101234"].relay_on() assert await self.test_relay_state_on assert stick.nodes["0098765432101234"].relay - assert stick.nodes["0098765432101234"].relay_state.relay_state + assert stick.nodes["0098765432101234"].relay_state.state unsub_relay() # Check if node is online assert await stick.nodes["0098765432101234"].is_online() - # Test non-support init relay state - with pytest.raises(pw_exceptions.NodeError): - assert stick.nodes["0098765432101234"].relay_init - with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].configure_relay_init_state(True) - with pytest.raises(pw_exceptions.NodeError): - await stick.nodes["0098765432101234"].configure_relay_init_state(False) + # Test non-support relay configuration + with pytest.raises(pw_exceptions.FeatureError): + assert stick.nodes["0098765432101234"].relay_config is not None + with pytest.raises(pw_exceptions.FeatureError): + await stick.nodes["0098765432101234"].set_relay_init(True) + with pytest.raises(pw_exceptions.FeatureError): + await stick.nodes["0098765432101234"].set_relay_init(False) # Check Circle is raising NodeError for unsupported features - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["0098765432101234"].motion - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["0098765432101234"].switch - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["0098765432101234"].humidity - with pytest.raises(pw_exceptions.NodeError): + with pytest.raises(pw_exceptions.FeatureError): assert stick.nodes["0098765432101234"].temperature # Test relay init # load node 2222222222222222 which has # the firmware with init relay feature + + # Validate if NodeError is raised when device is not loaded + with pytest.raises(pw_exceptions.NodeError): + await stick.nodes["2222222222222222"].set_relay_init(True) + assert await stick.nodes["2222222222222222"].load() self.test_init_relay_state_on = asyncio.Future() self.test_init_relay_state_off = asyncio.Future() @@ -818,19 +843,17 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No ) # Test async switching back init_state from on to off - assert stick.nodes["2222222222222222"].relay_init + assert stick.nodes["2222222222222222"].relay_config.init_state self.test_init_relay_state_off = asyncio.Future() - assert not await stick.nodes["2222222222222222"].configure_relay_init_state( - False - ) + assert not await stick.nodes["2222222222222222"].set_relay_init(False) assert not await self.test_init_relay_state_off - assert not stick.nodes["2222222222222222"].relay_init + assert not stick.nodes["2222222222222222"].relay_config.init_state # Test async switching back from off to on self.test_init_relay_state_on = asyncio.Future() - assert await stick.nodes["2222222222222222"].configure_relay_init_state(True) + assert await stick.nodes["2222222222222222"].set_relay_init(True) assert await self.test_init_relay_state_on - assert stick.nodes["2222222222222222"].relay_init + assert stick.nodes["2222222222222222"].relay_config.init_state unsub_inti_relay() @@ -1738,6 +1761,321 @@ async def test_node_cache(self, monkeypatch: pytest.MonkeyPatch) -> None: ] ) + @pytest.mark.asyncio + async def test_base_node(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Testing properties of base node.""" + + mock_stick_controller = MockStickController() + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Load callback for event.""" + + test_node = pw_sed.PlugwiseBaseNode( + "1298347650AFBECD", 1, mock_stick_controller, load_callback + ) + + # Validate base node properties which are always set + assert not test_node.is_battery_powered + + # 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 + + with pytest.raises(pw_exceptions.NodeError): + assert test_node.battery_config is not None + + with pytest.raises(pw_exceptions.NodeError): + assert await test_node.set_clock_interval(5) is not None + + with pytest.raises(pw_exceptions.NodeError): + assert await test_node.set_clock_sync(False) is not None + + with pytest.raises(pw_exceptions.NodeError): + assert await test_node.set_sleep_duration(5) is not None + + with pytest.raises(pw_exceptions.NodeError): + assert await test_node.set_motion_daylight_mode(True) is not None + + with pytest.raises(pw_exceptions.NodeError): + assert ( + await test_node.set_motion_sensitivity_level( + pw_api.MotionSensitivity.HIGH + ) + is not None + ) + + with pytest.raises(pw_exceptions.NodeError): + assert await test_node.set_motion_reset_timer(5) is not None + + # Validate to raise NotImplementedError calling load() at basenode + with pytest.raises(NotImplementedError): + await test_node.load() + # Mark test node as loaded + test_node._loaded = True # pylint: disable=protected-access + + # 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 + + with pytest.raises(pw_exceptions.FeatureError): + assert test_node.battery_config is not None + + with pytest.raises(pw_exceptions.FeatureError): + assert await test_node.set_clock_interval(5) is not None + + with pytest.raises(pw_exceptions.FeatureError): + assert await test_node.set_clock_sync(False) is not None + + with pytest.raises(pw_exceptions.FeatureError): + assert await test_node.set_sleep_duration(5) is not None + + with pytest.raises(pw_exceptions.FeatureError): + assert await test_node.set_motion_daylight_mode(True) is not None + + with pytest.raises(pw_exceptions.FeatureError): + assert ( + await test_node.set_motion_sensitivity_level( + pw_api.MotionSensitivity.HIGH + ) + is not None + ) + + with pytest.raises(pw_exceptions.FeatureError): + assert await test_node.set_motion_reset_timer(5) is not None + + # 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 + + with pytest.raises(NotImplementedError): + assert test_node.battery_config is not None + + with pytest.raises(NotImplementedError): + assert await test_node.set_clock_interval(5) is not None + + with pytest.raises(NotImplementedError): + assert await test_node.set_clock_sync(False) is not None + + with pytest.raises(NotImplementedError): + assert await test_node.set_sleep_duration(5) is not None + + 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 + with pytest.raises(NotImplementedError): + assert ( + await test_node.set_motion_sensitivity_level( + pw_api.MotionSensitivity.HIGH + ) + is not None + ) + with pytest.raises(NotImplementedError): + assert await test_node.set_motion_reset_timer(5) is not None + + assert not test_node.cache_enabled + assert test_node.mac == "1298347650AFBECD" + + @pytest.mark.asyncio + async def test_sed_node(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Testing properties of SED.""" + + def fake_cache(dummy: object, setting: str) -> str | None: + """Fake cache retrieval.""" + if setting == pw_node.CACHE_FIRMWARE: + 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: + return "20" + if setting == pw_sed.CACHE_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" + return None + + monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) + mock_stick_controller = MockStickController() + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Load callback for event.""" + + test_sed = pw_sed.NodeSED( + "1298347650AFBECD", 1, 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 + assert await test_sed.load() + assert sorted(test_sed.features) == sorted( + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.BATTERY, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.PING, + ) + ) + + sed_config_accepted = pw_responses.NodeResponse() + sed_config_accepted.deserialize( + construct_message(b"000000F65555555555555555", b"0000") + ) + sed_config_failed = pw_responses.NodeResponse() + sed_config_failed.deserialize( + construct_message(b"000000F75555555555555555", b"0000") + ) + + # test awake duration + assert test_sed.awake_duration == 10 + assert test_sed.battery_config.awake_duration == 10 + with pytest.raises(ValueError): + assert await test_sed.set_awake_duration(0) + with pytest.raises(ValueError): + assert await test_sed.set_awake_duration(256) + assert not await test_sed.set_awake_duration(10) + assert not test_sed.sed_config_task_scheduled + assert await test_sed.set_awake_duration(15) + assert test_sed.sed_config_task_scheduled + assert test_sed.battery_config.awake_duration == 15 + assert test_sed.awake_duration == 15 + + # Restore to original settings after failed config + awake_response1 = pw_responses.NodeAwakeResponse() + awake_response1.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + 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 + + # Successful config + awake_response2 = pw_responses.NodeAwakeResponse() + awake_response2.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response2.timestamp = awake_response1.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + assert await test_sed.set_awake_duration(15) + assert test_sed.sed_config_task_scheduled + 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 test_sed.battery_config.awake_duration == 15 + assert test_sed.awake_duration == 15 + + # test maintenance interval + 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) + with pytest.raises(ValueError): + assert await test_sed.set_maintenance_interval(65536) + assert not await test_sed.set_maintenance_interval(60) + assert await test_sed.set_maintenance_interval(30) + assert test_sed.sed_config_task_scheduled + awake_response3 = pw_responses.NodeAwakeResponse() + awake_response3.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response3.timestamp = awake_response2.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 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 + with pytest.raises(ValueError): + assert 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 + awake_response4 = pw_responses.NodeAwakeResponse() + awake_response4.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response4.timestamp = awake_response3.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 + + # 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 + awake_response5 = pw_responses.NodeAwakeResponse() + awake_response5.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response5.timestamp = awake_response4.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 test_sed.battery_config.clock_sync + assert test_sed.clock_sync + + # test sleep duration + 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) + with pytest.raises(ValueError): + assert 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 + awake_response6 = pw_responses.NodeAwakeResponse() + awake_response6.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response6.timestamp = awake_response5.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 test_sed.battery_config.sleep_duration == 120 + assert test_sed.sleep_duration == 120 + @pytest.mark.asyncio async def test_scan_node(self, monkeypatch: pytest.MonkeyPatch) -> None: """Testing properties of scan.""" @@ -1777,6 +2115,16 @@ def fake_cache(dummy: object, setting: str) -> str | None: monkeypatch.setattr(pw_node.PlugwiseBaseNode, "_get_cache", fake_cache) mock_stick_controller = MockStickController() + scan_config_accepted = pw_responses.NodeAckResponse() + scan_config_accepted.deserialize( + construct_message(b"0100555555555555555500BE", b"0000") + ) + scan_config_failed = pw_responses.NodeAckResponse() + scan_config_failed.deserialize( + construct_message(b"0100555555555555555500BF", b"0000") + ) + + async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] """Load callback for event.""" @@ -1784,16 +2132,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign "1298347650AFBECD", 1, mock_stick_controller, load_callback ) assert not test_scan.cache_enabled - with pytest.raises(pw_exceptions.NodeError): - battery_config = test_scan.battery_config - assert sorted(test_scan.features) == sorted( - ( - pw_api.NodeFeature.AVAILABLE, - pw_api.NodeFeature.INFO, - pw_api.NodeFeature.PING, - ) - ) await test_scan.update_node_details( firmware=dt(2011, 6, 27, 8, 55, 44, tzinfo=UTC), hardware="080007", @@ -1804,24 +2143,97 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) assert await test_scan.load() - # test default SED config - assert test_scan.sed_awake_duration == 10 - assert test_scan.sed_clock_interval == 25200 - assert test_scan.sed_clock_sync is False - assert test_scan.sed_maintenance_interval == 600 - assert test_scan.sed_sleep_duration == 60 - assert test_scan.battery_config.awake_duration == 10 - assert test_scan.battery_config.clock_interval == 25200 - assert test_scan.battery_config.clock_sync is False - assert test_scan.battery_config.maintenance_interval == 600 - assert test_scan.battery_config.sleep_duration == 60 - - # Scan specific defaults - assert test_scan.daylight_mode is False - assert test_scan.motion_reset_timer == 10 + # test motion reset timer + 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) + with pytest.raises(ValueError): + assert await test_scan.set_motion_reset_timer(256) + assert not await test_scan.set_motion_reset_timer(10) + assert not test_scan.scan_config_task_scheduled + assert await test_scan.set_motion_reset_timer(15) + assert test_scan.scan_config_task_scheduled + assert test_scan.reset_timer == 15 + assert test_scan.motion_config.reset_timer == 15 + + # Restore to original settings after failed config + awake_response1 = pw_responses.NodeAwakeResponse() + awake_response1.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + 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 + + # Successful config + awake_response2 = pw_responses.NodeAwakeResponse() + awake_response2.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response2.timestamp = awake_response1.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + mock_stick_controller.send_response = scan_config_accepted + assert await test_scan.set_motion_reset_timer(25) + assert test_scan.scan_config_task_scheduled + 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 test_scan.reset_timer == 25 + assert test_scan.motion_config.reset_timer == 25 + + # test motion daylight mode + 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 await test_scan.set_motion_daylight_mode(True) + assert test_scan.scan_config_task_scheduled + awake_response3 = pw_responses.NodeAwakeResponse() + awake_response3.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response3.timestamp = awake_response2.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 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 + awake_response4 = pw_responses.NodeAwakeResponse() + awake_response4.deserialize( + construct_message(b"004F555555555555555500", b"FFFE") + ) + awake_response4.timestamp = awake_response3.timestamp + td( + seconds=pw_sed.AWAKE_RETRY + ) + 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 + ) # scan with cache enabled + mock_stick_controller.send_response = None test_scan = pw_scan.PlugwiseScan( "1298347650AFBECD", 1, mock_stick_controller, load_callback ) @@ -1841,20 +2253,10 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign pw_api.NodeFeature.BATTERY, pw_api.NodeFeature.INFO, pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.MOTION_CONFIG, pw_api.NodeFeature.PING, ) ) - # Validate if we get values from (faked) cache - assert test_scan.sed_awake_duration == 20 - assert test_scan.sed_clock_interval == 12600 - assert test_scan.sed_clock_sync - assert test_scan.sed_maintenance_interval == 43200 - assert test_scan.sed_sleep_duration == 120 - assert test_scan.battery_config.awake_duration == 20 - assert test_scan.battery_config.clock_interval == 12600 - assert test_scan.battery_config.clock_sync - assert test_scan.battery_config.maintenance_interval == 43200 - assert test_scan.battery_config.sleep_duration == 120 state = await test_scan.get_state( ( @@ -1862,14 +2264,10 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign pw_api.NodeFeature.BATTERY, pw_api.NodeFeature.INFO, pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.MOTION_CONFIG, ) ) - assert not state[pw_api.NodeFeature.AVAILABLE] - assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 43200 - assert state[pw_api.NodeFeature.BATTERY].awake_duration == 20 - assert state[pw_api.NodeFeature.BATTERY].clock_sync - assert state[pw_api.NodeFeature.BATTERY].clock_interval == 12600 - assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 120 + assert not state[pw_api.NodeFeature.AVAILABLE].state @pytest.mark.asyncio async def test_switch_node(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -1907,8 +2305,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign "1298347650AFBECD", 1, mock_stick_controller, load_callback ) assert not test_switch.cache_enabled - with pytest.raises(pw_exceptions.NodeError): - battery_config = test_switch.battery_config assert sorted(test_switch.features) == sorted( ( @@ -1927,18 +2323,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) assert await test_switch.load() - # test default SED config - assert test_switch.sed_awake_duration == 10 - assert test_switch.sed_clock_interval == 25200 - assert test_switch.sed_clock_sync is False - assert test_switch.sed_maintenance_interval == 600 - assert test_switch.sed_sleep_duration == 60 - assert test_switch.battery_config.awake_duration == 10 - assert test_switch.battery_config.clock_interval == 25200 - assert test_switch.battery_config.clock_sync is False - assert test_switch.battery_config.maintenance_interval == 600 - assert test_switch.battery_config.sleep_duration == 60 - # Switch specific defaults assert test_switch.switch is False @@ -1966,17 +2350,6 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign pw_api.NodeFeature.SWITCH, ) ) - # Validate if we get values from (faked) cache - assert test_switch.sed_awake_duration == 15 - assert test_switch.sed_clock_interval == 14600 - assert test_switch.sed_clock_sync is False - assert test_switch.sed_maintenance_interval == 900 - assert test_switch.sed_sleep_duration == 180 - assert test_switch.battery_config.awake_duration == 15 - assert test_switch.battery_config.clock_interval == 14600 - assert test_switch.battery_config.clock_sync is False - assert test_switch.battery_config.maintenance_interval == 900 - assert test_switch.battery_config.sleep_duration == 180 state = await test_switch.get_state( ( @@ -1986,12 +2359,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign pw_api.NodeFeature.SWITCH, ) ) - assert not state[pw_api.NodeFeature.AVAILABLE] - assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 900 - assert state[pw_api.NodeFeature.BATTERY].awake_duration == 15 - assert state[pw_api.NodeFeature.BATTERY].clock_sync is False - assert state[pw_api.NodeFeature.BATTERY].clock_interval == 14600 - assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 180 + assert not state[pw_api.NodeFeature.AVAILABLE].state @pytest.mark.asyncio async def test_node_discovery_and_load( @@ -2049,9 +2417,13 @@ async def test_node_discovery_and_load( # Get state get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["0098765432101234"].get_state( - (pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) + (pw_api.NodeFeature.AVAILABLE, pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) ) + # Check Available + assert state[pw_api.NodeFeature.AVAILABLE].state + assert state[pw_api.NodeFeature.AVAILABLE].last_seen.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + # Check Ping assert state[pw_api.NodeFeature.PING].rssi_in == 69 assert state[pw_api.NodeFeature.PING].rssi_out == 70 @@ -2101,7 +2473,7 @@ async def test_node_discovery_and_load( ) assert state[pw_api.NodeFeature.INFO].version == "000000730007" - assert state[pw_api.NodeFeature.RELAY].relay_state + assert state[pw_api.NodeFeature.RELAY].state # Check 1111111111111111 get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) @@ -2130,7 +2502,8 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.POWER, ) ) - assert state[pw_api.NodeFeature.RELAY].relay_state + assert state[pw_api.NodeFeature.AVAILABLE].state + assert state[pw_api.NodeFeature.RELAY].state # region Scan self.test_node_awake = asyncio.Future() @@ -2157,6 +2530,7 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.INFO, pw_api.NodeFeature.PING, pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.MOTION_CONFIG, ) ) state = await stick.nodes["5555555555555555"].get_state( @@ -2165,10 +2539,11 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.BATTERY, pw_api.NodeFeature.INFO, pw_api.NodeFeature.MOTION, + pw_api.NodeFeature.MOTION_CONFIG, ) ) - assert state[pw_api.NodeFeature.AVAILABLE] - assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 600 + assert state[pw_api.NodeFeature.AVAILABLE].state + assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 60 assert state[pw_api.NodeFeature.BATTERY].awake_duration == 10 assert not state[pw_api.NodeFeature.BATTERY].clock_sync assert state[pw_api.NodeFeature.BATTERY].clock_interval == 25200 @@ -2196,18 +2571,19 @@ async def test_node_discovery_and_load( # endregion # region Switch - self.test_node_awake = asyncio.Future() - unsub_awake = stick.subscribe_to_node_events( - node_event_callback=self.node_awake, - events=(pw_api.NodeEvent.AWAKE,), + 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_awake - unsub_awake() + assert await self.test_node_loaded + unsub_loaded() + assert stick.nodes["8888888888888888"].node_info.firmware == dt( 2011, 6, 27, 9, 4, 10, tzinfo=UTC ) - assert stick.nodes["8888888888888888"].node_info.version == "000000070051" + assert stick.nodes["8888888888888888"].node_info.version == "000007005100" assert stick.nodes["8888888888888888"].node_info.model == "Switch" assert stick.nodes["8888888888888888"].node_info.model_type == "" assert stick.nodes["8888888888888888"].available From 3efea4008cc4bfd9b93fa7b8e9069dc4014b12da Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:03:08 +0100 Subject: [PATCH 441/979] Update api.py --- plugwise_usb/api.py | 440 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 350 insertions(+), 90 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 0774ab6dc..3aa54ab96 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -46,6 +46,7 @@ class NodeFeature(str, Enum): HUMIDITY = "humidity" INFO = "info" MOTION = "motion" + MOTION_CONFIG = "motion_config" PING = "ping" POWER = "power" RELAY = "relay" @@ -73,33 +74,52 @@ class NodeType(Enum): PUSHING_FEATURES = ( + NodeFeature.AVAILABLE, + NodeFeature.BATTERY, NodeFeature.HUMIDITY, NodeFeature.MOTION, + NodeFeature.MOTION_CONFIG, NodeFeature.TEMPERATURE, NodeFeature.SWITCH, ) -@dataclass -class BatteryConfig: - """Battery related configuration settings.""" +@dataclass(frozen=True) +class AvailableState: + """Availability of node. - # Duration in minutes the node synchronize its clock - clock_interval: int | None = None + Description: Availability of node on Zigbee network. - # Enable/disable clock sync - clock_sync: bool | None = None + Attributes: + state: bool: Indicate if node is operational (True) or off-line (False). Battery powered nodes which are in sleeping mode report to be operational. + last_seen: datetime: Last time a messages was received from the Node. - # Minimal interval in minutes the node will wake up - # and able to receive (maintenance) commands - maintenance_interval: int | None = None + """ + + state: bool + last_seen: datetime + + +@dataclass(frozen=True) +class BatteryConfig: + """Battery related configuration settings. - # Duration in seconds the SED will be awake for receiving commands - stay_active: int | None = None + Description: Configuration settings for battery powered devices. - # Duration in minutes the SED will be in sleeping mode - # and not able to respond any command - sleep_for: int | None = None + Attributes: + awake_duration: int | None: Duration in seconds a battery powered devices is awake to accept (configuration) messages. + clock_interval: int | None: Interval in minutes a battery powered devices is synchronizing its clock. + 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. + + """ + + awake_duration: int | None = None + clock_interval: int | None = None + clock_sync: bool | None = None + maintenance_interval: int | None = None + sleep_duration: int | None = None @dataclass @@ -138,22 +158,53 @@ class PowerStatistics: timestamp: datetime | None = None -@dataclass +@dataclass(frozen=True) +class RelayConfig: + """Configuration of relay. + + Description: Configuration settings for relay. + + Attributes: + init_state: bool | None: Configured state at which the relay must be at initial power-up of device. + + """ + + init_state: bool | None = None + + +@dataclass(frozen=True) class RelayState: """Status of relay.""" - relay_state: bool | None = None + state: bool | None = None timestamp: datetime | None = None -@dataclass +@dataclass(frozen=True) class MotionState: """Status of motion sensor.""" - motion: bool | None = None + state: bool | None = None timestamp: datetime | None = None - reset_timer: int | None = None + + +@dataclass(frozen=True) +class MotionConfig: + """Configuration of motion sensor. + + Description: Configuration settings for motion detection. + When value is scheduled to be changed the returned value is the optimistic value + + 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. + + """ + daylight_mode: bool | None = None + reset_timer: int | None = None + sensitivity_level: MotionSensitivity | None = None @dataclass @@ -187,22 +238,18 @@ def __init__( ) -> None: """Initialize plugwise node object.""" - # region Generic node details + # region Generic node properties @property def features(self) -> tuple[NodeFeature, ...]: """Supported feature types of node.""" @property def is_battery_powered(self) -> bool: - """Indicate if node is power by battery.""" + """Indicate if node is powered by battery.""" @property def is_loaded(self) -> bool: - """Indicate if node is loaded.""" - - @property - def last_update(self) -> datetime: - """Timestamp of last update.""" + """Indicate if node is loaded and available to interact.""" @property def name(self) -> str: @@ -210,32 +257,28 @@ def name(self) -> str: @property def node_info(self) -> NodeInfo: - """Node information.""" + """Return NodeInfo class with all node information.""" + # endregion async def load(self) -> bool: """Load configuration and activate node features.""" - async def update_node_details( - self, - firmware: datetime | None, - hardware: str | None, - node_type: NodeType | None, - timestamp: datetime | None, - relay_state: bool | None, - logaddress_pointer: int | None, - ) -> bool: - """Update node information.""" - async def unload(self) -> None: - """Load configuration and activate node features.""" - - # endregion + """Unload and deactivate node.""" - # region Network + # region Network properties @property def available(self) -> bool: """Last known network availability state.""" + @property + def available_state(self) -> AvailableState: + """Network availability state.""" + + @property + def last_seen(self) -> datetime: + """Timestamp of last network activity.""" + @property def mac(self) -> str: """Zigbee mac address.""" @@ -246,15 +289,12 @@ def network_address(self) -> int: @property def ping_stats(self) -> NetworkStatistics: - """Ping statistics.""" + """Ping statistics for node.""" - async def is_online(self) -> bool: - """Check network status.""" + # endregion - def update_ping_stats( - self, timestamp: datetime, rssi_in: int, rssi_out: int, rtt: int - ) -> None: - """Update ping statistics.""" + async def is_online(self) -> bool: + """Check network status of node.""" # TODO: Move to node with subscription to stick event async def reconnect(self) -> None: @@ -264,10 +304,7 @@ async def reconnect(self) -> None: async def disconnect(self) -> None: """Disconnect from Plugwise Zigbee network.""" - # endregion - - # region cache - + # region Cache settings @property def cache_folder(self) -> str: """Path to cache folder.""" @@ -302,16 +339,16 @@ async def save_cache( # endregion - # region sensors + # region Sensors @property - def energy(self) -> EnergyStatistics | None: + def energy(self) -> EnergyStatistics: """Energy statistics. Raises NodeError when energy feature is not present at device. """ @property - def humidity(self) -> float | None: + def humidity(self) -> float: """Last received humidity state. Raises NodeError when humidity feature is not present at device. @@ -353,19 +390,22 @@ def relay_state(self) -> RelayState: """ @property - def switch(self) -> bool | None: + def switch(self) -> bool: """Current state of the switch. Raises NodeError when switch feature is not present at device. """ @property - def temperature(self) -> float | None: + def temperature(self) -> float: """Last received temperature state. Raises NodeError when temperature feature is not present at device. """ + # endregion + + # region control async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Request an updated state for given feature. @@ -374,60 +414,280 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any # endregion - # region control & configure + # region Actions to execute + async def set_relay(self, state: bool) -> bool: + """Change the state of the relay. + + Description: + Configures the state of the relay. + + Args: + state: Boolean indicating the required state of the relay (True = ON, False = OFF) + + Returns: + Boolean: with the newly set state of the relay + + Raises: + FeatureError: When the relay feature is not present at device. + NodeError: When the node is not yet loaded or setting the state failed. + + """ + + # endregion + + # region configuration properties + @property def battery_config(self) -> BatteryConfig: """Battery configuration settings. - Raises NodeError when battery configuration feature is not present at device. + Returns: + BatteryConfig: Currently configured battery settings. + When settings are scheduled to be changed it will return the new settings. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + """ @property - def relay_init(self) -> bool | None: - """Configured state at which the relay must be at initial power-up of device. + def motion_config(self) -> MotionConfig: + """Motion configuration settings. + + Returns: + MotionConfig: with the current motion configuration settings. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. - Raises NodeError when relay configuration feature is not present at device. """ - async def switch_relay(self, state: bool) -> bool | None: - """Change the state of the relay and return the new state of relay. + @property + def relay_config(self) -> RelayConfig: + """Relay configuration settings. + + Returns: + RelayConfig: Current relay configuration settings. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + + """ + + # endregion + + # region Configuration actions + async def set_awake_duration(self, seconds: int) -> bool: + """Change the awake duration. + + Description: + Configure the duration for a battery powered device (Sleeping Endpoint Device) to be awake. + The configuration will be set the next time the device is awake for maintenance purposes. + + Use the 'is_battery_powered' property to determine if the device is battery powered. + + Args: + seconds: Number of seconds between each time the device must wake-up for maintenance purposes + Minimum value: 1 + Maximum value: 255 + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + ValueError: When the seconds value is out of range. - Raises NodeError when relay feature is not present at device. """ - async def switch_relay_init_off(self, state: bool) -> bool | None: - """Change the state of initial (power-up) state of the relay and return the new configured setting. + async def set_clock_interval(self, minutes: int) -> bool: + """Change the clock interval. + + Description: + Configure the duration for a battery powered device (Sleeping Endpoint Device) to synchronize the internal clock. + Use the 'is_battery_powered' property to determine if the device is battery powered. + + Args: + minutes: Number of minutes between each time the device must synchronize the clock + Minimum value: 1 + Maximum value: 65535 + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + ValueError: When the minutes value is out of range. - Raises NodeError when the initial (power-up) relay configure feature is not present at device. """ - @property - def energy_consumption_interval(self) -> int | None: ... # noqa: D102 + async def set_clock_sync(self, sync: bool) -> bool: + """Change the clock synchronization setting. - @property - def energy_production_interval(self) -> int | None: ... # noqa: D102 + Description: + Configure the duration for a battery powered device (Sleeping Endpoint Device) to synchronize the internal clock. + Use the 'is_battery_powered' property to determine if the device is battery powered. - @property - def maintenance_interval(self) -> int | None: ... # noqa: D102 + Args: + sync: Boolean indicating the internal clock must be synced (True = sync enabled, False = sync disabled) - @property - def motion_reset_timer(self) -> int: ... # noqa: D102 + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. - @property - def daylight_mode(self) -> bool: ... # noqa: D102 + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. - @property - def sensitivity_level(self) -> MotionSensitivity: ... # noqa: D102 + """ - async def configure_motion_reset(self, delay: int) -> bool: ... # noqa: D102 + async def set_maintenance_interval(self, minutes: int) -> bool: + """Change the maintenance interval. - async def scan_calibrate_light(self) -> bool: ... # noqa: D102 + Description: + Configure the maintenance interval for a battery powered device (Sleeping Endpoint Device). + The configuration will be set the next time the device is awake for maintenance purposes. + + Use the 'is_battery_powered' property to determine if the device is battery powered. + + Args: + minutes: Number of minutes between each time the device must wake-up for maintenance purposes + Minimum value: 1 + Maximum value: 1440 + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + ValueError: When the seconds value is out of range. + + """ + + async def set_motion_daylight_mode(self, state: bool) -> bool: + """Configure motion daylight mode. + + Description: + Configure if motion must be detected when light level is below threshold. + + Args: + state: Boolean indicating the required state (True = ON, False = OFF) + + Returns: + Boolean: with the newly configured state of the daylight mode + + Raises: + FeatureError: When the daylight mode feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + + """ + + async def set_motion_reset_timer(self, minutes: int) -> bool: + """Configure the motion reset timer in minutes. + + Description: + Configure the duration in minutes a Scan device must not detect motion before reporting no motion. + The configuration will be set the next time the device is awake for maintenance purposes. + + Use the 'is_battery_powered' property to determine if the device is battery powered. + + Args: + minutes: Number of minutes before the motion detection is switched off + Minimum value: 1 + Maximum value: 255 + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + ValueError: When the seconds value is out of range. + + """ + + async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool: + """Configure motion sensitivity level. + + Description: + Configure the sensitivity level of motion detection. + + Args: + level: MotionSensitivity indicating the required sensitivity level + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When the motion sensitivity feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + + """ + + async def set_relay_init(self, state: bool) -> bool: + """Change the initial state of the relay. + + Description: + Configures the state of the relay to be directly after power-up of the device. + + Args: + state: Boolean indicating the required state of the relay (True = ON, False = OFF) + + Returns: + Boolean: with the newly configured state of the relay + + Raises: + FeatureError: When the initial (power-up) relay configure feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + + """ + + async def set_sleep_duration(self, minutes: int) -> bool: + """Change the sleep duration. + + Description: + Configure the duration for a battery powered device (Sleeping Endpoint Device) to sleep. + Use the 'is_battery_powered' property to determine if the device is battery powered. + + Args: + minutes: Number of minutes to sleep + Minimum value: 1 + Maximum value: 65535 + + Returns: + Boolean: True when the configuration is successfully scheduled to be changed. False when + the configuration is already set. + + Raises: + FeatureError: When this configuration feature is not present at device. + NodeError: When the node is not yet loaded or configuration failed. + ValueError: When the minutes value is out of range. + + """ + + # endregion + + # region Helper functions + async def message_for_node(self, message: Any) -> None: + """Process message for node. + + Description: Submit a plugwise message for this node. + + Args: + message: Plugwise message to process. + + """ - async def scan_configure( # noqa: D102 - self, - motion_reset_timer: int, - sensitivity_level: MotionSensitivity, - daylight_mode: bool, - ) -> bool: ... # endregion From 80b238d727b324601f778e94dab82e525751180c Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:03:42 +0100 Subject: [PATCH 442/979] Improve error messages --- plugwise_usb/connection/__init__.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 74c2b63d3..2524bbfc7 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -50,8 +50,7 @@ def mac_stick(self) -> str: """MAC address of USB-Stick. Raises StickError when not connected.""" if not self._manager.is_connected or self._mac_stick is None: raise StickError( - "No mac address available. " + - "Connect and initialize USB-Stick first." + "No mac address available. Connect and initialize USB-Stick first." ) return self._mac_stick @@ -72,8 +71,7 @@ def network_id(self) -> int: """Returns the Zigbee network ID. Raises StickError when not connected.""" if not self._manager.is_connected or self._network_id is None: raise StickError( - "No network ID available. " + - "Connect and initialize USB-Stick first." + "No network ID available. Connect and initialize USB-Stick first." ) return self._network_id @@ -82,8 +80,7 @@ def network_online(self) -> bool: """Return the network state.""" if not self._manager.is_connected: raise StickError( - "Network status not available. " + - "Connect and initialize USB-Stick first." + "Network status not available. Connect and initialize USB-Stick first." ) return self._network_online @@ -156,15 +153,15 @@ async def initialize_stick(self) -> None: init_response: StickInitResponse | None = await request.send() except StickError as err: raise StickError( - "No response from USB-Stick to initialization request." + - " Validate USB-stick is connected to port " + - f"' {self._manager.serial_path}'" + "No response from USB-Stick to initialization request." + + " Validate USB-stick is connected to port " + + f"' {self._manager.serial_path}'" ) from err if init_response is None: raise StickError( - "No response from USB-Stick to initialization request." + - " Validate USB-stick is connected to port " + - f"' {self._manager.serial_path}'" + "No response from USB-Stick to initialization request." + + " Validate USB-stick is connected to port " + + f"' {self._manager.serial_path}'" ) self._mac_stick = init_response.mac_decoded self._network_online = init_response.network_online @@ -175,9 +172,7 @@ async def initialize_stick(self) -> None: self._is_initialized = True if not self._network_online: - raise StickError( - "Zigbee network connection to Circle+ is down." - ) + raise StickError("Zigbee network connection to Circle+ is down.") async def send( self, request: PlugwiseRequest, suppress_node_errors: bool = True From 05c443510fffad4929f3d8e8c3b8b389e4f449e2 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:03:58 +0100 Subject: [PATCH 443/979] Apply formatting --- plugwise_usb/connection/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 2524bbfc7..9a2cf7fe0 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -24,9 +24,7 @@ def __init__(self) -> None: self._manager = StickConnectionManager() self._queue = StickQueue() self._unsubscribe_stick_event: Callable[[], None] | None = None - self._init_sequence_id: bytes | None = None - self._is_initialized = False self._mac_stick: str | None = None self._mac_nc: str | None = None @@ -90,11 +88,9 @@ async def connect_to_stick(self, serial_path: str) -> None: raise StickError("Already connected") await self._manager.setup_connection_to_stick(serial_path) if self._unsubscribe_stick_event is None: - self._unsubscribe_stick_event = ( - self._manager.subscribe_to_stick_events( - self._handle_stick_event, - (StickEvent.CONNECTED, StickEvent.DISCONNECTED), - ) + self._unsubscribe_stick_event = self._manager.subscribe_to_stick_events( + self._handle_stick_event, + (StickEvent.CONNECTED, StickEvent.DISCONNECTED), ) self._queue.start(self._manager) From cb7e16b96d18fea784600356461c819450df5f07 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:04:19 +0100 Subject: [PATCH 444/979] Start sender --- plugwise_usb/connection/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index bd8787be2..7dea480be 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -151,6 +151,7 @@ async def setup_connection_to_stick(self, serial_path: str) -> None: if self._receiver is None: raise StickError("Protocol is not loaded") self._sender = StickSender(self._receiver, self._serial_transport) + await self._sender.start() await connected_future if connected_future.result(): await self._handle_stick_event(StickEvent.CONNECTED) From 2a933b08fe19a259fbf7ba39cc48819753a6d476 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:04:49 +0100 Subject: [PATCH 445/979] Add sleep to allow other tasks --- plugwise_usb/connection/queue.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 43de49c74..502a46caa 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,7 +1,7 @@ """Manage the communication sessions towards the USB-Stick.""" from __future__ import annotations -from asyncio import PriorityQueue, Task, get_running_loop +from asyncio import PriorityQueue, Task, get_running_loop, sleep from collections.abc import Callable from dataclasses import dataclass import logging @@ -97,7 +97,7 @@ async def submit( if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level _LOGGER.debug("%s, cancel because timeout is expected for NodePingRequests", e) - if request.resend: + elif request.resend: _LOGGER.info("%s, retrying", e) else: _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] @@ -142,5 +142,6 @@ async def _send_queue_worker(self) -> None: return await self._stick.write_to_stick(request) self._submit_queue.task_done() + await sleep(0.001) _LOGGER.debug("Sent from queue %s", request) _LOGGER.debug("Send_queue_worker stopped") From 61dba808fb852cc5c97240b67f9b9b17478420d2 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:07:18 +0100 Subject: [PATCH 446/979] Create data receiver queue worker to do extracting in separate task --- plugwise_usb/connection/receiver.py | 221 ++++++++++++++++++---------- 1 file changed, 144 insertions(+), 77 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 88b8a6751..f0b0b8555 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -19,12 +19,14 @@ from asyncio import ( Future, + Lock, PriorityQueue, Protocol, + Queue, Task, - TimerHandle, gather, get_running_loop, + sleep, ) from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass @@ -64,7 +66,7 @@ class StickResponseSubscription: callback_fn: Callable[[StickResponse], Coroutine[Any, Any, None]] seq_id: bytes | None - stick_response_type: StickResponseType | None + stick_response_type: tuple[StickResponseType, ...] | None @dataclass @@ -89,14 +91,23 @@ def __init__( self._loop = get_running_loop() self._connected_future = connected_future self._transport: SerialTransport | None = None - self._buffer: bytes = bytes([]) self._connection_state = False - self._receive_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() + + # Data processing + self._buffer: bytes = bytes([]) + self._data_queue: Queue[bytes] = Queue() + self._data_worker_task: Task[None] | None = None + + # Message processing + self._message_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} - self._receive_worker_task: Task[None] | None = None - self._delayed_processing_tasks: dict[bytes, TimerHandle] = {} + self._message_worker_task: Task[None] | None = None + self._delayed_processing_tasks: dict[bytes, Task[None]] = {} + # Subscribers + self._stick_subscription_lock = Lock() + self._node_subscription_lock = Lock() self._stick_event_subscribers: dict[ Callable[[], None], StickEventSubscription ] = {} @@ -110,7 +121,7 @@ def __init__( def connection_lost(self, exc: Exception | None = None) -> None: """Call when port was closed expectedly or unexpectedly.""" - _LOGGER.info("Connection lost") + _LOGGER.warning("Connection lost") if exc is not None: _LOGGER.warning("Connection to Plugwise USB-stick lost %s", exc) self._loop.create_task(self.close()) @@ -150,14 +161,19 @@ async def _stop_running_tasks(self) -> None: for task in self._delayed_processing_tasks.values(): task.cancel() if ( - self._receive_worker_task is not None - and not self._receive_worker_task.done() + self._message_worker_task is not None + and not self._message_worker_task.done() ): cancel_response = StickResponse() cancel_response.priority = Priority.CANCEL - await self._receive_queue.put(cancel_response) - await self._receive_worker_task - self._receive_worker_task = None + await self._message_queue.put(cancel_response) + await self._message_worker_task + self._message_worker_task = None + if self._data_worker_task is not None and not self._data_worker_task.done(): + await self._data_queue.put(b"FFFFFFFF") + await self._data_worker_task + + # region Process incoming data def data_received(self, data: bytes) -> None: """Receive data from USB-Stick connection. @@ -167,74 +183,103 @@ def data_received(self, data: bytes) -> None: _LOGGER.debug("Received data from USB-Stick: %s", data) self._buffer += data if MESSAGE_FOOTER in self._buffer: - msgs = self._buffer.split(MESSAGE_FOOTER) - for msg in msgs[:-1]: - if (response := self.extract_message_from_line_buffer(msg)) is not None: - self._put_message_in_receiver_queue(response) - if len(msgs) > 4: - _LOGGER.debug("Reading %d messages at once from USB-Stick", len(msgs)) - self._buffer = msgs[-1] # whatever was left over - if self._buffer == b"\x83": - self._buffer = b"" - - def _put_message_in_receiver_queue(self, response: PlugwiseResponse) -> None: - """Put message in queue.""" - _LOGGER.debug("Add response to queue: %s", response) - self._receive_queue.put_nowait(response) - if self._receive_worker_task is None or self._receive_worker_task.done(): - self._receive_worker_task = self._loop.create_task( - self._receive_queue_worker(), name="Receive queue worker" + data_of_messages = self._buffer.split(MESSAGE_FOOTER) + for msg_data in data_of_messages[:-1]: + # Ignore ASCII messages without a header and footer like: + # # SENDING PING UNICAST: Macid: ???????????????? + # # HANDLE: 0x?? + # # APSRequestNodeInfo + if (header_index := msg_data.find(MESSAGE_HEADER)) != -1: + data = msg_data[header_index:] + self._put_data_in_queue(data) + if len(data_of_messages) > 4: + _LOGGER.debug( + "Reading %d messages at once from USB-Stick", len(data_of_messages) + ) + self._buffer = data_of_messages[-1] # whatever was left over + + def _put_data_in_queue(self, data: bytes) -> None: + """Put raw message data in queue to be converted to messages.""" + self._data_queue.put_nowait(data) + if self._data_worker_task is None or self._data_worker_task.done(): + self._data_worker_task = self._loop.create_task( + self._data_queue_worker(), name="Plugwise data receiver queue worker" ) - def extract_message_from_line_buffer(self, msg: bytes) -> PlugwiseResponse | None: + async def _data_queue_worker(self) -> None: + """Convert collected data into messages and place then im message queue.""" + _LOGGER.debug("Data queue worker started") + while self.is_connected: + if (data := await self._data_queue.get()) != b"FFFFFFFF": + if (response := self.extract_message_from_data(data)) is not None: + await self._put_message_in_queue(response) + self._data_queue.task_done() + else: + self._data_queue.task_done() + return + await sleep(0) + _LOGGER.debug("Data queue worker stopped") + + def extract_message_from_data(self, msg_data: bytes) -> PlugwiseResponse | None: """Extract message from buffer.""" - # Lookup header of message, there are stray \x83 - if (_header_index := msg.find(MESSAGE_HEADER)) == -1: - return None - _LOGGER.debug("Extract message from data: %s", msg) - msg = msg[_header_index:] - # Detect response message type - identifier = msg[4:8] - seq_id = msg[8:12] - msg_length = len(msg) - if (response := get_message_object(identifier, msg_length, seq_id)) is None: - _raw_msg_data = msg[2:][: msg_length - 4] - _LOGGER.warning("Drop unknown message type %s", str(_raw_msg_data)) + identifier = msg_data[4:8] + seq_id = msg_data[8:12] + msg_data_length = len(msg_data) + if ( + response := get_message_object(identifier, msg_data_length, seq_id) + ) is None: + _raw_msg_data_data = msg_data[2:][: msg_data_length - 4] + _LOGGER.warning("Drop unknown message type %s", str(_raw_msg_data_data)) return None # Populate response message object with data try: - response.deserialize(msg, has_footer=False) + response.deserialize(msg_data, has_footer=False) except MessageError as err: _LOGGER.warning(err) return None - _LOGGER.debug("Data %s converted into %s", msg, response) + _LOGGER.debug("Data %s converted into %s", msg_data, response) return response - async def _receive_queue_worker(self) -> None: - """Process queue items.""" - _LOGGER.debug("Receive_queue_worker started") + # endregion + + # region Process incoming messages + + async def _put_message_in_queue( + self, response: PlugwiseResponse, delay: float = 0.0 + ) -> None: + """Put message in queue to be processed.""" + if delay > 0: + await sleep(delay) + _LOGGER.debug("Add response to queue: %s", response) + await self._message_queue.put(response) + if self._message_worker_task is None or self._message_worker_task.done(): + self._message_worker_task = self._loop.create_task( + self._message_queue_worker(), + name="Plugwise message receiver queue worker", + ) + + async def _message_queue_worker(self) -> None: + """Process messages in receiver queue.""" + _LOGGER.debug("Message queue worker started") while self.is_connected: - response: PlugwiseResponse = await self._receive_queue.get() + response: PlugwiseResponse = await self._message_queue.get() if response.priority == Priority.CANCEL: - self._receive_queue.task_done() + self._message_queue.task_done() return - _LOGGER.debug("Process from receive queue: %s", response) + _LOGGER.debug("Message queue worker queue: %s", response) if isinstance(response, StickResponse): await self._notify_stick_response_subscribers(response) else: await self._notify_node_response_subscribers(response) - self._receive_queue.task_done() - _LOGGER.debug("Receive_queue_worker stopped") + self._message_queue.task_done() + await sleep(0.001) + _LOGGER.debug("Message queue worker stopped") - def _reset_buffer(self, new_buffer: bytes) -> None: - if new_buffer[:2] == MESSAGE_FOOTER: - new_buffer = new_buffer[2:] - if new_buffer == b"\x83": - # Skip additional byte sometimes appended after footer - new_buffer = bytes([]) - self._buffer = new_buffer + # endregion + + # region Stick def subscribe_to_stick_events( self, @@ -267,13 +312,14 @@ async def _notify_stick_event_subscribers( if len(callback_list) > 0: await gather(*callback_list) - def subscribe_to_stick_responses( + async def subscribe_to_stick_responses( self, callback: Callable[[StickResponse], Coroutine[Any, Any, None]], seq_id: bytes | None = None, - response_type: StickResponseType | None = None, + response_type: tuple[StickResponseType, ...] | None = None, ) -> Callable[[], None]: """Subscribe to response messages from stick.""" + await self._stick_subscription_lock.acquire() def remove_subscription() -> None: """Remove update listener.""" @@ -282,6 +328,7 @@ def remove_subscription() -> None: self._stick_response_subscribers[remove_subscription] = ( StickResponseSubscription(callback, seq_id, response_type) ) + self._stick_subscription_lock.release() return remove_subscription async def _notify_stick_response_subscribers( @@ -296,13 +343,16 @@ async def _notify_stick_response_subscribers( continue if ( subscription.stick_response_type is not None - and subscription.stick_response_type != stick_response.response_type + and stick_response.response_type not in subscription.stick_response_type ): continue _LOGGER.debug("Notify stick response subscriber for %s", stick_response) await subscription.callback_fn(stick_response) - def subscribe_to_node_responses( + # endregion + # region node + + async def subscribe_to_node_responses( self, node_response_callback: Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], mac: bytes | None = None, @@ -313,13 +363,27 @@ def subscribe_to_node_responses( Returns function to unsubscribe. """ + await self._node_subscription_lock.acquire() def remove_listener() -> None: """Remove update listener.""" + _LOGGER.debug( + "Node response subscriber removed: mac=%s, msg_idS=%s, seq_id=%s", + mac, + message_ids, + seq_id, + ) self._node_response_subscribers.pop(remove_listener) self._node_response_subscribers[remove_listener] = NodeResponseSubscription( - node_response_callback, + callback_fn=node_response_callback, + mac=mac, + response_ids=message_ids, + seq_id=seq_id, + ) + self._node_subscription_lock.release() + _LOGGER.debug( + "Node response subscriber added: mac=%s, msg_idS=%s, seq_id=%s", mac, message_ids, seq_id, @@ -338,6 +402,8 @@ async def _notify_node_response_subscribers( _LOGGER.debug("Drop previously processed duplicate %s", node_response) return + await self._node_subscription_lock.acquire() + notify_tasks: list[Coroutine[Any, Any, bool]] = [] for node_subscription in self._node_response_subscribers.values(): if ( @@ -357,17 +423,20 @@ async def _notify_node_response_subscribers( continue notify_tasks.append(node_subscription.callback_fn(node_response)) + self._node_subscription_lock.release() if len(notify_tasks) > 0: - _LOGGER.info("Received %s", node_response) + _LOGGER.debug("Received %s", node_response) if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) - if node_response.seq_id in self._delayed_processing_tasks: - del self._delayed_processing_tasks[node_response.seq_id] # Limit tracking to only the last appended request (FIFO) self._last_processed_messages = self._last_processed_messages[ -CACHED_REQUESTS: ] + # Cleanup pending task + if node_response.seq_id in self._delayed_processing_tasks: + del self._delayed_processing_tasks[node_response.seq_id] + # execute callbacks _LOGGER.debug( "Notify node response subscribers (%s) about %s", @@ -388,17 +457,15 @@ async def _notify_node_response_subscribers( if node_response.retries > 10: _LOGGER.warning( - "No subscriber to handle %s, seq_id=%s from %s after 10 retries", - node_response.__class__.__name__, - node_response.seq_id, - node_response.mac_decoded, + "No subscriber to handle %s after 10 retries", + node_response, ) return node_response.retries += 1 - if node_response.retries > 2: - _LOGGER.info("No subscription for %s, retry later", node_response) - self._delayed_processing_tasks[node_response.seq_id] = self._loop.call_later( - 0.1 * node_response.retries, - self._put_message_in_receiver_queue, - node_response, + self._delayed_processing_tasks[node_response.seq_id] = self._loop.create_task( + self._put_message_in_queue(node_response, 0.1 * node_response.retries), + name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) + + +# endregion From 14e96fdf5c2d6095af0177349ae6dd9c81b799ca Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:08:09 +0100 Subject: [PATCH 447/979] Improve handling stick responses --- plugwise_usb/connection/sender.py | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 6a02aa85c..5b03ac730 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -14,9 +14,11 @@ 1. when accept, return sequence id for response message of node """ + from __future__ import annotations from asyncio import Future, Lock, Transport, get_running_loop, timeout +from collections.abc import Callable import logging from ..constants import STICK_TIME_OUT @@ -31,9 +33,7 @@ class StickSender: """Send request messages though USB Stick transport connection.""" - def __init__( - self, stick_receiver: StickReceiver, transport: Transport - ) -> None: + def __init__(self, stick_receiver: StickReceiver, transport: Transport) -> None: """Initialize the Stick Sender class.""" self._loop = get_running_loop() self._receiver = stick_receiver @@ -41,12 +41,16 @@ def __init__( self._stick_response: Future[StickResponse] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None + self._unsubscribe_stick_response: Callable[[], None] | None = None + async def start(self) -> None: + """Start the sender.""" # Subscribe to ACCEPT stick responses, which contain the seq_id we need. # Other stick responses are not related to this request. self._unsubscribe_stick_response = ( - self._receiver.subscribe_to_stick_responses( - self._process_stick_response, None, StickResponseType.ACCEPT + await self._receiver.subscribe_to_stick_responses( + self._process_stick_response, None, (StickResponseType.ACCEPT,) + # self._process_stick_response, None, (StickResponseType.ACCEPT, StickResponseType.FAILED) ) ) @@ -61,10 +65,6 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: request.add_send_attempt() _LOGGER.info("Send %s", request) - request.subscribe_to_responses( - self._receiver.subscribe_to_stick_responses, - self._receiver.subscribe_to_node_responses, - ) # Write message to serial port buffer serialized_data = request.serialize() @@ -77,7 +77,11 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: async with timeout(STICK_TIME_OUT): response: StickResponse = await self._stick_response except TimeoutError: - _LOGGER.warning("USB-Stick did not respond within %s seconds after writing %s", STICK_TIME_OUT, request) + _LOGGER.warning( + "USB-Stick did not respond within %s seconds after writing %s", + STICK_TIME_OUT, + request, + ) request.assign_error( BaseException( StickError( @@ -89,25 +93,30 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: _LOGGER.warning("Exception for %s: %s", request, exc) request.assign_error(exc) else: + _LOGGER.debug( + "USB-Stick replied with %s to request %s", response, request + ) if response.response_type == StickResponseType.ACCEPT: request.seq_id = response.seq_id - _LOGGER.debug("USB-Stick accepted %s with seq_id=%s", request, response.seq_id) + await request.subscribe_to_response( + self._receiver.subscribe_to_stick_responses, + self._receiver.subscribe_to_node_responses, + ) elif response.response_type == StickResponseType.TIMEOUT: - _LOGGER.warning("USB-Stick responded with communication timeout for %s", request) + _LOGGER.warning( + "USB-Stick directly responded with communication timeout for %s", + request, + ) request.assign_error( BaseException( - StickError( - f"USB-Stick responded with timeout for {request}" - ) + StickError(f"USB-Stick responded with timeout for {request}") ) ) elif response.response_type == StickResponseType.FAILED: _LOGGER.warning("USB-Stick failed communication for %s", request) request.assign_error( BaseException( - StickError( - f"USB-Stick failed communication for {request}" - ) + StickError(f"USB-Stick failed communication for {request}") ) ) finally: @@ -124,4 +133,5 @@ async def _process_stick_response(self, response: StickResponse) -> None: def stop(self) -> None: """Stop sender.""" - self._unsubscribe_stick_response() + if self._unsubscribe_stick_response is not None: + self._unsubscribe_stick_response() From 6c585570766516a939354f01eb073eee082150e1 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:09:27 +0100 Subject: [PATCH 448/979] Improve registering for message replies --- plugwise_usb/messages/requests.py | 73 +++++++++++++++---------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 801846c1f..a18be707e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -83,7 +83,7 @@ def __init__( [ Callable[[StickResponse], Coroutine[Any, Any, None]], bytes | None, - StickResponseType | None, + tuple[StickResponseType, ...] | None, ], Callable[[], None], ] @@ -141,42 +141,17 @@ def seq_id(self, seq_id: bytes) -> None: f"Unable to set seq_id to {seq_id!r}. Already set to {self._seq_id!r}" ) self._seq_id = seq_id - # Subscribe to receive the response messages - if self._stick_subscription_fn is not None: - self._unsubscribe_stick_response = self._stick_subscription_fn( - self._process_stick_response, self._seq_id, None - ) - if self._node_subscription_fn is not None: - self._unsubscribe_node_response = self._node_subscription_fn( - self._process_node_response, - self._mac, - (self._reply_identifier,), - self._seq_id, - ) - - def _unsubscribe_from_stick(self) -> None: - """Unsubscribe from StickResponse messages.""" - if self._unsubscribe_stick_response is not None: - self._unsubscribe_stick_response() - self._unsubscribe_stick_response = None - def _unsubscribe_from_node(self) -> None: - """Unsubscribe from NodeResponse messages.""" - if self._unsubscribe_node_response is not None: - self._unsubscribe_node_response() - self._unsubscribe_node_response = None - - def subscribe_to_responses( + async def subscribe_to_response( self, stick_subscription_fn: Callable[ [ Callable[[StickResponse], Coroutine[Any, Any, None]], bytes | None, - StickResponseType | None, + tuple[StickResponseType, ...] | None, ], - Callable[[], None], - ] - | None, + Coroutine[Any, Any, Callable[[], None]], + ], node_subscription_fn: Callable[ [ Callable[[PlugwiseResponse], Coroutine[Any, Any, bool]], @@ -184,13 +159,35 @@ def subscribe_to_responses( tuple[bytes, ...] | None, bytes | None, ], - Callable[[], None], - ] - | None, + Coroutine[Any, Any, Callable[[], None]], + ], ) -> None: - """Register for response messages.""" - self._node_subscription_fn = node_subscription_fn - self._stick_subscription_fn = stick_subscription_fn + """Subscribe to receive the response messages.""" + if self._seq_id is None: + raise MessageError( + "Unable to subscribe to response because seq_id is not set" + ) + self._unsubscribe_stick_response = await stick_subscription_fn( + self._process_stick_response, self._seq_id, None + ) + self._unsubscribe_node_response = await node_subscription_fn( + self.process_node_response, + self._mac, + (self._reply_identifier,), + self._seq_id, + ) + + def _unsubscribe_from_stick(self) -> None: + """Unsubscribe from StickResponse messages.""" + if self._unsubscribe_stick_response is not None: + self._unsubscribe_stick_response() + self._unsubscribe_stick_response = None + + def _unsubscribe_from_node(self) -> None: + """Unsubscribe from NodeResponse messages.""" + if self._unsubscribe_node_response is not None: + self._unsubscribe_node_response() + self._unsubscribe_node_response = None def start_response_timeout(self) -> None: """Start timeout for node response.""" @@ -237,7 +234,7 @@ def assign_error(self, error: BaseException) -> None: return self._response_future.set_exception(error) - async def _process_node_response(self, response: PlugwiseResponse) -> bool: + async def process_node_response(self, response: PlugwiseResponse) -> bool: """Process incoming message from node.""" if self._seq_id is None: _LOGGER.warning( @@ -262,7 +259,7 @@ async def _process_node_response(self, response: PlugwiseResponse) -> bool: self._unsubscribe_from_stick() self._unsubscribe_from_node() if self._send_counter > 1: - _LOGGER.info( + _LOGGER.debug( "Received %s after %s retries as reply to %s", self._response, self._send_counter, From 7209b5db7862d9fc0c01350a01583ec1c25d7f6f Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:10:14 +0100 Subject: [PATCH 449/979] Update NodeSleepConfigRequest --- plugwise_usb/messages/requests.py | 59 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index a18be707e..453f1a28d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1203,53 +1203,54 @@ class CircleHandlesOnRequest(PlugwiseRequest): class NodeSleepConfigRequest(PlugwiseRequest): """Configure timers for SED nodes to minimize battery usage. - stay_active : Duration in seconds the SED will be - awake for receiving commands - sleep_for : Duration in minutes the SED will be - in sleeping mode and not able to respond - any command - maintenance_interval : Interval in minutes the node will wake up - and able to receive commands - clock_sync : Enable/disable clock sync - clock_interval : Duration in minutes the node synchronize - its clock - - Response message: NodeAckResponse with SLEEP_SET + Description: + Response message: NodeResponse with SLEEP_SET + + Args: + send_fn: Send function + mac: MAC address of the node + awake_duration: Duration in seconds the SED will be awake for receiving commands + sleep_for: Duration in minutes the SED will be in sleeping mode and not able to respond any command + maintenance_interval: Interval in minutes the node will wake up and able to receive commands + sync_clock: Enable/disable clock sync + clock_interval: Duration in minutes the node synchronize its clock + """ _identifier = b"0050" - _reply_identifier = b"0100" + _reply_identifier = b"0000" def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, - stay_active: int, + awake_duration: int, maintenance_interval: int, - sleep_for: int, + sleep_duration: int, sync_clock: bool, clock_interval: int, ): """Initialize NodeSleepConfigRequest message object.""" super().__init__(send_fn, mac) - stay_active_val = Int(stay_active, length=2) - sleep_for_val = Int(sleep_for, length=4) - maintenance_interval_val = Int(maintenance_interval, length=4) + self.awake_duration_val = Int(awake_duration, length=2) + self.sleep_duration_val = Int(sleep_duration, length=4) + self.maintenance_interval_val = Int(maintenance_interval, length=4) val = 1 if sync_clock else 0 - clock_sync_val = Int(val, length=2) - clock_interval_val = Int(clock_interval, length=4) + self.clock_sync_val = Int(val, length=2) + self.clock_interval_val = Int(clock_interval, length=4) self._args += [ - stay_active_val, - maintenance_interval_val, - sleep_for_val, - clock_sync_val, - clock_interval_val, + self.awake_duration_val, + self.maintenance_interval_val, + self.sleep_duration_val, + self.clock_sync_val, + self.clock_interval_val, ] - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: """Send request.""" result = await self._send_request(suppress_node_errors) - if isinstance(result, NodeAckResponse): + _LOGGER.warning("NodeSleepConfigRequest result: %s", result) + if isinstance(result, NodeResponse): return result if result is None: return None @@ -1257,6 +1258,10 @@ async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | No f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" ) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()[:-1]}, awake_duration={self.awake_duration_val.value}, maintenance_interval={self.maintenance_interval_val.value}, sleep_duration={self.sleep_duration_val.value}, clock_interval={self.clock_interval_val.value}, clock_sync={self.clock_sync_val.value})" + class NodeSelfRemoveRequest(PlugwiseRequest): """TODO: Remove node?. From 528390bb1407f83cd932a6883b118767304b015f Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:12:31 +0100 Subject: [PATCH 450/979] Rename SLEEP_CONFIG_* const into SED_CONFIG._* --- plugwise_usb/messages/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index f1ff516db..c791eaefa 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -60,12 +60,12 @@ class NodeResponseType(bytes, Enum): RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" - SLEEP_CONFIG_ACCEPTED = b"00F6" + SED_CONFIG_ACCEPTED = b"00F6" REAL_TIME_CLOCK_ACCEPTED = b"00DF" REAL_TIME_CLOCK_FAILED = b"00E7" # TODO: Validate these response types - SLEEP_CONFIG_FAILED = b"00F7" + SED_CONFIG_FAILED = b"00F7" POWER_LOG_INTERVAL_ACCEPTED = b"00F8" POWER_CALIBRATION_ACCEPTED = b"00DA" CIRCLE_PLUS = b"00DD" From c661e7e24f1de3d18ab6cd63eb3b98f709f2f48e Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:14:55 +0100 Subject: [PATCH 451/979] Rewrite SED for battery configuration --- plugwise_usb/nodes/helpers/firmware.py | 1 + plugwise_usb/nodes/sed.py | 625 ++++++++++++++++++++++--- 2 files changed, 557 insertions(+), 69 deletions(-) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index bf5c37b08..471f1bb81 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -162,6 +162,7 @@ class SupportedVersions(NamedTuple): NodeFeature.RELAY: 2.0, NodeFeature.RELAY_INIT: 2.6, NodeFeature.MOTION: 2.0, + NodeFeature.MOTION_CONFIG: 2.0, NodeFeature.SWITCH: 2.0, } diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index d1dba62f8..9464a014f 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -12,11 +12,12 @@ wait_for, ) from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime +from dataclasses import replace +from datetime import datetime, timedelta import logging from typing import Any, Final -from ..api import NodeEvent, NodeFeature, NodeInfo +from ..api import BatteryConfig, NodeEvent, NodeFeature, NodeInfo from ..connection import StickController from ..exceptions import MessageError, NodeError from ..messages.requests import NodeSleepConfigRequest @@ -31,27 +32,37 @@ 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" + +# Number of seconds to ignore duplicate awake messages +AWAKE_RETRY: Final = 5 + # Defaults for 'Sleeping End Devices' # Time in seconds the SED keep itself awake to receive # and respond to other messages -SED_STAY_ACTIVE: Final = 10 - -# Time in minutes the SED will sleep -SED_SLEEP_FOR: Final = 60 +SED_DEFAULT_AWAKE_DURATION: Final = 10 -# 24 hours, Interval in minutes the SED will get awake and notify -# it's available for maintenance purposes -SED_MAINTENANCE_INTERVAL: Final = 1440 +# 7 days, duration in minutes the node synchronize its clock +SED_DEFAULT_CLOCK_INTERVAL: Final = 25200 # Enable or disable synchronizing clock -SED_CLOCK_SYNC: Final = True +SED_DEFAULT_CLOCK_SYNC: Final = False -# 7 days, duration in minutes the node synchronize its clock -SED_CLOCK_INTERVAL: Final = 25200 +# Interval in minutes the SED will awake for maintenance purposes +# Source [5min - 24h] +SED_DEFAULT_MAINTENANCE_INTERVAL: Final = 60 # Assume standard interval of 1 hour +SED_MAX_MAINTENANCE_INTERVAL_OFFSET: Final = 30 # seconds +# Time in minutes the SED will sleep +SED_DEFAULT_SLEEP_DURATION: Final = 60 -CACHE_MAINTENANCE_INTERVAL = "maintenance_interval" _LOGGER = logging.getLogger(__name__) @@ -59,17 +70,7 @@ class NodeSED(PlugwiseBaseNode): """provides base class for SED based nodes like Scan, Sense & Switch.""" - # SED configuration - _sed_configure_at_awake = False - _sed_config_stay_active: int | None = None - _sed_config_sleep_for: int | None = None - _sed_config_maintenance_interval: int | None = None - _sed_config_clock_sync: bool | None = None - _sed_config_clock_interval: int | None = None - # Maintenance - _maintenance_last_awake: datetime | None = None - _awake_future: Future[bool] | None = None _awake_timer_task: Task[None] | None = None _ping_at_awake: bool = False @@ -86,18 +87,50 @@ def __init__( super().__init__(mac, address, controller, loaded_callback) self._loop = get_running_loop() self._node_info.is_battery_powered = True - self._maintenance_interval = 86400 # Assume standard interval of 24h + + # 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._delayed_task: Task[None] | None = None + + self._last_awake: dict[NodeAwakeResponseType, datetime] = {} + self._last_awake_reason: str = "Unknown" + self._awake_future: Future[bool] | None = None + + # Maintenance + self._maintenance_last_awake: datetime | None = None + self._maintenance_interval_restored_from_cache = False + + async def load(self) -> bool: + """Load and activate SED node features.""" + if self._loaded: + return True + if self._cache_enabled: + _LOGGER.debug("Load SED node %s from cache", self._node_info.mac) + await self._load_from_cache() + else: + self._load_defaults() + self._loaded = True + self._features += (NodeFeature.BATTERY,) + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True + _LOGGER.debug("Load of SED node %s failed", self._node_info.mac) + return False async def unload(self) -> None: """Deactivate and unload node features.""" - if self._awake_future is not None: + if self._awake_future is not None and not self._awake_future.done(): self._awake_future.set_result(True) if self._awake_timer_task is not None and not self._awake_timer_task.done(): await self._awake_timer_task if self._awake_subscription is not 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", @@ -111,40 +144,350 @@ async def initialize(self) -> bool: """Initialize SED node.""" if self._initialized: return True - self._awake_subscription = self._message_subscribe( + self._awake_subscription = await self._message_subscribe( self._awake_response, self._mac_in_bytes, (NODE_AWAKE_RESPONSE_ID,), ) return await super().initialize() + 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, + ) + async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" if not await super()._load_from_cache(): + self._load_defaults() return False - self.maintenance_interval_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(), + ) + self._awake_timestamp_from_cache() + self._awake_reason_from_cache() return True - def maintenance_interval_from_cache(self) -> bool: + def _awake_duration_from_cache(self) -> int: + """Load awake duration from cache.""" + if (awake_duration := self._get_cache(CACHE_AWAKE_DURATION)) is not None: + return int(awake_duration) + return SED_DEFAULT_AWAKE_DURATION + + def _clock_interval_from_cache(self) -> int: + """Load clock interval from cache.""" + if (clock_interval := self._get_cache(CACHE_CLOCK_INTERVAL)) is not None: + return int(clock_interval) + return SED_DEFAULT_CLOCK_INTERVAL + + def _clock_sync_from_cache(self) -> bool: + """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 + + def _maintenance_interval_from_cache(self) -> int: """Load maintenance interval from cache.""" if ( - cached_maintenance_interval := self._get_cache(CACHE_MAINTENANCE_INTERVAL) + maintenance_interval := self._get_cache(CACHE_MAINTENANCE_INTERVAL) ) is not None: + self._maintenance_interval_restored_from_cache = True + return int(maintenance_interval) + return SED_DEFAULT_MAINTENANCE_INTERVAL + + def _sleep_duration_from_cache(self) -> int: + """Load sleep duration from cache.""" + if (sleep_duration := self._get_cache(CACHE_SLEEP_DURATION)) is not None: + return int(sleep_duration) + return SED_DEFAULT_SLEEP_DURATION + + def _awake_timestamp_from_cache(self) -> datetime | None: + """Load last awake timestamp from cache.""" + return self._get_cache_as_datetime(CACHE_AWAKE_TIMESTAMP) + + def _awake_reason_from_cache(self) -> str | None: + """Load last awake state from cache.""" + return self._get_cache(CACHE_AWAKE_REASON) + + # region Configuration actions + @raise_not_loaded + async def set_awake_duration(self, seconds: int) -> bool: + """Change the awake duration.""" + _LOGGER.debug( + "set_awake_duration | Device %s | %s -> %s", + self.name, + self._battery_config.awake_duration, + seconds, + ) + if seconds < 1 or seconds > 255: + raise ValueError( + f"Invalid awake duration ({seconds}). It must be between 1 and 255 seconds." + ) + if self._battery_config.awake_duration == seconds: + self._new_battery_config = replace( + self._new_battery_config, awake_duration=seconds + ) + return False + self._new_battery_config = replace( + self._new_battery_config, awake_duration=seconds + ) + 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( - "Restore maintenance interval cache for node %s", self._mac_in_str + "set_awake_duration | Device %s | config scheduled", + self.name, ) - self._maintenance_interval = int(cached_maintenance_interval) return True + @raise_not_loaded + async def set_clock_interval(self, minutes: int) -> bool: + """Change the clock interval.""" + _LOGGER.debug( + "set_clock_interval | Device %s | %s -> %s", + self.name, + self._battery_config.clock_interval, + minutes, + ) + if minutes < 1 or minutes > 65535: + raise ValueError( + f"Invalid clock interval ({minutes}). It must be between 1 and 65535 minutes." + ) + if self.battery_config.clock_interval == minutes: + self._new_battery_config = replace( + self._new_battery_config, clock_interval=minutes + ) + return False + self._new_battery_config = replace( + self._new_battery_config, clock_interval=minutes + ) + 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( + "set_clock_sync | Device %s | %s -> %s", + self.name, + self._battery_config.clock_sync, + sync, + ) + if self._battery_config.clock_sync == sync: + self._new_battery_config = replace( + self._new_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, + ) + return True + + @raise_not_loaded + async def set_maintenance_interval(self, minutes: int) -> bool: + """Change the maintenance interval.""" + _LOGGER.debug( + "set_maintenance_interval | Device %s | %s -> %s", + self.name, + self._battery_config.maintenance_interval, + minutes, + ) + if minutes < 1 or minutes > 1440: + raise ValueError( + f"Invalid maintenance interval ({minutes}). It must be between 1 and 1440 minutes." + ) + if self.battery_config.maintenance_interval == minutes: + self._new_battery_config = replace( + self._new_battery_config, maintenance_interval=minutes + ) + return False + self._new_battery_config = replace( + self._new_battery_config, maintenance_interval=minutes + ) + 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. + + Configuration will be applied next time when node is awake for maintenance. + """ + _LOGGER.debug( + "set_sleep_duration | Device %s | %s -> %s", + self.name, + self._battery_config.sleep_duration, + minutes, + ) + if minutes < 1 or minutes > 65535: + raise ValueError( + f"Invalid sleep duration ({minutes}). It must be between 1 and 65535 minutes." + ) + if self._battery_config.sleep_duration == minutes: + self._new_battery_config = replace( + self._new_battery_config, sleep_duration=minutes + ) + return False + self._new_battery_config = replace( + self._new_battery_config, sleep_duration=minutes + ) + 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 - def maintenance_interval(self) -> int | None: - """Heartbeat maintenance interval (seconds).""" - return self._maintenance_interval + @raise_not_loaded + 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( + 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, + ) + + @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 + + # 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 = True + if self._new_battery_config.awake_duration is not None: + change_required = True + if self._new_battery_config.clock_interval is not None: + change_required = True + if self._new_battery_config.clock_sync is not None: + change_required = True + if self._new_battery_config.maintenance_interval is not None: + change_required = True + if self._new_battery_config.sleep_duration is not None: + change_required = True + if not change_required: + _LOGGER.debug( + "_configure_sed_task | Device %s | no change", + self.name, + ) + return True + _LOGGER.debug( + "_configure_sed_task | Device %s | request change", + self.name, + ) + 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, + ): + 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 None and self.skip_update(self._node_info, 86400): return self._node_info return await super().node_info_update(node_info) @@ -153,32 +496,108 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: """Process awake message.""" if not isinstance(response, NodeAwakeResponse): raise MessageError( - f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse" ) - self._node_last_online = response.timestamp - await self._available_update_state(True) + _LOGGER.debug("Device %s is awake for %s", self.name, response.awake_type) + self._set_cache(CACHE_AWAKE_TIMESTAMP, response.timestamp) + await self._available_update_state(True, response.timestamp) + + # Pre populate the last awake timestamp + if self._last_awake.get(response.awake_type) is None: + self._last_awake[response.awake_type] = response.timestamp + + # Skip awake messages when they are shortly after each other + elif ( + self._last_awake[response.awake_type] + timedelta(seconds=AWAKE_RETRY) + > response.timestamp + ): + return True + + self._last_awake[response.awake_type] = response.timestamp + + tasks: list[Coroutine[Any, Any, None]] = [ + self._reset_awake(response.timestamp), + self.publish_feature_update_to_subscribers( + NodeFeature.BATTERY, + self._battery_config, + ), + ] + self._delayed_task = self._loop.create_task( + self._send_tasks(), name=f"Delayed update for {self._mac_in_str}" + ) if response.awake_type == NodeAwakeResponseType.MAINTENANCE: - if self._maintenance_last_awake is None: - self._maintenance_last_awake = response.timestamp - self._maintenance_interval = ( - response.timestamp - self._maintenance_last_awake - ).seconds + self._last_awake_reason = "Maintenance" + self._set_cache(CACHE_AWAKE_REASON, "Maintenance") + + if not self._maintenance_interval_restored_from_cache: + self._detect_maintenance_interval(response.timestamp) if self._ping_at_awake: - await self.ping_update() + tasks.append(self.update_ping_at_awake()) elif response.awake_type == NodeAwakeResponseType.FIRST: - _LOGGER.info("Device %s is turned on for first time", self.name) + self._last_awake_reason = "First" + self._set_cache(CACHE_AWAKE_REASON, "First") elif response.awake_type == NodeAwakeResponseType.STARTUP: - _LOGGER.info("Device %s is restarted", self.name) + self._last_awake_reason = "Startup" + self._set_cache(CACHE_AWAKE_REASON, "Startup") elif response.awake_type == NodeAwakeResponseType.STATE: - _LOGGER.info("Device %s is awake to send status update", self.name) + self._last_awake_reason = "State update" + self._set_cache(CACHE_AWAKE_REASON, "State update") elif response.awake_type == NodeAwakeResponseType.BUTTON: - _LOGGER.info("Button is pressed at device %s", self.name) - await self._reset_awake(response.timestamp) + self._last_awake_reason = "Button press" + self._set_cache(CACHE_AWAKE_REASON, "Button press") + if self._ping_at_awake: + tasks.append(self.update_ping_at_awake()) + + await gather(*tasks) return True + async def update_ping_at_awake(self) -> None: + """Get ping statistics.""" + await self.ping_update() + + def _detect_maintenance_interval(self, timestamp: datetime) -> None: + """Detect current maintenance interval.""" + if self._last_awake[NodeAwakeResponseType.MAINTENANCE] == timestamp: + return + new_interval_in_sec = ( + timestamp - self._last_awake[NodeAwakeResponseType.MAINTENANCE] + ).seconds + new_interval_in_min = round(new_interval_in_sec // 60) + _LOGGER.warning( + "Detect current maintenance interval for %s: %s (seconds), current %s (min)", + self.name, + new_interval_in_sec, + self._battery_config.maintenance_interval, + ) + # Validate new maintenance interval in seconds but store it in minutes + if (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) + 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) + else: + # Within off-set margin of default, so use the default + self._battery_config = replace( + self._battery_config, + maintenance_interval=SED_DEFAULT_MAINTENANCE_INTERVAL, + ) + self._set_cache( + CACHE_MAINTENANCE_INTERVAL, SED_DEFAULT_MAINTENANCE_INTERVAL + ) + self._maintenance_interval_restored_from_cache = True + async def _reset_awake(self, last_alive: datetime) -> None: """Reset node alive state.""" - if self._awake_future is not None: + if self._awake_future is not None and not self._awake_future.done(): self._awake_future.set_result(True) # Setup new maintenance timer self._awake_future = self._loop.create_future() @@ -188,22 +607,26 @@ async def _reset_awake(self, last_alive: datetime) -> None: async def _awake_timer(self) -> None: """Task to monitor to get next awake in time. If not it sets device to be unavailable.""" - # wait for next maintenance timer + # wait for next maintenance timer, but allow missing one if self._awake_future is None: return + timeout_interval = self.maintenance_interval * 60 * 2.1 try: await wait_for( self._awake_future, - timeout=(self._maintenance_interval * 1.05), + timeout=timeout_interval, ) except TimeoutError: # No maintenance awake message within expected time frame # Mark node as unavailable if self._available: - _LOGGER.info( - "No awake message received from %s within expected %s seconds.", + last_awake = self._last_awake.get(NodeAwakeResponseType.MAINTENANCE) + _LOGGER.warning( + "No awake message received from %s | last_maintenance_awake=%s | interval=%s (%s) | Marking node as unavailable", self.name, - str(self._maintenance_interval * 1.05), + last_awake, + self.maintenance_interval, + timeout_interval, ) await self._available_update_state(False) except CancelledError: @@ -223,8 +646,7 @@ async def _send_tasks(self) -> None: task_result, self.name, ) - else: - self._send_task_queue = [] + self._send_task_queue = [] self._send_task_lock.release() async def schedule_task_when_awake( @@ -237,30 +659,93 @@ async def schedule_task_when_awake( async def sed_configure( self, - stay_active: int = SED_STAY_ACTIVE, - sleep_for: int = SED_SLEEP_FOR, - maintenance_interval: int = SED_MAINTENANCE_INTERVAL, - clock_sync: bool = SED_CLOCK_SYNC, - clock_interval: int = SED_CLOCK_INTERVAL, + awake_duration: int, + sleep_duration: int, + maintenance_interval: int, + clock_sync: bool, + clock_interval: int, ) -> bool: """Reconfigure the sleep/awake settings for a SED send at next awake of SED.""" request = NodeSleepConfigRequest( self._send, self._mac_in_bytes, - stay_active, + awake_duration, maintenance_interval, - sleep_for, + sleep_duration, clock_sync, clock_interval, ) - if (response := await request.send()) is not None: - if response.ack_id == NodeResponseType.SLEEP_CONFIG_FAILED: - raise NodeError("SED failed to configure sleep settings") - if response.ack_id == NodeResponseType.SLEEP_CONFIG_ACCEPTED: - self._maintenance_interval = maintenance_interval - return True + _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, + ) + response = await request.send() + if response 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() + return True + _LOGGER.warning( + "Unexpected response type %s for %s", + response.response_type, + self.name, + ) return False + 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: + """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_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") + await gather( + *[ + self.save_cache(), + self.publish_feature_update_to_subscribers( + NodeFeature.BATTERY, + self._battery_config, + ), + ] + ) + @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" @@ -273,6 +758,8 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any ) if feature == NodeFeature.INFO: states[NodeFeature.INFO] = await self.node_info_update() + elif feature == NodeFeature.BATTERY: + states[NodeFeature.BATTERY] = self._battery_config else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] From dcdc34a53c213cab1e279e51388a7cf40e1f879e Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:19:07 +0100 Subject: [PATCH 452/979] Add missing timezone --- plugwise_usb/messages/__init__.py | 2 +- plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/helpers/pulses.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 20b68c088..e663f3cbf 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -31,7 +31,7 @@ def __init__(self) -> None: self._args: list[Any] = [] self._seq_id: bytes | None = None self.priority: Priority = Priority.MEDIUM - self.timestamp = datetime.now(UTC) + self.timestamp = datetime.now(tz=UTC) @property def seq_id(self) -> bytes | None: diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 293c657ca..74e175bcf 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -279,7 +279,7 @@ async def energy_update(self) -> EnergyStatistics | None: if await self.node_info_update() is None: if ( self._initialization_delay_expired is not None - and datetime.now(UTC) < self._initialization_delay_expired + and datetime.now(tz=UTC) < self._initialization_delay_expired ): _LOGGER.info( "Unable to return energy statistics for %s, because it is not responding", @@ -369,7 +369,7 @@ async def energy_update(self) -> EnergyStatistics | None: ) if ( self._initialization_delay_expired is not None - and datetime.now(UTC) < self._initialization_delay_expired + and datetime.now(tz=UTC) < self._initialization_delay_expired ): _LOGGER.info( "Unable to return energy statistics for %s, collecting required data...", diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index ea05b0dcb..89fc0c6c2 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -109,7 +109,7 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} - skip_before = datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) sorted_addresses = sorted(self._logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(self._logs[address].keys(), reverse=True) @@ -389,7 +389,7 @@ def add_log( def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( - datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ) def _add_log_record( @@ -406,7 +406,7 @@ def _add_log_record( return False # Drop useless log records when we have at least 4 logs if self.collected_logs > 4 and log_record.timestamp < ( - datetime.now(UTC) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ): return False if self._logs.get(address) is None: From 430e9244aef37ca74d4da73a104cc7cb4c9d669d Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:19:47 +0100 Subject: [PATCH 453/979] Update NodeInfoResponse message --- plugwise_usb/messages/responses.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index c791eaefa..6f6ff373e 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -616,7 +616,7 @@ def __init__(self, protocol_version: str = "2.0") -> None: ] self._frequency = Int(0, length=2) self._hw_ver = String(None, length=12) - self._fw_ver = UnixTimestamp(0) + self._fw_ver = UnixTimestamp(None) self._node_type = Int(0, length=2) self._params += [ self._frequency, @@ -864,6 +864,10 @@ def switch_state(self) -> bool: """Return state of switch (True = On, False = Off).""" return (self._power_state.value != 0) + def __repr__(self) -> str: + """Convert request into writable str.""" + return f"{super().__repr__()[:-1]}, power_state={self._power_state.value}, group={self.group.value})" + class NodeFeaturesResponse(PlugwiseResponse): """Returns supported features of node. From 355f736a702accffdc2b765592d70bd62408aee1 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:23:01 +0100 Subject: [PATCH 454/979] Add NodeReJoin feature --- plugwise_usb/messages/responses.py | 3 ++- plugwise_usb/network/__init__.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 6f6ff373e..ff0c1f5be 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -25,6 +25,7 @@ NODE_JOIN_ID: Final = b"0006" NODE_AWAKE_RESPONSE_ID: Final = b"004F" +NODE_REJOIN_ID: Final = b"0061" NODE_SWITCH_GROUP_ID: Final = b"0056" SENSE_REPORT_ID: Final = b"0105" @@ -896,7 +897,7 @@ class NodeRejoinResponse(PlugwiseResponse): def __init__(self) -> None: """Initialize NodeRejoinResponse message object.""" - super().__init__(b"0061") + super().__init__(NODE_REJOIN_ID) class NodeAckResponse(PlugwiseResponse): diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index c3b0e570c..ed022606f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -23,10 +23,12 @@ from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, NODE_JOIN_ID, + NODE_REJOIN_ID, NodeAwakeResponse, NodeInfoResponse, NodeJoinAvailableResponse, NodePingResponse, + NodeRejoinResponse, NodeResponseType, PlugwiseResponse, ) @@ -74,6 +76,7 @@ def __init__( self._unsubscribe_stick_event: Callable[[], None] | None = None self._unsubscribe_node_awake: Callable[[], None] | None = None self._unsubscribe_node_join: Callable[[], None] | None = None + self._unsubscribe_node_rejoin: Callable[[], None] | None = None # region - Properties @@ -244,6 +247,31 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True + async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: + """Handle NodeRejoinResponse messages.""" + if not isinstance(response, NodeRejoinResponse): + raise MessageError( + f"Invalid response message type ({response.__class__.__name__}) received, expected NodeRejoinResponse" + ) + mac = response.mac_decoded + address = self._register.network_address(mac) + if (address := self._register.network_address(mac)) is not None: + if self._nodes.get(mac) is None: + if self._discover_sed_tasks.get(mac) is None: + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + elif self._discover_sed_tasks[mac].done(): + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + else: + _LOGGER.debug("duplicate awake discovery for %s", mac) + return True + else: + raise NodeError("Unknown network address for node {mac}") + return True + def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" if self._unsubscribe_node_awake is not None: @@ -478,6 +506,7 @@ async def start(self) -> None: self._register.full_scan_finished(self._discover_registered_nodes) await self._register.start() self._subscribe_to_protocol_events() + await self._subscribe_to_node_events() self._is_running = True async def discover_nodes(self, load: bool = True) -> bool: From 5de8197429c674119d2addb6213e3d9e58b2e26c Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:23:08 +0100 Subject: [PATCH 455/979] Cleanup --- plugwise_usb/nodes/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index e9953e5fa..0258e1b81 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -29,40 +29,40 @@ def get_plugwise_node( address, controller, loaded_callback, - ) # type: ignore[return-value] + ) if node_type == NodeType.CIRCLE: return PlugwiseCircle( mac, address, controller, loaded_callback, - ) # type: ignore[return-value] + ) if node_type == NodeType.SWITCH: return PlugwiseSwitch( mac, address, controller, loaded_callback, - ) # type: ignore[return-value] + ) if node_type == NodeType.SENSE: return PlugwiseSense( mac, address, controller, loaded_callback, - ) # type: ignore[return-value] + ) if node_type == NodeType.SCAN: return PlugwiseScan( mac, address, controller, loaded_callback, - ) # type: ignore[return-value] + ) if node_type == NodeType.STEALTH: return PlugwiseStealth( mac, address, controller, loaded_callback, - ) # type: ignore[return-value] + ) return None From 9395411432f3fdc71638b28ce250ab94da6acebe Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:23:54 +0100 Subject: [PATCH 456/979] Sort, cleanup and raise correct errors --- plugwise_usb/nodes/node.py | 379 +++++++++++++++++++++++-------------- 1 file changed, 241 insertions(+), 138 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 86fb8490c..2d90951c8 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -10,8 +10,10 @@ from typing import Any from ..api import ( + AvailableState, BatteryConfig, EnergyStatistics, + MotionConfig, MotionSensitivity, MotionState, NetworkStatistics, @@ -20,17 +22,17 @@ NodeInfo, NodeType, PowerStatistics, + RelayConfig, RelayState, ) from ..connection import StickController from ..constants import SUPPRESS_INITIALIZATION_WARNINGS, UTF8 -from ..exceptions import NodeError +from ..exceptions import FeatureError, NodeError from ..helpers.util import version_to_model from ..messages.requests import NodeInfoRequest, NodePingRequest from ..messages.responses import NodeInfoResponse, NodePingResponse -from .helpers import EnergyCalibration, raise_not_loaded +from .helpers import raise_not_loaded from .helpers.cache import NodeCache -from .helpers.counter import EnergyCounters from .helpers.firmware import FEATURE_SUPPORTED_AT_FIRMWARE, SupportedVersions from .helpers.subscription import FeaturePublisher @@ -64,51 +66,61 @@ def __init__( self._loaded_callback = loaded_callback self._message_subscribe = controller.subscribe_to_messages self._features: tuple[NodeFeature, ...] = NODE_FEATURES - self._last_update = datetime.now(UTC) + self._last_seen = datetime.now(tz=UTC) self._node_info = NodeInfo(mac, address) self._ping = NetworkStatistics() - self._power = PowerStatistics() self._mac_in_bytes = bytes(mac, encoding=UTF8) self._mac_in_str = mac self._send = controller.send self._cache_enabled: bool = False + self._cache_folder_create: bool = False self._cache_save_task: Task[None] | None = None self._node_cache = NodeCache(mac, "") # Sensors self._available: bool = False - self._humidity: float | None = None - self._motion: bool | None = None - self._switch: bool | None = None - self._temperature: float | None = None self._connected: bool = False self._initialized: bool = False self._initialization_delay_expired: datetime | None = None self._loaded: bool = False self._node_protocols: SupportedVersions | None = None - self._node_last_online: datetime | None = None - # Battery - self._battery_config = BatteryConfig() - # Motion - self._motion = False - self._motion_state = MotionState() - self._scan_subscription: Callable[[], None] | None = None - self._sensitivity_level: MotionSensitivity | None = None + # Node info self._current_log_address: int | None = None - # Relay - self._relay: bool | None = None - self._relay_state: RelayState = RelayState() - self._relay_init_state: bool | None = None - # Power & energy - self._calibration: EnergyCalibration | None = None - self._energy_counters = EnergyCounters(mac) # region Properties @property - def network_address(self) -> int: - """Zigbee network registration address.""" - return self._node_info.zigbee_address + def available(self) -> bool: + """Return network availability state.""" + return self._available + + @property + def available_state(self) -> AvailableState: + """Network availability state.""" + return AvailableState( + self._available, + self._last_seen, + ) + + @property + @raise_not_loaded + def battery_config(self) -> BatteryConfig: + """Battery related configuration settings.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Battery configuration property is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @property + @raise_not_loaded + def clock_sync(self) -> bool: + """Indicate if the internal clock must be synced.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Clock sync property is not supported for node {self.mac}" + ) + raise NotImplementedError() @property def cache_folder(self) -> str: @@ -141,51 +153,32 @@ def cache_enabled(self, enable: bool) -> None: self._cache_enabled = enable @property - def available(self) -> bool: - """Return network availability state.""" - return self._available - - @property - def battery_config(self) -> BatteryConfig: - """Return battery configuration settings.""" - if NodeFeature.BATTERY not in self._features: - raise NodeError( - f"Battery configuration settings are not supported for node {self.mac}" - ) - return self._battery_config - - @property - def is_battery_powered(self) -> bool: - """Return if node is battery powered.""" - return self._node_info.is_battery_powered - - @property - def daylight_mode(self) -> bool: - """Daylight mode of motion sensor.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Daylight mode is not supported for node {self.mac}") - raise NotImplementedError() - - @property - def energy(self) -> EnergyStatistics | None: + @raise_not_loaded + def energy(self) -> EnergyStatistics: """Energy statistics.""" if NodeFeature.POWER not in self._features: - raise NodeError(f"Energy state is not supported for node {self.mac}") + raise FeatureError(f"Energy state is not supported for node {self.mac}") raise NotImplementedError() @property + @raise_not_loaded def energy_consumption_interval(self) -> int | None: """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" if NodeFeature.ENERGY not in self._features: - raise NodeError(f"Energy log interval is not supported for node {self.mac}") - return self._energy_counters.consumption_interval + raise FeatureError( + f"Energy log interval is not supported for node {self.mac}" + ) + raise NotImplementedError() @property + @raise_not_loaded def energy_production_interval(self) -> int | None: """Interval (minutes) energy production counters are locally logged at Circle devices.""" if NodeFeature.ENERGY not in self._features: - raise NodeError(f"Energy log interval is not supported for node {self.mac}") - return self._energy_counters.production_interval + raise FeatureError( + f"Energy log interval is not supported for node {self.mac}" + ) + raise NotImplementedError() @property def features(self) -> tuple[NodeFeature, ...]: @@ -193,27 +186,28 @@ def features(self) -> tuple[NodeFeature, ...]: return self._features @property - def node_info(self) -> NodeInfo: - """Node information.""" - return self._node_info - - @property - def humidity(self) -> float | None: + @raise_not_loaded + def humidity(self) -> float: """Humidity state.""" if NodeFeature.HUMIDITY not in self._features: - raise NodeError(f"Humidity state is not supported for node {self.mac}") - return self._humidity + raise FeatureError(f"Humidity state is not supported for node {self.mac}") + raise NotImplementedError() @property - def last_update(self) -> datetime: - """Timestamp of last update.""" - return self._last_update + def is_battery_powered(self) -> bool: + """Return if node is battery powered.""" + return self._node_info.is_battery_powered @property def is_loaded(self) -> bool: """Return load status.""" return self._loaded + @property + def last_seen(self) -> datetime: + """Timestamp of last network activity.""" + return self._last_seen + @property def name(self) -> str: """Return name of node.""" @@ -221,35 +215,45 @@ 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.""" + return self._node_info + @property def mac(self) -> str: """Zigbee mac address of node.""" return self._mac_in_str @property - def maintenance_interval(self) -> int | None: - """Maintenance interval (seconds) a battery powered node sends it heartbeat.""" + @raise_not_loaded + def motion(self) -> bool: + """Motion detection value.""" + if NodeFeature.MOTION not in self._features: + raise FeatureError(f"Motion state is not supported for node {self.mac}") raise NotImplementedError() @property - def motion(self) -> bool | None: - """Motion detection value.""" + @raise_not_loaded + def motion_config(self) -> MotionConfig: + """Motion configuration settings.""" if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion state is not supported for node {self.mac}") - return self._motion + raise FeatureError( + f"Motion configuration is not supported for node {self.mac}" + ) + raise NotImplementedError() @property + @raise_not_loaded def motion_state(self) -> MotionState: """Motion detection state.""" if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion state is not supported for node {self.mac}") - return self._motion_state - - @property - def motion_reset_timer(self) -> int: - """Total minutes without motion before no motion is reported.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Motion reset timer is not supported for node {self.mac}") + raise FeatureError(f"Motion state is not supported for node {self.mac}") raise NotImplementedError() @property @@ -258,55 +262,56 @@ def ping_stats(self) -> NetworkStatistics: return self._ping @property + @raise_not_loaded def power(self) -> PowerStatistics: """Power statistics.""" if NodeFeature.POWER not in self._features: - raise NodeError(f"Power state is not supported for node {self.mac}") - return self._power + raise FeatureError(f"Power state is not supported for node {self.mac}") + raise NotImplementedError() @property + @raise_not_loaded def relay_state(self) -> RelayState: """State of relay.""" if NodeFeature.RELAY not in self._features: - raise NodeError(f"Relay state is not supported for node {self.mac}") - return self._relay_state + raise FeatureError(f"Relay state is not supported for node {self.mac}") + raise NotImplementedError() @property + @raise_not_loaded def relay(self) -> bool: """Relay value.""" if NodeFeature.RELAY not in self._features: - raise NodeError(f"Relay value is not supported for node {self.mac}") - if self._relay is None: - raise NodeError(f"Relay value is unknown for node {self.mac}") - return self._relay - - @property - def relay_init( - self, - ) -> bool | None: - """Request the relay states at startup/power-up.""" + raise FeatureError(f"Relay value is not supported for node {self.mac}") raise NotImplementedError() @property - def sensitivity_level(self) -> MotionSensitivity: - """Sensitivity level of motion sensor.""" - if NodeFeature.MOTION not in self._features: - raise NodeError(f"Sensitivity level is not supported for node {self.mac}") + @raise_not_loaded + def relay_config(self) -> RelayConfig: + """Relay configuration.""" + if NodeFeature.RELAY_INIT not in self._features: + raise FeatureError( + f"Relay configuration is not supported for node {self.mac}" + ) raise NotImplementedError() @property - def switch(self) -> bool | None: + @raise_not_loaded + def switch(self) -> bool: """Switch button value.""" if NodeFeature.SWITCH not in self._features: - raise NodeError(f"Switch value is not supported for node {self.mac}") - return self._switch + raise FeatureError(f"Switch value is not supported for node {self.mac}") + raise NotImplementedError() @property - def temperature(self) -> float | None: + @raise_not_loaded + def temperature(self) -> float: """Temperature value.""" if NodeFeature.TEMPERATURE not in self._features: - raise NodeError(f"Temperature state is not supported for node {self.mac}") - return self._temperature + raise FeatureError( + f"Temperature state is not supported for node {self.mac}" + ) + raise NotImplementedError() # endregion @@ -345,30 +350,17 @@ async def reconnect(self) -> None: """Reconnect node to Plugwise Zigbee network.""" if await self.ping_update() is not None: self._connected = True - await self._available_update_state(True) + await self._available_update_state(True, None) async def disconnect(self) -> None: """Disconnect node from Plugwise Zigbee network.""" self._connected = False await self._available_update_state(False) - async def configure_motion_reset(self, delay: int) -> bool: - """Configure the duration to reset motion state.""" - raise NotImplementedError() - async def scan_calibrate_light(self) -> bool: """Request to calibration light sensitivity of Scan device. Returns True if successful.""" raise NotImplementedError() - async def scan_configure( - self, - motion_reset_timer: int, - sensitivity_level: MotionSensitivity, - daylight_mode: bool, - ) -> bool: - """Configure Scan device settings. Returns True if successful.""" - raise NotImplementedError() - async def load(self) -> bool: """Load configuration and activate node features.""" raise NotImplementedError() @@ -409,26 +401,39 @@ async def initialize(self) -> bool: """Initialize node configuration.""" if self._initialized: return True - self._initialization_delay_expired = datetime.now(UTC) + timedelta( + self._initialization_delay_expired = datetime.now(tz=UTC) + timedelta( minutes=SUPPRESS_INITIALIZATION_WARNINGS ) self._initialized = True return True - async def _available_update_state(self, available: bool) -> None: + async def _available_update_state( + self, available: bool, timestamp: datetime | None = None + ) -> None: """Update the node availability state.""" if self._available == available: + if ( + self._last_seen is not None + and timestamp is not None + and self._last_seen < timestamp + ): + self._last_seen = timestamp + await self.publish_feature_update_to_subscribers( + NodeFeature.AVAILABLE, self.available_state + ) return + if timestamp is not None: + self._last_seen = timestamp if available: _LOGGER.info("Device %s detected to be available (on-line)", self.name) self._available = True await self.publish_feature_update_to_subscribers( - NodeFeature.AVAILABLE, True + NodeFeature.AVAILABLE, self.available_state ) return _LOGGER.info("Device %s detected to be not available (off-line)", self.name) self._available = False - await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, False) + await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, self.available_state) async def node_info_update( self, node_info: NodeInfoResponse | None = None @@ -441,7 +446,7 @@ async def node_info_update( _LOGGER.debug("No response for node_info_update() for %s", self.mac) await self._available_update_state(False) return self._node_info - await self._available_update_state(True) + await self._available_update_state(True, node_info.timestamp) await self.update_node_details( firmware=node_info.firmware, node_type=node_info.node_type, @@ -517,6 +522,10 @@ async def update_node_details( self._node_info.node_type = NodeType(node_type) self._set_cache(CACHE_NODE_TYPE, self._node_info.node_type.value) await self.save_cache() + if timestamp is not None and timestamp > datetime.now(tz=UTC) - timedelta( + minutes=5 + ): + await self._available_update_state(True, timestamp) return complete async def is_online(self) -> bool: @@ -536,7 +545,7 @@ async def ping_update( if ping_response is None: await self._available_update_state(False) return None - await self._available_update_state(True) + await self._available_update_state(True, ping_response.timestamp) self.update_ping_stats( ping_response.timestamp, ping_response.rssi_in, @@ -556,14 +565,6 @@ def update_ping_stats( self._ping.rtt = rtt self._available = True - async def switch_relay(self, state: bool) -> bool | None: - """Switch relay state.""" - raise NodeError(f"Relay control is not supported for node {self.mac}") - - async def switch_relay_init(self, state: bool) -> bool: - """Switch state of initial power-up relay state. Returns new state of relay.""" - raise NodeError(f"Control of initial (power-up) state of relay is not supported for node {self.mac}") - @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" @@ -577,7 +578,7 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any if feature == NodeFeature.INFO: states[NodeFeature.INFO] = await self.node_info_update() elif feature == NodeFeature.AVAILABLE: - states[NodeFeature.AVAILABLE] = self._available + states[NodeFeature.AVAILABLE] = self.available_state elif feature == NodeFeature.PING: states[NodeFeature.PING] = await self.ping_update() else: @@ -655,6 +656,108 @@ def skip_update(data_class: Any, seconds: int) -> bool: return False if data_class.timestamp is None: return False - if data_class.timestamp + timedelta(seconds=seconds) > datetime.now(UTC): + if data_class.timestamp + timedelta(seconds=seconds) > datetime.now(tz=UTC): return True return False + + # region Configuration of properties + @raise_not_loaded + async def set_awake_duration(self, seconds: int) -> bool: + """Change the awake duration.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Changing awake duration is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_clock_interval(self, minutes: int) -> bool: + """Change the clock interval.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Changing clock interval is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_clock_sync(self, sync: bool) -> bool: + """Change the clock synchronization setting.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Configuration of clock sync is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_maintenance_interval(self, minutes: int) -> bool: + """Change the maintenance interval.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Changing maintenance interval is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @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.""" + if NodeFeature.MOTION not in self._features: + raise FeatureError( + f"Configuration of daylight mode is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_motion_reset_timer(self, minutes: int) -> bool: + """Configure the motion reset timer in minutes.""" + if NodeFeature.MOTION not in self._features: + raise FeatureError( + f"Changing motion reset timer is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool: + """Configure motion sensitivity level.""" + if NodeFeature.MOTION not in self._features: + raise FeatureError( + f"Configuration of motion sensitivity is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_relay(self, state: bool) -> bool: + """Change the state of the relay.""" + if NodeFeature.RELAY not in self._features: + raise FeatureError( + f"Changing state of relay is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_relay_init(self, state: bool) -> bool: + """Change the initial power-on state of the relay.""" + if NodeFeature.RELAY_INIT not in self._features: + raise FeatureError( + f"Configuration of initial power-up relay state is not supported for node {self.mac}" + ) + raise NotImplementedError() + + @raise_not_loaded + async def set_sleep_duration(self, minutes: int) -> bool: + """Change the sleep duration.""" + if NodeFeature.BATTERY not in self._features: + raise FeatureError( + f"Configuration of sleep duration is not supported for node {self.mac}" + ) + raise NotImplementedError() + + # endregion + + async def message_for_node(self, message: Any) -> None: + """Process message for node.""" + if isinstance(message, NodePingResponse): + await self.ping_update(message) + elif isinstance(message, NodeInfoResponse): + await self.node_info_update(message) + else: + raise NotImplementedError() From bd873477e5e768b4da717607b2de6372fea8abca Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:24:36 +0100 Subject: [PATCH 457/979] Improve network detection --- plugwise_usb/network/__init__.py | 67 +++++++++++++++++--------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index ed022606f..f92e4ad63 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations -from asyncio import gather, sleep +from asyncio import Task, create_task, gather, sleep from collections.abc import Callable, Coroutine from datetime import datetime, timedelta import logging @@ -78,6 +78,8 @@ def __init__( self._unsubscribe_node_join: Callable[[], None] | None = None self._unsubscribe_node_rejoin: Callable[[], None] | None = None + self._discover_sed_tasks: dict[str, Task[bool]] = {} + # region - Properties @property @@ -221,20 +223,26 @@ async def node_awake_message(self, response: PlugwiseResponse) -> bool: await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp return True - if self._register.network_address(mac) is None: + address = self._register.network_address(mac) + if address is None: if self._register.scan_completed: return True _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac, ) - return False - address = self._register.network_address(mac) - if (address := self._register.network_address(mac)) is not None: - if self._nodes.get(mac) is None: - return await self._discover_battery_powered_node(address, mac) - else: - raise NodeError("Unknown network address for node {mac}") + return True + + if self._nodes.get(mac) is None: + if ( + self._discover_sed_tasks.get(mac) is None + or self._discover_sed_tasks[mac].done() + ): + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + else: + _LOGGER.debug("duplicate maintenance awake discovery for %s", mac) return True async def node_join_available_message(self, response: PlugwiseResponse) -> bool: @@ -293,12 +301,12 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: # Validate the network controller is online # try to ping first and raise error at stick timeout + ping_request = NodePingRequest( + self._controller.send, + bytes(self._controller.mac_coordinator, UTF8), + retries=1, + ) try: - ping_request = NodePingRequest( - self._controller.send, - bytes(self._controller.mac_coordinator, UTF8), - retries=1, - ) ping_response = await ping_request.send() except StickTimeout as err: raise StickError( @@ -368,13 +376,20 @@ async def get_node_details( ping_request = NodePingRequest( self._controller.send, bytes(mac, UTF8), retries=1 ) - ping_response = await ping_request.send(suppress_node_errors=True) + try: + ping_response = await ping_request.send(suppress_node_errors=True) + except StickError: + return (None, None) if ping_response is None: return (None, None) + info_request = NodeInfoRequest( self._controller.send, bytes(mac, UTF8), retries=1 ) - info_response = await info_request.send() + try: + info_response = await info_request.send() + except StickError: + return (None, None) return (info_response, ping_response) async def _discover_battery_powered_node( @@ -424,21 +439,9 @@ async def _discover_node( self._create_node_object(mac, address, node_info.node_type) # Forward received NodeInfoResponse message to node - await self._nodes[mac].update_node_details( - node_info.firmware, - node_info.hardware, - node_info.node_type, - node_info.timestamp, - node_info.relay_state, - node_info.current_logaddress_pointer, - ) + await self._nodes[mac].message_for_node(node_info) if node_ping is not None: - self._nodes[mac].update_ping_stats( - node_ping.timestamp, - node_ping.rssi_in, - node_ping.rssi_out, - node_ping.rtt, - ) + await self._nodes[mac].message_for_node(node_ping) await self._notify_node_event_subscribers(NodeEvent.DISCOVERED, mac) return True @@ -501,7 +504,6 @@ async def _unload_discovered_nodes(self) -> None: # region - Network instance async def start(self) -> None: """Start and activate network.""" - self._register.quick_scan_finished(self._discover_registered_nodes) self._register.full_scan_finished(self._discover_registered_nodes) await self._register.start() @@ -523,6 +525,9 @@ async def discover_nodes(self, load: bool = True) -> bool: async def stop(self) -> None: """Stop network discovery.""" _LOGGER.debug("Stopping") + for task in self._discover_sed_tasks.values(): + if not task.done(): + task.cancel() self._is_running = False self._unsubscribe_to_protocol_events() await self._unload_discovered_nodes() From 169d98665d4105b25134ec44c01339c6c1837bd5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:25:35 +0100 Subject: [PATCH 458/979] Update circle.py --- plugwise_usb/nodes/circle.py | 196 ++++++++++++++++++++++------------- 1 file changed, 125 insertions(+), 71 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 74e175bcf..5095bcf9b 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -3,7 +3,8 @@ from __future__ import annotations from asyncio import Task, create_task, gather -from collections.abc import Callable +from collections.abc import Awaitable, Callable +from dataclasses import replace from datetime import UTC, datetime from functools import wraps import logging @@ -16,14 +17,17 @@ NodeInfo, NodeType, PowerStatistics, + RelayConfig, + RelayState, ) +from ..connection import StickController from ..constants import ( MAX_TIME_DRIFT, MINIMAL_POWER_UPDATE, PULSES_PER_KW_SECOND, SECOND_IN_NANOSECONDS, ) -from ..exceptions import NodeError +from ..exceptions import FeatureError, NodeError from ..messages.requests import ( CircleClockGetRequest, CircleClockSetRequest, @@ -36,6 +40,7 @@ ) from ..messages.responses import NodeInfoResponse, NodeResponse, NodeResponseType from .helpers import EnergyCalibration, raise_not_loaded +from .helpers.counter import EnergyCounters from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT from .helpers.pulses import PulseLogRecord, calc_log_address from .node import PlugwiseBaseNode @@ -68,8 +73,30 @@ def decorated(*args: Any, **kwargs: Any) -> Any: class PlugwiseCircle(PlugwiseBaseNode): """Plugwise Circle node.""" - _retrieve_energy_logs_task: None | Task[None] = None - _last_energy_log_requested: bool = False + def __init__( + self, + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ): + """Initialize base class for Sleeping End Device.""" + super().__init__(mac, address, controller, loaded_callback) + + # Relay + self._relay_state: RelayState = RelayState() + self._relay_config: RelayConfig = RelayConfig() + + # Power + self._power: PowerStatistics = PowerStatistics() + self._calibration: EnergyCalibration | None = None + + # Energy + self._energy_counters = EnergyCounters(mac) + self._retrieve_energy_logs_task: None | Task[None] = None + self._last_energy_log_requested: bool = False + + self._group_member: list[int] = [] # region Properties @@ -81,36 +108,62 @@ def calibrated(self) -> bool: return False @property - def energy(self) -> EnergyStatistics | None: + def energy(self) -> EnergyStatistics: """Energy statistics.""" return self._energy_counters.energy_statistics + @property + @raise_not_loaded + def energy_consumption_interval(self) -> int | None: + """Interval (minutes) energy consumption counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.consumption_interval + + @property + def energy_production_interval(self) -> int | None: + """Interval (minutes) energy production counters are locally logged at Circle devices.""" + if NodeFeature.ENERGY not in self._features: + raise NodeError(f"Energy log interval is not supported for node {self.mac}") + return self._energy_counters.production_interval + + @property + @raise_not_loaded + def power(self) -> PowerStatistics: + """Power statistics.""" + return self._power + @property @raise_not_loaded def relay(self) -> bool: """Current value of relay.""" - return bool(self._relay) + return bool(self._relay_state.state) + + @property + @raise_not_loaded + def relay_config(self) -> RelayConfig: + """Configuration state of relay.""" + if NodeFeature.RELAY_INIT not in self._features: + raise FeatureError( + f"Configuration of relay is not supported for device {self.name}" + ) + return self._relay_config + + @property + @raise_not_loaded + def relay_state(self) -> RelayState: + """State of relay.""" + return self._relay_state @raise_not_loaded async def relay_off(self) -> None: """Switch relay off.""" - await self.switch_relay(False) + await self.set_relay(False) @raise_not_loaded async def relay_on(self) -> None: """Switch relay on.""" - await self.switch_relay(True) - - @property - def relay_init( - self, - ) -> bool | None: - """Request the relay states at startup/power-up.""" - if NodeFeature.RELAY_INIT not in self._features: - raise NodeError( - f"Initial state of relay is not supported for device {self.name}" - ) - return self._relay_init_state + await self.set_relay(True) @raise_not_loaded async def relay_init_off(self) -> None: @@ -139,7 +192,7 @@ async def calibration_update(self) -> bool: ) await self._available_update_state(False) return False - await self._available_update_state(True) + await self._available_update_state(True, calibration_response.timestamp) await self._calibration_update_state( calibration_response.gain_a, calibration_response.gain_b, @@ -227,7 +280,7 @@ async def power_update(self) -> PowerStatistics | None: ) await self._available_update_state(False) return None - await self._available_update_state(True) + await self._available_update_state(True, response.timestamp) # Update power stats self._power.last_second = self._calc_watts( @@ -262,10 +315,10 @@ async def energy_update(self) -> EnergyStatistics | None: if await self.node_info_update() is None: if ( self._initialization_delay_expired is not None - and datetime.now(UTC) < self._initialization_delay_expired + and datetime.now(tz=UTC) < self._initialization_delay_expired ): _LOGGER.info( - "Unable to return energy statistics for %s, because it is not responding", + "Unable to return energy statistics for %s during initialization, because it is not responding", self.name, ) else: @@ -282,7 +335,7 @@ async def energy_update(self) -> EnergyStatistics | None: and datetime.now(tz=UTC) < self._initialization_delay_expired ): _LOGGER.info( - "Unable to return energy statistics for %s, because it is not responding", + "Unable to return energy statistics for %s during initialization, because it is not responding", self.name, ) else: @@ -372,7 +425,7 @@ async def energy_update(self) -> EnergyStatistics | None: and datetime.now(tz=UTC) < self._initialization_delay_expired ): _LOGGER.info( - "Unable to return energy statistics for %s, collecting required data...", + "Unable to return energy statistics for %s during initialization, collecting required data...", self.name, ) else: @@ -434,7 +487,7 @@ async def energy_log_update(self, address: int | None) -> bool: """Request energy log statistics from node. Returns true if successful.""" if address is None: return False - _LOGGER.info( + _LOGGER.debug( "Request of energy log at address %s for node %s", str(address), self.name, @@ -449,7 +502,7 @@ async def energy_log_update(self, address: int | None) -> bool: ) return False - await self._available_update_state(True) + await self._available_update_state(True, response.timestamp) energy_record_update = False # Forward historical energy log information to energy counters @@ -477,7 +530,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" cache_data = self._get_cache(CACHE_ENERGY_COLLECTION) if cache_data is None: - _LOGGER.info( + _LOGGER.debug( "Failed to restore energy log records from cache for node %s", self.name ) return False @@ -580,21 +633,19 @@ async def _energy_log_record_update_state( self._set_cache(CACHE_ENERGY_COLLECTION, log_cache_record) return True - async def switch_relay(self, state: bool) -> bool | None: - """Switch state of relay. - - Return new state of relay - """ - _LOGGER.debug("switch_relay() start") + @raise_not_loaded + async def set_relay(self, state: bool) -> bool: + """Change the state of the relay.""" + if NodeFeature.RELAY not in self._features: + raise FeatureError( + f"Changing state of relay is not supported for node {self.mac}" + ) + _LOGGER.debug("set_relay() start") request = CircleRelaySwitchRequest(self._send, self._mac_in_bytes, state) response = await request.send() if response is None or response.ack_id == NodeResponseType.RELAY_SWITCH_FAILED: - _LOGGER.warning( - "Request to switch relay for %s failed", - self.name, - ) - return None + raise NodeError(f"Request to switch relay for {self.name} failed") if response.ack_id == NodeResponseType.RELAY_SWITCHED_OFF: await self._relay_update_state(state=False, timestamp=response.timestamp) @@ -602,18 +653,14 @@ async def switch_relay(self, state: bool) -> bool | None: if response.ack_id == NodeResponseType.RELAY_SWITCHED_ON: await self._relay_update_state(state=True, timestamp=response.timestamp) return True - _LOGGER.warning( - "Unexpected NodeResponseType %s response for CircleRelaySwitchRequest at node %s...", - str(response.ack_id), - self.name, + + raise NodeError( + f"Unexpected NodeResponseType {response.ack_id!r} received " + + "in response to CircleRelaySwitchRequest for node {self.mac}" ) - return None async def _relay_load_from_cache(self) -> bool: """Load relay state from cache.""" - if self._relay is not None: - # State already known, no need to load from cache - return True if (cached_relay_data := self._get_cache(CACHE_RELAY)) is not None: _LOGGER.debug("Restore relay state cache for node %s", self._mac_in_str) relay_state = False @@ -633,18 +680,16 @@ async def _relay_update_state( self, state: bool, timestamp: datetime | None = None ) -> None: """Process relay state update.""" - self._relay_state.relay_state = state - self._relay_state.timestamp = timestamp state_update = False if state: self._set_cache(CACHE_RELAY, "True") - if self._relay is None or not self._relay: + if self._relay_state.state is None or not self._relay_state.state: state_update = True if not state: self._set_cache(CACHE_RELAY, "False") - if self._relay is None or self._relay: + if self._relay_state.state is None or self._relay_state.state: state_update = True - self._relay = state + self._relay_state = replace(self._relay_state, state=state, timestamp=timestamp) if state_update: await self.publish_feature_update_to_subscribers( NodeFeature.RELAY, self._relay_state @@ -812,9 +857,9 @@ async def initialize(self) -> bool: and await self.node_info_update() is None ): _LOGGER.debug("Failed to retrieve node info for %s", self._mac_in_str) - if NodeFeature.RELAY_INIT in self._features and self._relay_init_state is None: + if NodeFeature.RELAY_INIT in self._features: if (state := await self._relay_init_get()) is not None: - self._relay_init_state = state + self._relay_config = replace(self._relay_config, init_state=state) else: _LOGGER.debug( "Failed to initialized node %s, relay init", self._mac_in_str @@ -878,8 +923,9 @@ async def update_node_details( ) -> bool: """Process new node info and return true if all fields are updated.""" if relay_state is not None: - self._relay_state.relay_state = relay_state - self._relay_state.timestamp = timestamp + self._relay_state = replace( + self._relay_state, state=relay_state, timestamp=timestamp + ) if logaddress_pointer is not None: self._current_log_address = logaddress_pointer return await super().update_node_details( @@ -904,12 +950,17 @@ async def unload(self) -> None: await self._energy_log_records_save_to_cache() await super().unload() - async def switch_relay_init(self, state: bool) -> bool: - """Switch state of initial power-up relay state. Returns new state of relay.""" + @raise_not_loaded + async def set_relay_init(self, state: bool) -> bool: + """Change the initial power-on state of the relay.""" + if NodeFeature.RELAY_INIT not in self._features: + raise FeatureError( + f"Configuration of initial power-up relay state is not supported for node {self.mac}" + ) await self._relay_init_set(state) - if self._relay_init_state is None: - raise NodeError("Unknown relay init setting") - return self._relay_init_state + if self._relay_config.init_state is None: + raise NodeError("Failed to configure relay init setting") + return self._relay_config.init_state async def _relay_init_get(self) -> bool | None: """Get current configuration of the power-up state of the relay. Returns None if retrieval failed.""" @@ -923,7 +974,7 @@ async def _relay_init_get(self) -> bool | None: ) if (response := await request.send()) is not None: await self._relay_init_update_state(response.relay.value == 1) - return self._relay_init_state + return self._relay_config.init_state return None async def _relay_init_set(self, state: bool) -> bool | None: @@ -938,7 +989,7 @@ async def _relay_init_set(self, state: bool) -> bool | None: ) if (response := await request.send()) is not None: await self._relay_init_update_state(response.relay.value == 1) - return self._relay_init_state + return self._relay_config.init_state return None async def _relay_init_load_from_cache(self) -> bool: @@ -956,16 +1007,19 @@ async def _relay_init_update_state(self, state: bool) -> None: state_update = False if state: self._set_cache(CACHE_RELAY_INIT, "True") - if self._relay_init_state is None or not self._relay_init_state: + if ( + self._relay_config.init_state is None + or not self._relay_config.init_state + ): state_update = True if not state: self._set_cache(CACHE_RELAY_INIT, "False") - if self._relay_init_state is None or self._relay_init_state: + if self._relay_config.init_state is None or self._relay_config.init_state: state_update = True if state_update: - self._relay_init_state = state + self._relay_config = replace(self._relay_config, init_state=state) await self.publish_feature_update_to_subscribers( - NodeFeature.RELAY_INIT, self._relay_init_state + NodeFeature.RELAY_INIT, self._relay_config ) await self.save_cache() @@ -1035,7 +1089,7 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any ) for feature in features: states[feature] = None - states[NodeFeature.AVAILABLE] = False + states[NodeFeature.AVAILABLE] = self.available_state return states for feature in features: @@ -1058,7 +1112,7 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any states[feature], ) elif feature == NodeFeature.RELAY_INIT: - states[feature] = self._relay_init_state + states[feature] = self._relay_config elif feature == NodeFeature.POWER: states[feature] = await self.power_update() _LOGGER.debug( @@ -1069,6 +1123,6 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] - - states[NodeFeature.AVAILABLE] = self._available + if NodeFeature.AVAILABLE not in states: + states[NodeFeature.AVAILABLE] = self.available_state return states From f87d939aab2e5236bdb0d5d6c40ca66a013431c7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:26:10 +0100 Subject: [PATCH 459/979] add timestamp for last_seen --- plugwise_usb/nodes/circle_plus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 55bf6c656..1e5125fc4 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -90,7 +90,7 @@ async def clock_synchronize(self) -> bool: ) await self._available_update_state(False) return False - await self._available_update_state(True) + await self._available_update_state(True, clock_response.timestamp) _dt_of_circle: datetime = datetime.now(tz=UTC).replace( hour=clock_response.time.value.hour, From cc74aeb0bee9b90b9990984072e3c7d4d3de3a69 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:26:45 +0100 Subject: [PATCH 460/979] Fix unix timestamp for image validation not tested yet --- plugwise_usb/messages/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index ff0c1f5be..205ac4680 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -400,10 +400,10 @@ class NodeImageValidationResponse(PlugwiseResponse): Response to request : NodeImageValidationRequest """ - def __init__(self) -> None: + def __init__(self, timestamp: datetime | None = None) -> None: """Initialize NodeImageValidationResponse message object.""" super().__init__(b"0010") - self.image_timestamp = UnixTimestamp(0) + self.image_timestamp = UnixTimestamp(timestamp) self._params += [self.image_timestamp] From 1df841d1f97ecb2f5136bec344c3a9d0b723d275 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:27:39 +0100 Subject: [PATCH 461/979] Improve SCAN --- plugwise_usb/nodes/scan.py | 442 ++++++++++++++++++++++++------------- 1 file changed, 293 insertions(+), 149 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 9afae62a5..4137d66aa 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -2,13 +2,14 @@ from __future__ import annotations -from asyncio import Task +from asyncio import Task, gather from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime +from dataclasses import replace +from datetime import UTC, datetime import logging from typing import Any, Final -from ..api import MotionSensitivity, NodeEvent, NodeFeature +from ..api import MotionConfig, MotionSensitivity, MotionState, NodeEvent, NodeFeature from ..connection import StickController from ..exceptions import MessageError, NodeError, NodeTimeout from ..messages.requests import ScanConfigureRequest, ScanLightCalibrateRequest @@ -34,9 +35,11 @@ # region Defaults for Scan Devices +SCAN_DEFAULT_MOTION_STATE: Final = False + # Time in minutes the motion sensor should not sense motion to -# report "no motion" state -SCAN_DEFAULT_MOTION_RESET_TIMER: Final = 5 +# report "no motion" state [Source: 1min - 4uur] +SCAN_DEFAULT_MOTION_RESET_TIMER: Final = 10 # Default sensitivity of the motion sensors SCAN_DEFAULT_SENSITIVITY: Final = MotionSensitivity.MEDIUM @@ -59,13 +62,20 @@ def __init__( ): """Initialize Scan Device.""" super().__init__(mac, address, controller, loaded_callback) - self._config_task_scheduled = False - self._new_motion_reset_timer: int | None = None + 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 ) - self._new_sensitivity_level: MotionSensitivity | None = None # region Load & Initialize @@ -76,11 +86,18 @@ async def load(self) -> bool: if self._cache_enabled: _LOGGER.debug("Load Scan node %s from cache", self._node_info.mac) await self._load_from_cache() - + else: + self._load_defaults() self._loaded = True self._setup_protocol( SCAN_FIRMWARE_SUPPORT, - (NodeFeature.BATTERY, NodeFeature.INFO, NodeFeature.MOTION), + ( + NodeFeature.BATTERY, + NodeFeature.INFO, + NodeFeature.PING, + NodeFeature.MOTION, + NodeFeature.MOTION_CONFIG, + ), ) if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) @@ -93,7 +110,7 @@ async def initialize(self) -> bool: """Initialize Scan node.""" if self._initialized: return True - self._scan_subscription = self._message_subscribe( + self._unsubscribe_switch_group = await self._message_subscribe( self._switch_group, self._mac_in_bytes, (NODE_SWITCH_GROUP_ID,), @@ -102,65 +119,81 @@ async def initialize(self) -> bool: async def unload(self) -> None: """Unload node.""" - if self._scan_subscription is not None: - self._scan_subscription() + if self._unsubscribe_switch_group is not None: + self._unsubscribe_switch_group() await super().unload() # region Caching + def _load_defaults(self) -> None: + """Load default configuration settings.""" + 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, + ) + async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" if not await super()._load_from_cache(): + self._load_defaults() return False - if not await self.motion_from_cache() or not self.config_from_cache(): - return False - return True - - async def motion_from_cache(self) -> bool: - """Load motion state and timestamp from cache.""" - if ( - cached_motion_timestamp := self._get_cache_as_datetime( - CACHE_MOTION_TIMESTAMP - ) - ) is not None and ( - cached_motion_state := self._get_cache(CACHE_MOTION_STATE) - ) is not None: - motion_state = False - if cached_motion_state == "True": - motion_state = True - await self._motion_state_update(motion_state, cached_motion_timestamp) - _LOGGER.debug( - "Restore motion state (%s) and timestamp (%s) cache for node %s", - cached_motion_state, - cached_motion_timestamp, - self._mac_in_str, - ) + self._motion_state = MotionState( + state=self._motion_from_cache(), + timestamp=self._motion_timestamp_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(), + ) return True - def config_from_cache(self) -> bool: - """Load motion state and timestamp from cache.""" - if ( - cached_reset_timer := self._get_cache(CACHE_MOTION_RESET_TIMER) - ) is not None: - self._motion_state.reset_timer = int(cached_reset_timer) - else: - self._motion_state.reset_timer = SCAN_DEFAULT_MOTION_RESET_TIMER - - if ( - cached_sensitivity_level := self._get_cache(CACHE_SCAN_SENSITIVITY) - ) is not None: - self._sensitivity_level = MotionSensitivity[cached_sensitivity_level] - else: - self._sensitivity_level = SCAN_DEFAULT_SENSITIVITY + def _daylight_mode_from_cache(self) -> bool: + """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 + 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 == "True": + if ( + motion_timestamp := self._motion_timestamp_from_cache() + ) is not None: + if ( + datetime.now(tz=UTC) - motion_timestamp + ).seconds < self._reset_timer_from_cache() * 60: + return True + return False + return SCAN_DEFAULT_MOTION_STATE + + def _reset_timer_from_cache(self) -> int: + """Load reset timer from cache.""" + if (reset_timer := self._get_cache(CACHE_MOTION_RESET_TIMER)) is not None: + return int(reset_timer) + return SCAN_DEFAULT_MOTION_RESET_TIMER + + def _sensitivity_level_from_cache(self) -> MotionSensitivity: + """Load sensitivity level from cache.""" + if (sensitivity_level := self._get_cache(CACHE_SCAN_SENSITIVITY)) is not None: + return MotionSensitivity[sensitivity_level] + return SCAN_DEFAULT_SENSITIVITY + + def _motion_timestamp_from_cache(self) -> datetime | None: + """Load motion timestamp from cache.""" if ( - cached_daylight_mode := self._get_cache(CACHE_SCAN_DAYLIGHT_MODE) + motion_timestamp := self._get_cache_as_datetime(CACHE_MOTION_TIMESTAMP) ) is not None: - self._motion_state.daylight_mode = False - if cached_daylight_mode == "True": - self._motion_state.daylight_mode = True - else: - self._motion_state.daylight_mode = SCAN_DEFAULT_DAYLIGHT_MODE - return True + return motion_timestamp + return None # endregion @@ -170,82 +203,142 @@ def config_from_cache(self) -> bool: @raise_not_loaded def daylight_mode(self) -> bool: """Daylight mode of motion sensor.""" - if self._config_task_scheduled and self._new_daylight_mode is not None: - _LOGGER.debug( - "Return the new (scheduled to be changed) daylight_mode for %s", - self.mac, - ) + if self._new_daylight_mode is not None: return self._new_daylight_mode - if self._motion_state.daylight_mode is None: - raise NodeError(f"Daylight mode is unknown for node {self.mac}") - return self._motion_state.daylight_mode + if self._motion_config.daylight_mode is not None: + return self._motion_config.daylight_mode + return SCAN_DEFAULT_DAYLIGHT_MODE + @property @raise_not_loaded - async def update_daylight_mode(self, state: bool) -> None: - """Reconfigure daylight mode of motion sensor. - - Configuration will be applied next time when node is online. - """ - if state == self._motion_state.daylight_mode: - if self._new_daylight_mode is not None: - self._new_daylight_mode = None - return - self._new_daylight_mode = state - if self._config_task_scheduled: - return - await self.schedule_task_when_awake(self._configure_scan_task()) + def motion(self) -> bool: + """Motion detection value.""" + if self._motion_state.state is not None: + return self._motion_state.state + raise NodeError(f"Motion state is not available for {self.name}") @property @raise_not_loaded - def motion_reset_timer(self) -> int: - """Total minutes without motion before no motion is reported.""" - if self._config_task_scheduled and self._new_motion_reset_timer is not None: - _LOGGER.debug( - "Return the new (scheduled to be changed) motion reset timer for %s", - self.mac, - ) - return self._new_motion_reset_timer - if self._motion_state.reset_timer is None: - raise NodeError(f"Motion reset timer is unknown for node {self.mac}") - return self._motion_state.reset_timer + def motion_state(self) -> MotionState: + """Motion detection state.""" + return self._motion_state + @property @raise_not_loaded - async def update_motion_reset_timer(self, reset_timer: int) -> None: - """Reconfigure minutes without motion before no motion is reported. + def motion_timestamp(self) -> datetime: + """Timestamp of last motion state change.""" + if self._motion_state.timestamp is not None: + return self._motion_state.timestamp + raise NodeError(f"Motion timestamp is currently not available for {self.name}") - Configuration will be applied next time when node is online. - """ - if reset_timer == self._motion_state.reset_timer: - return - self._new_motion_reset_timer = reset_timer - if self._config_task_scheduled: - return - await self.schedule_task_when_awake(self._configure_scan_task()) + @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, + ) @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 + + @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: """Sensitivity level of motion sensor.""" - if self._config_task_scheduled and self._new_sensitivity_level is not None: + if self._new_sensitivity_level is not None: return self._new_sensitivity_level - if self._sensitivity_level is None: - raise NodeError(f"Sensitivity value is unknown for node {self.mac}") - return self._sensitivity_level + if self._motion_config.sensitivity_level is not None: + return self._motion_config.sensitivity_level + return SCAN_DEFAULT_SENSITIVITY + + # endregion + # region Configuration actions @raise_not_loaded - async def update_sensitivity_level( - self, sensitivity_level: MotionSensitivity - ) -> None: - """Reconfigure the sensitivity level for motion sensor. + async def set_motion_daylight_mode(self, state: bool) -> bool: + """Configure if motion must be detected when light level is below threshold. - Configuration will be applied next time when node is awake. + Configuration request will be queued and will be applied the next time when node is awake for maintenance. """ - if sensitivity_level == self._sensitivity_level: - return - self._new_sensitivity_level = sensitivity_level - if self._config_task_scheduled: - return - await self.schedule_task_when_awake(self._configure_scan_task()) + _LOGGER.debug( + "set_motion_daylight_mode | Device %s | %s -> %s", + self.name, + 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, + ) + return True + + @raise_not_loaded + async def set_motion_reset_timer(self, minutes: int) -> bool: + """Configure the motion reset timer in minutes.""" + _LOGGER.debug( + "set_motion_reset_timer | Device %s | %s -> %s", + self.name, + self._motion_config.reset_timer, + minutes, + ) + if minutes < 1 or minutes > 255: + 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, + ) + return True + + @raise_not_loaded + async def set_motion_sensitivity_level(self, level: MotionSensitivity) -> bool: + """Configure the motion sensitivity level.""" + _LOGGER.debug( + "set_motion_sensitivity_level | Device %s | %s -> %s", + self.name, + 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, + ) + return True + + # endregion # endregion @@ -258,67 +351,98 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" ) - await self._available_update_state(True) - await self._motion_state_update(response.switch_state, response.timestamp) + _LOGGER.warning("%s received %s", self.name, response) + await gather( + self._available_update_state(True, response.timestamp), + self._motion_state_update(response.switch_state, response.timestamp) + ) return True async def _motion_state_update( - self, motion_state: bool, timestamp: datetime | None = None + self, motion_state: bool, timestamp: datetime ) -> None: """Process motion state update.""" - self._motion_state.motion = motion_state - self._motion_state.timestamp = timestamp + _LOGGER.debug( + "motion_state_update for %s: %s -> %s", + self.name, + self._motion_state.state, + motion_state, + ) state_update = False if motion_state: self._set_cache(CACHE_MOTION_STATE, "True") - if self._motion is None or not self._motion: + if self._motion_state.state is None or not self._motion_state.state: + self._reset_timer_motion_on = timestamp state_update = True - if not motion_state: + else: self._set_cache(CACHE_MOTION_STATE, "False") - if self._motion is None or self._motion: + if self._motion_state.state is None or self._motion_state.state: + if self._reset_timer_motion_on is not None: + reset_timer = (timestamp - self._reset_timer_motion_on).seconds + if self._motion_config.reset_timer is None: + self._motion_config = replace( + self._motion_config, + reset_timer=reset_timer, + ) + elif reset_timer < self._motion_config.reset_timer: + _LOGGER.warning( + "Adjust reset timer for %s from %s -> %s", + self.name, + self._motion_config.reset_timer, + reset_timer, + ) + self._motion_config = replace( + self._motion_config, + reset_timer=reset_timer, + ) state_update = True self._set_cache(CACHE_MOTION_TIMESTAMP, timestamp) if state_update: - self._motion = motion_state - await self.publish_feature_update_to_subscribers( - NodeFeature.MOTION, + self._motion_state = replace( self._motion_state, + state=motion_state, + timestamp=timestamp, + ) + await gather( + *[ + self.publish_feature_update_to_subscribers( + NodeFeature.MOTION, + self._motion_state, + ), + self.save_cache(), + ] ) - await self.save_cache() 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_motion_reset_timer is not None: + 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: return True - if not await self.scan_configure( - motion_reset_timer=self.motion_reset_timer, + motion_reset_timer=self.reset_timer, sensitivity_level=self.sensitivity_level, daylight_mode=self.daylight_mode, ): return False - if self._new_motion_reset_timer is not None: + 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_state.reset_timer, - self._new_motion_reset_timer, + self._motion_config.reset_timer, + self._new_reset_timer, self.name, ) - self._new_motion_reset_timer = None + 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._sensitivity_level, + self._motion_config.sensitivity_level, self._new_sensitivity_level, self.name, ) @@ -326,7 +450,7 @@ async def _configure_scan_task(self) -> bool: 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_state.daylight_mode else "Off", + "On" if self._motion_config.daylight_mode else "Off", "On" if self._new_daylight_mode else "Off", self.name, ) @@ -346,7 +470,6 @@ async def scan_configure( sensitivity_value = 20 # b'14' if sensitivity_level == MotionSensitivity.OFF: sensitivity_value = 255 # b'FF' - request = ScanConfigureRequest( self._send, self._mac_in_bytes, @@ -356,17 +479,25 @@ async def scan_configure( ) if (response := await request.send()) is not None: if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_FAILED: - raise NodeError(f"Scan {self.mac} failed to configure scan settings") + 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 - else: - raise NodeTimeout( - f"No response from Scan device {self.mac} " - + "for configuration request." + _LOGGER.warning( + "Unexpected response ack type %s for %s", + response.node_ack_type, + self.name, ) + return False + self._new_reset_timer = None + self._new_sensitivity_level = None + self._new_daylight_mode = None return False async def _scan_configure_update( @@ -376,16 +507,25 @@ async def _scan_configure_update( daylight_mode: bool, ) -> None: """Process result of scan configuration update.""" - self._motion_state.reset_timer = motion_reset_timer + self._motion_config = replace( + self._motion_config, + reset_timer=motion_reset_timer, + sensitivity_level=sensitivity_level, + daylight_mode=daylight_mode, + ) self._set_cache(CACHE_MOTION_RESET_TIMER, str(motion_reset_timer)) - self._sensitivity_level = sensitivity_level self._set_cache(CACHE_SCAN_SENSITIVITY, sensitivity_level.value) - self._motion_state.daylight_mode = daylight_mode if daylight_mode: self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "True") else: self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "False") - await self.save_cache() + await gather( + self.publish_feature_update_to_subscribers( + NodeFeature.MOTION_CONFIG, + self._motion_config, + ), + self.save_cache(), + ) async def scan_calibrate_light(self) -> bool: """Request to calibration light sensitivity of Scan device.""" @@ -419,7 +559,11 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any ) if feature == NodeFeature.MOTION: states[NodeFeature.MOTION] = self._motion_state + elif feature == NodeFeature.MOTION_CONFIG: + states[NodeFeature.MOTION_CONFIG] = self._motion_config else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: + states[NodeFeature.AVAILABLE] = self.available_state return states From a165bc3d662fadecc58e5ee14bac0577d0286c16 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:27:52 +0100 Subject: [PATCH 462/979] Update sense.py --- plugwise_usb/nodes/sense.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 5e9634d1b..e73724ef0 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -2,11 +2,12 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging from typing import Any, Final from ..api import NodeEvent, NodeFeature +from ..connection import StickController from ..exceptions import MessageError, NodeError from ..messages.responses import SENSE_REPORT_ID, PlugwiseResponse, SenseReportResponse from ..nodes.sed import NodeSED @@ -32,7 +33,20 @@ class PlugwiseSense(NodeSED): """Plugwise Sense node.""" - _sense_subscription: Callable[[], None] | None = None + def __init__( + self, + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ): + """Initialize Scan Device.""" + super().__init__(mac, address, controller, loaded_callback) + + self._humidity: float | None = None + self._temperature: float | None = None + + self._sense_subscription: Callable[[], None] | None = None async def load(self) -> bool: """Load and activate Sense node features.""" @@ -58,7 +72,7 @@ async def initialize(self) -> bool: """Initialize Sense node.""" if self._initialized: return True - self._sense_subscription = self._message_subscribe( + self._sense_subscription = await self._message_subscribe( self._sense_report, self._mac_in_bytes, (SENSE_REPORT_ID,), @@ -78,7 +92,7 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected SenseReportResponse" ) - await self._available_update_state(True) + await self._available_update_state(True, response.timestamp) if response.temperature.value != 65535: self._temperature = int( SENSE_TEMPERATURE_MULTIPLIER * (response.temperature.value / 65536) @@ -121,6 +135,6 @@ async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any else: state_result = await super().get_state((feature,)) states[feature] = state_result[feature] - - states[NodeFeature.AVAILABLE] = self._available + if NodeFeature.AVAILABLE not in states: + states[NodeFeature.AVAILABLE] = self.available_state return states From d251c7e25f61d8ad76f6c7e80ca2c0e99fcd66ea Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:28:04 +0100 Subject: [PATCH 463/979] Update Switch --- plugwise_usb/nodes/switch.py | 130 +++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 4c63ef642..bf84fb10a 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -2,11 +2,15 @@ from __future__ import annotations -from collections.abc import Callable +from asyncio import gather +from collections.abc import Awaitable, Callable +from datetime import datetime import logging +from typing import Any, Final from ..api import NodeEvent, NodeFeature -from ..exceptions import MessageError +from ..connection import StickController +from ..exceptions import MessageError, NodeError from ..messages.responses import ( NODE_SWITCH_GROUP_ID, NodeSwitchGroupResponse, @@ -18,29 +22,43 @@ _LOGGER = logging.getLogger(__name__) +CACHE_SWITCH_STATE: Final = "switch_state" +CACHE_SWITCH_TIMESTAMP: Final = "switch_timestamp" + class PlugwiseSwitch(NodeSED): """Plugwise Switch node.""" - _switch_subscription: Callable[[], None] | None = None - _switch_state: bool | None = None + def __init__( + self, + mac: str, + address: int, + controller: StickController, + loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], + ): + """Initialize Scan Device.""" + super().__init__(mac, address, controller, loaded_callback) + self._switch_subscription: Callable[[], None] | None = None + self._switch_state: bool | None = None + self._switch: bool | None = None async def load(self) -> bool: """Load and activate Switch node features.""" if self._loaded: return True - self._node_info.is_battery_powered = True if self._cache_enabled: _LOGGER.debug("Load Switch node %s from cache", self._node_info.mac) - if await self._load_from_cache(): - self._loaded = True - self._setup_protocol( - SWITCH_FIRMWARE_SUPPORT, - (NodeFeature.INFO, NodeFeature.SWITCH), - ) - if await self.initialize(): - await self._loaded_callback(NodeEvent.LOADED, self.mac) - return True + await self._load_from_cache() + else: + self._load_defaults() + self._loaded = True + self._setup_protocol( + SWITCH_FIRMWARE_SUPPORT, + (NodeFeature.BATTERY, NodeFeature.INFO, NodeFeature.PING, NodeFeature.SWITCH), + ) + if await self.initialize(): + await self._loaded_callback(NodeEvent.LOADED, self.mac) + return True _LOGGER.debug("Load of Switch node %s failed", self._node_info.mac) return False @@ -49,7 +67,7 @@ async def initialize(self) -> bool: """Initialize Switch node.""" if self._initialized: return True - self._switch_subscription = self._message_subscribe( + self._switch_subscription = await self._message_subscribe( self._switch_group, self._mac_in_bytes, (NODE_SWITCH_GROUP_ID,), @@ -58,29 +76,87 @@ async def initialize(self) -> bool: async def unload(self) -> None: """Unload node.""" - self._loaded = False if self._switch_subscription is not None: self._switch_subscription() await super().unload() + # region Properties + + @property + @raise_not_loaded + def switch(self) -> bool: + """Current state of switch.""" + return bool(self._switch_state) + + #endregion + async def _switch_group(self, response: PlugwiseResponse) -> bool: """Switch group request from Switch.""" if not isinstance(response, NodeSwitchGroupResponse): raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeSwitchGroupResponse" ) + await gather( + self._available_update_state(True, response.timestamp), + self._switch_state_update(response.switch_state, response.timestamp) + ) + return True + + async def _switch_state_update( + self, switch_state: bool, timestamp: datetime + ) -> None: + """Process motion state update.""" + _LOGGER.debug( + "_switch_state_update for %s: %s -> %s", + self.name, + self._switch_state, + switch_state, + ) + state_update = False # Switch on - if response.switch_state: + if switch_state: + self._set_cache(CACHE_SWITCH_STATE, "True") if self._switch_state is None or not self._switch: self._switch_state = True - await self.publish_feature_update_to_subscribers( - NodeFeature.SWITCH, True - ) - return True - # Switch off - if self._switch is None or self._switch: - self._switch = False - await self.publish_feature_update_to_subscribers( - NodeFeature.SWITCH, False + state_update = True + else: + # Switch off + self._set_cache(CACHE_SWITCH_STATE, "False") + if self._switch is None or self._switch: + self._switch_state = False + state_update = True + self._set_cache(CACHE_SWITCH_TIMESTAMP, timestamp) + if state_update: + self._switch = switch_state + await gather( + *[ + self.publish_feature_update_to_subscribers( + NodeFeature.SWITCH, self._switch_state + ), + self.save_cache(), + ] ) - return True + + @raise_not_loaded + async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: + """Update latest state for given feature.""" + states: dict[NodeFeature, Any] = {} + for feature in features: + _LOGGER.debug( + "Updating node %s - feature '%s'", + self._node_info.mac, + feature, + ) + if feature not in self._features: + raise NodeError( + f"Update of feature '{feature.name}' is " + + f"not supported for {self.mac}" + ) + if feature == NodeFeature.SWITCH: + states[NodeFeature.SWITCH] = self._switch_state + else: + state_result = await super().get_state((feature,)) + states[feature] = state_result[feature] + if NodeFeature.AVAILABLE not in states: + states[NodeFeature.AVAILABLE] = self.available_state + return states From 211557a532d42a56fd8be5d3f2972143905c5f85 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:28:28 +0100 Subject: [PATCH 464/979] Add send --- plugwise_usb/messages/requests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 453f1a28d..fb9dd61a7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1098,6 +1098,17 @@ def __init__( port_mask_val = String(port_mask, length=16) self._args += [group_mac_val, task_id_val, port_mask_val] + async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + """Send request.""" + result = await self._send_request(suppress_node_errors) + if isinstance(result, NodeResponse): + return result + if result is None: + return None + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" + ) + class NodeRemoveFromGroupRequest(PlugwiseRequest): """Remove node from group. From 4a49e80658a34c58a14e35a9521f11aba7479076 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:48:00 +0100 Subject: [PATCH 465/979] Add pylint exemptions --- plugwise_usb/messages/requests.py | 9 ++++----- plugwise_usb/nodes/circle.py | 2 +- plugwise_usb/nodes/helpers/counter.py | 2 +- plugwise_usb/nodes/helpers/pulses.py | 2 +- plugwise_usb/nodes/sed.py | 4 ++-- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fb9dd61a7..e51ccfbe6 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -750,7 +750,7 @@ class CircleClockSetRequest(PlugwiseRequest): _identifier = b"0016" _reply_identifier = b"0000" - def __init__( + def __init__( #pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1083,7 +1083,7 @@ class NodeAddToGroupRequest(PlugwiseRequest): _identifier = b"0045" _reply_identifier = b"0000" - def __init__( + def __init__( #pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1231,7 +1231,7 @@ class NodeSleepConfigRequest(PlugwiseRequest): _identifier = b"0050" _reply_identifier = b"0000" - def __init__( + def __init__( #pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1283,7 +1283,6 @@ class NodeSelfRemoveRequest(PlugwiseRequest): - """ _identifier = b"0051" @@ -1403,7 +1402,7 @@ class ScanConfigureRequest(PlugwiseRequest): _identifier = b"0101" _reply_identifier = b"0100" - def __init__( + def __init__( #pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5095bcf9b..18841ccdf 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -912,7 +912,7 @@ async def _node_info_load_from_cache(self) -> bool: return result return False - async def update_node_details( + async def update_node_details( #pylint: disable=too-many-arguments self, firmware: datetime | None, hardware: str | None, diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 6e620346d..64e010b77 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -88,7 +88,7 @@ def add_pulse_log( timestamp: datetime, pulses: int, import_only: bool = False - ) -> None: + ) -> None: #pylint: disable=too-many-arguments """Add pulse log.""" if self._pulse_collection.add_log( address, diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 89fc0c6c2..7d3d30e64 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -370,7 +370,7 @@ def add_log( timestamp: datetime, pulses: int, import_only: bool = False, - ) -> bool: + ) -> bool: #pylint: disable=too-many-arguments """Store pulse log.""" log_record = PulseLogRecord(timestamp, pulses, CONSUMED) if not self._add_log_record(address, slot, log_record): diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 9464a014f..0beb9d558 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -657,7 +657,7 @@ async def schedule_task_when_awake( self._send_task_queue.append(task_fn) self._send_task_lock.release() - async def sed_configure( + async def sed_configure( #pylint: disable=too-many-arguments self, awake_duration: int, sleep_duration: int, @@ -712,7 +712,7 @@ async def sed_configure( ) return False - async def _sed_configure_update( + async def _sed_configure_update( #pylint: disable=too-many-arguments self, awake_duration: int = SED_DEFAULT_AWAKE_DURATION, clock_interval: int = SED_DEFAULT_CLOCK_INTERVAL, From 72c4e3583318cccde7f766460989d6c21467ed09 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:49:03 +0100 Subject: [PATCH 466/979] Use walrus operator --- plugwise_usb/network/__init__.py | 5 ++--- plugwise_usb/nodes/circle.py | 9 ++++----- plugwise_usb/nodes/circle_plus.py | 2 +- plugwise_usb/nodes/sed.py | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f92e4ad63..537ecc6b8 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -223,8 +223,7 @@ async def node_awake_message(self, response: PlugwiseResponse) -> bool: await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp return True - address = self._register.network_address(mac) - if address is None: + if (address := self._register.network_address(mac)) is None: if self._register.scan_completed: return True _LOGGER.debug( @@ -540,7 +539,7 @@ async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" request = CirclePlusAllowJoiningRequest(self._controller.send, state) response = await request.send() - if response is None: + if (response := await request.send()) is None: raise NodeError("No response to get notifications for join request.") if response.response_type != NodeResponseType.JOIN_ACCEPTED: raise MessageError( diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 18841ccdf..fa1efa685 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -185,7 +185,7 @@ async def calibration_update(self) -> bool: ) request = EnergyCalibrationRequest(self._send, self._mac_in_bytes) calibration_response = await request.send() - if calibration_response is None: + if (calibration_response := await request.send()) is None: _LOGGER.warning( "Retrieving energy calibration information for %s failed", self.name, @@ -494,7 +494,7 @@ async def energy_log_update(self, address: int | None) -> bool: ) request = CircleEnergyLogsRequest(self._send, self._mac_in_bytes, address) response = await request.send() - if response is None: + if (response := await request.send()) is None: _LOGGER.debug( "Retrieving of energy log at address %s for node %s failed", str(address), @@ -529,7 +529,7 @@ async def energy_log_update(self, address: int | None) -> bool: async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" cache_data = self._get_cache(CACHE_ENERGY_COLLECTION) - if cache_data is None: + if (cache_data := self._get_cache(CACHE_ENERGY_COLLECTION)) is None: _LOGGER.debug( "Failed to restore energy log records from cache for node %s", self.name ) @@ -730,8 +730,7 @@ async def clock_synchronize(self) -> bool: self._node_protocols.max, ) node_response: NodeResponse | None = await set_clock_request.send() - - if node_response is None: + if (node_response := await set_clock_request.send()) is None: _LOGGER.warning( "Failed to (re)set the internal clock of %s", self.name, diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index 1e5125fc4..f63c34ddc 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -84,7 +84,7 @@ async def clock_synchronize(self) -> bool: self._send, self._mac_in_bytes ) clock_response = await clock_request.send() - if clock_response is None: + if (clock_response := await clock_request.send()) is None: _LOGGER.debug( "No response for async_realtime_clock_synchronize() for %s", self.mac ) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 0beb9d558..ddb269430 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -685,7 +685,7 @@ async def sed_configure( #pylint: disable=too-many-arguments sleep_duration, ) response = await request.send() - if response is None: + 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 From 321135c20ce73a9b4cde6f417afa50520d22914d Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:49:24 +0100 Subject: [PATCH 467/979] Cleanup --- plugwise_usb/helpers/util.py | 3 +-- plugwise_usb/messages/requests.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/helpers/util.py b/plugwise_usb/helpers/util.py index 37e06458e..c8529d65c 100644 --- a/plugwise_usb/helpers/util.py +++ b/plugwise_usb/helpers/util.py @@ -11,7 +11,7 @@ def validate_mac(mac: str) -> bool: - """Validate the supplied string to be an MAC address.""" + """Validate the supplied string is in a MAC address format.""" if not re.match("^[A-F0-9]+$", mac): return False try: @@ -25,7 +25,6 @@ def version_to_model(version: str | None) -> str: """Translate hardware_version to device type.""" if version is None: return "Unknown" - model = HW_MODELS.get(version) if model is None: model = HW_MODELS.get(version[4:10]) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index e51ccfbe6..59b85ca39 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -320,7 +320,7 @@ def retries_left(self) -> int: @property def resend(self) -> bool: """Return true if retry counter is not reached yet.""" - return (self._max_retries > self._send_counter) + return self._max_retries > self._send_counter def add_send_attempt(self) -> None: """Increase the number of retries.""" From dd57b00cbf8955a6ca6f66e24812d96e7ecd20d5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 21:53:28 +0100 Subject: [PATCH 468/979] Add missing space --- plugwise_usb/messages/requests.py | 8 ++++---- plugwise_usb/nodes/circle.py | 2 +- plugwise_usb/nodes/helpers/counter.py | 2 +- plugwise_usb/nodes/helpers/pulses.py | 2 +- plugwise_usb/nodes/sed.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 59b85ca39..07850879e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -750,7 +750,7 @@ class CircleClockSetRequest(PlugwiseRequest): _identifier = b"0016" _reply_identifier = b"0000" - def __init__( #pylint: disable=too-many-arguments + def __init__( # pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1083,7 +1083,7 @@ class NodeAddToGroupRequest(PlugwiseRequest): _identifier = b"0045" _reply_identifier = b"0000" - def __init__( #pylint: disable=too-many-arguments + def __init__( # pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1231,7 +1231,7 @@ class NodeSleepConfigRequest(PlugwiseRequest): _identifier = b"0050" _reply_identifier = b"0000" - def __init__( #pylint: disable=too-many-arguments + def __init__( # pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1402,7 +1402,7 @@ class ScanConfigureRequest(PlugwiseRequest): _identifier = b"0101" _reply_identifier = b"0100" - def __init__( #pylint: disable=too-many-arguments + def __init__( # pylint: disable=too-many-arguments self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index fa1efa685..8925f76ec 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -911,7 +911,7 @@ async def _node_info_load_from_cache(self) -> bool: return result return False - async def update_node_details( #pylint: disable=too-many-arguments + async def update_node_details( # pylint: disable=too-many-arguments self, firmware: datetime | None, hardware: str | None, diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 64e010b77..c1505bcb3 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -88,7 +88,7 @@ def add_pulse_log( timestamp: datetime, pulses: int, import_only: bool = False - ) -> None: #pylint: disable=too-many-arguments + ) -> None: # pylint: disable=too-many-arguments """Add pulse log.""" if self._pulse_collection.add_log( address, diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 7d3d30e64..cd5b1f9c3 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -370,7 +370,7 @@ def add_log( timestamp: datetime, pulses: int, import_only: bool = False, - ) -> bool: #pylint: disable=too-many-arguments + ) -> bool: # pylint: disable=too-many-arguments """Store pulse log.""" log_record = PulseLogRecord(timestamp, pulses, CONSUMED) if not self._add_log_record(address, slot, log_record): diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index ddb269430..e26fbbbd4 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -657,7 +657,7 @@ async def schedule_task_when_awake( self._send_task_queue.append(task_fn) self._send_task_lock.release() - async def sed_configure( #pylint: disable=too-many-arguments + async def sed_configure( # pylint: disable=too-many-arguments self, awake_duration: int, sleep_duration: int, @@ -712,7 +712,7 @@ async def sed_configure( #pylint: disable=too-many-arguments ) return False - async def _sed_configure_update( #pylint: disable=too-many-arguments + async def _sed_configure_update( # pylint: disable=too-many-arguments self, awake_duration: int = SED_DEFAULT_AWAKE_DURATION, clock_interval: int = SED_DEFAULT_CLOCK_INTERVAL, From 92985b90fbfd5e5c68b36ee512ee3f1629dc72e9 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 22:01:52 +0100 Subject: [PATCH 469/979] Use walrus operator --- plugwise_usb/network/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index f6201b51a..a5e0c5cab 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -145,7 +145,7 @@ async def retrieve_network_registration( """Return the network mac registration of specified address.""" request = CirclePlusScanRequest(self._send_to_controller, self._mac_nc, address) response: CirclePlusScanResponse | None = await request.send() - if response is None: + if (response := await request.send()) is None: if retry: return await self.retrieve_network_registration(address, retry=False) return None @@ -276,7 +276,7 @@ async def unregister_node(self, mac: str) -> None: request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) response = await request.send() - if response is None: + if (response := await request.send()) is None: raise NodeError( f"The Zigbee network coordinator '{self._mac_nc!r}'" + f" did not respond to unregister node '{mac}'" From 8c8e0175a89f5c645fbfa34b5e23834a68447ec2 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 22:03:54 +0100 Subject: [PATCH 470/979] Apply formattnig --- plugwise_usb/messages/requests.py | 12 ++++-- plugwise_usb/nodes/circle.py | 3 +- plugwise_usb/nodes/helpers/counter.py | 53 ++++++++++----------------- plugwise_usb/nodes/helpers/pulses.py | 3 +- plugwise_usb/nodes/node.py | 5 ++- plugwise_usb/nodes/sed.py | 5 ++- 6 files changed, 38 insertions(+), 43 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 07850879e..44d2ccb46 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -750,7 +750,8 @@ class CircleClockSetRequest(PlugwiseRequest): _identifier = b"0016" _reply_identifier = b"0000" - def __init__( # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments + def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1083,7 +1084,8 @@ class NodeAddToGroupRequest(PlugwiseRequest): _identifier = b"0045" _reply_identifier = b"0000" - def __init__( # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments + def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1231,7 +1233,8 @@ class NodeSleepConfigRequest(PlugwiseRequest): _identifier = b"0050" _reply_identifier = b"0000" - def __init__( # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments + def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, @@ -1402,7 +1405,8 @@ class ScanConfigureRequest(PlugwiseRequest): _identifier = b"0101" _reply_identifier = b"0100" - def __init__( # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments + def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 8925f76ec..cbe222c57 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -911,7 +911,8 @@ async def _node_info_load_from_cache(self) -> bool: return result return False - async def update_node_details( # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments + async def update_node_details( self, firmware: datetime | None, hardware: str | None, diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index c1505bcb3..f894d8244 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -1,4 +1,5 @@ """Energy counter.""" + from __future__ import annotations from datetime import datetime, timedelta @@ -81,21 +82,17 @@ def add_empty_log(self, address: int, slot: int) -> None: """Add empty energy log record to mark any start of beginning of energy log collection.""" self._pulse_collection.add_empty_log(address, slot) - def add_pulse_log( + def add_pulse_log( # pylint: disable=too-many-arguments self, address: int, slot: int, timestamp: datetime, pulses: int, - import_only: bool = False - ) -> None: # pylint: disable=too-many-arguments + import_only: bool = False, + ) -> None: """Add pulse log.""" if self._pulse_collection.add_log( - address, - slot, - timestamp, - pulses, - import_only + address, slot, timestamp, pulses, import_only ): if not import_only: self.update() @@ -160,45 +157,37 @@ def update(self) -> None: self._pulse_collection.recalculate_missing_log_addresses() if self._calibration is None: return - self._energy_statistics.log_interval_consumption = self._pulse_collection.log_interval_consumption - self._energy_statistics.log_interval_production = self._pulse_collection.log_interval_production + self._energy_statistics.log_interval_consumption = ( + self._pulse_collection.log_interval_consumption + ) + self._energy_statistics.log_interval_production = ( + self._pulse_collection.log_interval_production + ) ( self._energy_statistics.hour_consumption, self._energy_statistics.hour_consumption_reset, - ) = self._counters[EnergyType.CONSUMPTION_HOUR].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.CONSUMPTION_HOUR].update(self._pulse_collection) ( self._energy_statistics.day_consumption, self._energy_statistics.day_consumption_reset, - ) = self._counters[EnergyType.CONSUMPTION_DAY].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.CONSUMPTION_DAY].update(self._pulse_collection) ( self._energy_statistics.week_consumption, self._energy_statistics.week_consumption_reset, - ) = self._counters[EnergyType.CONSUMPTION_WEEK].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.CONSUMPTION_WEEK].update(self._pulse_collection) ( self._energy_statistics.hour_production, self._energy_statistics.hour_production_reset, - ) = self._counters[EnergyType.PRODUCTION_HOUR].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.PRODUCTION_HOUR].update(self._pulse_collection) ( self._energy_statistics.day_production, self._energy_statistics.day_production_reset, - ) = self._counters[EnergyType.PRODUCTION_DAY].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.PRODUCTION_DAY].update(self._pulse_collection) ( self._energy_statistics.week_production, self._energy_statistics.week_production_reset, - ) = self._counters[EnergyType.PRODUCTION_WEEK].update( - self._pulse_collection - ) + ) = self._counters[EnergyType.PRODUCTION_WEEK].update(self._pulse_collection) @property def timestamp(self) -> datetime | None: @@ -223,9 +212,7 @@ def __init__( """Initialize energy counter based on energy id.""" self._mac = mac if energy_id not in ENERGY_COUNTERS: - raise EnergyError( - f"Invalid energy id '{energy_id}' for Energy counter" - ) + raise EnergyError(f"Invalid energy id '{energy_id}' for Energy counter") self._calibration: EnergyCalibration | None = None self._duration = "hour" if energy_id in ENERGY_DAY_COUNTERS: @@ -311,9 +298,7 @@ def update( if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) elif self._energy_id in ENERGY_DAY_COUNTERS: - last_reset = last_reset.replace( - hour=0, minute=0, second=0, microsecond=0 - ) + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) elif self._energy_id in ENERGY_WEEK_COUNTERS: last_reset = last_reset - timedelta(days=last_reset.weekday()) last_reset = last_reset.replace( diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index cd5b1f9c3..a3c0f6511 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -363,6 +363,7 @@ def add_empty_log(self, address: int, slot: int) -> None: if recalculate: self.recalculate_missing_log_addresses() + # pylint: disable=too-many-arguments def add_log( self, address: int, @@ -370,7 +371,7 @@ def add_log( timestamp: datetime, pulses: int, import_only: bool = False, - ) -> bool: # pylint: disable=too-many-arguments + ) -> bool: """Store pulse log.""" log_record = PulseLogRecord(timestamp, pulses, CONSUMED) if not self._add_log_record(address, slot, log_record): diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 2d90951c8..fd586406d 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -433,7 +433,9 @@ async def _available_update_state( return _LOGGER.info("Device %s detected to be not available (off-line)", self.name) self._available = False - await self.publish_feature_update_to_subscribers(NodeFeature.AVAILABLE, self.available_state) + await self.publish_feature_update_to_subscribers( + NodeFeature.AVAILABLE, self.available_state + ) async def node_info_update( self, node_info: NodeInfoResponse | None = None @@ -474,6 +476,7 @@ async def _node_info_load_from_cache(self) -> bool: logaddress_pointer=None, ) + # pylint: disable=too-many-arguments async def update_node_details( self, firmware: datetime | None, diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index e26fbbbd4..69197c19b 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -657,7 +657,7 @@ async def schedule_task_when_awake( self._send_task_queue.append(task_fn) self._send_task_lock.release() - async def sed_configure( # pylint: disable=too-many-arguments + async def sed_configure( # pylint: disable=too-many-arguments self, awake_duration: int, sleep_duration: int, @@ -712,7 +712,8 @@ async def sed_configure( # pylint: disable=too-many-arguments ) return False - async def _sed_configure_update( # pylint: disable=too-many-arguments + # 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, From 5872dc4fd0012401f8541f93a19ea64ff3884ea7 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 5 Jan 2025 22:17:14 +0100 Subject: [PATCH 471/979] Add too-many-positional-arguments as excemption --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 21b6caf71..138279fef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,6 +145,7 @@ disable = [ "wrong-import-order", ] # for now (20201031) added the below while we are codemerging/-improving +# too-many-positional-arguments # missing-class-docstring # missing-function-docstring # missing-module-docstring From 9c399bbb0d7d184b5781cfebed8b700dc6bb884f Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 16:58:38 +0100 Subject: [PATCH 472/979] Accept unavailable Network Coordinator --- plugwise_usb/network/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 537ecc6b8..ff06d5921 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -512,8 +512,7 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" - if not await self.discover_network_coordinator(load=load): - return False + await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() await self._discover_registered_nodes() From 9116521b0b8e014a1ffa08fc2c9fbc1f4143db84 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 16:59:11 +0100 Subject: [PATCH 473/979] Return response from local variable --- plugwise_usb/messages/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 44d2ccb46..2896a37ab 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -121,9 +121,9 @@ def response_future(self) -> Future[PlugwiseResponse]: @property def response(self) -> PlugwiseResponse: """Return response message.""" - if not self._response_future.done(): + if self._response is None: raise StickError("No response available") - return self._response_future.result() + return self._response @property def seq_id(self) -> bytes | None: From cf1e1379db7cabfa870201679097a32158896f16 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:04:58 +0100 Subject: [PATCH 474/979] Use seq_id in priority sorting if available --- plugwise_usb/messages/__init__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index e663f3cbf..9e4934e8a 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -79,31 +79,31 @@ def calculate_checksum(data: bytes) -> bytes: def __gt__(self, other: PlugwiseMessage) -> bool: """Greater than.""" if self.priority.value == other.priority.value: + if self.seq_id is not None and other.seq_id is not None: + return self.seq_id < other.seq_id return self.timestamp > other.timestamp - if self.priority.value < other.priority.value: - return True - return False + return self.priority.value < other.priority.value def __lt__(self, other: PlugwiseMessage) -> bool: """Less than.""" if self.priority.value == other.priority.value: + if self.seq_id is not None and other.seq_id is not None: + return self.seq_id > other.seq_id return self.timestamp < other.timestamp - if self.priority.value > other.priority.value: - return True - return False + return self.priority.value > other.priority.value def __ge__(self, other: PlugwiseMessage) -> bool: """Greater than or equal.""" if self.priority.value == other.priority.value: + if self.seq_id is not None and other.seq_id is not None: + return self.seq_id < other.seq_id return self.timestamp >= other.timestamp - if self.priority.value < other.priority.value: - return True - return False + return self.priority.value < other.priority.value def __le__(self, other: PlugwiseMessage) -> bool: """Less than or equal.""" if self.priority.value == other.priority.value: + if self.seq_id is not None and other.seq_id is not None: + return self.seq_id <= other.seq_id return self.timestamp <= other.timestamp - if self.priority.value > other.priority.value: - return True - return False + return self.priority.value > other.priority.value From 3ebbf9345668b67fb0a4a8551ec3cb1fe9d3771f Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 16:55:10 +0100 Subject: [PATCH 475/979] Remove duplicate send() --- plugwise_usb/network/registry.py | 1 - plugwise_usb/nodes/circle.py | 2 -- plugwise_usb/nodes/circle_plus.py | 2 -- 3 files changed, 5 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index a5e0c5cab..7c6b591b8 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -144,7 +144,6 @@ async def retrieve_network_registration( ) -> tuple[int, str] | None: """Return the network mac registration of specified address.""" request = CirclePlusScanRequest(self._send_to_controller, self._mac_nc, address) - response: CirclePlusScanResponse | None = await request.send() if (response := await request.send()) is None: if retry: return await self.retrieve_network_registration(address, retry=False) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index cbe222c57..4a9854dcd 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -184,7 +184,6 @@ async def calibration_update(self) -> bool: self._mac_in_str, ) request = EnergyCalibrationRequest(self._send, self._mac_in_bytes) - calibration_response = await request.send() if (calibration_response := await request.send()) is None: _LOGGER.warning( "Retrieving energy calibration information for %s failed", @@ -493,7 +492,6 @@ async def energy_log_update(self, address: int | None) -> bool: self.name, ) request = CircleEnergyLogsRequest(self._send, self._mac_in_bytes, address) - response = await request.send() if (response := await request.send()) is None: _LOGGER.debug( "Retrieving of energy log at address %s for node %s failed", diff --git a/plugwise_usb/nodes/circle_plus.py b/plugwise_usb/nodes/circle_plus.py index f63c34ddc..fcda89dee 100644 --- a/plugwise_usb/nodes/circle_plus.py +++ b/plugwise_usb/nodes/circle_plus.py @@ -83,7 +83,6 @@ async def clock_synchronize(self) -> bool: clock_request = CirclePlusRealTimeClockGetRequest( self._send, self._mac_in_bytes ) - clock_response = await clock_request.send() if (clock_response := await clock_request.send()) is None: _LOGGER.debug( "No response for async_realtime_clock_synchronize() for %s", self.mac @@ -113,7 +112,6 @@ async def clock_synchronize(self) -> bool: clock_set_request = CirclePlusRealTimeClockSetRequest( self._send, self._mac_in_bytes, datetime.now(tz=UTC) ) - node_response = await clock_set_request.send() if (node_response := await clock_set_request.send()) is not None: return node_response.ack_id == NodeResponseType.CLOCK_ACCEPTED _LOGGER.warning( From f5b920cb71fb47ff2d9f42d6d261e89356c0df18 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:22:59 +0100 Subject: [PATCH 476/979] Update queue.py --- plugwise_usb/connection/queue.py | 57 ++++++++++++++------------------ 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 502a46caa..12362a22d 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -1,4 +1,5 @@ """Manage the communication sessions towards the USB-Stick.""" + from __future__ import annotations from asyncio import PriorityQueue, Task, get_running_loop, sleep @@ -7,7 +8,7 @@ import logging from ..api import StickEvent -from ..exceptions import NodeTimeout, StickError, StickTimeout +from ..exceptions import MessageError, NodeTimeout, StickError, StickTimeout from ..messages import Priority from ..messages.requests import NodePingRequest, PlugwiseCancelRequest, PlugwiseRequest from ..messages.responses import PlugwiseResponse @@ -41,21 +42,15 @@ def is_running(self) -> bool: """Return the state of the queue.""" return self._running - def start( - self, - stick_connection_manager: StickConnectionManager - ) -> None: + def start(self, stick_connection_manager: StickConnectionManager) -> None: """Start sending request from queue.""" if self._running: raise StickError("Cannot start queue manager, already running") self._stick = stick_connection_manager if self._stick.is_connected: self._running = True - self._unsubscribe_connection_events = ( - self._stick.subscribe_to_stick_events( - self._handle_stick_event, - (StickEvent.CONNECTED, StickEvent.DISCONNECTED) - ) + self._unsubscribe_connection_events = self._stick.subscribe_to_stick_events( + self._handle_stick_event, (StickEvent.CONNECTED, StickEvent.DISCONNECTED) ) async def _handle_stick_event(self, event: StickEvent) -> None: @@ -79,16 +74,19 @@ async def stop(self) -> None: self._stick = None _LOGGER.debug("queue stopped") - async def submit( - self, request: PlugwiseRequest - ) -> PlugwiseResponse: + async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse: """Add request to queue and return the response of node. Raises an error when something fails.""" - _LOGGER.debug("Submit %s", request) - while request.resend: + if request.waiting_for_response: + raise MessageError( + f"Cannot send message {request} which is currently waiting for response." + ) + + while request.resend and not request.waiting_for_response: + _LOGGER.warning("submit | start (%s) %s", request.retries_left, request) if not self._running or self._stick is None: raise StickError( - f"Cannot send message {request.__class__.__name__} for" + - f"{request.mac_decoded} because queue manager is stopped" + f"Cannot send message {request.__class__.__name__} for" + + f"{request.mac_decoded} because queue manager is stopped" ) await self._add_request_to_queue(request) try: @@ -96,30 +94,26 @@ async def submit( except (NodeTimeout, StickTimeout) as e: if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level - _LOGGER.debug("%s, cancel because timeout is expected for NodePingRequests", e) + _LOGGER.debug( + "%s, cancel because timeout is expected for NodePingRequests", e + ) elif request.resend: - _LOGGER.info("%s, retrying", e) + _LOGGER.debug("%s, retrying", e) else: _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] except StickError as exception: _LOGGER.error(exception) raise StickError( - f"No response received for {request.__class__.__name__} " + - f"to {request.mac_decoded}" + f"No response received for {request.__class__.__name__} " + + f"to {request.mac_decoded}" ) from exception except BaseException as exception: raise StickError( - f"No response received for {request.__class__.__name__} " + - f"to {request.mac_decoded}" + f"No response received for {request.__class__.__name__} " + + f"to {request.mac_decoded}" ) from exception - else: - return response - raise StickError( - f"Failed to send {request.__class__.__name__} " + - f"to node {request.mac_decoded}, maximum number " + - f"of retries ({request.max_retries}) has been reached" - ) + return response async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue.""" @@ -127,8 +121,7 @@ async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: await self._submit_queue.put(request) if self._submit_worker_task is None or self._submit_worker_task.done(): self._submit_worker_task = self._loop.create_task( - self._send_queue_worker(), - name="Send queue worker" + self._send_queue_worker(), name="Send queue worker" ) async def _send_queue_worker(self) -> None: From 2e5c0eb46ae9d185e8f9306a119eab08475a96d8 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:24:07 +0100 Subject: [PATCH 477/979] Add waiting_for_response property --- plugwise_usb/messages/requests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 2896a37ab..3ee6e0ed1 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -105,6 +105,7 @@ def __init__( self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None self._response_future: Future[PlugwiseResponse] = self._loop.create_future() + self._waiting_for_response = False def __repr__(self) -> str: """Convert request into writable str.""" @@ -195,12 +196,19 @@ def start_response_timeout(self) -> None: self._response_timeout = self._loop.call_later( NODE_TIME_OUT, self._response_timeout_expired ) + self._waiting_for_response = True def stop_response_timeout(self) -> None: """Stop timeout for node response.""" + self._waiting_for_response = True if self._response_timeout is not None: self._response_timeout.cancel() + @property + def waiting_for_response(self) -> bool: + """Indicate if request is actively waiting for a response.""" + return self._waiting_for_response + def _response_timeout_expired(self, stick_timeout: bool = False) -> None: """Handle response timeout.""" if self._response_future.done(): @@ -232,6 +240,7 @@ def assign_error(self, error: BaseException) -> None: self._unsubscribe_from_node() if self._response_future.done(): return + self._waiting_for_response = False self._response_future.set_exception(error) async def process_node_response(self, response: PlugwiseResponse) -> bool: From 1e59a6fc8df6b07eaa767b3253b54a07b011eded Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:24:28 +0100 Subject: [PATCH 478/979] Update docstring --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 3ee6e0ed1..4cf6dd3ed 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1402,7 +1402,7 @@ class ScanConfigureRequest(PlugwiseRequest): """Configure a Scan node. reset_timer : Delay in minutes when signal is send - when no motion is detected + when no motion is detected. Minimum 1, max 255 sensitivity : Sensitivity of Motion sensor (High, Medium, Off) light : Daylight override to only report motion From e7ce40ba2d7541331ab26c30be513c624ea72dd8 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:26:53 +0100 Subject: [PATCH 479/979] Update stick subscription --- plugwise_usb/connection/receiver.py | 60 ++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index f0b0b8555..0cb8dc1ba 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -18,6 +18,7 @@ from __future__ import annotations from asyncio import ( + ensure_future, Future, Lock, PriorityQueue, @@ -101,6 +102,7 @@ def __init__( # Message processing self._message_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] + self._current_seq_id: bytes | None = None self._responses: dict[bytes, Callable[[PlugwiseResponse], None]] = {} self._message_worker_task: Task[None] | None = None self._delayed_processing_tasks: dict[bytes, Task[None]] = {} @@ -108,10 +110,14 @@ def __init__( # Subscribers self._stick_subscription_lock = Lock() self._node_subscription_lock = Lock() + self._stick_event_subscribers: dict[ Callable[[], None], StickEventSubscription ] = {} - self._stick_response_subscribers: dict[ + self._stick_subscribers_for_requests: dict[ + Callable[[], None], StickResponseSubscription + ] = {} + self._stick_subscribers_for_responses: dict[ Callable[[], None], StickResponseSubscription ] = {} @@ -250,7 +256,7 @@ async def _put_message_in_queue( self, response: PlugwiseResponse, delay: float = 0.0 ) -> None: """Put message in queue to be processed.""" - if delay > 0: + if delay > 0.0: await sleep(delay) _LOGGER.debug("Add response to queue: %s", response) await self._message_queue.put(response) @@ -270,11 +276,11 @@ async def _message_queue_worker(self) -> None: return _LOGGER.debug("Message queue worker queue: %s", response) if isinstance(response, StickResponse): - await self._notify_stick_response_subscribers(response) + await self._notify_stick_subscribers(response) else: await self._notify_node_response_subscribers(response) self._message_queue.task_done() - await sleep(0.001) + await sleep(0) _LOGGER.debug("Message queue worker stopped") # endregion @@ -319,23 +325,49 @@ async def subscribe_to_stick_responses( response_type: tuple[StickResponseType, ...] | None = None, ) -> Callable[[], None]: """Subscribe to response messages from stick.""" - await self._stick_subscription_lock.acquire() - def remove_subscription() -> None: + def remove_subscription_for_requests() -> None: + """Remove update listener.""" + self._stick_subscribers_for_requests.pop(remove_subscription_for_requests) + + def remove_subscription_for_responses() -> None: """Remove update listener.""" - self._stick_response_subscribers.pop(remove_subscription) + self._stick_subscribers_for_responses.pop(remove_subscription_for_responses) - self._stick_response_subscribers[remove_subscription] = ( + if seq_id is None: + await self._stick_subscription_lock.acquire() + self._stick_subscribers_for_requests[remove_subscription_for_requests] = ( + StickResponseSubscription(callback, seq_id, response_type) + ) + self._stick_subscription_lock.release() + return remove_subscription_for_requests + + self._stick_subscribers_for_responses[remove_subscription_for_responses] = ( StickResponseSubscription(callback, seq_id, response_type) ) - self._stick_subscription_lock.release() - return remove_subscription + return remove_subscription_for_responses - async def _notify_stick_response_subscribers( + async def _notify_stick_subscribers( self, stick_response: StickResponse ) -> None: """Call callback for all stick response message subscribers.""" - for subscription in list(self._stick_response_subscribers.values()): + await self._stick_subscription_lock.acquire() + for subscription in self._stick_subscribers_for_requests.values(): + if ( + subscription.seq_id is not None + and subscription.seq_id != stick_response.seq_id + ): + continue + if ( + subscription.stick_response_type is not None + and stick_response.response_type not in subscription.stick_response_type + ): + continue + _LOGGER.debug("Notify stick request subscriber for %s", stick_response) + await subscription.callback_fn(stick_response) + self._stick_subscription_lock.release() + + for subscription in list(self._stick_subscribers_for_responses.values()): if ( subscription.seq_id is not None and subscription.seq_id != stick_response.seq_id @@ -348,6 +380,9 @@ async def _notify_stick_response_subscribers( continue _LOGGER.debug("Notify stick response subscriber for %s", stick_response) await subscription.callback_fn(stick_response) + _LOGGER.debug("Finished Notify stick response subscriber for %s", stick_response) + + # endregion # region node @@ -467,5 +502,4 @@ async def _notify_node_response_subscribers( name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) - # endregion From f80311aca7913ab635d9d107e16f43038e0919db Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:33:49 +0100 Subject: [PATCH 480/979] Update write_request_to_port method --- plugwise_usb/connection/sender.py | 33 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 5b03ac730..a87ac33df 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -49,8 +49,13 @@ async def start(self) -> None: # Other stick responses are not related to this request. self._unsubscribe_stick_response = ( await self._receiver.subscribe_to_stick_responses( - self._process_stick_response, None, (StickResponseType.ACCEPT,) - # self._process_stick_response, None, (StickResponseType.ACCEPT, StickResponseType.FAILED) + self._process_stick_response, + None, + ( + StickResponseType.ACCEPT, + StickResponseType.TIMEOUT, + StickResponseType.FAILED, + ), ) ) @@ -68,7 +73,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: # Write message to serial port buffer serialized_data = request.serialize() - _LOGGER.debug("Write %s to port as %s", request, serialized_data) + _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) request.start_response_timeout() @@ -93,15 +98,21 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: _LOGGER.warning("Exception for %s: %s", request, exc) request.assign_error(exc) else: - _LOGGER.debug( - "USB-Stick replied with %s to request %s", response, request - ) + _LOGGER.debug("write_request_to_port | USB-Stick replied with %s to request %s", response, request) if response.response_type == StickResponseType.ACCEPT: - request.seq_id = response.seq_id - await request.subscribe_to_response( - self._receiver.subscribe_to_stick_responses, - self._receiver.subscribe_to_node_responses, - ) + if request.seq_id is not None: + request.assign_error( + BaseException( + StickError(f"USB-Stick failed communication for {request}") + ) + ) + else: + request.seq_id = response.seq_id + await request.subscribe_to_response( + self._receiver.subscribe_to_stick_responses, + self._receiver.subscribe_to_node_responses, + ) + _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning( "USB-Stick directly responded with communication timeout for %s", From f11d1429371f9f842a5f6ddb07aafa909db565ed Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:34:00 +0100 Subject: [PATCH 481/979] Add extra test --- tests/test_usb.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 363cb0796..3bd1ef5c1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -696,6 +696,7 @@ async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.initialize() await stick.discover_nodes(load=False) assert stick.joined_nodes == 11 + assert stick.nodes.get("0098765432101234") is not None assert len(stick.nodes) == 6 # Discovered nodes await stick.disconnect() From be9859f67a5bab09cc943678d7996b6cace0d02c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:39:38 +0100 Subject: [PATCH 482/979] Remove unused import --- plugwise_usb/connection/receiver.py | 1 - plugwise_usb/network/registry.py | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 0cb8dc1ba..968ad0881 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -18,7 +18,6 @@ from __future__ import annotations from asyncio import ( - ensure_future, Future, Lock, PriorityQueue, diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 7c6b591b8..70f478298 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,11 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import ( - CirclePlusScanResponse, - NodeResponseType, - PlugwiseResponse, -) +from ..messages.responses import NodeResponseType, PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) From 4228a5e12b57d86934f03f2c109c2dd94f9ac0d0 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jan 2025 17:46:50 +0100 Subject: [PATCH 483/979] Apply formatting --- plugwise_usb/connection/receiver.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 968ad0881..6b36be0d3 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -346,9 +346,7 @@ def remove_subscription_for_responses() -> None: ) return remove_subscription_for_responses - async def _notify_stick_subscribers( - self, stick_response: StickResponse - ) -> None: + async def _notify_stick_subscribers(self, stick_response: StickResponse) -> None: """Call callback for all stick response message subscribers.""" await self._stick_subscription_lock.acquire() for subscription in self._stick_subscribers_for_requests.values(): @@ -379,9 +377,9 @@ async def _notify_stick_subscribers( continue _LOGGER.debug("Notify stick response subscriber for %s", stick_response) await subscription.callback_fn(stick_response) - _LOGGER.debug("Finished Notify stick response subscriber for %s", stick_response) - - + _LOGGER.debug( + "Finished Notify stick response subscriber for %s", stick_response + ) # endregion # region node @@ -501,4 +499,5 @@ async def _notify_node_response_subscribers( name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) + # endregion From 80f3664341cce6ac7e8b279a9c7f097b1b6e29d7 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 14 Jan 2025 22:37:37 +0100 Subject: [PATCH 484/979] Correct subscription to feature updates for only intended node --- plugwise_usb/nodes/helpers/subscription.py | 10 ++++++---- plugwise_usb/nodes/node.py | 4 +++- tests/test_usb.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/helpers/subscription.py b/plugwise_usb/nodes/helpers/subscription.py index da91c656c..a3b2c0554 100644 --- a/plugwise_usb/nodes/helpers/subscription.py +++ b/plugwise_usb/nodes/helpers/subscription.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any + from ...api import NodeFeature @@ -20,11 +21,12 @@ class NodeFeatureSubscription: class FeaturePublisher: """Base Class to call awaitable of subscription when event happens.""" + def __init__(self) -> None: + self._feature_update_subscribers: dict[ + Callable[[], None], + NodeFeatureSubscription, + ] = {} - _feature_update_subscribers: dict[ - Callable[[], None], - NodeFeatureSubscription, - ] = {} def subscribe_to_feature_update( self, diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index fd586406d..75ec5dd64 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -63,6 +63,7 @@ def __init__( loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Plugwise base node class.""" + super().__init__() self._loaded_callback = loaded_callback self._message_subscribe = controller.subscribe_to_messages self._features: tuple[NodeFeature, ...] = NODE_FEATURES @@ -415,7 +416,8 @@ async def _available_update_state( if ( self._last_seen is not None and timestamp is not None - and self._last_seen < timestamp + and (timestamp - self._last_seen).seconds > 5 + ): self._last_seen = timestamp await self.publish_feature_update_to_subscribers( diff --git a/tests/test_usb.py b/tests/test_usb.py index 3bd1ef5c1..97731aec6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -838,7 +838,7 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No assert await stick.nodes["2222222222222222"].load() self.test_init_relay_state_on = asyncio.Future() self.test_init_relay_state_off = asyncio.Future() - unsub_inti_relay = stick.nodes["0098765432101234"].subscribe_to_feature_update( + unsub_inti_relay = stick.nodes["2222222222222222"].subscribe_to_feature_update( node_feature_callback=self.node_init_relay_state, features=(pw_api.NodeFeature.RELAY_INIT,), ) From 479e755dac63685b3eb806547b415d2e1cf869d7 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 14 Jan 2025 22:43:23 +0100 Subject: [PATCH 485/979] Correct message overload on intitial circle log fetch. Limit outstanding messages to 4 --- plugwise_usb/connection/manager.py | 7 +++++++ plugwise_usb/connection/queue.py | 14 +++++++++++--- plugwise_usb/connection/receiver.py | 13 ++++++++++++- plugwise_usb/connection/sender.py | 7 +++++++ plugwise_usb/nodes/circle.py | 18 +++++++----------- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index 7dea480be..74f1203e3 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -36,6 +36,13 @@ def __init__(self) -> None: ] = {} self._unsubscribe_stick_events: Callable[[], None] | None = None + @property + def queue_depth(self) -> int: + return self._sender.processed_messages - self._receiver.processed_messages + + def correct_received_messages(self, correction: int) -> None: + self._receiver.correct_processed_messages(correction) + @property def serial_path(self) -> str: """Return current port.""" diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 12362a22d..f84754868 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -82,7 +82,7 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse: ) while request.resend and not request.waiting_for_response: - _LOGGER.warning("submit | start (%s) %s", request.retries_left, request) + _LOGGER.debug("submit | start (%s) %s", request.retries_left, request) if not self._running or self._stick is None: raise StickError( f"Cannot send message {request.__class__.__name__} for" @@ -91,6 +91,7 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse: await self._add_request_to_queue(request) try: response: PlugwiseResponse = await request.response_future() + return response except (NodeTimeout, StickTimeout) as e: if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level @@ -103,17 +104,19 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse: _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] except StickError as exception: _LOGGER.error(exception) + self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" ) from exception except BaseException as exception: + self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" ) from exception - return response + return None async def _add_request_to_queue(self, request: PlugwiseRequest) -> None: """Add request to send queue.""" @@ -133,8 +136,13 @@ async def _send_queue_worker(self) -> None: if request.priority == Priority.CANCEL: self._submit_queue.task_done() return + + while self._stick.queue_depth > 3: + _LOGGER.info("Awaiting plugwise responses %d", self._stick.queue_depth) + await sleep(0.125) + await self._stick.write_to_stick(request) self._submit_queue.task_done() - await sleep(0.001) + _LOGGER.debug("Sent from queue %s", request) _LOGGER.debug("Send_queue_worker stopped") diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 6b36be0d3..917537afb 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -99,6 +99,7 @@ def __init__( self._data_worker_task: Task[None] | None = None # Message processing + self._processed_msgs = 0 self._message_queue: PriorityQueue[PlugwiseResponse] = PriorityQueue() self._last_processed_messages: list[bytes] = [] self._current_seq_id: bytes | None = None @@ -137,11 +138,20 @@ def connection_lost(self, exc: Exception | None = None) -> None: self._transport = None self._connection_state = False + @property + def processed_messages(self) -> int: + """Return the number of processed messages.""" + return self._processed_msgs + @property def is_connected(self) -> bool: """Return current connection state of the USB-Stick.""" return self._connection_state + def correct_processed_messages(self, correction: int) -> None: + """Return the number of processed messages.""" + self._processed_msgs += correction + def connection_made(self, transport: SerialTransport) -> None: """Call when the serial connection to USB-Stick is established.""" _LOGGER.info("Connection made") @@ -278,6 +288,7 @@ async def _message_queue_worker(self) -> None: await self._notify_stick_subscribers(response) else: await self._notify_node_response_subscribers(response) + self._processed_msgs += 1 self._message_queue.task_done() await sleep(0) _LOGGER.debug("Message queue worker stopped") @@ -457,7 +468,7 @@ async def _notify_node_response_subscribers( self._node_subscription_lock.release() if len(notify_tasks) > 0: - _LOGGER.debug("Received %s", node_response) + _LOGGER.debug("Received %s %s", node_response, node_response.seq_id) if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) # Limit tracking to only the last appended request (FIFO) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index a87ac33df..007103a25 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -38,11 +38,17 @@ def __init__(self, stick_receiver: StickReceiver, transport: Transport) -> None: self._loop = get_running_loop() self._receiver = stick_receiver self._transport = transport + self._processed_msgs = 0 self._stick_response: Future[StickResponse] | None = None self._stick_lock = Lock() self._current_request: None | PlugwiseRequest = None self._unsubscribe_stick_response: Callable[[], None] | None = None + @property + def processed_messages(self) -> int: + """Return the number of processed messages.""" + return self._processed_msgs + async def start(self) -> None: """Start the sender.""" # Subscribe to ACCEPT stick responses, which contain the seq_id we need. @@ -133,6 +139,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: finally: self._stick_response.cancel() self._stick_lock.release() + self._processed_msgs += 1 async def _process_stick_response(self, response: StickResponse) -> None: """Process stick response.""" diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 4a9854dcd..5886f580b 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather +from asyncio import Task, create_task from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -453,11 +453,8 @@ async def get_missing_energy_logs(self) -> None: log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 - if not all(await gather(*log_update_tasks)): - _LOGGER.info( - "Failed to request one or more update energy log for %s", - self._mac_in_str, - ) + for task in log_update_tasks: + await task if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -475,9 +472,8 @@ async def get_missing_energy_logs(self) -> None: ) missing_addresses = sorted(missing_addresses, reverse=True) - await gather( - *[self.energy_log_update(address) for address in missing_addresses] - ) + for address in missing_addresses: + await self.energy_log_update(address) if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -528,7 +524,7 @@ async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" cache_data = self._get_cache(CACHE_ENERGY_COLLECTION) if (cache_data := self._get_cache(CACHE_ENERGY_COLLECTION)) is None: - _LOGGER.debug( + _LOGGER.warning( "Failed to restore energy log records from cache for node %s", self.name ) return False @@ -811,7 +807,7 @@ async def _load_from_cache(self) -> bool: return False # Energy collection if await self._energy_log_records_load_from_cache(): - _LOGGER.debug( + _LOGGER.warning( "Node %s failed to load energy_log_records from cache", self._mac_in_str, ) From aff81cf8b41b98af8de82488b0cbfbd12d198c77 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 14 Jan 2025 22:44:13 +0100 Subject: [PATCH 486/979] use os.path.join --- plugwise_usb/helpers/cache.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/helpers/cache.py b/plugwise_usb/helpers/cache.py index ad00184cb..256a59094 100644 --- a/plugwise_usb/helpers/cache.py +++ b/plugwise_usb/helpers/cache.py @@ -59,10 +59,8 @@ async def initialize_cache(self, create_root_folder: bool = False) -> None: cache_dir = self._get_writable_os_dir() await makedirs(cache_dir, exist_ok=True) self._cache_path = cache_dir - if os_name == "nt": - self._cache_file = f"{cache_dir}\\{self._file_name}" - else: - self._cache_file = f"{cache_dir}/{self._file_name}" + + self._cache_file = os_path_join(self._cache_path, self._file_name) self._cache_file_exists = await ospath.exists(self._cache_file) self._initialized = True _LOGGER.debug("Start using network cache file: %s", self._cache_file) From 228de0e3d64e517c08f3716753a94d572aa5ecfc Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Tue, 14 Jan 2025 22:50:11 +0100 Subject: [PATCH 487/979] Negate SQ issue --- plugwise_usb/connection/queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index f84754868..29d1f74d1 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -74,7 +74,7 @@ async def stop(self) -> None: self._stick = None _LOGGER.debug("queue stopped") - async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse: + async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: """Add request to queue and return the response of node. Raises an error when something fails.""" if request.waiting_for_response: raise MessageError( From 597663a3df802db561c9e74df8c0f8d322f55ca8 Mon Sep 17 00:00:00 2001 From: Arnout Dieterman Date: Mon, 20 Jan 2025 13:08:43 +0100 Subject: [PATCH 488/979] Updating build to v0.40.0a22 Update to link to new release to test in my production HA --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 138279fef..52ca3ab50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a21" +version = "v0.40.0a22" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 4b0a293436591f8949efb52760db2c006ec49029 Mon Sep 17 00:00:00 2001 From: Arnout Dieterman Date: Mon, 20 Jan 2025 13:59:01 +0100 Subject: [PATCH 489/979] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b1f3d42..cc2295cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.40.0 (a22) + + - Correcting messageflow to HA + ## v0.40.0 (a4) Full rewrite of library into async version. Main list of changes: From 40776245651ef9ace335875478bc03252b4a79a6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 21 Jan 2025 18:06:39 +0100 Subject: [PATCH 490/979] Remove Protocol typing --- plugwise_usb/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 3aa54ab96..93eda01d5 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -5,7 +5,7 @@ from datetime import datetime from enum import Enum, auto import logging -from typing import Any, Protocol +from typing import Any _LOGGER = logging.getLogger(__name__) @@ -227,7 +227,7 @@ class EnergyStatistics: week_production_reset: datetime | None = None -class PlugwiseNode(Protocol): +class PlugwiseNode: """Protocol definition of a Plugwise device node.""" def __init__( From 4bfb65c2f28dd98f146e587c5b65a5a764be56b4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 21 Jan 2025 18:52:58 +0100 Subject: [PATCH 491/979] Fix CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2295cd7..deb700187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v0.40.0 (a22) - - Correcting messageflow to HA +- Correcting messageflow to HA ## v0.40.0 (a4) From 1b3a78030981e4073c5ef3ca9a2b885e9b41929d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 31 Jan 2025 18:52:06 +0100 Subject: [PATCH 492/979] Set to v0.40.0a23 test-version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 52ca3ab50..4772ac5b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a22" +version = "v0.40.0a23" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 7ec2eef8151fdf2006c428507831a1d342cf155a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 2 Feb 2025 13:51:03 +0100 Subject: [PATCH 493/979] Try alternative solution --- plugwise_usb/api.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 93eda01d5..4cb81e252 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -5,7 +5,7 @@ from datetime import datetime from enum import Enum, auto import logging -from typing import Any +from typing import Any, Protocol _LOGGER = logging.getLogger(__name__) @@ -227,17 +227,9 @@ class EnergyStatistics: week_production_reset: datetime | None = None -class PlugwiseNode: +class PlugwiseNode(Protocol): """Protocol definition of a Plugwise device node.""" - def __init__( - self, - mac: str, - address: int, - loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], - ) -> None: - """Initialize plugwise node object.""" - # region Generic node properties @property def features(self) -> tuple[NodeFeature, ...]: From 9a26a01ab73b20c7bd485834e6f93350ef8f11d3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 2 Feb 2025 13:51:28 +0100 Subject: [PATCH 494/979] Set to a25 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4772ac5b1..0aa28dcd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a23" +version = "v0.40.0a25" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 782b40571d4aaf09cf6f6dfc34aeae028d6de904 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 2 Feb 2025 13:57:36 +0100 Subject: [PATCH 495/979] Remove unused imports --- plugwise_usb/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index 4cb81e252..bbbfdaa6c 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -1,6 +1,5 @@ """Plugwise USB-Stick API.""" -from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime from enum import Enum, auto From f80cd0611859364e2237aacd9ee7d817ac05290b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 2 Feb 2025 12:19:30 +0100 Subject: [PATCH 496/979] Set receiver-logger to info --- plugwise_usb/connection/receiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 917537afb..950bc70f3 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -468,7 +468,7 @@ async def _notify_node_response_subscribers( self._node_subscription_lock.release() if len(notify_tasks) > 0: - _LOGGER.debug("Received %s %s", node_response, node_response.seq_id) + _LOGGER.info("Received %s %s", node_response, node_response.seq_id) if node_response.seq_id not in BROADCAST_IDS: self._last_processed_messages.append(node_response.seq_id) # Limit tracking to only the last appended request (FIFO) From 88eb341913af296cbad0142ae3d2bd30201c9135 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 2 Feb 2025 14:35:31 +0100 Subject: [PATCH 497/979] Set to a26 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0aa28dcd9..2aa948e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a25" +version = "v0.40.0a26" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 478746f2f4059e7b1dd72cc57d6d3f068ef4b61e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 10:03:41 +0100 Subject: [PATCH 498/979] Bump to a27 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2aa948e73..47a873930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a26" +version = "v0.40.0a27" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 8a16fb1e611b26490f1b078d4d7e6570eb406fa6 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 8 Feb 2025 15:41:40 +0100 Subject: [PATCH 499/979] Update to python 3.13 update pre-commit hooks --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a56962d5..b48abef4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ ci: default_language_version: # force all unspecified python hooks to run python3 - python: python3.12 + python: python3.13 repos: # Run manually in CI skipping the branch checks From 7ff01c3db0fe118ffcb16ce957e6088a62db4800 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 8 Feb 2025 15:56:15 +0100 Subject: [PATCH 500/979] always stop running tasks even on no transport --- plugwise_usb/connection/receiver.py | 9 +++++---- tests/bandit.yaml | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 950bc70f3..7ea263038 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -166,10 +166,9 @@ def connection_made(self, transport: SerialTransport) -> None: async def close(self) -> None: """Close connection.""" - if self._transport is None: - return - self._transport.close() await self._stop_running_tasks() + if self._transport: + self._transport.close() async def _stop_running_tasks(self) -> None: """Cancel and stop any running task.""" @@ -183,10 +182,12 @@ async def _stop_running_tasks(self) -> None: cancel_response.priority = Priority.CANCEL await self._message_queue.put(cancel_response) await self._message_worker_task - self._message_worker_task = None + self._message_worker_task = None + if self._data_worker_task is not None and not self._data_worker_task.done(): await self._data_queue.put(b"FFFFFFFF") await self._data_worker_task + self._data_worker_task = None # region Process incoming data diff --git a/tests/bandit.yaml b/tests/bandit.yaml index 46566cc98..4a8cda726 100644 --- a/tests/bandit.yaml +++ b/tests/bandit.yaml @@ -12,7 +12,6 @@ tests: - B317 - B318 - B319 - - B320 - B601 - B602 - B604 From e6ba81fa3831383c9ee501f4fc14a9cb4f577402 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 8 Feb 2025 15:57:55 +0100 Subject: [PATCH 501/979] No output on stick timeout, ruff formatting --- tests/test_usb.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 97731aec6..17002b9d1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -77,7 +77,7 @@ class DummyTransport: def __init__( self, loop: asyncio.AbstractEventLoop, - test_data: dict[bytes, tuple[str, bytes, bytes]] | None = None, + test_data: dict[bytes, tuple[str, bytes, bytes | None]] | None = None, ) -> None: """Initialize dummy transport class.""" self._loop = loop @@ -169,7 +169,7 @@ class MockSerial: """Mock serial connection.""" def __init__( - self, custom_response: dict[bytes, tuple[str, bytes, bytes]] | None + self, custom_response: dict[bytes, tuple[str, bytes, bytes | None]] | None ) -> None: """Init mocked serial connection.""" self.custom_response = custom_response @@ -421,7 +421,7 @@ async def test_stick_connect_timeout(self, monkeypatch: pytest.MonkeyPatch) -> N b"\x05\x05\x03\x03000AB43C\r\n": ( "STICK INIT timeout", b"000000E1", # Timeout ack - b"", + None, ), } ).mock_connection, @@ -526,6 +526,7 @@ async def node_loaded(self, event: pw_api.NodeEvent, mac: str) -> None: # type: f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.LOADED}" ) ) + async def node_motion_state( self, feature: pw_api.NodeFeature, # type: ignore[name-defined] @@ -1497,7 +1498,7 @@ async def test_stick_network_down(self, monkeypatch: pytest.MonkeyPatch) -> None def fake_env(self, env: str) -> str | None: """Fake environment.""" if env == "APPDATA": - return "c:\\user\\tst\\appdata" + return "appdata_folder" if env == "~": return "/home/usr" return None @@ -2125,7 +2126,6 @@ def fake_cache(dummy: object, setting: str) -> str | None: construct_message(b"0100555555555555555500BF", b"0000") ) - async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] """Load callback for event.""" @@ -2418,12 +2418,22 @@ async def test_node_discovery_and_load( # Get state get_state_timestamp = dt.now(UTC).replace(minute=0, second=0, microsecond=0) state = await stick.nodes["0098765432101234"].get_state( - (pw_api.NodeFeature.AVAILABLE, pw_api.NodeFeature.PING, pw_api.NodeFeature.INFO, pw_api.NodeFeature.RELAY) + ( + pw_api.NodeFeature.AVAILABLE, + pw_api.NodeFeature.PING, + pw_api.NodeFeature.INFO, + pw_api.NodeFeature.RELAY, + ) ) # Check Available assert state[pw_api.NodeFeature.AVAILABLE].state - assert state[pw_api.NodeFeature.AVAILABLE].last_seen.replace(minute=0, second=0, microsecond=0) == get_state_timestamp + assert ( + state[pw_api.NodeFeature.AVAILABLE].last_seen.replace( + minute=0, second=0, microsecond=0 + ) + == get_state_timestamp + ) # Check Ping assert state[pw_api.NodeFeature.PING].rssi_in == 69 From 1030d50b26dbb879d3ca161878fb637cdb4c1231 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 8 Feb 2025 15:59:39 +0100 Subject: [PATCH 502/979] python 3.13 --- pyproject.toml | 1 + scripts/python-venv.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 47a873930..f610d9015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,7 @@ lint.ignore = [ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "UP006", # keep type annotation style as is "UP007", # keep type annotation style as is + "UP031" # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 #"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] diff --git a/scripts/python-venv.sh b/scripts/python-venv.sh index 75b374fb8..655803ccc 100755 --- a/scripts/python-venv.sh +++ b/scripts/python-venv.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -eu -pyversions=(3.12 3.11) +pyversions=( 3.13 ) my_path=$(git rev-parse --show-toplevel) my_venv=${my_path}/venv From e13fa284f929ef24ee0ff94fa0f7d86bc2697aa6 Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 8 Feb 2025 20:32:36 +0100 Subject: [PATCH 503/979] prevent tight loop on queue depth --- plugwise_usb/connection/queue.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 29d1f74d1..4aa075a9f 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -137,9 +137,10 @@ async def _send_queue_worker(self) -> None: self._submit_queue.task_done() return - while self._stick.queue_depth > 3: - _LOGGER.info("Awaiting plugwise responses %d", self._stick.queue_depth) + if self._stick.queue_depth > 3: await sleep(0.125) + if self._stick.queue_depth > 3: + _LOGGER.warning("Awaiting plugwise responses %d", self._stick.queue_depth) await self._stick.write_to_stick(request) self._submit_queue.task_done() From b4852584d4a09f182169f2dfda09cbab3e723ce4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 10 Feb 2025 08:27:27 +0100 Subject: [PATCH 504/979] Bump to a29 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f610d9015..2e16b27fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a27" +version = "v0.40.0a29" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 06c8c21db89b50d78aefcd637c0ef0d19c553c75 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 10 Feb 2025 20:20:26 +0100 Subject: [PATCH 505/979] Provide decoded version-number too --- plugwise_usb/helpers/util.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/helpers/util.py b/plugwise_usb/helpers/util.py index c8529d65c..13c7a9fd4 100644 --- a/plugwise_usb/helpers/util.py +++ b/plugwise_usb/helpers/util.py @@ -27,12 +27,14 @@ def version_to_model(version: str | None) -> str: return "Unknown" model = HW_MODELS.get(version) if model is None: - model = HW_MODELS.get(version[4:10]) + version = version[4:10] + model = HW_MODELS.get(version) if model is None: # Try again with reversed order - model = HW_MODELS.get(version[-2:] + version[-4:-2] + version[-6:-4]) + version = version[-2:] + version[-4:-2] + version[-6:-4] + model = HW_MODELS.get(version) - return model if model is not None else "Unknown" + return (version, model) if model is not None else (None, "Unknown") # octals (and hex) type as int according to From a21e7a0ad125e03572f25581f6d03873fc2c3899 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 10 Feb 2025 20:30:21 +0100 Subject: [PATCH 506/979] Provide decode version-number as hardware/version --- plugwise_usb/nodes/node.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 75ec5dd64..982f64bfa 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -499,15 +499,15 @@ async def update_node_details( complete = False else: if self._node_info.version != hardware: - self._node_info.version = hardware # Generate modelname based on hardware version - model_info = version_to_model(hardware).split(" ") + hardware, model_info = version_to_model(hardware).split(" ") self._node_info.model = model_info[0] + self._node_info.version = hardware if self._node_info.model == "Unknown": _LOGGER.warning( "Failed to detect hardware model for %s based on '%s'", self.mac, - hardware, + version, ) if len(model_info) > 1: self._node_info.model_type = " ".join(model_info[1:]) From 87c1e46dcf3d04b6f93061f64d2eea13e97fe0c6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 10 Feb 2025 20:33:48 +0100 Subject: [PATCH 507/979] Improve --- plugwise_usb/nodes/node.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 982f64bfa..d75177991 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -500,14 +500,15 @@ async def update_node_details( else: if self._node_info.version != hardware: # Generate modelname based on hardware version - hardware, model_info = version_to_model(hardware).split(" ") + hardware, model_info = version_to_model(hardware) + model_info = model_info.split(" ") self._node_info.model = model_info[0] self._node_info.version = hardware if self._node_info.model == "Unknown": _LOGGER.warning( "Failed to detect hardware model for %s based on '%s'", self.mac, - version, + hardware, ) if len(model_info) > 1: self._node_info.model_type = " ".join(model_info[1:]) From 95e70c743d26c37c1051eece2e266c1ab7d4948a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 07:57:58 +0100 Subject: [PATCH 508/979] Fix Scan hardware version --- tests/stick_test_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index ac32b151a..7726f1b6a 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -654,7 +654,7 @@ + b"00000000" # log address + b"00" # relay + b"01" # hz - + b"000000070008" # hw_ver + + b"000000080007" # hw_ver + b"4E084590" # fw_ver + b"06", # node_type (Scan) ), From b5388831932617e611acaaf4c621a67fc4f050f7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 08:08:20 +0100 Subject: [PATCH 509/979] Correct hardware id's --- tests/stick_test_data.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 7726f1b6a..2d98689c4 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -47,7 +47,7 @@ + b"00044280" # log address 20 + b"01" # relay + b"01" # hz - + b"000000730007" # hw_ver + + b"000073000700" # hw_ver + b"4E0843A9" # fw_ver + b"01", # node_type (Circle+) ), @@ -445,7 +445,7 @@ + b"000442C0" # log address 44000 + b"01" # relay + b"01" # hz - + b"000000070140" # hw_ver + + b"000007014000" # hw_ver + b"4E0844C2" # fw_ver + b"02", # node_type (Circle) ), @@ -458,7 +458,7 @@ + b"00044300" # log address + b"01" # relay + b"01" # hz - + b"000000090011" # hw_ver + + b"000009001100" # hw_ver + b"4EB28FD5" # fw_ver + b"09", # node_type (Stealth - Legrand) ), @@ -495,7 +495,7 @@ + b"00044340" # log address + b"01" # relay + b"01" # hz - + b"000000070073" # hw_ver + + b"000007007300" # hw_ver + b"4DCCDB7B" # fw_ver + b"02", # node_type (Circle) ), @@ -508,7 +508,7 @@ + b"000443C0" # log address + b"01" # relay + b"01" # hz - + b"000000070073" # hw_ver + + b"000007007300" # hw_ver + b"4E0844C2" # fw_ver + b"02", # node_type (Circle) ), @@ -654,7 +654,7 @@ + b"00000000" # log address + b"00" # relay + b"01" # hz - + b"000000080007" # hw_ver + + b"000008000700" # hw_ver + b"4E084590" # fw_ver + b"06", # node_type (Scan) ), From 64627a5c2aaff0f520309e2f2911bb0a52878d3d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 17:28:36 +0100 Subject: [PATCH 510/979] Fix version asserts --- tests/test_usb.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 17002b9d1..acef8a0d8 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -586,7 +586,7 @@ async def test_stick_node_discovered_subscription( assert stick.nodes["5555555555555555"].node_info.firmware == dt( 2011, 6, 27, 8, 55, 44, tzinfo=UTC ) - assert stick.nodes["5555555555555555"].node_info.version == "000000070008" + assert stick.nodes["5555555555555555"].node_info.version == "080007" assert stick.nodes["5555555555555555"].node_info.model == "Scan" assert stick.nodes["5555555555555555"].node_info.model_type == "" assert stick.nodes["5555555555555555"].available @@ -2397,7 +2397,7 @@ async def test_node_discovery_and_load( assert stick.nodes["0098765432101234"].node_info.firmware == dt( 2011, 6, 27, 8, 47, 37, tzinfo=UTC ) - assert stick.nodes["0098765432101234"].node_info.version == "000000730007" + assert stick.nodes["0098765432101234"].node_info.version == "070073" assert stick.nodes["0098765432101234"].node_info.model == "Circle+" assert stick.nodes["0098765432101234"].node_info.model_type == "type F" assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" @@ -2482,7 +2482,7 @@ async def test_node_discovery_and_load( ) == get_state_timestamp ) - assert state[pw_api.NodeFeature.INFO].version == "000000730007" + assert state[pw_api.NodeFeature.INFO].version == "070073" assert state[pw_api.NodeFeature.RELAY].state @@ -2495,7 +2495,7 @@ async def test_node_discovery_and_load( 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 == "000000070140" + assert state[pw_api.NodeFeature.INFO].version == "070140" assert state[pw_api.NodeFeature.INFO].node_type == pw_api.NodeType.CIRCLE assert ( state[pw_api.NodeFeature.INFO].timestamp.replace( @@ -2529,7 +2529,7 @@ async def test_node_discovery_and_load( assert stick.nodes["5555555555555555"].node_info.firmware == dt( 2011, 6, 27, 8, 55, 44, tzinfo=UTC ) - assert stick.nodes["5555555555555555"].node_info.version == "000000070008" + assert stick.nodes["5555555555555555"].node_info.version == "080007" assert stick.nodes["5555555555555555"].node_info.model == "Scan" assert stick.nodes["5555555555555555"].node_info.model_type == "" assert stick.nodes["5555555555555555"].available @@ -2594,7 +2594,7 @@ async def test_node_discovery_and_load( assert stick.nodes["8888888888888888"].node_info.firmware == dt( 2011, 6, 27, 9, 4, 10, tzinfo=UTC ) - assert stick.nodes["8888888888888888"].node_info.version == "000007005100" + assert stick.nodes["8888888888888888"].node_info.version == "070051" assert stick.nodes["8888888888888888"].node_info.model == "Switch" assert stick.nodes["8888888888888888"].node_info.model_type == "" assert stick.nodes["8888888888888888"].available From f2554c22475efbcf7e883a80b0956111a9c2f080 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 17:43:43 +0100 Subject: [PATCH 511/979] Keep input version intact --- plugwise_usb/helpers/util.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/helpers/util.py b/plugwise_usb/helpers/util.py index 13c7a9fd4..342438359 100644 --- a/plugwise_usb/helpers/util.py +++ b/plugwise_usb/helpers/util.py @@ -24,17 +24,18 @@ def validate_mac(mac: str) -> bool: def version_to_model(version: str | None) -> str: """Translate hardware_version to device type.""" if version is None: - return "Unknown" - model = HW_MODELS.get(version) + return (None, "Unknown") + local_version = version + model = HW_MODELS.get(local_version) if model is None: - version = version[4:10] - model = HW_MODELS.get(version) + local_version = version[4:10] + model = HW_MODELS.get(local_version) if model is None: # Try again with reversed order - version = version[-2:] + version[-4:-2] + version[-6:-4] - model = HW_MODELS.get(version) + local_version = version[-2:] + version[-4:-2] + version[-6:-4] + model = HW_MODELS.get(local_version) - return (version, model) if model is not None else (None, "Unknown") + return (local_version, model) if model is not None else (None, "Unknown") # octals (and hex) type as int according to From 08425fe9a22265c56b3edf2b795eada11e9aa17a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 17:51:00 +0100 Subject: [PATCH 512/979] Fix typing --- plugwise_usb/helpers/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/helpers/util.py b/plugwise_usb/helpers/util.py index 342438359..8e85878fc 100644 --- a/plugwise_usb/helpers/util.py +++ b/plugwise_usb/helpers/util.py @@ -21,12 +21,12 @@ def validate_mac(mac: str) -> bool: return True -def version_to_model(version: str | None) -> str: +def version_to_model(version: str | None) -> tuple[str|None, str]: """Translate hardware_version to device type.""" if version is None: return (None, "Unknown") local_version = version - model = HW_MODELS.get(local_version) + model = HW_MODELS.get(version) if model is None: local_version = version[4:10] model = HW_MODELS.get(local_version) From 894d2a5ff49049807d53b860003a601f3ce7516c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 11 Feb 2025 20:17:39 +0100 Subject: [PATCH 513/979] Fix Circle+ hardware id --- tests/stick_test_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 2d98689c4..be98c323c 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -47,7 +47,7 @@ + b"00044280" # log address 20 + b"01" # relay + b"01" # hz - + b"000073000700" # hw_ver + + b"000000730007" # hw_ver + b"4E0843A9" # fw_ver + b"01", # node_type (Circle+) ), From cc97dd1d7252cff97945f8fb264cf0d96fa24abf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 11:50:37 +0100 Subject: [PATCH 514/979] Model_type should be None when not existing --- plugwise_usb/nodes/node.py | 3 +-- tests/test_usb.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index d75177991..dfeb0fe31 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -510,10 +510,9 @@ async def update_node_details( self.mac, hardware, ) + self._node_info.model_type = None if len(model_info) > 1: self._node_info.model_type = " ".join(model_info[1:]) - else: - self._node_info.model_type = "" if self._node_info.model is not None: self._node_info.name = f"{model_info[0]} {self._node_info.mac[-5:]}" self._set_cache(CACHE_HARDWARE, hardware) diff --git a/tests/test_usb.py b/tests/test_usb.py index acef8a0d8..7a10c3e87 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -588,7 +588,7 @@ async def test_stick_node_discovered_subscription( ) assert stick.nodes["5555555555555555"].node_info.version == "080007" assert stick.nodes["5555555555555555"].node_info.model == "Scan" - assert stick.nodes["5555555555555555"].node_info.model_type == "" + assert stick.nodes["5555555555555555"].node_info.model_type == None assert stick.nodes["5555555555555555"].available assert stick.nodes["5555555555555555"].node_info.is_battery_powered assert sorted(stick.nodes["5555555555555555"].features) == sorted( @@ -2531,7 +2531,7 @@ async def test_node_discovery_and_load( ) assert stick.nodes["5555555555555555"].node_info.version == "080007" assert stick.nodes["5555555555555555"].node_info.model == "Scan" - assert stick.nodes["5555555555555555"].node_info.model_type == "" + assert stick.nodes["5555555555555555"].node_info.model_type == None assert stick.nodes["5555555555555555"].available assert stick.nodes["5555555555555555"].node_info.is_battery_powered assert sorted(stick.nodes["5555555555555555"].features) == sorted( @@ -2596,7 +2596,7 @@ async def test_node_discovery_and_load( ) assert stick.nodes["8888888888888888"].node_info.version == "070051" assert stick.nodes["8888888888888888"].node_info.model == "Switch" - assert stick.nodes["8888888888888888"].node_info.model_type == "" + assert stick.nodes["8888888888888888"].node_info.model_type == None assert stick.nodes["8888888888888888"].available assert stick.nodes["8888888888888888"].node_info.is_battery_powered assert sorted(stick.nodes["8888888888888888"].features) == sorted( From f8b3c8a11c2643b08d2e1f87f57c7464afaf7c24 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:35:15 +0100 Subject: [PATCH 515/979] Reorder hardware devices in constants --- plugwise_usb/constants.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 47aab62e4..9068abc4d 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -65,8 +65,12 @@ "090088": "Circle+ type E", "070073": "Circle+ type F", "090048": "Circle+ type G", - "120049": "Stealth M+", + "090011": "Stealth", + "001200": "Stealth", "090188": "Stealth+", + "120048": "Stealth M", + "120049": "Stealth M+", + "120029": "Stealth Legrand", "120040": "Circle Legrand type E", "120001": "Circle Legrand type F", "090079": "Circle type B", @@ -74,10 +78,6 @@ "070140": "Circle type F", "090093": "Circle type G", "100025": "Circle", - "120048": "Stealth M", - "120029": "Stealth Legrand", - "090011": "Stealth", - "001200": "Stealth", "080007": "Scan", "110028": "Scan Legrand", "070030": "Sense", From 574800c8e4a605f562804c0b4e3842ddd49ea821 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:41:52 +0100 Subject: [PATCH 516/979] Handle Stealth M+ model correctly --- plugwise_usb/nodes/node.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index dfeb0fe31..2f3517ae1 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -503,6 +503,12 @@ async def update_node_details( hardware, model_info = version_to_model(hardware) model_info = model_info.split(" ") self._node_info.model = model_info[0] + # Handle Stealth M+ correctly + if model_info[1] == "M+": + self._node_info.model = model_info[0:1] + model_info[0] = model_info[0:1] + model_info.pop[1] + self._node_info.version = hardware if self._node_info.model == "Unknown": _LOGGER.warning( From c47ad1e196a123881d30f141076f68a6ce0ec55f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:52:16 +0100 Subject: [PATCH 517/979] Add + with space --- plugwise_usb/constants.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 9068abc4d..df8442103 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -58,26 +58,26 @@ "038500": "Stick", "070085": "Stick", "120002": "Stick Legrand", - "120041": "Circle+ Legrand type E", - "120000": "Circle+ Legrand type F", - "090000": "Circle+ type B", - "090007": "Circle+ type B", - "090088": "Circle+ type E", - "070073": "Circle+ type F", - "090048": "Circle+ type G", - "090011": "Stealth", - "001200": "Stealth", - "090188": "Stealth+", - "120048": "Stealth M", + "120041": "Circle + Legrand type E", + "120000": "Circle + Legrand type F", + "090000": "Circle + type B", + "090007": "Circle + type B", + "090088": "Circle + type E", + "070073": "Circle + type F", + "090048": "Circle + type G", + "090188": "Stealth +", "120049": "Stealth M+", "120029": "Stealth Legrand", + "100025": "Circle", "120040": "Circle Legrand type E", "120001": "Circle Legrand type F", "090079": "Circle type B", "090087": "Circle type E", "070140": "Circle type F", "090093": "Circle type G", - "100025": "Circle", + "090011": "Stealth", + "001200": "Stealth", + "120048": "Stealth M", "080007": "Scan", "110028": "Scan Legrand", "070030": "Sense", From 9f5d56024774c57c9f4399d987f1040243835e13 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:54:48 +0100 Subject: [PATCH 518/979] Handle all + models correctly --- plugwise_usb/nodes/node.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 2f3517ae1..a512a101d 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -503,10 +503,9 @@ async def update_node_details( hardware, model_info = version_to_model(hardware) model_info = model_info.split(" ") self._node_info.model = model_info[0] - # Handle Stealth M+ correctly - if model_info[1] == "M+": - self._node_info.model = model_info[0:1] - model_info[0] = model_info[0:1] + # Handle + devices + if "+" in model_info[1]: + self._node_info.model = model_info[0] = model_info[0:1] model_info.pop[1] self._node_info.version = hardware From d42c75916164bd24e8e0ad1b8fb81131c30f155a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:56:19 +0100 Subject: [PATCH 519/979] Adapt test-asserts --- tests/test_usb.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 7a10c3e87..bfd4ec430 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1466,7 +1466,7 @@ async def test_creating_request_messages(self) -> None: @pytest.mark.asyncio async def test_stick_network_down(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Testing timeout circle+ discovery.""" + """Testing timeout Circle + discovery.""" mock_serial = MockSerial( { b"\x05\x05\x03\x03000AB43C\r\n": ( @@ -2393,14 +2393,14 @@ async def test_node_discovery_and_load( assert len(stick.nodes) == 6 assert stick.nodes["0098765432101234"].is_loaded - assert stick.nodes["0098765432101234"].name == "Circle+ 01234" + assert stick.nodes["0098765432101234"].name == "Circle + 01234" assert stick.nodes["0098765432101234"].node_info.firmware == dt( 2011, 6, 27, 8, 47, 37, tzinfo=UTC ) assert stick.nodes["0098765432101234"].node_info.version == "070073" - assert stick.nodes["0098765432101234"].node_info.model == "Circle+" + assert stick.nodes["0098765432101234"].node_info.model == "Circle +" assert stick.nodes["0098765432101234"].node_info.model_type == "type F" - assert stick.nodes["0098765432101234"].node_info.name == "Circle+ 01234" + assert stick.nodes["0098765432101234"].node_info.name == "Circle + 01234" assert stick.nodes["0098765432101234"].available assert not stick.nodes["0098765432101234"].node_info.is_battery_powered assert not stick.nodes["0098765432101234"].is_battery_powered @@ -2472,8 +2472,8 @@ async def test_node_discovery_and_load( assert state[pw_api.NodeFeature.INFO].firmware == dt( 2011, 6, 27, 8, 47, 37, tzinfo=UTC ) - assert state[pw_api.NodeFeature.INFO].name == "Circle+ 01234" - assert state[pw_api.NodeFeature.INFO].model == "Circle+" + assert state[pw_api.NodeFeature.INFO].name == "Circle + 01234" + assert state[pw_api.NodeFeature.INFO].model == "Circle +" assert state[pw_api.NodeFeature.INFO].model_type == "type F" assert state[pw_api.NodeFeature.INFO].node_type == pw_api.NodeType.CIRCLE_PLUS assert ( From 95350844aef195967cf7268fdc847835d502c9f1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 13:59:56 +0100 Subject: [PATCH 520/979] Fix --- plugwise_usb/nodes/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index a512a101d..0d554662a 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -506,7 +506,7 @@ async def update_node_details( # Handle + devices if "+" in model_info[1]: self._node_info.model = model_info[0] = model_info[0:1] - model_info.pop[1] + model_info.pop(1) self._node_info.version = hardware if self._node_info.model == "Unknown": From c901250818e2ac48e0febb642ec7752e19948d86 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 14:06:34 +0100 Subject: [PATCH 521/979] Improve guarding --- plugwise_usb/nodes/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 0d554662a..b2430f77b 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -504,7 +504,7 @@ async def update_node_details( model_info = model_info.split(" ") self._node_info.model = model_info[0] # Handle + devices - if "+" in model_info[1]: + if len(model_info) > 1 and "+" in model_info[1]: self._node_info.model = model_info[0] = model_info[0:1] model_info.pop(1) From 628c07adcbab9a090359e580e7ae63cc28c12028 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 14:14:00 +0100 Subject: [PATCH 522/979] Fix combining two list items --- plugwise_usb/nodes/node.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index b2430f77b..1f937b99c 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -505,7 +505,8 @@ async def update_node_details( self._node_info.model = model_info[0] # Handle + devices if len(model_info) > 1 and "+" in model_info[1]: - self._node_info.model = model_info[0] = model_info[0:1] + self._node_info.model = model_info[0] + " " + model_info[1] + model_info[0] = self._node_info.model model_info.pop(1) self._node_info.version = hardware From f5d6033f19285914ca2dc15cfcbdb4acaaa7a6cb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 17:51:30 +0100 Subject: [PATCH 523/979] Try adding initial NodeInfoRequest to the Stick --- plugwise_usb/connection/__init__.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 9a2cf7fe0..61c381bc7 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -7,8 +7,9 @@ from typing import Any from ..api import StickEvent +from ..constants import UTF8 from ..exceptions import NodeError, StickError -from ..messages.requests import PlugwiseRequest, StickInitRequest +from ..messages.requests import NodeInfoRequest, PlugwiseRequest, StickInitRequest from ..messages.responses import PlugwiseResponse, StickInitResponse from .manager import StickConnectionManager from .queue import StickQueue @@ -26,6 +27,8 @@ def __init__(self) -> None: self._unsubscribe_stick_event: Callable[[], None] | None = None self._init_sequence_id: bytes | None = None self._is_initialized = False + self._fw_stick: str | None = None + self._hw_stick: str | None = None self._mac_stick: str | None = None self._mac_nc: str | None = None self._network_id: int | None = None @@ -43,6 +46,16 @@ def is_connected(self) -> bool: """Return connection state from connection manager.""" return self._manager.is_connected + @property + def firmware_stick(self) -> str | None: + """Firmware version of the Stick.""" + return self._fw_stick + + @property + def hardware_stick(self) -> str | None: + """Hardware version of the Stick.""" + return self._hw_stick + @property def mac_stick(self) -> str: """MAC address of USB-Stick. Raises StickError when not connected.""" @@ -167,6 +180,14 @@ async def initialize_stick(self) -> None: self._network_id = init_response.network_id self._is_initialized = True + # add Stick NodeInfoRequest + info_request = NodeInfoRequest( + self.send, bytes(self._mac_stick, UTF8), retries=1 + ) + info_response = await info_request.send() + self._fw_stick = info_response.firmware + self._hw_stick = info_response.hardware + if not self._network_online: raise StickError("Zigbee network connection to Circle+ is down.") From 27900f250b582f1c5c10397988ed9bf28238d9ee Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 18:02:44 +0100 Subject: [PATCH 524/979] Add Stick NodeInfoResponse --- tests/stick_test_data.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index be98c323c..8f61aef6b 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -38,6 +38,19 @@ + b"4321" # network_id + b"00", # unknown2 ), + b"\x05\x05\x03\x03002301234567890123451AE2\r\n": ( + "Node Info of stick 0123456789012345", + b"000000C1", # Success ack + b"0024" # msg_id + + b"0123456789012345" # mac + + b"22026A68" # datetime + + b"00044280" # log address 20 + + b"01" # relay + + b"01" # hz + + b"000000730007" # hw_ver + + b"4E0843A9" # fw_ver + + b"01", # node_type (Circle+) + ), b"\x05\x05\x03\x03002300987654321012341AE2\r\n": ( "Node Info of network controller 0098765432101234", b"000000C1", # Success ack From 7ba1f6635d66688e95d06c84939f14a6c3f78930 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 18:55:34 +0100 Subject: [PATCH 525/979] Fix --- tests/stick_test_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 8f61aef6b..c2d20cfd2 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -38,7 +38,7 @@ + b"4321" # network_id + b"00", # unknown2 ), - b"\x05\x05\x03\x03002301234567890123451AE2\r\n": ( + b"\x05\x05\x03\x0300230123456789012345A0EC\r\n": ( "Node Info of stick 0123456789012345", b"000000C1", # Success ack b"0024" # msg_id From d8169f245c5fca8230b1894eec01404524378742 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 19:02:32 +0100 Subject: [PATCH 526/979] Debug, full test-output --- scripts/tests_and_coverage.sh | 3 ++- tests/test_usb.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 884a5221b..7e62cab10 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -23,7 +23,8 @@ set +u if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then # Python tests (rerun with debug if failures) - PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ + # PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || + PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ fi if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then diff --git a/tests/test_usb.py b/tests/test_usb.py index bfd4ec430..9b4e94dea 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -460,6 +460,8 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.initialize() assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" + _LOGGER.debug("HOI fw_stick: %s", stick.firmware_stick) + _LOGGER.debug("HOI hw_stick: %s", stick.hardware_stick) assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 4b411bc7796af5f4b6f6173a4cb106f844d8e50e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 19:21:15 +0100 Subject: [PATCH 527/979] Fix data --- tests/stick_test_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index c2d20cfd2..91dab5bec 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -36,7 +36,7 @@ + b"01" # network_is_online + b"0098765432101234" # circle_plus_mac + b"4321" # network_id - + b"00", # unknown2 + + b"FF", # unknown2 ), b"\x05\x05\x03\x0300230123456789012345A0EC\r\n": ( "Node Info of stick 0123456789012345", @@ -49,7 +49,7 @@ + b"01" # hz + b"000000730007" # hw_ver + b"4E0843A9" # fw_ver - + b"01", # node_type (Circle+) + + b"00", # node_type (Stick) ), b"\x05\x05\x03\x03002300987654321012341AE2\r\n": ( "Node Info of network controller 0098765432101234", From 9ba31003350313d1dd90b6c5610bf09df2d4217b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 19:25:23 +0100 Subject: [PATCH 528/979] Add Stick properties --- plugwise_usb/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 5eb1ac63d..a1ee49837 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -117,6 +117,16 @@ def joined_nodes(self) -> int | None: return None return len(self._network.registry) + 1 + @property + def firmware_stick(self) -> str: + """Firmware of USB-Stick.""" + return self._controller.firmware_stick + + @property + def hardware_stick(self) -> str: + """Hardware of USB-Stick.""" + return self._controller.hardware_stick + @property def mac_stick(self) -> str: """MAC address of USB-Stick. Raises StickError is connection is missing.""" From 221e830d6155a1fe187c972853a5010c8661095f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 7 Feb 2025 19:41:18 +0100 Subject: [PATCH 529/979] Add stick-hw-fw asserts --- tests/test_usb.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 9b4e94dea..056b4e6a6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -460,8 +460,10 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.initialize() assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" - _LOGGER.debug("HOI fw_stick: %s", stick.firmware_stick) - _LOGGER.debug("HOI hw_stick: %s", stick.hardware_stick) + assert stick.firmware_stick == dt( + 2011, 6, 27, 8, 47, 37, tzinfo=UTC + ) + assert stick.hardware_stick == "000000730007" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 040ce916903abdc64eebee115a688e1af4f08adf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 10:22:29 +0100 Subject: [PATCH 530/979] Use existing function --- plugwise_usb/connection/__init__.py | 50 ++++++++++++++++++++++++----- plugwise_usb/network/__init__.py | 34 ++------------------ 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 61c381bc7..26154faff 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -9,8 +9,18 @@ from ..api import StickEvent from ..constants import UTF8 from ..exceptions import NodeError, StickError -from ..messages.requests import NodeInfoRequest, PlugwiseRequest, StickInitRequest -from ..messages.responses import PlugwiseResponse, StickInitResponse +from ..messages.requests import ( + NodeInfoRequest, + NodePingRequest, + PlugwiseRequest, + StickInitRequest, +) +from ..messages.responses import ( + NodeInfoResponse, + NodePingResponse, + PlugwiseResponse, + StickInitResponse, +) from .manager import StickConnectionManager from .queue import StickQueue @@ -181,16 +191,40 @@ async def initialize_stick(self) -> None: self._is_initialized = True # add Stick NodeInfoRequest - info_request = NodeInfoRequest( - self.send, bytes(self._mac_stick, UTF8), retries=1 - ) - info_response = await info_request.send() - self._fw_stick = info_response.firmware - self._hw_stick = info_response.hardware + node_info, _ = await self.get_node_details(self._mac_stick, False) + if node_info is not None: + self._fw_stick = node_info.firmware + self._hw_stick = node_info.hardware if not self._network_online: raise StickError("Zigbee network connection to Circle+ is down.") + async def get_node_details( + self, mac: str, ping_first: bool + ) -> tuple[NodeInfoResponse | None, NodePingResponse | None]: + """Return node discovery type.""" + ping_response: NodePingResponse | None = None + if ping_first: + # Define ping request with one retry + ping_request = NodePingRequest( + self.send, bytes(mac, UTF8), retries=1 + ) + try: + ping_response = await ping_request.send(suppress_node_errors=True) + except StickError: + return (None, None) + if ping_response is None: + return (None, None) + + info_request = NodeInfoRequest( + self.send, bytes(mac, UTF8), retries=1 + ) + try: + info_response = await info_request.send() + except StickError: + return (None, None) + return (info_response, ping_response) + async def send( self, request: PlugwiseRequest, suppress_node_errors: bool = True ) -> PlugwiseResponse | None: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index ff06d5921..43d62f3f9 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -15,11 +15,7 @@ from ..constants import UTF8 from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout from ..helpers.util import validate_mac -from ..messages.requests import ( - CirclePlusAllowJoiningRequest, - NodeInfoRequest, - NodePingRequest, -) +from ..messages.requests import CirclePlusAllowJoiningRequest, NodePingRequest from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, NODE_JOIN_ID, @@ -365,32 +361,6 @@ def _create_node_object( self._nodes[mac].cache_folder_create = self._cache_folder_create self._nodes[mac].cache_enabled = True - async def get_node_details( - self, mac: str, ping_first: bool - ) -> tuple[NodeInfoResponse | None, NodePingResponse | None]: - """Return node discovery type.""" - ping_response: NodePingResponse | None = None - if ping_first: - # Define ping request with one retry - ping_request = NodePingRequest( - self._controller.send, bytes(mac, UTF8), retries=1 - ) - try: - ping_response = await ping_request.send(suppress_node_errors=True) - except StickError: - return (None, None) - if ping_response is None: - return (None, None) - - info_request = NodeInfoRequest( - self._controller.send, bytes(mac, UTF8), retries=1 - ) - try: - info_response = await info_request.send() - except StickError: - return (None, None) - return (info_response, ping_response) - async def _discover_battery_powered_node( self, address: int, @@ -432,7 +402,7 @@ async def _discover_node( # Node type is unknown, so we need to discover it first _LOGGER.debug("Starting the discovery of node %s", mac) - node_info, node_ping = await self.get_node_details(mac, ping_first) + node_info, node_ping = await self._controller.get_node_details(mac, ping_first) if node_info is None: return False self._create_node_object(mac, address, node_info.node_type) From d6ff12f43a797878c90c2f8ab3ea7ca6d9f32609 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 10:24:35 +0100 Subject: [PATCH 531/979] Back to short test-output --- scripts/tests_and_coverage.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 7e62cab10..884a5221b 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -23,8 +23,7 @@ set +u if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then # Python tests (rerun with debug if failures) - # PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || - PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ + PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ fi if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then From 24ec453f5c5c0b68747faf158a6a19205781062d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 13:02:31 +0100 Subject: [PATCH 532/979] Enter real stick-response data --- tests/stick_test_data.py | 10 +++++----- tests/test_usb.py | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 91dab5bec..e3623f61e 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -43,11 +43,11 @@ b"000000C1", # Success ack b"0024" # msg_id + b"0123456789012345" # mac - + b"22026A68" # datetime - + b"00044280" # log address 20 - + b"01" # relay - + b"01" # hz - + b"000000730007" # hw_ver + + b"00000000" # datetime + + b"00000000" # log address 0 + + b"00" # relay + + b"80" # hz + + b"653907008512" # hw_ver + b"4E0843A9" # fw_ver + b"00", # node_type (Stick) ), diff --git a/tests/test_usb.py b/tests/test_usb.py index 056b4e6a6..b2c33cf43 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -460,9 +460,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.initialize() assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" - assert stick.firmware_stick == dt( - 2011, 6, 27, 8, 47, 37, tzinfo=UTC - ) + assert stick.firmware_stick == None assert stick.hardware_stick == "000000730007" assert not stick.network_discovered assert stick.network_state From c8c5b1b10bb42c507c347714bbe5d7ed50576f5c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 13:18:34 +0100 Subject: [PATCH 533/979] Debug --- plugwise_usb/messages/properties.py | 3 +++ plugwise_usb/messages/responses.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 40f76d4f8..0d9f4c2a0 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -2,6 +2,7 @@ import binascii from datetime import UTC, date, datetime, time, timedelta +import logging import struct from typing import Any @@ -9,6 +10,7 @@ from ..exceptions import MessageError from ..helpers.util import int_to_uint +_LOGGER = logging.getLogger(__name__) class BaseType: """Generic single instance property.""" @@ -227,6 +229,7 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" + _LOGGER.debug("HOI val: %s", val) if val == b"FFFFFFFF": self._value = None else: diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 205ac4680..5a3b26db9 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum +import logging from typing import Any, Final from ..api import NodeType @@ -41,6 +42,8 @@ SWITCH_GROUP_RESPONSE_SEQ_ID, ) +_LOGGER = logging.getLogger(__name__) + class StickResponseType(bytes, Enum): """Response message types for stick.""" @@ -190,6 +193,7 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: self._mac = response[:16] response = response[16:] if len(response) > 0: + _LOGGER.debug("HOI response: %s", response) try: response = self._parse_params(response) except ValueError as ve: @@ -203,8 +207,12 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: def _parse_params(self, response: bytes) -> bytes: for param in self._params: my_val = response[: len(param)] + _LOGGER.debug("HOI param: %s", param) + _LOGGER.debug("HOI my_val: %s", my_val) param.deserialize(my_val) response = response[len(my_val) :] + _LOGGER.debug("HOI response: %s", response) + return response def __len__(self) -> int: From 40166e498e6018d8389cc1c41496b537b40ed633 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 13:50:56 +0100 Subject: [PATCH 534/979] Handle Stick zero-response for firmware --- plugwise_usb/messages/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 0d9f4c2a0..453ccb471 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -230,7 +230,7 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" _LOGGER.debug("HOI val: %s", val) - if val == b"FFFFFFFF": + if val == b"FFFFFFFF" or val == b"00000000": self._value = None else: CompositeType.deserialize(self, val) From 2e63bb21eaedcc9256c8cb14513d54b195f36beb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 13:55:20 +0100 Subject: [PATCH 535/979] Fix assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index b2c33cf43..65fcc9aec 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == None - assert stick.hardware_stick == "000000730007" + assert stick.hardware_stick == "653907008512" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 7d6bb113b9dff7a4e1c4c91f1a49017a46da7ae5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 14:01:31 +0100 Subject: [PATCH 536/979] Disable --- plugwise_usb/connection/__init__.py | 10 +++++----- tests/test_usb.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 26154faff..4c765a275 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -190,11 +190,11 @@ async def initialize_stick(self) -> None: self._network_id = init_response.network_id self._is_initialized = True - # add Stick NodeInfoRequest - node_info, _ = await self.get_node_details(self._mac_stick, False) - if node_info is not None: - self._fw_stick = node_info.firmware - self._hw_stick = node_info.hardware + # # add Stick NodeInfoRequest + # node_info, _ = await self.get_node_details(self._mac_stick, False) + # if node_info is not None: + # self._fw_stick = node_info.firmware + # self._hw_stick = node_info.hardware if not self._network_online: raise StickError("Zigbee network connection to Circle+ is down.") diff --git a/tests/test_usb.py b/tests/test_usb.py index 65fcc9aec..23982934b 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == None - assert stick.hardware_stick == "653907008512" + assert stick.hardware_stick == None # "653907008512" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 13ca582504a2770f2b508bb12d043f1e080e0d43 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 14:11:07 +0100 Subject: [PATCH 537/979] Try --- plugwise_usb/network/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 43d62f3f9..10b99e806 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -286,6 +286,17 @@ def _unsubscribe_to_protocol_events(self) -> None: # endregion + async def discover_stick(self, load: bool = False) -> bool: + """Fetch data from the Stick.""" + if self._controller.mac_stick is None: + raise NodeError("Unknown mac address for the Stick.") + + _LOGGER.debug("Optain Stick info") + node_info, _ = await self._controller.get_node_details(self._controller.mac_stick, ping_first=False) + if node_info is not None: + self._controller.fw_stick = node_info.firmware + self._controller.hw_stick = node_info.hardware + # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: """Discover the Zigbee network coordinator (Circle+/Stealth+).""" @@ -482,6 +493,7 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" + await self.discover_stick(load=load) await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() From a30bf2cb13e84e6767d24dbd9095748324a8b992 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 14:29:04 +0100 Subject: [PATCH 538/979] Debug --- plugwise_usb/network/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 10b99e806..1fc5e3235 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -291,12 +291,15 @@ async def discover_stick(self, load: bool = False) -> bool: if self._controller.mac_stick is None: raise NodeError("Unknown mac address for the Stick.") - _LOGGER.debug("Optain Stick info") + _LOGGER.debug("Obtain Stick info") node_info, _ = await self._controller.get_node_details(self._controller.mac_stick, ping_first=False) if node_info is not None: + _LOGGER.debug("HOI fw_stick before: %s", self._controller.fw_stick) self._controller.fw_stick = node_info.firmware self._controller.hw_stick = node_info.hardware - + _LOGGER.debug("HOI fw_stick: %s", self._controller.fw_stick) + _LOGGER.debug("HOI hw_stick: %s", self._controller.hw_stick) + # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: """Discover the Zigbee network coordinator (Circle+/Stealth+).""" From bd84f77cfec236291462b04977023dcf09058c90 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 14:40:26 +0100 Subject: [PATCH 539/979] Fixes --- plugwise_usb/network/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 1fc5e3235..68dae9536 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -294,11 +294,11 @@ async def discover_stick(self, load: bool = False) -> bool: _LOGGER.debug("Obtain Stick info") node_info, _ = await self._controller.get_node_details(self._controller.mac_stick, ping_first=False) if node_info is not None: - _LOGGER.debug("HOI fw_stick before: %s", self._controller.fw_stick) - self._controller.fw_stick = node_info.firmware - self._controller.hw_stick = node_info.hardware - _LOGGER.debug("HOI fw_stick: %s", self._controller.fw_stick) - _LOGGER.debug("HOI hw_stick: %s", self._controller.hw_stick) + _LOGGER.debug("HOI fw_stick before: %s", self._controller._fw_stick) + self._controller._fw_stick = node_info.firmware + self._controller._hw_stick = node_info.hardware + _LOGGER.debug("HOI fw_stick: %s", self._controller._fw_stick) + _LOGGER.debug("HOI hw_stick: %s", self._controller._hw_stick) # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: From 8e3d4b2a8af3c185e24d7d40549d2eac1a416c9e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 15:05:28 +0100 Subject: [PATCH 540/979] Try --- plugwise_usb/messages/properties.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 453ccb471..ee79ef94a 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -392,6 +392,9 @@ def serialize(self) -> bytes: def deserialize(self, val: bytes) -> None: """Convert data into integer value based on log address formatted data.""" + if val == b"00000000": + self._value = int(0) + return Int.deserialize(self, val) self._value = (self.value - LOGADDR_OFFSET) // 32 From 10fb48fc467cdc53a1ce8df6b2d587dae3388468 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 15:16:18 +0100 Subject: [PATCH 541/979] Add debug-message --- plugwise_usb/messages/properties.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index ee79ef94a..31a49d96c 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -231,6 +231,7 @@ def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" _LOGGER.debug("HOI val: %s", val) if val == b"FFFFFFFF" or val == b"00000000": + _LOGGER.debug("Invalid DateTime value result") self._value = None else: CompositeType.deserialize(self, val) From eb130d52bfb7cefa30cadf756a013ee8efbcc2bc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 9 Feb 2025 15:21:04 +0100 Subject: [PATCH 542/979] Try --- plugwise_usb/__init__.py | 4 ++-- plugwise_usb/connection/__init__.py | 18 +++++++++--------- tests/test_usb.py | 5 ++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a1ee49837..0c8345da7 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -120,12 +120,12 @@ def joined_nodes(self) -> int | None: @property def firmware_stick(self) -> str: """Firmware of USB-Stick.""" - return self._controller.firmware_stick + return self._controller._fw_stick @property def hardware_stick(self) -> str: """Hardware of USB-Stick.""" - return self._controller.hardware_stick + return self._controller._hw_stick @property def mac_stick(self) -> str: diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 4c765a275..dc85a61da 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -56,15 +56,15 @@ def is_connected(self) -> bool: """Return connection state from connection manager.""" return self._manager.is_connected - @property - def firmware_stick(self) -> str | None: - """Firmware version of the Stick.""" - return self._fw_stick - - @property - def hardware_stick(self) -> str | None: - """Hardware version of the Stick.""" - return self._hw_stick + # @property + # def firmware_stick(self) -> str | None: + # """Firmware version of the Stick.""" + # return self._fw_stick + + # @property + # def hardware_stick(self) -> str | None: + # """Hardware version of the Stick.""" + # return self._hw_stick @property def mac_stick(self) -> str: diff --git a/tests/test_usb.py b/tests/test_usb.py index 23982934b..a832bcd08 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == None - assert stick.hardware_stick == None # "653907008512" + assert stick.hardware_stick == None assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 @@ -578,6 +578,9 @@ async def test_stick_node_discovered_subscription( events=(pw_api.NodeEvent.AWAKE,), ) + #assert stick.firmware_stick == None + assert stick.hardware_stick == "653907008512" + # Inject NodeAwakeResponse message to trigger a 'node discovered' event mock_serial.inject_message(b"004F555555555555555500", b"FFFE") mac_awake_node = await self.test_node_awake From 6322690af45364e8526f87e8bdeabe89df6c556f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 08:44:10 +0100 Subject: [PATCH 543/979] Remove logging, add missing --- plugwise_usb/__init__.py | 10 ++++++++++ plugwise_usb/network/__init__.py | 7 ++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 0c8345da7..c9fe4b180 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -316,6 +316,16 @@ async def load_nodes(self) -> bool: ) return await self._network.discover_nodes(load=True) + @raise_not_connected + @raise_not_initialized + async def discover_stick(self) -> None: + """Discover all nodes.""" + if self._network is None: + raise StickError( + "Cannot load nodes when network is not initialized" + ) + await self._network.discover_stick() + @raise_not_connected @raise_not_initialized async def discover_coordinator(self, load: bool = False) -> None: diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 68dae9536..9f4379ebc 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -286,19 +286,16 @@ def _unsubscribe_to_protocol_events(self) -> None: # endregion - async def discover_stick(self, load: bool = False) -> bool: + async def discover_stick(self) -> bool: """Fetch data from the Stick.""" if self._controller.mac_stick is None: raise NodeError("Unknown mac address for the Stick.") - _LOGGER.debug("Obtain Stick info") + _LOGGER.debug("Obtaining Stick info") node_info, _ = await self._controller.get_node_details(self._controller.mac_stick, ping_first=False) if node_info is not None: - _LOGGER.debug("HOI fw_stick before: %s", self._controller._fw_stick) self._controller._fw_stick = node_info.firmware self._controller._hw_stick = node_info.hardware - _LOGGER.debug("HOI fw_stick: %s", self._controller._fw_stick) - _LOGGER.debug("HOI hw_stick: %s", self._controller._hw_stick) # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: From 9bcd962eba8c4a412c259d4bbd02c0736586fba3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 08:46:25 +0100 Subject: [PATCH 544/979] Fix --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 9f4379ebc..2659f6b5b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -493,7 +493,7 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" - await self.discover_stick(load=load) + await self.discover_stick() await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() From 4c3ca23cfd351ddf6858c8e2937569c9af3e5fb4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 08:47:49 +0100 Subject: [PATCH 545/979] Update stick hardware assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index a832bcd08..396ebc9dc 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == None - assert stick.hardware_stick == None + assert stick.hardware_stick == "070085" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 74195bcc1a2ebcb63cb08e60de6d06c6d732c08a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 08:51:13 +0100 Subject: [PATCH 546/979] Try --- plugwise_usb/__init__.py | 4 ++-- plugwise_usb/connection/__init__.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index c9fe4b180..77b5d1421 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -120,12 +120,12 @@ def joined_nodes(self) -> int | None: @property def firmware_stick(self) -> str: """Firmware of USB-Stick.""" - return self._controller._fw_stick + return self._controller.firmware_stick @property def hardware_stick(self) -> str: """Hardware of USB-Stick.""" - return self._controller._hw_stick + return self._controller.hardware_stick @property def mac_stick(self) -> str: diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index dc85a61da..4c765a275 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -56,15 +56,15 @@ def is_connected(self) -> bool: """Return connection state from connection manager.""" return self._manager.is_connected - # @property - # def firmware_stick(self) -> str | None: - # """Firmware version of the Stick.""" - # return self._fw_stick - - # @property - # def hardware_stick(self) -> str | None: - # """Hardware version of the Stick.""" - # return self._hw_stick + @property + def firmware_stick(self) -> str | None: + """Firmware version of the Stick.""" + return self._fw_stick + + @property + def hardware_stick(self) -> str | None: + """Hardware version of the Stick.""" + return self._hw_stick @property def mac_stick(self) -> str: From ae560a9a94a299234ec8265c8f9ba37c76ac82dc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 08:56:23 +0100 Subject: [PATCH 547/979] Add stick firmware-assert --- tests/test_usb.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 396ebc9dc..d9270ceb6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == None - assert stick.hardware_stick == "070085" + assert stick.hardware_stick == None assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 @@ -578,7 +578,9 @@ async def test_stick_node_discovered_subscription( events=(pw_api.NodeEvent.AWAKE,), ) - #assert stick.firmware_stick == None + assert stick.firmware_stick == dt( + 2011, 6, 27, 8, 47, 37, tzinfo=UTC + ) assert stick.hardware_stick == "653907008512" # Inject NodeAwakeResponse message to trigger a 'node discovered' event From 5803fbd96bfa77e5bd824018b045950da411a9c6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:19:00 +0100 Subject: [PATCH 548/979] Remove, not needed --- plugwise_usb/__init__.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 77b5d1421..a1ee49837 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -316,16 +316,6 @@ async def load_nodes(self) -> bool: ) return await self._network.discover_nodes(load=True) - @raise_not_connected - @raise_not_initialized - async def discover_stick(self) -> None: - """Discover all nodes.""" - if self._network is None: - raise StickError( - "Cannot load nodes when network is not initialized" - ) - await self._network.discover_stick() - @raise_not_connected @raise_not_initialized async def discover_coordinator(self, load: bool = False) -> None: From 967d4ee0c29e86f39e7152f3ddb1996b13480910 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:27:22 +0100 Subject: [PATCH 549/979] Collect Stick data during initialization --- plugwise_usb/connection/__init__.py | 10 +++++----- plugwise_usb/network/__init__.py | 12 ------------ 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 4c765a275..9ee3f2270 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -190,11 +190,11 @@ async def initialize_stick(self) -> None: self._network_id = init_response.network_id self._is_initialized = True - # # add Stick NodeInfoRequest - # node_info, _ = await self.get_node_details(self._mac_stick, False) - # if node_info is not None: - # self._fw_stick = node_info.firmware - # self._hw_stick = node_info.hardware + # Add Stick NodeInfoRequest + node_info, _ = await self.get_node_details(self._mac_stick, ping_first=False) + if node_info is not None: + self._fw_stick = node_info.firmware + self._hw_stick = node_info.hardware if not self._network_online: raise StickError("Zigbee network connection to Circle+ is down.") diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 2659f6b5b..cab52a073 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -285,17 +285,6 @@ def _unsubscribe_to_protocol_events(self) -> None: self._unsubscribe_stick_event = None # endregion - - async def discover_stick(self) -> bool: - """Fetch data from the Stick.""" - if self._controller.mac_stick is None: - raise NodeError("Unknown mac address for the Stick.") - - _LOGGER.debug("Obtaining Stick info") - node_info, _ = await self._controller.get_node_details(self._controller.mac_stick, ping_first=False) - if node_info is not None: - self._controller._fw_stick = node_info.firmware - self._controller._hw_stick = node_info.hardware # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: @@ -493,7 +482,6 @@ async def start(self) -> None: async def discover_nodes(self, load: bool = True) -> bool: """Discover nodes.""" - await self.discover_stick() await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() From 630d9f3fdc488cc74f8eec88ed6a88128c663270 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:29:20 +0100 Subject: [PATCH 550/979] Adapt asserts --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d9270ceb6..095243d58 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -460,8 +460,8 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: await stick.initialize() assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" - assert stick.firmware_stick == None - assert stick.hardware_stick == None + assert stick.firmware_stick == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) + assert stick.hardware_stick == "653907008512" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 036e16a42d085bda30cb97f82534910f221f2131 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:38:46 +0100 Subject: [PATCH 551/979] Translate to short hardware-version --- plugwise_usb/connection/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 9ee3f2270..92c6c7593 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -9,6 +9,7 @@ from ..api import StickEvent from ..constants import UTF8 from ..exceptions import NodeError, StickError +from ..helpers.util import version_to_model from ..messages.requests import ( NodeInfoRequest, NodePingRequest, @@ -194,7 +195,8 @@ async def initialize_stick(self) -> None: node_info, _ = await self.get_node_details(self._mac_stick, ping_first=False) if node_info is not None: self._fw_stick = node_info.firmware - self._hw_stick = node_info.hardware + hardware, _ = version_to_model(node_info.hardware) + self._hw_stick = hardware if not self._network_online: raise StickError("Zigbee network connection to Circle+ is down.") From 4ecc744e90f5025baa1f4da27a4b1c75593f1d9d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:41:13 +0100 Subject: [PATCH 552/979] Adapt and clean-up test-asserts --- tests/test_usb.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 095243d58..1fcf09f90 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,7 +461,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) - assert stick.hardware_stick == "653907008512" + assert stick.hardware_stick == "070085" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 @@ -578,11 +578,6 @@ async def test_stick_node_discovered_subscription( events=(pw_api.NodeEvent.AWAKE,), ) - assert stick.firmware_stick == dt( - 2011, 6, 27, 8, 47, 37, tzinfo=UTC - ) - assert stick.hardware_stick == "653907008512" - # Inject NodeAwakeResponse message to trigger a 'node discovered' event mock_serial.inject_message(b"004F555555555555555500", b"FFFE") mac_awake_node = await self.test_node_awake From a8ab97abda7b4fcdf81d55ff1261d531e6bfea50 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:43:59 +0100 Subject: [PATCH 553/979] Clean up debug-logging --- plugwise_usb/messages/properties.py | 1 - plugwise_usb/messages/responses.py | 7 ------- 2 files changed, 8 deletions(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 31a49d96c..19f8bf4b4 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -229,7 +229,6 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" - _LOGGER.debug("HOI val: %s", val) if val == b"FFFFFFFF" or val == b"00000000": _LOGGER.debug("Invalid DateTime value result") self._value = None diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 5a3b26db9..50dedbccc 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -4,7 +4,6 @@ from datetime import datetime from enum import Enum -import logging from typing import Any, Final from ..api import NodeType @@ -42,8 +41,6 @@ SWITCH_GROUP_RESPONSE_SEQ_ID, ) -_LOGGER = logging.getLogger(__name__) - class StickResponseType(bytes, Enum): """Response message types for stick.""" @@ -193,7 +190,6 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: self._mac = response[:16] response = response[16:] if len(response) > 0: - _LOGGER.debug("HOI response: %s", response) try: response = self._parse_params(response) except ValueError as ve: @@ -207,11 +203,8 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: def _parse_params(self, response: bytes) -> bytes: for param in self._params: my_val = response[: len(param)] - _LOGGER.debug("HOI param: %s", param) - _LOGGER.debug("HOI my_val: %s", my_val) param.deserialize(my_val) response = response[len(my_val) :] - _LOGGER.debug("HOI response: %s", response) return response From f3b5b39d9003286e3f99f7490780f4fb373d5be3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 17:52:27 +0100 Subject: [PATCH 554/979] Clean-up --- plugwise_usb/messages/properties.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 19f8bf4b4..2e42dc4f1 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -2,7 +2,6 @@ import binascii from datetime import UTC, date, datetime, time, timedelta -import logging import struct from typing import Any @@ -10,7 +9,6 @@ from ..exceptions import MessageError from ..helpers.util import int_to_uint -_LOGGER = logging.getLogger(__name__) class BaseType: """Generic single instance property.""" @@ -230,7 +228,6 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" if val == b"FFFFFFFF" or val == b"00000000": - _LOGGER.debug("Invalid DateTime value result") self._value = None else: CompositeType.deserialize(self, val) From 250252ec14d125382e685fdd13e52a406b40c7a1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 19:06:45 +0100 Subject: [PATCH 555/979] Pylint fix --- plugwise_usb/messages/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index 2e42dc4f1..a2a065aa4 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -227,7 +227,7 @@ def __init__(self, year: int = 0, month: int = 1, minutes: int = 0) -> None: def deserialize(self, val: bytes) -> None: """Convert data into datetime based on timestamp with offset to Y2k.""" - if val == b"FFFFFFFF" or val == b"00000000": + if val in (b"FFFFFFFF", b"00000000"): self._value = None else: CompositeType.deserialize(self, val) From 316bd8a267ec4a68d51c79a8ab43e9431a974bcf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 12 Feb 2025 19:14:11 +0100 Subject: [PATCH 556/979] Bump to a30 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2e16b27fb..23c2f2186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a29" +version = "v0.40.0a30" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From dc37b754b866e39936241e7c951fa617e29de427 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Feb 2025 08:30:44 +0100 Subject: [PATCH 557/979] Add Stick-name property --- plugwise_usb/__init__.py | 5 +++++ plugwise_usb/connection/__init__.py | 1 + 2 files changed, 6 insertions(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a1ee49837..3699db56a 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -137,6 +137,11 @@ def mac_coordinator(self) -> str: """MAC address of the network coordinator (Circle+). Raises StickError is connection is missing.""" return self._controller.mac_coordinator + @property + def name(self) -> str: + """Return name of Stick.""" + return self._controller._stick_name + @property def network_discovered(self) -> bool: """Indicate if discovery of network is active. Raises StickError is connection is missing.""" diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 92c6c7593..484479c03 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -184,6 +184,7 @@ async def initialize_stick(self) -> None: + f"' {self._manager.serial_path}'" ) self._mac_stick = init_response.mac_decoded + self._stick_name = f"Stick {self._mac_stick[-5:]}" self._network_online = init_response.network_online # Replace first 2 characters by 00 for mac of circle+ node From 72853ce297bdffab270d1beca4edb62ad114a766 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Feb 2025 08:31:56 +0100 Subject: [PATCH 558/979] Add related test-assert --- tests/test_usb.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 1fcf09f90..470b67709 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -459,6 +459,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert await self.test_connected await stick.initialize() assert stick.mac_stick == "0123456789012345" + assert stick.name == "Stick 12345" assert stick.mac_coordinator == "0098765432101234" assert stick.firmware_stick == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) assert stick.hardware_stick == "070085" From 65de5cfeadb5567984e2567559929ee5f17926b3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Feb 2025 08:34:34 +0100 Subject: [PATCH 559/979] Bump to a31 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 23c2f2186..228ec241d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a30" +version = "v0.40.0a31" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From caac9e61300ac707e332c3c4214235e9994a7dc0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Feb 2025 08:39:30 +0100 Subject: [PATCH 560/979] Simplify property-names, improve --- plugwise_usb/__init__.py | 4 ++-- plugwise_usb/connection/__init__.py | 1 + tests/test_usb.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 3699db56a..a28c4ec79 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -118,12 +118,12 @@ def joined_nodes(self) -> int | None: return len(self._network.registry) + 1 @property - def firmware_stick(self) -> str: + def firmware(self) -> str: """Firmware of USB-Stick.""" return self._controller.firmware_stick @property - def hardware_stick(self) -> str: + def hardware(self) -> str: """Hardware of USB-Stick.""" return self._controller.hardware_stick diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 484479c03..9f842eab3 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -44,6 +44,7 @@ def __init__(self) -> None: self._mac_nc: str | None = None self._network_id: int | None = None self._network_online = False + self._stick_name: str | None = None @property def is_initialized(self) -> bool: diff --git a/tests/test_usb.py b/tests/test_usb.py index 470b67709..c96e5cc27 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -461,8 +461,8 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.mac_stick == "0123456789012345" assert stick.name == "Stick 12345" assert stick.mac_coordinator == "0098765432101234" - assert stick.firmware_stick == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) - assert stick.hardware_stick == "070085" + assert stick.firmware == dt(2011, 6, 27, 8, 47, 37, tzinfo=UTC) + assert stick.hardware == "070085" assert not stick.network_discovered assert stick.network_state assert stick.network_id == 17185 From 34f33bf5131e2093fef2660e05514357e987f1df Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Feb 2025 08:45:10 +0100 Subject: [PATCH 561/979] Fix --- plugwise_usb/__init__.py | 2 +- plugwise_usb/connection/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a28c4ec79..a00ca68da 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -140,7 +140,7 @@ def mac_coordinator(self) -> str: @property def name(self) -> str: """Return name of Stick.""" - return self._controller._stick_name + return self._controller.stick_name @property def network_discovered(self) -> bool: diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index 9f842eab3..bb2559b3c 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -44,7 +44,7 @@ def __init__(self) -> None: self._mac_nc: str | None = None self._network_id: int | None = None self._network_online = False - self._stick_name: str | None = None + self.stick_name: str | None = None @property def is_initialized(self) -> bool: @@ -185,7 +185,7 @@ async def initialize_stick(self) -> None: + f"' {self._manager.serial_path}'" ) self._mac_stick = init_response.mac_decoded - self._stick_name = f"Stick {self._mac_stick[-5:]}" + self.stick_name = f"Stick {self._mac_stick[-5:]}" self._network_online = init_response.network_online # Replace first 2 characters by 00 for mac of circle+ node From 85ca6fb8b1e446ec2c82f06b1d20fbcb4931c39b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 19 Feb 2025 17:23:14 +0100 Subject: [PATCH 562/979] Try negative amounts of pulses --- tests/stick_test_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index e3623f61e..d009aab2b 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -689,8 +689,8 @@ b"000000C1", # Success ack b"0013" # msg_id + b"0098765432101234" # mac - + b"000A" # pulses 1s - + b"0066" # pulses 8s + + b"FFF6" # pulses 1s + + b"FF9A" # pulses 8s + b"00001234" + b"00000000" + b"0004", From f4b41867b9812b59709d967ac6289dcdb51d176d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 19 Feb 2025 17:27:02 +0100 Subject: [PATCH 563/979] Disable protection --- plugwise_usb/nodes/circle.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 5886f580b..abd165c9c 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1039,16 +1039,16 @@ def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | No ) # Fix minor miscalculations - if ( - calc_value := corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) - ) >= 0.0: - return calc_value - _LOGGER.debug( - "Correct negative power %s to 0.0 for %s", - str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), - self._mac_in_str, - ) - return 0.0 + # if ( + calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) + # ) >= 0.0: + return calc_value + # _LOGGER.debug( + # "Correct negative power %s to 0.0 for %s", + # str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), + # self._mac_in_str, + # ) + # return 0.0 def _correct_power_pulses(self, pulses: int, offset: int) -> float: """Correct pulses based on given measurement time offset (ns).""" From 07f2e87c38d457406c90fec94c1e2d3f8c872379 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 19 Feb 2025 19:23:43 +0100 Subject: [PATCH 564/979] Try method from PHP-code --- plugwise_usb/nodes/circle.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index abd165c9c..7daa30265 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1024,6 +1024,11 @@ def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | No return None pulses_per_s = self._correct_power_pulses(pulses, nano_offset) / float(seconds) + negative = False + if pulses_per_s < 0: + negative = True + pulses_per_s = abs(pulses_per_s) + corrected_pulses = seconds * ( ( ( @@ -1037,18 +1042,10 @@ def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | No ) + self._calibration.off_tot ) + if negative: + corrected_pulses = -corrected_pulses - # Fix minor miscalculations - # if ( - calc_value = corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) - # ) >= 0.0: - return calc_value - # _LOGGER.debug( - # "Correct negative power %s to 0.0 for %s", - # str(corrected_pulses / PULSES_PER_KW_SECOND / seconds * 1000), - # self._mac_in_str, - # ) - # return 0.0 + return corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) def _correct_power_pulses(self, pulses: int, offset: int) -> float: """Correct pulses based on given measurement time offset (ns).""" From fd684194c63058116b0daf4c938bd7922247ea6b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 21 Feb 2025 18:46:41 +0100 Subject: [PATCH 565/979] Change to a production/negative power return and assert --- tests/stick_test_data.py | 2 +- tests/test_usb.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index d009aab2b..8c7c57293 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -689,7 +689,7 @@ b"000000C1", # Success ack b"0013" # msg_id + b"0098765432101234" # mac - + b"FFF6" # pulses 1s + + b"000A" # pulses 1s + b"FF9A" # pulses 8s + b"00001234" + b"00000000" diff --git a/tests/test_usb.py b/tests/test_usb.py index c96e5cc27..da959c3a4 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -905,7 +905,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) pu = await stick.nodes["0098765432101234"].power_update() assert pu.last_second == 21.2780505980402 - assert pu.last_8_seconds == 27.150578775440106 + assert pu.last_8_seconds == -27.150578775440106 # Test energy state without request assert stick.nodes["0098765432101234"].energy == pw_api.EnergyStatistics( From 631684d4d3ac0b3bbddf56d41e0b989da182db30 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 21 Feb 2025 19:08:23 +0100 Subject: [PATCH 566/979] Bump to a33 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 228ec241d..9f3671a10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a31" +version = "v0.40.0a33" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From b604a2c8c3a97a1f5b8d5b3d0d7ab08a995b8eec Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 3 Mar 2025 08:03:18 +0100 Subject: [PATCH 567/979] Handle negative energy-pulse values --- plugwise_usb/nodes/helpers/counter.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index f894d8244..0b6e0566b 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -261,7 +261,12 @@ def energy(self) -> float | None: return None if self._pulses == 0: return 0.0 - pulses_per_s = self._pulses / float(HOUR_IN_SECONDS) + # Handle both positive and negative pulses values + negative = False + if self._pulses < 0: + negative = True + + pulses_per_s = abs(self._pulses) / float(HOUR_IN_SECONDS) corrected_pulses = HOUR_IN_SECONDS * ( ( ( @@ -276,8 +281,8 @@ def energy(self) -> float | None: + self._calibration.off_tot ) calc_value = corrected_pulses / PULSES_PER_KW_SECOND / HOUR_IN_SECONDS - # Guard for minor negative miscalculations - calc_value = max(calc_value, 0.0) + if negative: + calc_value = -calc_value return calc_value @property From 479a9f438cddd50b96adb9cf5c72a61339d6110f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 5 Mar 2025 09:26:16 +0100 Subject: [PATCH 568/979] Extend logging showing production pulses --- plugwise_usb/nodes/helpers/counter.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 0b6e0566b..91b164d47 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -105,11 +105,8 @@ def add_pulse_stats( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: """Add pulse statistics.""" - _LOGGER.debug( - "add_pulse_stats | consumed=%s, for %s", - str(pulses_consumed), - self._mac, - ) + _LOGGER.debug("add_pulse_stats for %s with timestamp=%s", self._mac, timestamp) + _LOGGER.debug("consumed=%s | produced=%s", pulses_consumed, pulses_produced) self._pulse_collection.update_pulse_counter( pulses_consumed, pulses_produced, timestamp ) From 0cc5b9ab15b4945e7396f54d3ec12223f3ae5316 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 10 Mar 2025 11:38:03 +0100 Subject: [PATCH 569/979] Add debug-message showing CircleEnergyLog data --- plugwise_usb/nodes/circle.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 7daa30265..7f77c2651 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -504,7 +504,12 @@ async def energy_log_update(self, address: int | None) -> bool: # energy pulses collected during the previous hour of given timestamp for _slot in range(4, 0, -1): log_timestamp, log_pulses = response.log_data[_slot] - + _LOGGER.debug( + "Energy data from slot=%s: pulses=%s, timestamp=%s", + _slot, + log_pulses, + log_timestamp + ) if log_timestamp is None or log_pulses is None: self._energy_counters.add_empty_log(response.log_address, _slot) elif await self._energy_log_record_update_state( From 5728de3af1b5ea35fbbc71a6c0c701ca00980054 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:27:09 +0100 Subject: [PATCH 570/979] Add MAC to CircleEnergy log-message --- plugwise_usb/nodes/circle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 7f77c2651..deb7d7817 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -496,6 +496,7 @@ async def energy_log_update(self, address: int | None) -> bool: ) return False + _LOGGER.debug("EnergyLogs data from %s, address=%s", self._mac_in_str, address) await self._available_update_state(True, response.timestamp) energy_record_update = False From 9677206bdf83646bea1c72ab3ee3b0ce686c8ece Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Mar 2025 20:07:32 +0100 Subject: [PATCH 571/979] Add collected_pulses debug-logging --- plugwise_usb/nodes/helpers/pulses.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a3c0f6511..25d7e7d12 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -196,6 +196,13 @@ def collected_pulses( is_consumption, ) return (None, None) + _LOGGER.debug( + "collected_pulses | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", + pulses, + log_pulses, + is_consumption, + timestamp, + ) return (pulses + log_pulses, timestamp) def _collect_pulses_from_logs( From 747cb39f52d577da2b87c6a332d0e9056e2c33bc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 13 Mar 2025 20:08:10 +0100 Subject: [PATCH 572/979] Fix typo in logging --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 91b164d47..4726ed320 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -326,5 +326,5 @@ def update( self._pulses = pulses energy = self.energy - _LOGGER.debug("energy=%s or last_update=%s", energy, last_update) + _LOGGER.debug("energy=%s on last_update=%s", energy, last_update) return (energy, last_reset) From 6cb323fef6e2acdb200d0d92e5d914a97dee8926 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 14 Mar 2025 13:21:26 +0100 Subject: [PATCH 573/979] Extend collected_pulses logging --- plugwise_usb/nodes/helpers/pulses.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 25d7e7d12..d753891c4 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -159,24 +159,28 @@ def collected_pulses( self, from_timestamp: datetime, is_consumption: bool ) -> tuple[int | None, datetime | None]: """Calculate total pulses from given timestamp.""" - - # _LOGGER.debug("collected_pulses | %s | is_cons=%s, from=%s", self._mac, is_consumption, from_timestamp) - + _LOGGER.debug( + "collected_pulses 1 | %s | is_cons=%s, from_timestamp=%s", + self._mac, + is_consumption, + from_timestamp, + ) + _LOGGER.debug("collected_pulses 1a | _log_production=%s", self._log_production) if not is_consumption: if self._log_production is None or not self._log_production: return (None, None) if is_consumption and self._rollover_consumption: - _LOGGER.debug("collected_pulses | %s | _rollover_consumption", self._mac) + _LOGGER.debug("collected_pulses 2 | %s | _rollover_consumption", self._mac) return (None, None) if not is_consumption and self._rollover_production: - _LOGGER.debug("collected_pulses | %s | _rollover_production", self._mac) + _LOGGER.debug("collected_pulses 3 | %s | _rollover_production", self._mac) return (None, None) if ( log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) ) is None: - _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) + _LOGGER.debug("collected_pulses 4 | %s | log_pulses:None", self._mac) return (None, None) pulses: int | None = None @@ -191,13 +195,13 @@ def collected_pulses( if pulses is None: _LOGGER.debug( - "collected_pulses | %s | is_consumption=%s, pulses=None", + "collected_pulses 5 | %s | is_consumption=%s, pulses=None", self._mac, is_consumption, ) return (None, None) _LOGGER.debug( - "collected_pulses | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", + "collected_pulses 6 | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", pulses, log_pulses, is_consumption, From ea14e6e5863f40b14c5467c578e01e89fb9826bb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:35:51 +0100 Subject: [PATCH 574/979] Add_log(): support both cons-/prod-type of logs --- plugwise_usb/nodes/helpers/pulses.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d753891c4..d3763e10e 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -384,7 +384,11 @@ def add_log( import_only: bool = False, ) -> bool: """Store pulse log.""" - log_record = PulseLogRecord(timestamp, pulses, CONSUMED) + direction = CONSUMED + if self._log_production and pulses < 0: + direction = PRODUCED + + log_record = PulseLogRecord(timestamp, pulses, direction) if not self._add_log_record(address, slot, log_record): if not self._log_exists(address, slot): return False From 633e9026985c41ea34df468476e5e97d16d262d0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 16 Mar 2025 12:17:42 +0100 Subject: [PATCH 575/979] Add missing await for energy_log_update() --- plugwise_usb/nodes/circle.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index deb7d7817..e08b23ac7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task +from asyncio import Task, create_task, gather from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -440,6 +440,7 @@ async def get_missing_energy_logs(self) -> None: self._energy_counters.update() if self._current_log_address is None: return None + if self._energy_counters.log_addresses_missing is None: _LOGGER.debug( "Start with initial energy request for the last 10 log addresses for node %s.", @@ -453,12 +454,13 @@ async def get_missing_energy_logs(self) -> None: log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 - for task in log_update_tasks: - await task + await gather(*log_update_tasks) if self._cache_enabled: await self._energy_log_records_save_to_cache() + return + if self._energy_counters.log_addresses_missing is not None: _LOGGER.debug("Task created to get missing logs of %s", self._mac_in_str) if ( From 1b745602991cee4e7c4d06708e3d48e902e47874 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 17 Mar 2025 12:20:42 +0100 Subject: [PATCH 576/979] Add missing line in _reset_log_references() --- plugwise_usb/nodes/helpers/pulses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d3763e10e..99a0d350d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -624,6 +624,7 @@ def _reset_log_references(self) -> None: if self._last_log_production_timestamp is None: self._last_log_production_timestamp = log_record.timestamp if self._last_log_production_timestamp <= log_record.timestamp: + self._last_log_production_timestamp = log_record.timestamp self._last_log_production_address = address self._last_log_production_slot = slot From cf5f894871d12a19352732add1f2a11a2fa208c2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:40:06 +0100 Subject: [PATCH 577/979] Formatting, remove wrong guarding(?) --- plugwise_usb/nodes/helpers/pulses.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 99a0d350d..924a5f931 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -234,6 +234,7 @@ def _collect_pulses_from_logs( return None if from_timestamp > self._last_log_production_timestamp: return 0 + missing_logs = self._logs_missing(from_timestamp) if missing_logs is None or missing_logs: _LOGGER.debug( @@ -312,6 +313,7 @@ def _update_rollover(self) -> None: if not self._log_production: return + if ( self._last_log_production_timestamp is None or self._next_log_production_timestamp is None @@ -495,6 +497,7 @@ def _update_log_interval(self) -> None: self._log_production, ) return + last_cons_address, last_cons_slot = self._last_log_reference( is_consumption=True ) @@ -513,8 +516,7 @@ def _update_log_interval(self) -> None: delta1.total_seconds() / MINUTE_IN_SECONDS ) break - if not self._log_production: - return + address, slot = calc_log_address(address, slot, -1) if ( self._log_interval_consumption is not None From be1f7a8a1fc76d1e0147852aac6d2c7a2a101853 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 18 Mar 2025 08:20:19 +0100 Subject: [PATCH 578/979] Don't update production_log_refs when not required --- plugwise_usb/nodes/helpers/pulses.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 924a5f931..bca4a5f5f 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -700,8 +700,7 @@ def _update_log_references(self, address: int, slot: int) -> None: if is_consumption: self._update_first_consumption_log_reference(address, slot, log_time_stamp) self._update_last_consumption_log_reference(address, slot, log_time_stamp) - else: - # production + elif self._log_production: self._update_first_production_log_reference(address, slot, log_time_stamp) self._update_last_production_log_reference(address, slot, log_time_stamp) From 572b973fc30857a7250e3b7919804e8818df6088 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:41:28 +0100 Subject: [PATCH 579/979] Formatting --- plugwise_usb/nodes/helpers/pulses.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index bca4a5f5f..787d053aa 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -690,6 +690,7 @@ def _update_log_references(self, address: int, slot: int) -> None: """Update next expected log timestamps.""" if self._logs is None: return + log_time_stamp = self._logs[address][slot].timestamp is_consumption = self._logs[address][slot].is_consumption @@ -812,12 +813,14 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: log_interval = self._log_interval_consumption elif self._log_interval_production is not None: log_interval = self._log_interval_production + if ( self._log_interval_production is not None and log_interval is not None and self._log_interval_production < log_interval ): log_interval = self._log_interval_production + if log_interval is None: return None @@ -877,7 +880,7 @@ def _missing_addresses_before( if self._log_interval_consumption == 0: pass - if self._log_production is not True: + if not self._log_production: #False expected_timestamp = ( self._logs[address][slot].timestamp - calc_interval_cons ) @@ -935,7 +938,7 @@ def _missing_addresses_after( # Use consumption interval calc_interval_cons = timedelta(minutes=self._log_interval_consumption) - if self._log_production is not True: + if not self._log_production: # False expected_timestamp = ( self._logs[address][slot].timestamp + calc_interval_cons ) From 422b2612f4b72640cdb9e1c3e7204e25dce26f05 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:43:30 +0100 Subject: [PATCH 580/979] Add guarding for production _energy_statistics --- plugwise_usb/nodes/helpers/counter.py | 28 +++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 4726ed320..28677c33f 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -173,18 +173,22 @@ def update(self) -> None: self._energy_statistics.week_consumption_reset, ) = self._counters[EnergyType.CONSUMPTION_WEEK].update(self._pulse_collection) - ( - self._energy_statistics.hour_production, - self._energy_statistics.hour_production_reset, - ) = self._counters[EnergyType.PRODUCTION_HOUR].update(self._pulse_collection) - ( - self._energy_statistics.day_production, - self._energy_statistics.day_production_reset, - ) = self._counters[EnergyType.PRODUCTION_DAY].update(self._pulse_collection) - ( - self._energy_statistics.week_production, - self._energy_statistics.week_production_reset, - ) = self._counters[EnergyType.PRODUCTION_WEEK].update(self._pulse_collection) + if self._pulse_collection.production_logging: + self._energy_statistics.log_interval_production = ( + self._pulse_collection.log_interval_production + ) + ( + self._energy_statistics.hour_production, + self._energy_statistics.hour_production_reset, + ) = self._counters[EnergyType.PRODUCTION_HOUR].update(self._pulse_collection) + ( + self._energy_statistics.day_production, + self._energy_statistics.day_production_reset, + ) = self._counters[EnergyType.PRODUCTION_DAY].update(self._pulse_collection) + ( + self._energy_statistics.week_production, + self._energy_statistics.week_production_reset, + ) = self._counters[EnergyType.PRODUCTION_WEEK].update(self._pulse_collection) @property def timestamp(self) -> datetime | None: From 753bad29d76a066e873dffc9405b50bd2b4a1d1f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 18 Mar 2025 09:29:27 +0100 Subject: [PATCH 581/979] Don't update production rollover when no production --- plugwise_usb/nodes/helpers/pulses.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 787d053aa..65d5ef831 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -288,6 +288,7 @@ def _update_rollover(self) -> None: ): # Unable to determine rollover return + if self._pulses_timestamp > self._next_log_consumption_timestamp: self._rollover_consumption = True _LOGGER.debug( @@ -320,6 +321,10 @@ def _update_rollover(self) -> None: ): # Unable to determine rollover return + + if not self._log_production: + return + if self._pulses_timestamp > self._next_log_production_timestamp: self._rollover_production = True _LOGGER.debug( From 6a6d3808dc68016c19cf5990f3c90d4a25087d21 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 22 Mar 2025 16:02:49 +0100 Subject: [PATCH 582/979] Update _update_rollover() docstring --- plugwise_usb/nodes/helpers/pulses.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 65d5ef831..1c3c412f4 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -278,9 +278,15 @@ def update_pulse_counter( self._pulses_production = pulses_produced def _update_rollover(self) -> None: - """Update rollover states. Returns True if rollover is applicable.""" + """Update rollover states. + + When the last found timestamp is outside the interval `_last_log_timestamp` + to `_next_log_timestamp` the pulses should not be counted as part of the + ongoing collection-interval. + """ if self._log_addresses_missing is not None and self._log_addresses_missing: return + if ( self._pulses_timestamp is None or self._last_log_consumption_timestamp is None From dd823196e603d82653101589ef67955a23de9e9d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:53:08 +0100 Subject: [PATCH 583/979] Output production-statistics as positive values --- plugwise_usb/nodes/helpers/pulses.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 1c3c412f4..d8aa75d43 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -207,7 +207,9 @@ def collected_pulses( is_consumption, timestamp, ) - return (pulses + log_pulses, timestamp) + + # Always return positive values of energy_statistics + return (abs(pulses + log_pulses), timestamp) def _collect_pulses_from_logs( self, from_timestamp: datetime, is_consumption: bool From 2a28d1ae53599ed64d17dba10876ea3735a16ed8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 10:56:07 +0100 Subject: [PATCH 584/979] Bump to a55 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9f3671a10..19dba51a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a33" +version = "v0.40.0a55" license = {file = "LICENSE"} description = "Plugwise USB (Stick) module for Python 3." readme = "README.md" From 3f2cf80244cba01756c27c570f95a69a127fe689 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 11:08:23 +0100 Subject: [PATCH 585/979] Change-update _logs_missing logging --- plugwise_usb/nodes/helpers/pulses.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d8aa75d43..e976eb266 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -803,11 +803,12 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if self._logs[address][slot].timestamp <= from_timestamp: break + _LOGGER.debug( + "_logs_missing | %s | missing in range=%s", self._mac, missing + ) + # return missing logs in range first if len(missing) > 0: - _LOGGER.debug( - "_logs_missing | %s | missing in range=%s", self._mac, missing - ) return missing if first_address not in self._logs: @@ -841,6 +842,11 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: calculated_timestamp = self._logs[first_address][ first_slot ].timestamp - timedelta(minutes=log_interval) + _LOGGER.debug( + "_logs_missing | %s | calculated timestamp=%s", + self._mac, + calculated_timestamp, + ) while from_timestamp < calculated_timestamp: if ( address == self._first_empty_log_address From fb8c40d7034fe306b8be177f116211c3ce88e083 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 11:11:43 +0100 Subject: [PATCH 586/979] Improve logging, formatting --- plugwise_usb/nodes/circle.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e08b23ac7..835365a8c 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -508,7 +508,7 @@ async def energy_log_update(self, address: int | None) -> bool: for _slot in range(4, 0, -1): log_timestamp, log_pulses = response.log_data[_slot] _LOGGER.debug( - "Energy data from slot=%s: pulses=%s, timestamp=%s", + "In slot=%s: pulses=%s, timestamp=%s", _slot, log_pulses, log_timestamp @@ -523,9 +523,11 @@ async def energy_log_update(self, address: int | None) -> bool: import_only=True, ): energy_record_update = True + self._energy_counters.update() if energy_record_update: await self.save_cache() + return True async def _energy_log_records_load_from_cache(self) -> bool: From 7ba64ed4742135450704028c9133d4c3aeba98cd Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 11:16:38 +0100 Subject: [PATCH 587/979] Bump to a56 --- pyproject.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 19dba51a6..357f8d5ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a55" -license = {file = "LICENSE"} -description = "Plugwise USB (Stick) module for Python 3." -readme = "README.md" +version = "v0.40.0a56" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From dec782bdebf50b3516f0095f38c42c2cbfedaa25 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 14:16:57 +0100 Subject: [PATCH 588/979] Pulses_production/_produced are negative --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e976eb266..a84edc3cf 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -273,7 +273,7 @@ def update_pulse_counter( self._rollover_consumption = True if ( self._pulses_production is not None - and self._pulses_production > pulses_produced + and self._pulses_production < pulses_produced ): self._rollover_production = True self._pulses_consumption = pulses_consumed From cfabb767524f69d8b00fd21d736bdd9edd567ada Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 14:17:27 +0100 Subject: [PATCH 589/979] Bump to a57 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 357f8d5ca..5c758ec68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a56" +version = "v0.40.0a57" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From e27f6e53af96253bbf5fb9a7ce5539f1aee4867a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 14:41:21 +0100 Subject: [PATCH 590/979] Formatting --- plugwise_usb/nodes/helpers/counter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 28677c33f..8e3560e8f 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -260,8 +260,10 @@ def energy(self) -> float | None: """Total energy (in kWh) since last reset.""" if self._pulses is None or self._calibration is None: return None + if self._pulses == 0: return 0.0 + # Handle both positive and negative pulses values negative = False if self._pulses < 0: @@ -284,6 +286,7 @@ def energy(self) -> float | None: calc_value = corrected_pulses / PULSES_PER_KW_SECOND / HOUR_IN_SECONDS if negative: calc_value = -calc_value + return calc_value @property From a390ede8e2296a239424cd6b7dc7bb47410114cb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 16:07:41 +0100 Subject: [PATCH 591/979] Formatting --- plugwise_usb/nodes/helpers/pulses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a84edc3cf..78e4c9044 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -751,8 +751,10 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if self._logs is None: self._log_addresses_missing = None return None + if self.collected_logs < 2: return None + last_address, last_slot = self._last_log_reference() if last_address is None or last_slot is None: _LOGGER.debug( From 890534ed7708dfc84d2445f9667b4d0efe116d29 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 16:17:24 +0100 Subject: [PATCH 592/979] Set MAX_LOG_HOURS to 24 --- plugwise_usb/nodes/helpers/pulses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 78e4c9044..44c41ca51 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -7,14 +7,14 @@ import logging from typing import Final -from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, WEEK_IN_HOURS +from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, DAY_IN_HOURS from ...exceptions import EnergyError _LOGGER = logging.getLogger(__name__) CONSUMED: Final = True PRODUCED: Final = False -MAX_LOG_HOURS = WEEK_IN_HOURS +MAX_LOG_HOURS = DAY_IN_HOURS def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: From 28c8d54459a131fa6f82b618a5d64730338c6421 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 16:17:50 +0100 Subject: [PATCH 593/979] Bump to a58 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c758ec68..cb50986f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a57" +version = "v0.40.0a58" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 5f246910a4dd11886f4fe74f7cbc1a2d014c36f3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 23 Mar 2025 19:54:34 +0100 Subject: [PATCH 594/979] Add idea-text to update_pulse_counter() docstring --- plugwise_usb/nodes/helpers/pulses.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 44c41ca51..a21cab129 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -260,7 +260,12 @@ def _collect_pulses_from_logs( def update_pulse_counter( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: - """Update pulse counter.""" + """Update pulse counter. + + Both consumption and production counters reset at the beginning of a new hour. + IDEA: should we treat this event not as a rollover but as a counter reset? + What should be the effect of this reset? + """ self._pulses_timestamp = timestamp self._update_rollover() if not (self._rollover_consumption or self._rollover_production): From 4b66da335c7a3c9d4f4650d92f24bd76f4c92205 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 09:36:49 +0100 Subject: [PATCH 595/979] update_pulse_counter(): add rollover log-debug-messages --- plugwise_usb/nodes/helpers/pulses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a21cab129..1fbeec7b2 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -275,12 +275,15 @@ def update_pulse_counter( self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed ): + _LOGGER.debug("update_pulse_counter | rollover consumption") self._rollover_consumption = True if ( self._pulses_production is not None and self._pulses_production < pulses_produced ): + _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True + self._pulses_consumption = pulses_consumed self._pulses_production = pulses_produced From 7092e21754117c8ac62c9fe51dabd27a4ee57c42 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 09:50:23 +0100 Subject: [PATCH 596/979] collected_pulses(): don't return None's at rollover Improve logging --- plugwise_usb/nodes/helpers/pulses.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 1fbeec7b2..68ad45e3f 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -160,27 +160,27 @@ def collected_pulses( ) -> tuple[int | None, datetime | None]: """Calculate total pulses from given timestamp.""" _LOGGER.debug( - "collected_pulses 1 | %s | is_cons=%s, from_timestamp=%s", + "collected_pulses | %s | from_timestamp=%s | is_cons=%s | _log_production=%s", self._mac, - is_consumption, from_timestamp, + is_consumption, + self._log_production ) - _LOGGER.debug("collected_pulses 1a | _log_production=%s", self._log_production) if not is_consumption: if self._log_production is None or not self._log_production: return (None, None) - if is_consumption and self._rollover_consumption: - _LOGGER.debug("collected_pulses 2 | %s | _rollover_consumption", self._mac) - return (None, None) - if not is_consumption and self._rollover_production: - _LOGGER.debug("collected_pulses 3 | %s | _rollover_production", self._mac) - return (None, None) + # if is_consumption and self._rollover_consumption: + # _LOGGER.debug("collected_pulses 2 | %s | _rollover_consumption", self._mac) + # return (None, None) + # if not is_consumption and self._rollover_production: + # _LOGGER.debug("collected_pulses 3 | %s | _rollover_production", self._mac) + # return (None, None) if ( log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) ) is None: - _LOGGER.debug("collected_pulses 4 | %s | log_pulses:None", self._mac) + _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) return (None, None) pulses: int | None = None @@ -188,20 +188,20 @@ def collected_pulses( if is_consumption and self._pulses_consumption is not None: pulses = self._pulses_consumption timestamp = self._pulses_timestamp + if not is_consumption and self._pulses_production is not None: pulses = self._pulses_production timestamp = self._pulses_timestamp - # _LOGGER.debug("collected_pulses | %s | pulses=%s", self._mac, pulses) if pulses is None: _LOGGER.debug( - "collected_pulses 5 | %s | is_consumption=%s, pulses=None", + "collected_pulses | %s | is_consumption=%s, pulses=None", self._mac, is_consumption, ) return (None, None) _LOGGER.debug( - "collected_pulses 6 | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", + "collected_pulses | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", pulses, log_pulses, is_consumption, From 8f286470d9828481c71c82ecc9b8b1d25dd5d9d2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 09:55:04 +0100 Subject: [PATCH 597/979] Set to a59 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cb50986f0..0ffb0c5d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a58" +version = "v0.40.0a59" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 183de89621e7465649aa1709a12ccaabeb2d2197 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 09:58:28 +0100 Subject: [PATCH 598/979] Fix related test-assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index da959c3a4..2020506f8 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1126,7 +1126,7 @@ def test_pulse_collection_consumption( test_timestamp = fixed_this_hour + td(hours=1, seconds=5) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True - ) == (None, None) + ) == (45, pulse_update_3) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses( From 8a2deb972ce0de8ab5963e0b41f262fa54d5e135 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 10:11:44 +0100 Subject: [PATCH 599/979] Fix 2nd related test-assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 2020506f8..f8074bf91 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1141,7 +1141,7 @@ def test_pulse_collection_consumption( assert tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (None, None) + ) == (45+ 2222 + 3333, pulse_update_3) pulse_update_4 = fixed_this_hour + td(hours=2, seconds=10) tst_consumption.update_pulse_counter(321, 0, pulse_update_4) assert not tst_consumption.log_rollover From cc60d4c90bef6bc208ccbb92e4b0dfa873946dfc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 12:51:46 +0100 Subject: [PATCH 600/979] Remove commented-out code --- plugwise_usb/nodes/helpers/pulses.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 68ad45e3f..e7464f5bb 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -170,13 +170,6 @@ def collected_pulses( if self._log_production is None or not self._log_production: return (None, None) - # if is_consumption and self._rollover_consumption: - # _LOGGER.debug("collected_pulses 2 | %s | _rollover_consumption", self._mac) - # return (None, None) - # if not is_consumption and self._rollover_production: - # _LOGGER.debug("collected_pulses 3 | %s | _rollover_production", self._mac) - # return (None, None) - if ( log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) ) is None: From 294081872ae6487a30ffa9a44ef4268bb00248b1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 13:06:54 +0100 Subject: [PATCH 601/979] Clean up update_pulse_counter() docstring --- plugwise_usb/nodes/helpers/pulses.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e7464f5bb..f0fb6ffbe 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -255,9 +255,7 @@ def update_pulse_counter( ) -> None: """Update pulse counter. - Both consumption and production counters reset at the beginning of a new hour. - IDEA: should we treat this event not as a rollover but as a counter reset? - What should be the effect of this reset? + Both device consumption and production counters reset after the beginning of a new hour. """ self._pulses_timestamp = timestamp self._update_rollover() From 1d20076033481455aa8126706a5dd0571f684b89 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 13:11:11 +0100 Subject: [PATCH 602/979] Revert back to WEEK_IN_HOURS --- plugwise_usb/nodes/helpers/pulses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f0fb6ffbe..fe31ee7da 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -7,14 +7,14 @@ import logging from typing import Final -from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, DAY_IN_HOURS +from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, WEEK_IN_HOURS from ...exceptions import EnergyError _LOGGER = logging.getLogger(__name__) CONSUMED: Final = True PRODUCED: Final = False -MAX_LOG_HOURS = DAY_IN_HOURS +MAX_LOG_HOURS = WEEK_IN_HOURS def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: From bd6de0f14187a698efd0a8261c51c06fd9bca5c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 13:27:35 +0100 Subject: [PATCH 603/979] Bump to a60 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ffb0c5d9..59d7ed3ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a59" +version = "v0.40.0a60" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 7789713d932baf8add0196095a97d4d6247165bb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 08:12:33 +0100 Subject: [PATCH 604/979] _add_log_record(): formatting --- plugwise_usb/nodes/helpers/pulses.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index fe31ee7da..bee3288b9 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -432,15 +432,19 @@ def _add_log_record( if self._logs is None: self._logs = {address: {slot: log_record}} return True + if self._log_exists(address, slot): return False + # Drop useless log records when we have at least 4 logs if self.collected_logs > 4 and log_record.timestamp < ( datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ): return False + if self._logs.get(address) is None: self._logs[address] = {slot: log_record} + self._logs[address][slot] = log_record if ( address == self._first_empty_log_address @@ -448,12 +452,14 @@ def _add_log_record( ): self._first_empty_log_address = None self._first_empty_log_slot = None + if ( address == self._last_empty_log_address and slot == self._last_empty_log_slot ): self._last_empty_log_address = None self._last_empty_log_slot = None + return True def _update_log_direction( From f5ebaf68122edc51d6def2c52345ae1bb7d40e12 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 24 Mar 2025 18:09:36 +0100 Subject: [PATCH 605/979] Fix typo in comment --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index bee3288b9..84ce76cca 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -261,7 +261,7 @@ def update_pulse_counter( self._update_rollover() if not (self._rollover_consumption or self._rollover_production): # No rollover based on time, check rollover based on counter reset - # Required for special cases like nodes which have been power off for several days + # Required for special cases like nodes which have been powered off for several days if ( self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed From caa52e3ffc8b23a210aa99613a0fb459e3081ca0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 26 Mar 2025 08:43:47 +0100 Subject: [PATCH 606/979] Formatting --- plugwise_usb/nodes/helpers/pulses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 84ce76cca..ae39cb23a 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -211,6 +211,7 @@ def _collect_pulses_from_logs( if self._logs is None: _LOGGER.debug("_collect_pulses_from_logs | %s | self._logs=None", self._mac) return None + if is_consumption: if self._last_log_consumption_timestamp is None: _LOGGER.debug( @@ -240,7 +241,6 @@ def _collect_pulses_from_logs( return None log_pulses = 0 - for log_item in self._logs.values(): for slot_item in log_item.values(): if ( @@ -248,6 +248,7 @@ def _collect_pulses_from_logs( and slot_item.timestamp > from_timestamp ): log_pulses += slot_item.pulses + return log_pulses def update_pulse_counter( From 39690d53d6461a16ed6198b52fb3495941982204 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 26 Mar 2025 13:50:10 +0100 Subject: [PATCH 607/979] Add pulsecounter_reset booleans, and detection --- plugwise_usb/nodes/helpers/pulses.py | 35 ++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index ae39cb23a..d04952aab 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -83,6 +83,8 @@ def __init__(self, mac: str) -> None: self._first_log_production_slot: int | None = None self._next_log_production_timestamp: datetime | None = None + self._cons_pulsecounter_reset = False + self._prod_pulsecounter_reset = False self._rollover_consumption = False self._rollover_production = False @@ -258,21 +260,34 @@ def update_pulse_counter( Both device consumption and production counters reset after the beginning of a new hour. """ + self._cons_pulsecounter_reset = False + self._prod_pulsecounter_reset = False self._pulses_timestamp = timestamp - self._update_rollover() - if not (self._rollover_consumption or self._rollover_production): # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days - if ( - self._pulses_consumption is not None - and self._pulses_consumption > pulses_consumed - ): + if ( + self._pulses_consumption is not None + and self._pulses_consumption > pulses_consumed + ): + self._cons_pulsecounter_reset = True + + if ( + self._pulses_production is not None + and self._pulses_production < pulses_produced + ): + self._prod_pulsecounter_reset = True + + if consumption_counter_reset or production_counter_reset: + _LOGGER.debug("update_pulse_counter | pulsecounter reset") + self._pulsecounter_reset = True + + self._update_rollover() + if not (self._rollover_consumption or self._rollover_production): + if self._cons_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover consumption") self._rollover_consumption = True - if ( - self._pulses_production is not None - and self._pulses_production < pulses_produced - ): + + if self._prod_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True From 549d0fa5020141c3910b6bd10185dd79bf159852 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 08:15:09 +0100 Subject: [PATCH 608/979] Add counter_reset guarding for log_pulses reset --- plugwise_usb/nodes/helpers/pulses.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d04952aab..ef5364b50 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -221,7 +221,10 @@ def _collect_pulses_from_logs( self._mac, ) return None - if from_timestamp > self._last_log_consumption_timestamp: + if ( + from_timestamp > self._last_log_consumption_timestamp + and self._cons_pulsecounter_reset + ): return 0 else: if self._last_log_production_timestamp is None: @@ -230,7 +233,10 @@ def _collect_pulses_from_logs( self._mac, ) return None - if from_timestamp > self._last_log_production_timestamp: + if ( + from_timestamp > self._last_log_production_timestamp + and self._prod_pulsecounter_reset + ): return 0 missing_logs = self._logs_missing(from_timestamp) From fbc26644c351501a28c1ceefe2aa793c14deee3a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 08:20:21 +0100 Subject: [PATCH 609/979] Improve --- plugwise_usb/nodes/helpers/pulses.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index ef5364b50..b56babb23 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -269,25 +269,23 @@ def update_pulse_counter( self._cons_pulsecounter_reset = False self._prod_pulsecounter_reset = False self._pulses_timestamp = timestamp - # No rollover based on time, check rollover based on counter reset - # Required for special cases like nodes which have been powered off for several days + self._update_rollover() if ( self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed ): + _LOGGER.debug("update_pulse_counter | consumption pulses reset") self._cons_pulsecounter_reset = True if ( self._pulses_production is not None and self._pulses_production < pulses_produced ): + _LOGGER.debug("update_pulse_counter | production pulses reset") self._prod_pulsecounter_reset = True - if consumption_counter_reset or production_counter_reset: - _LOGGER.debug("update_pulse_counter | pulsecounter reset") - self._pulsecounter_reset = True - - self._update_rollover() + # No rollover based on time, check rollover based on counter reset + # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): if self._cons_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover consumption") From fffb805d5896b843bb6a6f20199bbaee841a7062 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 08:24:08 +0100 Subject: [PATCH 610/979] Bump to a62 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 59d7ed3ae..e1a86e055 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a60" +version = "v0.40.0a62" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 13d1b9192ed62c3444cfe82b217ac64ca953ac82 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 14:30:48 +0100 Subject: [PATCH 611/979] Use common _pulsecounter_reset to reset both counters while only one is active --- plugwise_usb/nodes/helpers/pulses.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b56babb23..d6444f7af 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -83,8 +83,7 @@ def __init__(self, mac: str) -> None: self._first_log_production_slot: int | None = None self._next_log_production_timestamp: datetime | None = None - self._cons_pulsecounter_reset = False - self._prod_pulsecounter_reset = False + self._pulsecounter_reset = False self._rollover_consumption = False self._rollover_production = False @@ -223,7 +222,7 @@ def _collect_pulses_from_logs( return None if ( from_timestamp > self._last_log_consumption_timestamp - and self._cons_pulsecounter_reset + and self._pulsecounter_reset ): return 0 else: @@ -235,7 +234,7 @@ def _collect_pulses_from_logs( return None if ( from_timestamp > self._last_log_production_timestamp - and self._prod_pulsecounter_reset + and self._pulsecounter_reset ): return 0 @@ -266,8 +265,8 @@ def update_pulse_counter( Both device consumption and production counters reset after the beginning of a new hour. """ - self._cons_pulsecounter_reset = False - self._prod_pulsecounter_reset = False + cons_pulsecounter_reset = False + prod_pulsecounter_reset = False self._pulses_timestamp = timestamp self._update_rollover() if ( @@ -275,23 +274,28 @@ def update_pulse_counter( and self._pulses_consumption > pulses_consumed ): _LOGGER.debug("update_pulse_counter | consumption pulses reset") - self._cons_pulsecounter_reset = True + cons_pulsecounter_reset = True if ( self._pulses_production is not None and self._pulses_production < pulses_produced ): _LOGGER.debug("update_pulse_counter | production pulses reset") - self._prod_pulsecounter_reset = True + prod_pulsecounter_reset = True + + if cons_pulsecounter_reset or prod_pulsecounter_reset: + self._pulsecounter_reset = True + else: + self._pulsecounter_reset = False # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): - if self._cons_pulsecounter_reset: + if cons_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover consumption") self._rollover_consumption = True - if self._prod_pulsecounter_reset: + if prod_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True From c94b1dac50d16ee685f2155af5c414c74cd1b140 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 14:46:13 +0100 Subject: [PATCH 612/979] Bump to a63 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1a86e055..b1b89a5a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a62" +version = "v0.40.0a63" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From aa0ed30bbaa49384931d23ada0a73700f8e2f942 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 15:10:37 +0100 Subject: [PATCH 613/979] Set MAX_LOG_HOURS to DAY_IN_HOURS --- plugwise_usb/nodes/helpers/pulses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d6444f7af..9c71835f7 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -7,14 +7,14 @@ import logging from typing import Final -from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, WEEK_IN_HOURS +from ...constants import LOGADDR_MAX, MINUTE_IN_SECONDS, DAY_IN_HOURS from ...exceptions import EnergyError _LOGGER = logging.getLogger(__name__) CONSUMED: Final = True PRODUCED: Final = False -MAX_LOG_HOURS = WEEK_IN_HOURS +MAX_LOG_HOURS = DAY_IN_HOURS def calc_log_address(address: int, slot: int, offset: int) -> tuple[int, int]: From 87aeb27cee21b47dc00523cd01e052efa4f77276 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 15:13:33 +0100 Subject: [PATCH 614/979] Remove all week-related EnergyTypes --- plugwise_usb/nodes/helpers/counter.py | 30 --------------------------- 1 file changed, 30 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 8e3560e8f..9611cf4db 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -21,8 +21,6 @@ class EnergyType(Enum): PRODUCTION_HOUR = auto() CONSUMPTION_DAY = auto() PRODUCTION_DAY = auto() - CONSUMPTION_WEEK = auto() - PRODUCTION_WEEK = auto() ENERGY_COUNTERS: Final = ( @@ -30,8 +28,6 @@ class EnergyType(Enum): EnergyType.PRODUCTION_HOUR, EnergyType.CONSUMPTION_DAY, EnergyType.PRODUCTION_DAY, - EnergyType.CONSUMPTION_WEEK, - EnergyType.PRODUCTION_WEEK, ) ENERGY_HOUR_COUNTERS: Final = ( EnergyType.CONSUMPTION_HOUR, @@ -41,20 +37,13 @@ class EnergyType(Enum): EnergyType.CONSUMPTION_DAY, EnergyType.PRODUCTION_DAY, ) -ENERGY_WEEK_COUNTERS: Final = ( - EnergyType.CONSUMPTION_WEEK, - EnergyType.PRODUCTION_WEEK, -) - ENERGY_CONSUMPTION_COUNTERS: Final = ( EnergyType.CONSUMPTION_HOUR, EnergyType.CONSUMPTION_DAY, - EnergyType.CONSUMPTION_WEEK, ) ENERGY_PRODUCTION_COUNTERS: Final = ( EnergyType.PRODUCTION_HOUR, EnergyType.PRODUCTION_DAY, - EnergyType.PRODUCTION_WEEK, ) _LOGGER = logging.getLogger(__name__) @@ -168,11 +157,6 @@ def update(self) -> None: self._energy_statistics.day_consumption, self._energy_statistics.day_consumption_reset, ) = self._counters[EnergyType.CONSUMPTION_DAY].update(self._pulse_collection) - ( - self._energy_statistics.week_consumption, - self._energy_statistics.week_consumption_reset, - ) = self._counters[EnergyType.CONSUMPTION_WEEK].update(self._pulse_collection) - if self._pulse_collection.production_logging: self._energy_statistics.log_interval_production = ( self._pulse_collection.log_interval_production @@ -185,10 +169,6 @@ def update(self) -> None: self._energy_statistics.day_production, self._energy_statistics.day_production_reset, ) = self._counters[EnergyType.PRODUCTION_DAY].update(self._pulse_collection) - ( - self._energy_statistics.week_production, - self._energy_statistics.week_production_reset, - ) = self._counters[EnergyType.PRODUCTION_WEEK].update(self._pulse_collection) @property def timestamp(self) -> datetime | None: @@ -218,8 +198,6 @@ def __init__( self._duration = "hour" if energy_id in ENERGY_DAY_COUNTERS: self._duration = "day" - elif energy_id in ENERGY_WEEK_COUNTERS: - self._duration = "week" self._energy_id: EnergyType = energy_id self._is_consumption = True self._direction = "consumption" @@ -308,14 +286,6 @@ def update( last_reset = last_reset.replace(minute=0, second=0, microsecond=0) elif self._energy_id in ENERGY_DAY_COUNTERS: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - elif self._energy_id in ENERGY_WEEK_COUNTERS: - last_reset = last_reset - timedelta(days=last_reset.weekday()) - last_reset = last_reset.replace( - hour=0, - minute=0, - second=0, - microsecond=0, - ) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From db54ddaef4196a53674f78db0f98ac051b205472 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 15:16:16 +0100 Subject: [PATCH 615/979] Start cleaning week-related test-asserts --- tests/test_usb.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index f8074bf91..02dd4b906 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -915,14 +915,10 @@ async def fake_get_missing_energy_logs(address: int) -> None: hour_consumption_reset=None, day_consumption=None, day_consumption_reset=None, - week_consumption=None, - week_consumption_reset=None, hour_production=None, hour_production_reset=None, day_production=None, day_production_reset=None, - week_production=None, - week_production_reset=None, ) # energy_update is not complete and should return none utc_now = dt.now(UTC) @@ -936,14 +932,10 @@ async def fake_get_missing_energy_logs(address: int) -> None: hour_consumption_reset=utc_now.replace(minute=0, second=0, microsecond=0), day_consumption=None, day_consumption_reset=None, - week_consumption=None, - week_consumption_reset=None, hour_production=None, hour_production_reset=None, day_production=None, day_production_reset=None, - week_production=None, - week_production_reset=None, ) await stick.disconnect() From e5d78af1f3137fdd81bb293fa33b9790020f58c2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 15:20:39 +0100 Subject: [PATCH 616/979] Counter.py: clean up imports --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 9611cf4db..57c403063 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime from enum import Enum, auto import logging from typing import Final From 00515b44d0212cecb5fcdaeec26c4d955dd9737b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 19:37:44 +0100 Subject: [PATCH 617/979] Don't use gather --- plugwise_usb/nodes/circle.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 835365a8c..c81e67c51 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather +from asyncio import Task, create_task from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -448,14 +448,11 @@ async def get_missing_energy_logs(self) -> None: ) total_addresses = 11 log_address = self._current_log_address - log_update_tasks = [] while total_addresses > 0: - log_update_tasks.append(self.energy_log_update(log_address)) + await self.energy_log_update(log_address) log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 - await gather(*log_update_tasks) - if self._cache_enabled: await self._energy_log_records_save_to_cache() From aebe07478efe56236007d4f923d012182bb197e9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 27 Mar 2025 19:45:57 +0100 Subject: [PATCH 618/979] Bump to a64 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b1b89a5a5..42934c299 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a63" +version = "v0.40.0a64" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 50c7ea6462919879a1798d77be74dae74e7d12d6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 09:57:03 +0100 Subject: [PATCH 619/979] _collect_pulses_from_logs(): add debug-logging --- plugwise_usb/nodes/helpers/pulses.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 9c71835f7..03b9a03ff 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -213,6 +213,7 @@ def _collect_pulses_from_logs( _LOGGER.debug("_collect_pulses_from_logs | %s | self._logs=None", self._mac) return None + timestamp: datetime | None = None if is_consumption: if self._last_log_consumption_timestamp is None: _LOGGER.debug( @@ -224,6 +225,8 @@ def _collect_pulses_from_logs( from_timestamp > self._last_log_consumption_timestamp and self._pulsecounter_reset ): + _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses collection") + timestamp = self._last_log_consumption_timestamp return 0 else: if self._last_log_production_timestamp is None: @@ -236,6 +239,8 @@ def _collect_pulses_from_logs( from_timestamp > self._last_log_production_timestamp and self._pulsecounter_reset ): + _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses collection") + timestamp = self._last_log_production_timestamp return 0 missing_logs = self._logs_missing(from_timestamp) @@ -256,6 +261,12 @@ def _collect_pulses_from_logs( ): log_pulses += slot_item.pulses + _LOGGER.debug( + "_collect_pulses_from_logs | log_pulses=%s | from %s to %s", + log_pulses, + from_timestamp, + timestamp, + ) return log_pulses def update_pulse_counter( From ff38fa134255434839c23a7965c933403420b94c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 09:58:28 +0100 Subject: [PATCH 620/979] Fix typo in comment --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 03b9a03ff..f847a9a09 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -202,7 +202,7 @@ def collected_pulses( timestamp, ) - # Always return positive values of energy_statistics + # Always return positive values for energy_statistics return (abs(pulses + log_pulses), timestamp) def _collect_pulses_from_logs( From 5a8189bcecb96518132d26bca5b2c329e08a07e5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 10:04:29 +0100 Subject: [PATCH 621/979] Bump to a65 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 42934c299..92014bf33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a64" +version = "v0.40.0a65" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 6dea1c1d510a45f74d2bad65464a2ad337f78749 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 10:38:19 +0100 Subject: [PATCH 622/979] Try to not block the event_loop --- plugwise_usb/nodes/circle.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c81e67c51..80b691326 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task +from asyncio import Task, create_task, gather from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -449,10 +449,13 @@ async def get_missing_energy_logs(self) -> None: total_addresses = 11 log_address = self._current_log_address while total_addresses > 0: - await self.energy_log_update(log_address) + task = create_task(self.energy_log_update(log_address)) + await task log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 + await gather(*task) + if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -472,7 +475,10 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) for address in missing_addresses: - await self.energy_log_update(address) + task = create_task(self.energy_log_update(address)) + await task + + gather(*task) if self._cache_enabled: await self._energy_log_records_save_to_cache() From 2638ceb1a24276ed35ca6cfd1f559fdaf6bc621f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 10:47:53 +0100 Subject: [PATCH 623/979] Bump to a66 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 92014bf33..aee6c78a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a65" +version = "v0.40.0a66" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 41d7090987612a2ac266e8893ba6402ea0c3deb3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 11:03:52 +0100 Subject: [PATCH 624/979] Revert "Try to not block the event_loop" This reverts commit 975d8bcaabf35e7321e9dac6530902b2af3b0927. --- plugwise_usb/nodes/circle.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 80b691326..c81e67c51 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, gather +from asyncio import Task, create_task from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -449,13 +449,10 @@ async def get_missing_energy_logs(self) -> None: total_addresses = 11 log_address = self._current_log_address while total_addresses > 0: - task = create_task(self.energy_log_update(log_address)) - await task + await self.energy_log_update(log_address) log_address, _ = calc_log_address(log_address, 1, -4) total_addresses -= 1 - await gather(*task) - if self._cache_enabled: await self._energy_log_records_save_to_cache() @@ -475,10 +472,7 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) for address in missing_addresses: - task = create_task(self.energy_log_update(address)) - await task - - gather(*task) + await self.energy_log_update(address) if self._cache_enabled: await self._energy_log_records_save_to_cache() From fb5f39a94be1544921d1c38e286091041fcf59a0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 11:07:47 +0100 Subject: [PATCH 625/979] Try something else --- plugwise_usb/nodes/circle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index c81e67c51..0566a6798 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task +from asyncio import Task, create_task, sleep from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -472,6 +472,7 @@ async def get_missing_energy_logs(self) -> None: missing_addresses = sorted(missing_addresses, reverse=True) for address in missing_addresses: + await sleep(0) await self.energy_log_update(address) if self._cache_enabled: From da8ffbf94e1e263114e6241297c7c349cf89942d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 11:12:31 +0100 Subject: [PATCH 626/979] Bump to a67 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aee6c78a2..3958e211f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a66" +version = "v0.40.0a67" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 7d654864efea8e935dfdd28abcd7263d555413f8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 12:13:13 +0100 Subject: [PATCH 627/979] Improve _collect_pulses_from_logs() --- plugwise_usb/nodes/helpers/pulses.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f847a9a09..b345ea88b 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -221,12 +221,13 @@ def _collect_pulses_from_logs( self._mac, ) return None + + timestamp = self._last_log_consumption_timestamp if ( - from_timestamp > self._last_log_consumption_timestamp + from_timestamp > timestamp and self._pulsecounter_reset ): - _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses collection") - timestamp = self._last_log_consumption_timestamp + _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") return 0 else: if self._last_log_production_timestamp is None: @@ -235,12 +236,13 @@ def _collect_pulses_from_logs( self._mac, ) return None + + timestamp = self._last_log_production_timestamp if ( - from_timestamp > self._last_log_production_timestamp + from_timestamp > timestamp and self._pulsecounter_reset ): - _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses collection") - timestamp = self._last_log_production_timestamp + _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") return 0 missing_logs = self._logs_missing(from_timestamp) @@ -262,8 +264,9 @@ def _collect_pulses_from_logs( log_pulses += slot_item.pulses _LOGGER.debug( - "_collect_pulses_from_logs | log_pulses=%s | from %s to %s", + "_collect_pulses_from_logs | log_pulses=%s | is_consumption=%s | from %s to %s", log_pulses, + is_consumption, from_timestamp, timestamp, ) From 23d60e1047cfe1f1f14d0f697f271b60318002b7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 13:10:14 +0100 Subject: [PATCH 628/979] Bump to a68 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3958e211f..86d17542c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a67" +version = "v0.40.0a68" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 446f24343c66af11a58ce359d21caed76e0b229e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 14:55:40 +0100 Subject: [PATCH 629/979] Try to avoid using asyncio_sleep() --- plugwise_usb/nodes/circle.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 0566a6798..dde3c5098 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Task, create_task, sleep +from asyncio import Task, create_task from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime @@ -458,8 +458,7 @@ async def get_missing_energy_logs(self) -> None: return - if self._energy_counters.log_addresses_missing is not None: - _LOGGER.debug("Task created to get missing logs of %s", self._mac_in_str) + _LOGGER.debug("Task created to get missing logs of %s", self._mac_in_str) if ( missing_addresses := self._energy_counters.log_addresses_missing ) is not None: @@ -471,9 +470,12 @@ async def get_missing_energy_logs(self) -> None: ) missing_addresses = sorted(missing_addresses, reverse=True) - for address in missing_addresses: - await sleep(0) - await self.energy_log_update(address) + tasks = [ + create_task(self.energy_log_update(address)) + for address in missing_addresses + ] + for task in tasks: + await task if self._cache_enabled: await self._energy_log_records_save_to_cache() From a06e7ec6ea441a8a2a649e32b8d10f00ca9bb309 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 28 Mar 2025 19:41:55 +0100 Subject: [PATCH 630/979] Bump to a69 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86d17542c..4e61d6090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a68" +version = "v0.40.0a69" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 1dca9bca1ddc83a6c5be0dec196e117dc8d619d1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 10:29:56 +0200 Subject: [PATCH 631/979] Collect last_hourly_reset timestamps --- plugwise_usb/nodes/helpers/pulses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b345ea88b..6a512b598 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -67,6 +67,8 @@ def __init__(self, mac: str) -> None: self._last_empty_log_address: int | None = None self._last_empty_log_slot: int | None = None + self.cons_last_hourly_reset: datetime | None = None + self.prod_last_hourly_reset: datetime | None = None self._last_log_consumption_timestamp: datetime | None = None self._last_log_consumption_address: int | None = None self._last_log_consumption_slot: int | None = None @@ -288,6 +290,7 @@ def update_pulse_counter( and self._pulses_consumption > pulses_consumed ): _LOGGER.debug("update_pulse_counter | consumption pulses reset") + self.cons_last_hourly_reset = timestamp cons_pulsecounter_reset = True if ( @@ -295,6 +298,7 @@ def update_pulse_counter( and self._pulses_production < pulses_produced ): _LOGGER.debug("update_pulse_counter | production pulses reset") + self.prod_last_hourly_reset = timestamp prod_pulsecounter_reset = True if cons_pulsecounter_reset or prod_pulsecounter_reset: From 8181103487d52b13705ebba1d430b5ee485778c6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 10:46:59 +0200 Subject: [PATCH 632/979] Line up interval reset with device hourly pulsecounter reset --- plugwise_usb/nodes/helpers/counter.py | 14 +++++++++++--- plugwise_usb/nodes/helpers/pulses.py | 8 ++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 57c403063..4720da0ed 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -206,6 +206,7 @@ def __init__( self._is_consumption = False self._last_reset: datetime | None = None self._last_update: datetime | None = None + self._pulse_collection = PulseCollection(mac) self._pulses: int | None = None @property @@ -282,10 +283,17 @@ def update( ) -> tuple[float | None, datetime | None]: """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) + timestamp = self._pulse_collection.hourly_reset_time if self._energy_id in ENERGY_HOUR_COUNTERS: - last_reset = last_reset.replace(minute=0, second=0, microsecond=0) - elif self._energy_id in ENERGY_DAY_COUNTERS: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + if timestamp is not None: + last_reset = timestamp + else: + last_reset = last_reset.replace(minute=0, second=0, microsecond=0) + if self._energy_id in ENERGY_DAY_COUNTERS: + if timestamp is not None: + last_reset = timestamp.replace(hour=0) + else: + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 6a512b598..44d216f23 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -106,6 +106,14 @@ def collected_logs(self) -> int: counter += len(self._logs[address]) return counter + @property + def hourly_reset_time(self) -> datetime: + """Provide the device hourly pulse reset time.""" + if timestamp := self.cons_last_hourly_reset is not None: + return timestamp + if timestamp := self.prod_last_hourly_reset is not None: + return timestamp + @property def logs(self) -> dict[int, dict[int, PulseLogRecord]]: """Return currently collected pulse logs in reversed order.""" From aae8b6feba80c36094f36d36fd3a5ca610849848 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:08:53 +0200 Subject: [PATCH 633/979] Adapt hourly reset time, add hourly_reset_time test-assert --- tests/test_usb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 02dd4b906..2a6c3fd05 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1112,9 +1112,10 @@ def test_pulse_collection_consumption( # Test rollover by updating pulses before log record assert not tst_consumption.log_rollover - pulse_update_3 = fixed_this_hour + td(hours=1, seconds=3) + pulse_update_3 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_3) assert tst_consumption.log_rollover + assert tst_consumption.hourly_reset_time == pulse_update_3 test_timestamp = fixed_this_hour + td(hours=1, seconds=5) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True From 4f119421b8dfab50be2eb0ecc00dcb2b1a6a8162 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:15:36 +0200 Subject: [PATCH 634/979] Improve hourly_reset_time property --- plugwise_usb/nodes/helpers/pulses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 44d216f23..4c135f12a 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -107,12 +107,13 @@ def collected_logs(self) -> int: return counter @property - def hourly_reset_time(self) -> datetime: + def hourly_reset_time(self) -> datetime | None: """Provide the device hourly pulse reset time.""" if timestamp := self.cons_last_hourly_reset is not None: return timestamp if timestamp := self.prod_last_hourly_reset is not None: return timestamp + return None @property def logs(self) -> dict[int, dict[int, PulseLogRecord]]: From 4faa29bb29d563c2a60fc5126fdebcc522f92c13 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:20:32 +0200 Subject: [PATCH 635/979] Add debug logging --- plugwise_usb/nodes/helpers/pulses.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 4c135f12a..c1f257bd1 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -300,6 +300,10 @@ def update_pulse_counter( ): _LOGGER.debug("update_pulse_counter | consumption pulses reset") self.cons_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | consumption hourly_reset_time=%s", + self.cons_last_hourly_reset, + ) cons_pulsecounter_reset = True if ( @@ -308,6 +312,10 @@ def update_pulse_counter( ): _LOGGER.debug("update_pulse_counter | production pulses reset") self.prod_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | production hourly_reset_time=%s", + self.prod_last_hourly_reset, + ) prod_pulsecounter_reset = True if cons_pulsecounter_reset or prod_pulsecounter_reset: From 23d485acd1cc481b25273c058e5f00d3a5879f4f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:25:42 +0200 Subject: [PATCH 636/979] Fix walrus --- plugwise_usb/nodes/helpers/pulses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index c1f257bd1..2c15678fc 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -109,9 +109,9 @@ def collected_logs(self) -> int: @property def hourly_reset_time(self) -> datetime | None: """Provide the device hourly pulse reset time.""" - if timestamp := self.cons_last_hourly_reset is not None: + if (timestamp := self.cons_last_hourly_reset) is not None: return timestamp - if timestamp := self.prod_last_hourly_reset is not None: + if (timestamp := self.prod_last_hourly_reset) is not None: return timestamp return None From 5083dead17ebb076def76c09fa2d894a47e6296b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:27:35 +0200 Subject: [PATCH 637/979] Test: line up 2nd test-time --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 2a6c3fd05..70ad1e3c1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1116,7 +1116,7 @@ def test_pulse_collection_consumption( tst_consumption.update_pulse_counter(45, 0, pulse_update_3) assert tst_consumption.log_rollover assert tst_consumption.hourly_reset_time == pulse_update_3 - test_timestamp = fixed_this_hour + td(hours=1, seconds=5) + test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=5) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True ) == (45, pulse_update_3) From d58cd0618a4455b70712835ee57b1c32310374c9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 11:32:15 +0200 Subject: [PATCH 638/979] Bump to a71 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4e61d6090..9686c0cd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a69" +version = "v0.40.0a71" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 692b7e25696702f130151cf6f734def3abba79e0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:18:36 +0200 Subject: [PATCH 639/979] Reset log_pulses after pulsecounter resets. --- plugwise_usb/nodes/helpers/pulses.py | 30 ++++++++++++---------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 2c15678fc..730698620 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -182,12 +182,6 @@ def collected_pulses( if self._log_production is None or not self._log_production: return (None, None) - if ( - log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) - ) is None: - _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) - return (None, None) - pulses: int | None = None timestamp: datetime | None = None if is_consumption and self._pulses_consumption is not None: @@ -205,6 +199,13 @@ def collected_pulses( is_consumption, ) return (None, None) + + if ( + log_pulses := self._collect_pulses_from_logs(from_timestamp, is_consumption) + ) is None: + _LOGGER.debug("collected_pulses | %s | log_pulses:None", self._mac) + return (None, None) + _LOGGER.debug( "collected_pulses | pulses=%s | log_pulses=%s | consumption=%s at timestamp=%s", pulses, @@ -236,7 +237,7 @@ def _collect_pulses_from_logs( timestamp = self._last_log_consumption_timestamp if ( from_timestamp > timestamp - and self._pulsecounter_reset + and self._cons_pulsecounter_reset ): _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") return 0 @@ -251,7 +252,7 @@ def _collect_pulses_from_logs( timestamp = self._last_log_production_timestamp if ( from_timestamp > timestamp - and self._pulsecounter_reset + and self._prod_pulsecounter_reset ): _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") return 0 @@ -290,8 +291,8 @@ def update_pulse_counter( Both device consumption and production counters reset after the beginning of a new hour. """ - cons_pulsecounter_reset = False - prod_pulsecounter_reset = False + self._cons_pulsecounter_reset = False + self._prod_pulsecounter_reset = False self._pulses_timestamp = timestamp self._update_rollover() if ( @@ -304,7 +305,7 @@ def update_pulse_counter( "update_pulse_counter | consumption hourly_reset_time=%s", self.cons_last_hourly_reset, ) - cons_pulsecounter_reset = True + self._cons_pulsecounter_reset = True if ( self._pulses_production is not None @@ -316,12 +317,7 @@ def update_pulse_counter( "update_pulse_counter | production hourly_reset_time=%s", self.prod_last_hourly_reset, ) - prod_pulsecounter_reset = True - - if cons_pulsecounter_reset or prod_pulsecounter_reset: - self._pulsecounter_reset = True - else: - self._pulsecounter_reset = False + self._prod_pulsecounter_reset = True # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days From 7bb111ac9de170803f0724167fea9bcf16bd5e1a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:20:21 +0200 Subject: [PATCH 640/979] Revert changes in counter.py --- plugwise_usb/nodes/helpers/counter.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 4720da0ed..e7c585c61 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -283,17 +283,10 @@ def update( ) -> tuple[float | None, datetime | None]: """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) - timestamp = self._pulse_collection.hourly_reset_time if self._energy_id in ENERGY_HOUR_COUNTERS: - if timestamp is not None: - last_reset = timestamp - else: - last_reset = last_reset.replace(minute=0, second=0, microsecond=0) + last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - if timestamp is not None: - last_reset = timestamp.replace(hour=0) - else: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From 3f5a5637858a363f8cf5b190cc0771f3085de179 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:22:43 +0200 Subject: [PATCH 641/979] Fixes --- plugwise_usb/nodes/helpers/pulses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 730698620..088384944 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -322,11 +322,11 @@ def update_pulse_counter( # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): - if cons_pulsecounter_reset: + if self._cons_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover consumption") self._rollover_consumption = True - if prod_pulsecounter_reset: + if self._prod_pulsecounter_reset: _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True From b7be4128675f06f220856e7e218f0c55d0c0f715 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:32:46 +0200 Subject: [PATCH 642/979] Add/improve testing --- tests/test_usb.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 70ad1e3c1..f469242bf 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1122,25 +1122,28 @@ def test_pulse_collection_consumption( ) == (45, pulse_update_3) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover + pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) + test_timestamp_2 = fixed_this_hour + td(hours=1, minutes=1, seconds=20) + tst_consumption.update_pulse_counter(145, 0, pulse_update_4) assert tst_consumption.collected_pulses( - test_timestamp, is_consumption=True - ) == (45, pulse_update_3) + test_timestamp_2, is_consumption=True + ) == (145, pulse_update_4) assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (45 + 2222, pulse_update_3) + ) == (145 + 2222, pulse_update_4) # Test log rollover by updating log first before updating pulses tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) assert tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (45+ 2222 + 3333, pulse_update_3) - pulse_update_4 = fixed_this_hour + td(hours=2, seconds=10) - tst_consumption.update_pulse_counter(321, 0, pulse_update_4) + ) == (145+ 2222 + 3333, pulse_update_4) + pulse_update_5 = fixed_this_hour + td(hours=2, seconds=10) + tst_consumption.update_pulse_counter(321, 0, pulse_update_5) assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (2222 + 3333 + 321, pulse_update_4) + ) == (2222 + 3333 + 321, pulse_update_5) @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( From 726aad080ce4df403b973eb540fcc6a61b6b7678 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:52:05 +0200 Subject: [PATCH 643/979] Bump to a72 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9686c0cd0..c0a5ba1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a71" +version = "v0.40.0a72" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 4551bae06b6f928daf900c6937c678c9f3f025a8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 30 Mar 2025 17:56:03 +0200 Subject: [PATCH 644/979] Init new selfs --- plugwise_usb/nodes/helpers/pulses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 088384944..e497fdf31 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -85,7 +85,8 @@ def __init__(self, mac: str) -> None: self._first_log_production_slot: int | None = None self._next_log_production_timestamp: datetime | None = None - self._pulsecounter_reset = False + self._cons_pulsecounter_reset = False + self._prod_pulsecounter_reset = False self._rollover_consumption = False self._rollover_production = False From a7c05bd4883f5e42e08f1a96f1c3b7938beb6902 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 08:08:11 +0200 Subject: [PATCH 645/979] Add testcase for midnight rollover --- tests/test_usb.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index f469242bf..ca5d4e894 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1145,6 +1145,21 @@ def test_pulse_collection_consumption( fixed_this_hour, is_consumption=True ) == (2222 + 3333 + 321, pulse_update_5) + # Test next midnight rollover + pulse_update_6 = fixed_this_hour.replace(hour=0, minute=0, second=3) + td(days=1) + assert not tst_consumption.log_rollover + tst_consumption.update_pulse_counter(584, 0, pulse_update_6) + test_timestamp_3 = pulse_update_6 + td(hours=0, minutes=0, seconds=5) + assert tst_consumption.collected_pulses( + test_timestamp_3, is_consumption=True + ) == (14000, pulse_update_6) + pulse_update_7 = fixed_this_hour.replace(hour=0, minute=1, second=3) + tst_consumption.update_pulse_counter(50, 0, pulse_update_7) + test_timestamp_4 = pulse_update_7 + td(hours=0, minutes=1, seconds=5) + assert tst_consumption.collected_pulses( + test_timestamp_3, is_consumption=True + ) == (500, pulse_update_7) + @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( self, monkeypatch: pytest.MonkeyPatch From 8137d212f92d7e53a6750e58c231440c249fe24b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 11:04:48 +0200 Subject: [PATCH 646/979] Remove double resetting of log_pulses Also happens when slot_item.timestamp > from_timestamp --- plugwise_usb/nodes/helpers/pulses.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e497fdf31..bd810471a 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -234,14 +234,6 @@ def _collect_pulses_from_logs( self._mac, ) return None - - timestamp = self._last_log_consumption_timestamp - if ( - from_timestamp > timestamp - and self._cons_pulsecounter_reset - ): - _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") - return 0 else: if self._last_log_production_timestamp is None: _LOGGER.debug( @@ -250,14 +242,6 @@ def _collect_pulses_from_logs( ) return None - timestamp = self._last_log_production_timestamp - if ( - from_timestamp > timestamp - and self._prod_pulsecounter_reset - ): - _LOGGER.debug("_collect_pulses_from_logs | resetting log_pulses to 0") - return 0 - missing_logs = self._logs_missing(from_timestamp) if missing_logs is None or missing_logs: _LOGGER.debug( From 8c4685c7c644d816e7d67eceb48315dd541bb4fa Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 17:07:52 +0200 Subject: [PATCH 647/979] More debug --- plugwise_usb/nodes/helpers/pulses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index bd810471a..e567b8040 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -234,6 +234,7 @@ def _collect_pulses_from_logs( self._mac, ) return None + _LOGGER.debug("HOI _last_log_consumption_timestamp=%s", self._last_log_consumption_timestamp) else: if self._last_log_production_timestamp is None: _LOGGER.debug( @@ -241,6 +242,7 @@ def _collect_pulses_from_logs( self._mac, ) return None + _LOGGER.debug("HOI _last_log_production_timestamp=%s", self._last_log_production_timestamp) missing_logs = self._logs_missing(from_timestamp) if missing_logs is None or missing_logs: From 8512cba9781743c8e68c5ef09ddfb5c60a55a498 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 17:11:01 +0200 Subject: [PATCH 648/979] Party revert removal --- plugwise_usb/nodes/helpers/pulses.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e567b8040..8b2d60c22 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -234,6 +234,8 @@ def _collect_pulses_from_logs( self._mac, ) return None + + timestamp = self._last_log_consumption_timestamp _LOGGER.debug("HOI _last_log_consumption_timestamp=%s", self._last_log_consumption_timestamp) else: if self._last_log_production_timestamp is None: @@ -242,6 +244,8 @@ def _collect_pulses_from_logs( self._mac, ) return None + + timestamp = self._last_log_production_timestamp _LOGGER.debug("HOI _last_log_production_timestamp=%s", self._last_log_production_timestamp) missing_logs = self._logs_missing(from_timestamp) From dee94eeeff3d1c934eb09414af2fdb3c7dfb5ee4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 17:20:08 +0200 Subject: [PATCH 649/979] Add add_log debugging --- plugwise_usb/nodes/helpers/pulses.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 8b2d60c22..a8c36ce95 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -460,6 +460,14 @@ def add_log( self._update_rollover() if not import_only: self.recalculate_missing_log_addresses() + + _LOGGER.debug( + "add_log | pulses=%s | address=%s | slot= %s |time:%s", + pulses, + address, + slot, + timestamp, + ) return True def recalculate_missing_log_addresses(self) -> None: From 7fe07692d2b10505d09dd82fdc6a75e827fac0d5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 17:50:11 +0200 Subject: [PATCH 650/979] Try test --- tests/test_usb.py | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ca5d4e894..4108c9f9a 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1122,23 +1122,30 @@ def test_pulse_collection_consumption( ) == (45, pulse_update_3) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover + # Test collection of the last full hour + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (45 + 2222, pulse_update_3) pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) test_timestamp_2 = fixed_this_hour + td(hours=1, minutes=1, seconds=20) tst_consumption.update_pulse_counter(145, 0, pulse_update_4) + # Test collection of the last new hour assert tst_consumption.collected_pulses( test_timestamp_2, is_consumption=True ) == (145, pulse_update_4) - assert tst_consumption.collected_pulses( - fixed_this_hour, is_consumption=True - ) == (145 + 2222, pulse_update_4) + #tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) + #assert not tst_consumption.log_rollover + #pulse_update_5 = fixed_this_hour + td(hours=2, minutes=1, seconds=18) + #test_timestamp_3 = fixed_this_hour + td(hours=2, minutes=1, seconds=20) + # Test log rollover by updating log first before updating pulses - tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) + tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=3)), 3333) assert tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (145+ 2222 + 3333, pulse_update_4) - pulse_update_5 = fixed_this_hour + td(hours=2, seconds=10) + ) == (145 + 2222 + 3333, pulse_update_4) + pulse_update_5 = fixed_this_hour + td(hours=3, seconds=10) tst_consumption.update_pulse_counter(321, 0, pulse_update_5) assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses( @@ -1146,19 +1153,19 @@ def test_pulse_collection_consumption( ) == (2222 + 3333 + 321, pulse_update_5) # Test next midnight rollover - pulse_update_6 = fixed_this_hour.replace(hour=0, minute=0, second=3) + td(days=1) - assert not tst_consumption.log_rollover - tst_consumption.update_pulse_counter(584, 0, pulse_update_6) - test_timestamp_3 = pulse_update_6 + td(hours=0, minutes=0, seconds=5) - assert tst_consumption.collected_pulses( - test_timestamp_3, is_consumption=True - ) == (14000, pulse_update_6) - pulse_update_7 = fixed_this_hour.replace(hour=0, minute=1, second=3) - tst_consumption.update_pulse_counter(50, 0, pulse_update_7) - test_timestamp_4 = pulse_update_7 + td(hours=0, minutes=1, seconds=5) - assert tst_consumption.collected_pulses( - test_timestamp_3, is_consumption=True - ) == (500, pulse_update_7) + #pulse_update_6 = fixed_this_hour.replace(hour=0, minute=0, second=3) + td(days=1) + #assert not tst_consumption.log_rollover + #tst_consumption.update_pulse_counter(584, 0, pulse_update_6) + #test_timestamp_3 = pulse_update_6 + td(hours=0, minutes=0, seconds=5) + #assert tst_consumption.collected_pulses( + # test_timestamp_3, is_consumption=True + #) == (14000, pulse_update_6) + #pulse_update_7 = fixed_this_hour.replace(hour=0, minute=1, second=3) + #tst_consumption.update_pulse_counter(50, 0, pulse_update_7) + #test_timestamp_4 = pulse_update_7 + td(hours=0, minutes=1, seconds=5) + #assert tst_consumption.collected_pulses( + # test_timestamp_3, is_consumption=True + #) == (500, pulse_update_7) @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( From 35d2f46598042adef01746ce4f70a9c9b9a63ab3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 19:01:34 +0200 Subject: [PATCH 651/979] Try update _last_log_consumption_timestamp --- plugwise_usb/nodes/helpers/pulses.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index a8c36ce95..13f582dd3 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -67,8 +67,8 @@ def __init__(self, mac: str) -> None: self._last_empty_log_address: int | None = None self._last_empty_log_slot: int | None = None - self.cons_last_hourly_reset: datetime | None = None - self.prod_last_hourly_reset: datetime | None = None + self._cons_last_hourly_reset: datetime | None = None + self._prod_last_hourly_reset: datetime | None = None self._last_log_consumption_timestamp: datetime | None = None self._last_log_consumption_address: int | None = None self._last_log_consumption_slot: int | None = None @@ -110,9 +110,9 @@ def collected_logs(self) -> int: @property def hourly_reset_time(self) -> datetime | None: """Provide the device hourly pulse reset time.""" - if (timestamp := self.cons_last_hourly_reset) is not None: + if (timestamp := self._cons_last_hourly_reset) is not None: return timestamp - if (timestamp := self.prod_last_hourly_reset) is not None: + if (timestamp := self._prod_last_hourly_reset) is not None: return timestamp return None @@ -291,10 +291,10 @@ def update_pulse_counter( and self._pulses_consumption > pulses_consumed ): _LOGGER.debug("update_pulse_counter | consumption pulses reset") - self.cons_last_hourly_reset = timestamp + self._cons_last_hourly_reset = timestamp _LOGGER.debug( "update_pulse_counter | consumption hourly_reset_time=%s", - self.cons_last_hourly_reset, + self._cons_last_hourly_reset, ) self._cons_pulsecounter_reset = True @@ -765,6 +765,19 @@ def _update_log_references(self, address: int, slot: int) -> None: log_time_stamp = self._logs[address][slot].timestamp is_consumption = self._logs[address][slot].is_consumption + if is_consumption: + if self._cons_last_hourly_reset is not None: + log_time_stamp = log_time_stamp + timedelta( + minutes=self._cons_last_hourly_reset.minute, + seconds=self._cons_last_hourly_reset.second, + microseconds=self._cons_last_hourly_reset.microsecond, + ) + elif self._prod_last_hourly_reset is not None: + log_time_stamp = log_time_stamp + timedelta( + minutes=self._prod_last_hourly_reset.minute, + seconds=self._prod_last_hourly_reset.second, + microseconds=self._prod_last_hourly_reset.microsecond, + ) # Update log references self._update_first_log_reference(address, slot, log_time_stamp, is_consumption) From c8e894d565488dfd16336850e0d6880a772ad0d1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 31 Mar 2025 20:46:42 +0200 Subject: [PATCH 652/979] Try test 2 --- tests/test_usb.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 4108c9f9a..74686d74a 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1110,10 +1110,16 @@ def test_pulse_collection_consumption( tst_consumption.add_log(94, 1, (fixed_this_hour - td(hours=24)), 1000) assert tst_consumption.collected_logs == 24 + # Test rollover by updating pulses before log record + pulse_update_3 = fixed_this_hour + td(hours=0, minutes=0, seconds=30) + tst_consumption.update_pulse_counter(2500, 0, pulse_update_3) assert not tst_consumption.log_rollover - pulse_update_3 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) - tst_consumption.update_pulse_counter(45, 0, pulse_update_3) + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (45, pulse_update_3) + pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) + tst_consumption.update_pulse_counter(45, 0, pulse_update_) assert tst_consumption.log_rollover assert tst_consumption.hourly_reset_time == pulse_update_3 test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=5) @@ -1126,13 +1132,13 @@ def test_pulse_collection_consumption( assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True ) == (45 + 2222, pulse_update_3) - pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) + pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) test_timestamp_2 = fixed_this_hour + td(hours=1, minutes=1, seconds=20) tst_consumption.update_pulse_counter(145, 0, pulse_update_4) # Test collection of the last new hour assert tst_consumption.collected_pulses( test_timestamp_2, is_consumption=True - ) == (145, pulse_update_4) + ) == (145, pulse_update_5) #tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) #assert not tst_consumption.log_rollover #pulse_update_5 = fixed_this_hour + td(hours=2, minutes=1, seconds=18) From e8738db2f2c429de7cc12185b5f4fc8774b88029 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 08:23:27 +0200 Subject: [PATCH 653/979] Try test 3 --- tests/test_usb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 74686d74a..ee23233b8 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1117,15 +1117,15 @@ def test_pulse_collection_consumption( assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True - ) == (45, pulse_update_3) + ) == (2500 + 1111 + 1000 + 750, pulse_update_3) pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) - tst_consumption.update_pulse_counter(45, 0, pulse_update_) + tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover - assert tst_consumption.hourly_reset_time == pulse_update_3 + assert tst_consumption.hourly_reset_time == pulse_update_4 test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=5) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True - ) == (45, pulse_update_3) + ) == (45, pulse_update_4) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover # Test collection of the last full hour From 6f3016ddd4486822fd2f3075de89939ddfb2cf55 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 08:32:02 +0200 Subject: [PATCH 654/979] More debug --- plugwise_usb/nodes/helpers/pulses.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 13f582dd3..2d79d8de1 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -290,7 +290,11 @@ def update_pulse_counter( self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed ): - _LOGGER.debug("update_pulse_counter | consumption pulses reset") + _LOGGER.debug( + "update_pulse_counter | consumption pulses (%s > %s) reset", + self._pulses_consumption, + pulses_consumed + ) self._cons_last_hourly_reset = timestamp _LOGGER.debug( "update_pulse_counter | consumption hourly_reset_time=%s", @@ -453,6 +457,7 @@ def add_log( if not self._log_exists(address, slot): return False if address != self._last_log_address and slot != self._last_log_slot: + _LOGGER.debug("add_log | address-slot already exists") return False self._update_log_direction(address, slot, timestamp) self._update_log_references(address, slot) From 4f9f612d6169e081332410f72e7d3955c88dfe08 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:05:36 +0200 Subject: [PATCH 655/979] Try test 4 --- tests/test_usb.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ee23233b8..f47151387 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1127,14 +1127,14 @@ def test_pulse_collection_consumption( test_timestamp, is_consumption=True ) == (45, pulse_update_4) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) - assert not tst_consumption.log_rollover + assert tst_consumption.log_rollover # Test collection of the last full hour assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (45 + 2222, pulse_update_3) + ) == (45 + 2222, pulse_update_4) pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) test_timestamp_2 = fixed_this_hour + td(hours=1, minutes=1, seconds=20) - tst_consumption.update_pulse_counter(145, 0, pulse_update_4) + tst_consumption.update_pulse_counter(145, 0, pulse_update_5) # Test collection of the last new hour assert tst_consumption.collected_pulses( test_timestamp_2, is_consumption=True @@ -1146,17 +1146,17 @@ def test_pulse_collection_consumption( # Test log rollover by updating log first before updating pulses - tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=3)), 3333) + tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) assert tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (145 + 2222 + 3333, pulse_update_4) - pulse_update_5 = fixed_this_hour + td(hours=3, seconds=10) - tst_consumption.update_pulse_counter(321, 0, pulse_update_5) - assert not tst_consumption.log_rollover + ) == (145 + 2222 + 3333, pulse_update_5) + pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) + tst_consumption.update_pulse_counter(321, 0, pulse_update_6) + assert tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (2222 + 3333 + 321, pulse_update_5) + ) == (2222 + 3333 + 321, pulse_update_6) # Test next midnight rollover #pulse_update_6 = fixed_this_hour.replace(hour=0, minute=0, second=3) + td(days=1) From 6bed5f00e2ddbaf04a5d61ebba069108a3c4af6a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:24:01 +0200 Subject: [PATCH 656/979] Extend update_pulse-counter logging --- plugwise_usb/nodes/helpers/pulses.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 2d79d8de1..57eb36dd1 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -290,11 +290,7 @@ def update_pulse_counter( self._pulses_consumption is not None and self._pulses_consumption > pulses_consumed ): - _LOGGER.debug( - "update_pulse_counter | consumption pulses (%s > %s) reset", - self._pulses_consumption, - pulses_consumed - ) + _LOGGER.debug("update_pulse_counter | consumption pulses reset") self._cons_last_hourly_reset = timestamp _LOGGER.debug( "update_pulse_counter | consumption hourly_reset_time=%s", @@ -326,7 +322,9 @@ def update_pulse_counter( self._rollover_production = True self._pulses_consumption = pulses_consumed + _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) self._pulses_production = pulses_produced + _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) def _update_rollover(self) -> None: """Update rollover states. From 73c472091afe7b74473a1442a7143b9a68e0d66e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:27:07 +0200 Subject: [PATCH 657/979] Clean up test-code --- tests/test_usb.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index f47151387..0d01e22e7 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1139,11 +1139,6 @@ def test_pulse_collection_consumption( assert tst_consumption.collected_pulses( test_timestamp_2, is_consumption=True ) == (145, pulse_update_5) - #tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) - #assert not tst_consumption.log_rollover - #pulse_update_5 = fixed_this_hour + td(hours=2, minutes=1, seconds=18) - #test_timestamp_3 = fixed_this_hour + td(hours=2, minutes=1, seconds=20) - # Test log rollover by updating log first before updating pulses tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) @@ -1158,21 +1153,6 @@ def test_pulse_collection_consumption( fixed_this_hour, is_consumption=True ) == (2222 + 3333 + 321, pulse_update_6) - # Test next midnight rollover - #pulse_update_6 = fixed_this_hour.replace(hour=0, minute=0, second=3) + td(days=1) - #assert not tst_consumption.log_rollover - #tst_consumption.update_pulse_counter(584, 0, pulse_update_6) - #test_timestamp_3 = pulse_update_6 + td(hours=0, minutes=0, seconds=5) - #assert tst_consumption.collected_pulses( - # test_timestamp_3, is_consumption=True - #) == (14000, pulse_update_6) - #pulse_update_7 = fixed_this_hour.replace(hour=0, minute=1, second=3) - #tst_consumption.update_pulse_counter(50, 0, pulse_update_7) - #test_timestamp_4 = pulse_update_7 + td(hours=0, minutes=1, seconds=5) - #assert tst_consumption.collected_pulses( - # test_timestamp_3, is_consumption=True - #) == (500, pulse_update_7) - @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( self, monkeypatch: pytest.MonkeyPatch From a76fd74c5badac41beeefeb023240acb409ce837 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:31:49 +0200 Subject: [PATCH 658/979] Reorganize updfate_pulse_counter() --- plugwise_usb/nodes/helpers/pulses.py | 49 +++++++++++++--------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 57eb36dd1..b4ec542ce 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -286,29 +286,29 @@ def update_pulse_counter( self._prod_pulsecounter_reset = False self._pulses_timestamp = timestamp self._update_rollover() - if ( - self._pulses_consumption is not None - and self._pulses_consumption > pulses_consumed - ): - _LOGGER.debug("update_pulse_counter | consumption pulses reset") - self._cons_last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | consumption hourly_reset_time=%s", - self._cons_last_hourly_reset, - ) - self._cons_pulsecounter_reset = True + if self._pulses_consumption is not None: + self._pulses_consumption = pulses_consumed + _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) + if self._pulses_consumption > pulses_consumed: + self._cons_pulsecounter_reset = True + _LOGGER.debug("update_pulse_counter | consumption pulses reset") + self._cons_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | consumption hourly_reset_time=%s", + self._cons_last_hourly_reset, + ) - if ( - self._pulses_production is not None - and self._pulses_production < pulses_produced - ): - _LOGGER.debug("update_pulse_counter | production pulses reset") - self.prod_last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | production hourly_reset_time=%s", - self.prod_last_hourly_reset, - ) - self._prod_pulsecounter_reset = True + if self._pulses_production is not None: + self._pulses_production = pulses_produced + _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) + if self._pulses_production < pulses_produced: + self._prod_pulsecounter_reset = True + _LOGGER.debug("update_pulse_counter | production pulses reset") + self.prod_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | production hourly_reset_time=%s", + self.prod_last_hourly_reset, + ) # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days @@ -321,11 +321,6 @@ def update_pulse_counter( _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True - self._pulses_consumption = pulses_consumed - _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) - self._pulses_production = pulses_produced - _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) - def _update_rollover(self) -> None: """Update rollover states. From f46038c607720ba4666dba54eeca4d11ed9bf24c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:38:59 +0200 Subject: [PATCH 659/979] Revert most --- plugwise_usb/nodes/helpers/pulses.py | 49 +++++++++++++++------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b4ec542ce..e48b1a60f 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -286,29 +286,29 @@ def update_pulse_counter( self._prod_pulsecounter_reset = False self._pulses_timestamp = timestamp self._update_rollover() - if self._pulses_consumption is not None: - self._pulses_consumption = pulses_consumed - _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) - if self._pulses_consumption > pulses_consumed: - self._cons_pulsecounter_reset = True - _LOGGER.debug("update_pulse_counter | consumption pulses reset") - self._cons_last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | consumption hourly_reset_time=%s", - self._cons_last_hourly_reset, - ) + if ( + self._pulses_consumption is not None + and self._pulses_consumption > pulses_consumed + ): + self._cons_pulsecounter_reset = True + _LOGGER.debug("update_pulse_counter | consumption pulses reset") + self._cons_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | consumption hourly_reset_time=%s", + self._cons_last_hourly_reset, + ) - if self._pulses_production is not None: - self._pulses_production = pulses_produced - _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) - if self._pulses_production < pulses_produced: - self._prod_pulsecounter_reset = True - _LOGGER.debug("update_pulse_counter | production pulses reset") - self.prod_last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | production hourly_reset_time=%s", - self.prod_last_hourly_reset, - ) + if ( + self._pulses_production is not None + and self._pulses_production < pulses_produced + ): + self._prod_pulsecounter_reset = True + _LOGGER.debug("update_pulse_counter | production pulses reset") + self.prod_last_hourly_reset = timestamp + _LOGGER.debug( + "update_pulse_counter | production hourly_reset_time=%s", + self.prod_last_hourly_reset, + ) # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days @@ -321,6 +321,11 @@ def update_pulse_counter( _LOGGER.debug("update_pulse_counter | rollover production") self._rollover_production = True + self._pulses_consumption = pulses_consumed + _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) + self._pulses_production = pulses_produced + _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) + def _update_rollover(self) -> None: """Update rollover states. From 99d2bc75321c81f043a8a43e6a337ad70cffd955 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:44:51 +0200 Subject: [PATCH 660/979] Combine loggers --- plugwise_usb/nodes/helpers/pulses.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e48b1a60f..542018f30 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -322,9 +322,12 @@ def update_pulse_counter( self._rollover_production = True self._pulses_consumption = pulses_consumed - _LOGGER.debug("update_pulse_counter | consumption pulses=%s", self._pulses_consumption) self._pulses_production = pulses_produced - _LOGGER.debug("update_pulse_counter | production pulses=%s", self._pulses_production) + _LOGGER.debug( + "update_pulse_counter | consumption pulses=%s | production pulses=%s", + self._pulses_consumption, + self._pulses_production, + ) def _update_rollover(self) -> None: """Update rollover states. From b14550af7d20b9250b0c93877f8444d75f05456d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:46:51 +0200 Subject: [PATCH 661/979] Clean up test-debugging --- plugwise_usb/nodes/helpers/pulses.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 542018f30..23b7ca789 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -236,7 +236,6 @@ def _collect_pulses_from_logs( return None timestamp = self._last_log_consumption_timestamp - _LOGGER.debug("HOI _last_log_consumption_timestamp=%s", self._last_log_consumption_timestamp) else: if self._last_log_production_timestamp is None: _LOGGER.debug( @@ -246,7 +245,6 @@ def _collect_pulses_from_logs( return None timestamp = self._last_log_production_timestamp - _LOGGER.debug("HOI _last_log_production_timestamp=%s", self._last_log_production_timestamp) missing_logs = self._logs_missing(from_timestamp) if missing_logs is None or missing_logs: From 957865e159d2aea7785ee399eb553f861eb9c0ec Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 17:47:26 +0200 Subject: [PATCH 662/979] Bump to a73 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0a5ba1a3..a6afe099b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a72" +version = "v0.40.0a73" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 0a56650d31aca6f514909f958eeabcce4aba5ce1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 18:01:30 +0200 Subject: [PATCH 663/979] Pre-commit fixes --- plugwise_usb/nodes/helpers/pulses.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 23b7ca789..6b2826338 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -302,10 +302,10 @@ def update_pulse_counter( ): self._prod_pulsecounter_reset = True _LOGGER.debug("update_pulse_counter | production pulses reset") - self.prod_last_hourly_reset = timestamp + self._prod_last_hourly_reset = timestamp _LOGGER.debug( "update_pulse_counter | production hourly_reset_time=%s", - self.prod_last_hourly_reset, + self._prod_last_hourly_reset, ) # No rollover based on time, check rollover based on counter reset @@ -768,8 +768,7 @@ def _update_log_references(self, address: int, slot: int) -> None: return log_time_stamp = self._logs[address][slot].timestamp - is_consumption = self._logs[address][slot].is_consumption - if is_consumption: + if (is_consumption := self._logs[address][slot].is_consumption): if self._cons_last_hourly_reset is not None: log_time_stamp = log_time_stamp + timedelta( minutes=self._cons_last_hourly_reset.minute, From 0e1d9b06f3a9e8ac274c08833af5189cdee278c5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 18:53:55 +0200 Subject: [PATCH 664/979] Extend docstring of hourly_reset_time property --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 6b2826338..8126c1a0b 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -109,7 +109,7 @@ def collected_logs(self) -> int: @property def hourly_reset_time(self) -> datetime | None: - """Provide the device hourly pulse reset time.""" + """Provide the device hourly pulse reset time, using in testing.""" if (timestamp := self._cons_last_hourly_reset) is not None: return timestamp if (timestamp := self._prod_last_hourly_reset) is not None: From 9316730314d965cd0b0d59ebcc3a1b6199a8d47f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 19:00:16 +0200 Subject: [PATCH 665/979] Add comment on log_time_stamp changes --- plugwise_usb/nodes/helpers/pulses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 8126c1a0b..7f5c33454 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -768,6 +768,8 @@ def _update_log_references(self, address: int, slot: int) -> None: return log_time_stamp = self._logs[address][slot].timestamp + # Sync log_time_stamp with the device pulsecounter reset-time + # This syncs the daily reset of energy counters with the corresponding device pulsecounter reset if (is_consumption := self._logs[address][slot].is_consumption): if self._cons_last_hourly_reset is not None: log_time_stamp = log_time_stamp + timedelta( From d1ca7cf38e839771b04f42b1a0d41b59414cd7fc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 1 Apr 2025 19:02:01 +0200 Subject: [PATCH 666/979] Shorten log_time_stamp --- plugwise_usb/nodes/helpers/pulses.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 7f5c33454..362df432c 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -767,33 +767,33 @@ def _update_log_references(self, address: int, slot: int) -> None: if self._logs is None: return - log_time_stamp = self._logs[address][slot].timestamp - # Sync log_time_stamp with the device pulsecounter reset-time + log_timestamp = self._logs[address][slot].timestamp + # Sync log_timestamp with the device pulsecounter reset-time # This syncs the daily reset of energy counters with the corresponding device pulsecounter reset if (is_consumption := self._logs[address][slot].is_consumption): if self._cons_last_hourly_reset is not None: - log_time_stamp = log_time_stamp + timedelta( + log_timestamp = log_timestamp + timedelta( minutes=self._cons_last_hourly_reset.minute, seconds=self._cons_last_hourly_reset.second, microseconds=self._cons_last_hourly_reset.microsecond, ) elif self._prod_last_hourly_reset is not None: - log_time_stamp = log_time_stamp + timedelta( + log_timestamp = log_timestamp + timedelta( minutes=self._prod_last_hourly_reset.minute, seconds=self._prod_last_hourly_reset.second, microseconds=self._prod_last_hourly_reset.microsecond, ) # Update log references - self._update_first_log_reference(address, slot, log_time_stamp, is_consumption) - self._update_last_log_reference(address, slot, log_time_stamp, is_consumption) + self._update_first_log_reference(address, slot, log_timestamp, is_consumption) + self._update_last_log_reference(address, slot, log_timestamp, is_consumption) if is_consumption: - self._update_first_consumption_log_reference(address, slot, log_time_stamp) - self._update_last_consumption_log_reference(address, slot, log_time_stamp) + self._update_first_consumption_log_reference(address, slot, log_timestamp) + self._update_last_consumption_log_reference(address, slot, log_timestamp) elif self._log_production: - self._update_first_production_log_reference(address, slot, log_time_stamp) - self._update_last_production_log_reference(address, slot, log_time_stamp) + self._update_first_production_log_reference(address, slot, log_timestamp) + self._update_last_production_log_reference(address, slot, log_timestamp) @property def log_addresses_missing(self) -> list[int] | None: From f9370e0723188d2760a540d896877c640eb89c1e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 09:30:51 +0200 Subject: [PATCH 667/979] Remove week-related from api --- plugwise_usb/api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugwise_usb/api.py b/plugwise_usb/api.py index bbbfdaa6c..ce9cdbf38 100644 --- a/plugwise_usb/api.py +++ b/plugwise_usb/api.py @@ -216,14 +216,10 @@ class EnergyStatistics: hour_consumption_reset: datetime | None = None day_consumption: float | None = None day_consumption_reset: datetime | None = None - week_consumption: float | None = None - week_consumption_reset: datetime | None = None hour_production: float | None = None hour_production_reset: datetime | None = None day_production: float | None = None day_production_reset: datetime | None = None - week_production: float | None = None - week_production_reset: datetime | None = None class PlugwiseNode(Protocol): From 6eec8610349d03fa29fb6f165963253ef46dc8d4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 09:52:34 +0200 Subject: [PATCH 668/979] Delay/sync the from_timestamps as well --- plugwise_usb/nodes/helpers/pulses.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 362df432c..94cd54ed5 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -172,6 +172,23 @@ def collected_pulses( self, from_timestamp: datetime, is_consumption: bool ) -> tuple[int | None, datetime | None]: """Calculate total pulses from given timestamp.""" + + # Sync from_timestamp with the device pulsecounter reset-time + # This syncs the hourly/daily reset of energy counters with the corresponding device pulsecounter reset + if is_consumption: + if self._cons_last_hourly_reset is not None : + from_timestamp = from_timestamp + timedelta( + minutes=self._cons_last_hourly_reset.minute, + seconds=self._cons_last_hourly_reset.second, + microseconds=self._cons_last_hourly_reset.microsecond, + ) + elif self._prod_last_hourly_reset is not None: + from_timestamp = from_timestamp + timedelta( + minutes=self._prod_last_hourly_reset.minute, + seconds=self._prod_last_hourly_reset.second, + microseconds=self._prod_last_hourly_reset.microsecond, + ) + _LOGGER.debug( "collected_pulses | %s | from_timestamp=%s | is_cons=%s | _log_production=%s", self._mac, From 64fbd348b4cb69d6d9758e5d58a50e95a949aaee Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 10:05:02 +0200 Subject: [PATCH 669/979] Bump to a74 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a6afe099b..7fe49e249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a73" +version = "v0.40.0a74" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From db1bf3012fbebc0c67f41eb6c09d77e1b3d4fef6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 10:33:29 +0200 Subject: [PATCH 670/979] Revert from_timestamp additions, add properties instead --- plugwise_usb/nodes/helpers/pulses.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 94cd54ed5..e35204e01 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -107,6 +107,16 @@ def collected_logs(self) -> int: counter += len(self._logs[address]) return counter + @property + def consumption_last_hourly_reset(self) -> datetime | None: + """Consumption last hourly reset.""" + return self._cons_last_hourly_reset + + @property + def production_last_hourly_reset(self) -> datetime | None: + """Production last hourly reset.""" + return self._prod_last_hourly_reset + @property def hourly_reset_time(self) -> datetime | None: """Provide the device hourly pulse reset time, using in testing.""" @@ -172,23 +182,6 @@ def collected_pulses( self, from_timestamp: datetime, is_consumption: bool ) -> tuple[int | None, datetime | None]: """Calculate total pulses from given timestamp.""" - - # Sync from_timestamp with the device pulsecounter reset-time - # This syncs the hourly/daily reset of energy counters with the corresponding device pulsecounter reset - if is_consumption: - if self._cons_last_hourly_reset is not None : - from_timestamp = from_timestamp + timedelta( - minutes=self._cons_last_hourly_reset.minute, - seconds=self._cons_last_hourly_reset.second, - microseconds=self._cons_last_hourly_reset.microsecond, - ) - elif self._prod_last_hourly_reset is not None: - from_timestamp = from_timestamp + timedelta( - minutes=self._prod_last_hourly_reset.minute, - seconds=self._prod_last_hourly_reset.second, - microseconds=self._prod_last_hourly_reset.microsecond, - ) - _LOGGER.debug( "collected_pulses | %s | from_timestamp=%s | is_cons=%s | _log_production=%s", self._mac, From 0a7a50e2bf8d4bd9063b1f55d9cbdfcb561e556d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 10:46:37 +0200 Subject: [PATCH 671/979] Sync the daily reset time with the device pulsecounter(s) reset --- plugwise_usb/nodes/helpers/counter.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index e7c585c61..3d0279dac 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -284,9 +284,27 @@ def update( """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) if self._energy_id in ENERGY_HOUR_COUNTERS: + # No syncing needed for the hour-counters, they reset when the device pulsecounter(s) reset last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + # Sync the daily reset time with the device pulsecounter(s) reset time + if self._is_consumption: + if self._pulse_collection.consumption_last_hourly_reset is not None: + last_reset = last_reset.replace( + hour=0, + minute=self._pulse_collection.consumption_last_hourly_reset.minutes, + second=self._pulse_collection.consumption_last_hourly_reset.seconds, + microsecond=self._pulse_collection.consumption_last_hourly_reset.microseconds, + ) + elif self._pulse_collection.production_last_hourly_reset is not None: + last_reset = last_reset.replace( + hour=0, + minute=self._pulse_collection.production_last_hourly_reset.minutes, + second=self._pulse_collection.production_last_hourly_reset.seconds, + microsecond=self._pulse_collection.production_last_hourly_reset.microseconds, + ) + else: + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From c50300898649ba0e7003637882ac8d404e21e03c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 10:54:14 +0200 Subject: [PATCH 672/979] Fix missing logic --- plugwise_usb/nodes/helpers/counter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 3d0279dac..19d26b9b2 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -296,6 +296,8 @@ def update( second=self._pulse_collection.consumption_last_hourly_reset.seconds, microsecond=self._pulse_collection.consumption_last_hourly_reset.microseconds, ) + else: + last_reset = last_reset.replace(minute=0, second=0, microsecond=0) elif self._pulse_collection.production_last_hourly_reset is not None: last_reset = last_reset.replace( hour=0, From d332f813d779d98036473393071d37ecbcd9e70b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 11:02:59 +0200 Subject: [PATCH 673/979] Fix missing hour-reset --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 19d26b9b2..17a3c7e7e 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -297,7 +297,7 @@ def update( microsecond=self._pulse_collection.consumption_last_hourly_reset.microseconds, ) else: - last_reset = last_reset.replace(minute=0, second=0, microsecond=0) + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) elif self._pulse_collection.production_last_hourly_reset is not None: last_reset = last_reset.replace( hour=0, From 4fa782590146af08d1adc956c18ff557e735a6d6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 18:53:32 +0200 Subject: [PATCH 674/979] Bump to a75 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7fe49e249..3d395488d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a74" +version = "v0.40.0a75" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 744b5c80aa9d31c148a09682ca7ee2f676c1633f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 20:22:05 +0200 Subject: [PATCH 675/979] Consumption/production last_hourly resets are the same --- plugwise_usb/nodes/helpers/pulses.py | 45 ++++++++-------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index e35204e01..feacb0f36 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -67,8 +67,7 @@ def __init__(self, mac: str) -> None: self._last_empty_log_address: int | None = None self._last_empty_log_slot: int | None = None - self._cons_last_hourly_reset: datetime | None = None - self._prod_last_hourly_reset: datetime | None = None + self._last_hourly_reset: datetime | None = None self._last_log_consumption_timestamp: datetime | None = None self._last_log_consumption_address: int | None = None self._last_log_consumption_slot: int | None = None @@ -108,21 +107,14 @@ def collected_logs(self) -> int: return counter @property - def consumption_last_hourly_reset(self) -> datetime | None: - """Consumption last hourly reset.""" - return self._cons_last_hourly_reset - - @property - def production_last_hourly_reset(self) -> datetime | None: - """Production last hourly reset.""" - return self._prod_last_hourly_reset + def last_hourly_reset(self) -> datetime | None: + """Consumption and production last hourly reset.""" + return self._last_hourly_reset @property def hourly_reset_time(self) -> datetime | None: """Provide the device hourly pulse reset time, using in testing.""" - if (timestamp := self._cons_last_hourly_reset) is not None: - return timestamp - if (timestamp := self._prod_last_hourly_reset) is not None: + if (timestamp := self._last_hourly_reset) is not None: return timestamp return None @@ -300,10 +292,10 @@ def update_pulse_counter( ): self._cons_pulsecounter_reset = True _LOGGER.debug("update_pulse_counter | consumption pulses reset") - self._cons_last_hourly_reset = timestamp + self._last_hourly_reset = timestamp _LOGGER.debug( - "update_pulse_counter | consumption hourly_reset_time=%s", - self._cons_last_hourly_reset, + "update_pulse_counter | hourly_reset_time=%s", + self._last_hourly_reset, ) if ( @@ -311,12 +303,6 @@ def update_pulse_counter( and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True - _LOGGER.debug("update_pulse_counter | production pulses reset") - self._prod_last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | production hourly_reset_time=%s", - self._prod_last_hourly_reset, - ) # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days @@ -780,18 +766,11 @@ def _update_log_references(self, address: int, slot: int) -> None: log_timestamp = self._logs[address][slot].timestamp # Sync log_timestamp with the device pulsecounter reset-time # This syncs the daily reset of energy counters with the corresponding device pulsecounter reset - if (is_consumption := self._logs[address][slot].is_consumption): - if self._cons_last_hourly_reset is not None: - log_timestamp = log_timestamp + timedelta( - minutes=self._cons_last_hourly_reset.minute, - seconds=self._cons_last_hourly_reset.second, - microseconds=self._cons_last_hourly_reset.microsecond, - ) - elif self._prod_last_hourly_reset is not None: + if self._last_hourly_reset is not None: log_timestamp = log_timestamp + timedelta( - minutes=self._prod_last_hourly_reset.minute, - seconds=self._prod_last_hourly_reset.second, - microseconds=self._prod_last_hourly_reset.microsecond, + minutes=self._last_hourly_reset.minute, + seconds=self._last_hourly_reset.second, + microseconds=self._last_hourly_reset.microsecond, ) # Update log references From 77858c4cd2ad417a053380f6f0046db61773fba0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 20:24:30 +0200 Subject: [PATCH 676/979] Adapt code in counter.py --- plugwise_usb/nodes/helpers/counter.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 17a3c7e7e..374f18b2e 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -288,25 +288,14 @@ def update( last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: # Sync the daily reset time with the device pulsecounter(s) reset time - if self._is_consumption: - if self._pulse_collection.consumption_last_hourly_reset is not None: - last_reset = last_reset.replace( - hour=0, - minute=self._pulse_collection.consumption_last_hourly_reset.minutes, - second=self._pulse_collection.consumption_last_hourly_reset.seconds, - microsecond=self._pulse_collection.consumption_last_hourly_reset.microseconds, - ) - else: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - elif self._pulse_collection.production_last_hourly_reset is not None: + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + if self._pulse_collection.last_hourly_reset is not None: last_reset = last_reset.replace( hour=0, - minute=self._pulse_collection.production_last_hourly_reset.minutes, - second=self._pulse_collection.production_last_hourly_reset.seconds, - microsecond=self._pulse_collection.production_last_hourly_reset.microseconds, + minute=self._pulse_collection.last_hourly_reset.minutes, + second=self._pulse_collection.last_hourly_reset.seconds, + microsecond=self._pulse_collection.last_hourly_reset.microseconds, ) - else: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From b4f7e4b1aaa5c9578a6410ba23260e2a6faf1cc8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 2 Apr 2025 20:27:13 +0200 Subject: [PATCH 677/979] Fix missing line --- plugwise_usb/nodes/helpers/pulses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index feacb0f36..5bf015b9d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -764,6 +764,7 @@ def _update_log_references(self, address: int, slot: int) -> None: return log_timestamp = self._logs[address][slot].timestamp + is_consumption = self._logs[address][slot].is_consumption # Sync log_timestamp with the device pulsecounter reset-time # This syncs the daily reset of energy counters with the corresponding device pulsecounter reset if self._last_hourly_reset is not None: From 8298bd3a1af021580697ccae9a8f70af4cf1bb39 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 08:02:54 +0200 Subject: [PATCH 678/979] Don't use self, use availble input --- plugwise_usb/nodes/helpers/counter.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 374f18b2e..b44a8acc0 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -206,7 +206,6 @@ def __init__( self._is_consumption = False self._last_reset: datetime | None = None self._last_update: datetime | None = None - self._pulse_collection = PulseCollection(mac) self._pulses: int | None = None @property @@ -289,12 +288,12 @@ def update( if self._energy_id in ENERGY_DAY_COUNTERS: # Sync the daily reset time with the device pulsecounter(s) reset time last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - if self._pulse_collection.last_hourly_reset is not None: + if pulse_collection.last_hourly_reset is not None: last_reset = last_reset.replace( hour=0, - minute=self._pulse_collection.last_hourly_reset.minutes, - second=self._pulse_collection.last_hourly_reset.seconds, - microsecond=self._pulse_collection.last_hourly_reset.microseconds, + minute=pulse_collection.last_hourly_reset.minutes, + second=pulse_collection.last_hourly_reset.seconds, + microsecond=pulse_collection.last_hourly_reset.microseconds, ) pulses, last_update = pulse_collection.collected_pulses( From 8b066d6b6bcacc0e9859db1f0fc9db2cbf5af5f6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 08:07:55 +0200 Subject: [PATCH 679/979] Revert deletion, improve debug-logging --- plugwise_usb/nodes/helpers/pulses.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 5bf015b9d..eac96671a 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -291,10 +291,9 @@ def update_pulse_counter( and self._pulses_consumption > pulses_consumed ): self._cons_pulsecounter_reset = True - _LOGGER.debug("update_pulse_counter | consumption pulses reset") self._last_hourly_reset = timestamp _LOGGER.debug( - "update_pulse_counter | hourly_reset_time=%s", + "update_pulse_counter | consumption pulses reset | hourly_reset_time=%s", self._last_hourly_reset, ) @@ -303,7 +302,10 @@ def update_pulse_counter( and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True - + _LOGGER.debug( + "update_pulse_counter | production pulses reset | hourly_reset_time=%s", + self._last_hourly_reset, + ) # No rollover based on time, check rollover based on counter reset # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): From 12e8b703291ce51b8e5f373c88b3c96ed402ae30 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 08:13:49 +0200 Subject: [PATCH 680/979] Bump to a76 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3d395488d..37e2516d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a75" +version = "v0.40.0a76" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 0fac9a883dd052e8c2484d5e2e61f9f9e9467f81 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 09:10:42 +0200 Subject: [PATCH 681/979] Fix datetime attributes --- plugwise_usb/nodes/helpers/counter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index b44a8acc0..dd4ee3451 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -291,9 +291,9 @@ def update( if pulse_collection.last_hourly_reset is not None: last_reset = last_reset.replace( hour=0, - minute=pulse_collection.last_hourly_reset.minutes, - second=pulse_collection.last_hourly_reset.seconds, - microsecond=pulse_collection.last_hourly_reset.microseconds, + minute=pulse_collection.last_hourly_reset.minute, + second=pulse_collection.last_hourly_reset.second, + microsecond=pulse_collection.last_hourly_reset.microsecond, ) pulses, last_update = pulse_collection.collected_pulses( From 0b593253c1c7f25ca1ad9ca96045ab0cf56d91ec Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 09:11:11 +0200 Subject: [PATCH 682/979] Bump to a77 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 37e2516d7..4f9a2b833 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a76" +version = "v0.40.0a77" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From c610599c9cf1271b09e307224e7d43f54a34fedd Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 17:54:45 +0200 Subject: [PATCH 683/979] Refactor update_rollover() --- plugwise_usb/nodes/helpers/pulses.py | 87 ++++++++++++---------------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index eac96671a..f574f2ba2 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -335,72 +335,59 @@ def _update_rollover(self) -> None: if self._log_addresses_missing is not None and self._log_addresses_missing: return + self._rollover_consumption = self._detect_rollover( + self._last_log_consumption_timestamp, + self._next_log_consumption_timestamp, + ) + if self._log_production: + self._rollover_production = self._detect_rollover( + self._last_log_production_timestamp, + self._next_log_production_timestamp, + False, + ) + + def _detect_rollover( + self, + last_log_timestamp: datetime | None, + next_log_timestamp: datetime | None, + is_consumption=True, + ) -> bool: + """Helper function for _update_rollover().""" if ( self._pulses_timestamp is None - or self._last_log_consumption_timestamp is None - or self._next_log_consumption_timestamp is None + or last_log_timestamp is None + or next_log_timestamp is None ): # Unable to determine rollover return - if self._pulses_timestamp > self._next_log_consumption_timestamp: - self._rollover_consumption = True - _LOGGER.debug( - "_update_rollover | %s | set consumption rollover => pulses newer", - self._mac, - ) - elif self._pulses_timestamp < self._last_log_consumption_timestamp: - self._rollover_consumption = True + direction = "consumption" + if not is_consumption: + direction = "production" + + if self._pulses_timestamp > next_log_timestamp: _LOGGER.debug( - "_update_rollover | %s | set consumption rollover => log newer", + "_update_rollover | %s | set %s rollover => pulses newer", self._mac, + direction, ) - elif ( - self._last_log_consumption_timestamp - < self._pulses_timestamp - < self._next_log_consumption_timestamp - ): - if self._rollover_consumption: - _LOGGER.debug("_update_rollover | %s | reset consumption", self._mac) - self._rollover_consumption = False - else: - _LOGGER.debug("_update_rollover | %s | unexpected consumption", self._mac) - - if not self._log_production: - return - - if ( - self._last_log_production_timestamp is None - or self._next_log_production_timestamp is None - ): - # Unable to determine rollover - return - - if not self._log_production: - return + return True - if self._pulses_timestamp > self._next_log_production_timestamp: - self._rollover_production = True + if self._pulses_timestamp < last_log_timestamp: _LOGGER.debug( - "_update_rollover | %s | set production rollover => pulses newer", + "_update_rollover | %s | set %s rollover => log newer", self._mac, + direction, ) - elif self._pulses_timestamp < self._last_log_production_timestamp: - self._rollover_production = True + return True + + if last_log_timestamp < self._pulses_timestamp < next_log_timestamp: _LOGGER.debug( - "_update_rollover | %s | reset production rollover => log newer", + "_update_rollover | %s | reset %s rollover", self._mac, + direction ) - elif ( - self._last_log_production_timestamp - < self._pulses_timestamp - < self._next_log_production_timestamp - ): - if self._rollover_production: - _LOGGER.debug("_update_rollover | %s | reset production", self._mac) - self._rollover_production = False - else: - _LOGGER.debug("_update_rollover | %s | unexpected production", self._mac) + return False def add_empty_log(self, address: int, slot: int) -> None: """Add empty energy log record to mark any start of beginning of energy log collection.""" From bd143508c557329b6bfedd44baedb13dc1cfca2e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 20:13:43 +0200 Subject: [PATCH 684/979] _detect_rollover(): full coverage by using <= --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f574f2ba2..3aeb66a11 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -381,7 +381,7 @@ def _detect_rollover( ) return True - if last_log_timestamp < self._pulses_timestamp < next_log_timestamp: + if last_log_timestamp <= self._pulses_timestamp <= next_log_timestamp: _LOGGER.debug( "_update_rollover | %s | reset %s rollover", self._mac, From da43c6349fed2ff0ed386117dc33be622db51e4f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 3 Apr 2025 20:19:41 +0200 Subject: [PATCH 685/979] Test: add log after pulse counter reset timestamp --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 0d01e22e7..13aa9eb72 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1122,11 +1122,11 @@ def test_pulse_collection_consumption( tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover assert tst_consumption.hourly_reset_time == pulse_update_4 - test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=5) + test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=4) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True ) == (45, pulse_update_4) - tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) + tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1, minutes=1, seconds=5)), 2222) assert tst_consumption.log_rollover # Test collection of the last full hour assert tst_consumption.collected_pulses( From c6411d3500b79355f213f82b3cfe0b5c413f8c7a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 08:12:12 +0200 Subject: [PATCH 686/979] Improve comment --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 3aeb66a11..997e51092 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -306,7 +306,7 @@ def update_pulse_counter( "update_pulse_counter | production pulses reset | hourly_reset_time=%s", self._last_hourly_reset, ) - # No rollover based on time, check rollover based on counter reset + # No rollover based on time, set rollover based on counter reset # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): if self._cons_pulsecounter_reset: From d68fe3b7e19e797e815737685436c57d5d4bec9f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 08:21:23 +0200 Subject: [PATCH 687/979] Add working-comments --- plugwise_usb/nodes/helpers/pulses.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 997e51092..610184780 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -124,7 +124,7 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} - skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) + skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # Should this timedelta be adapted? sorted_addresses = sorted(self._logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(self._logs[address].keys(), reverse=True) @@ -462,7 +462,7 @@ def add_log( def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( - datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # idem ) def _add_log_record( @@ -481,7 +481,7 @@ def _add_log_record( # Drop useless log records when we have at least 4 logs if self.collected_logs > 4 and log_record.timestamp < ( - datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # idem ): return False From c73168deb8ae7f006eb550dc050553b5d6459ef5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 10:55:37 +0200 Subject: [PATCH 688/979] Remove double occurence --- plugwise_usb/nodes/helpers/counter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index dd4ee3451..6b3d5b05d 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -146,9 +146,6 @@ def update(self) -> None: self._energy_statistics.log_interval_consumption = ( self._pulse_collection.log_interval_consumption ) - self._energy_statistics.log_interval_production = ( - self._pulse_collection.log_interval_production - ) ( self._energy_statistics.hour_consumption, self._energy_statistics.hour_consumption_reset, From 700ad75a8aa5817ab454a4eab8b79703b96ebf9e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 11:22:57 +0200 Subject: [PATCH 689/979] Remove last_hourly_reset Already present as hourly_reset_time --- plugwise_usb/nodes/helpers/counter.py | 8 ++++---- plugwise_usb/nodes/helpers/pulses.py | 7 +------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 6b3d5b05d..0d9d53d81 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -285,12 +285,12 @@ def update( if self._energy_id in ENERGY_DAY_COUNTERS: # Sync the daily reset time with the device pulsecounter(s) reset time last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - if pulse_collection.last_hourly_reset is not None: + if pulse_collection.hourly_reset_time is not None: last_reset = last_reset.replace( hour=0, - minute=pulse_collection.last_hourly_reset.minute, - second=pulse_collection.last_hourly_reset.second, - microsecond=pulse_collection.last_hourly_reset.microsecond, + minute=pulse_collection.hourly_reset_time.minute, + second=pulse_collection.hourly_reset_time.second, + microsecond=pulse_collection.hourly_reset_time.microsecond, ) pulses, last_update = pulse_collection.collected_pulses( diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 610184780..6ada54eaa 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -106,14 +106,9 @@ def collected_logs(self) -> int: counter += len(self._logs[address]) return counter - @property - def last_hourly_reset(self) -> datetime | None: - """Consumption and production last hourly reset.""" - return self._last_hourly_reset - @property def hourly_reset_time(self) -> datetime | None: - """Provide the device hourly pulse reset time, using in testing.""" + """Provide the device hourly pulse reset time.""" if (timestamp := self._last_hourly_reset) is not None: return timestamp return None From 156c799df69a08c65b1f4b0b0a865b0a36adcea7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 11:43:15 +0200 Subject: [PATCH 690/979] Use the pulse_counter_reset event to update the daily last_reset --- plugwise_usb/nodes/helpers/counter.py | 12 +++--------- plugwise_usb/nodes/helpers/pulses.py | 4 ++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 0d9d53d81..a1e124751 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -283,15 +283,9 @@ def update( # No syncing needed for the hour-counters, they reset when the device pulsecounter(s) reset last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - # Sync the daily reset time with the device pulsecounter(s) reset time - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - if pulse_collection.hourly_reset_time is not None: - last_reset = last_reset.replace( - hour=0, - minute=pulse_collection.hourly_reset_time.minute, - second=pulse_collection.hourly_reset_time.second, - microsecond=pulse_collection.hourly_reset_time.microsecond, - ) + # Postpone the daily reset time change until the device pulsecounter(s) reset + if pulse_collection.pulse_counter_reset: + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 6ada54eaa..76c5cd796 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -165,6 +165,10 @@ def last_update(self) -> datetime | None: """Return timestamp of last update.""" return self._pulses_timestamp + def pulse_counter_reset(self) -> bool: + """Return a pulse_counter reset.""" + return self._cons_pulsecounter_reset or self._prod_pulsecounter_reset + def collected_pulses( self, from_timestamp: datetime, is_consumption: bool ) -> tuple[int | None, datetime | None]: From 046bcbdd1daaf4d59ce239f76d5009ff9565fcbb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 11:46:04 +0200 Subject: [PATCH 691/979] Improve comment --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index a1e124751..2cb1da3d5 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -283,7 +283,7 @@ def update( # No syncing needed for the hour-counters, they reset when the device pulsecounter(s) reset last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - # Postpone the daily reset time change until the device pulsecounter(s) reset + # Postpone the daily last_reset time-change until a device pulsecounter resets if pulse_collection.pulse_counter_reset: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) From 1629a8325569f1c890a20b222c56c560a99438e0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 11:51:44 +0200 Subject: [PATCH 692/979] Remove working-comments --- plugwise_usb/nodes/helpers/pulses.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 76c5cd796..ae6e0d68d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -119,7 +119,7 @@ def logs(self) -> dict[int, dict[int, PulseLogRecord]]: if self._logs is None: return {} sorted_log: dict[int, dict[int, PulseLogRecord]] = {} - skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # Should this timedelta be adapted? + skip_before = datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) sorted_addresses = sorted(self._logs.keys(), reverse=True) for address in sorted_addresses: sorted_slots = sorted(self._logs[address].keys(), reverse=True) @@ -461,7 +461,7 @@ def add_log( def recalculate_missing_log_addresses(self) -> None: """Recalculate missing log addresses.""" self._log_addresses_missing = self._logs_missing( - datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # idem + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ) def _add_log_record( @@ -480,7 +480,7 @@ def _add_log_record( # Drop useless log records when we have at least 4 logs if self.collected_logs > 4 and log_record.timestamp < ( - datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) # idem + datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ): return False From 7bc4d437a281ef5b7ce561d3707ddb3c33888251 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 12:07:52 +0200 Subject: [PATCH 693/979] Bump to a78 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4f9a2b833..43b9a9ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a77" +version = "v0.40.0a78" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From e8225405b74f336d74a03e8e16fa896b2e2e7cf9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 5 Apr 2025 11:39:10 +0200 Subject: [PATCH 694/979] Use sorted logs to collect log_pulses --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index ae6e0d68d..27dd8bcb6 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -257,7 +257,7 @@ def _collect_pulses_from_logs( return None log_pulses = 0 - for log_item in self._logs.values(): + for log_item in self.logs.values(): for slot_item in log_item.values(): if ( slot_item.is_consumption == is_consumption From ea14c567e42f6e4e011de29ffb14cc369fd43831 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 5 Apr 2025 12:42:09 +0200 Subject: [PATCH 695/979] Update last_reset logic once more --- plugwise_usb/nodes/helpers/counter.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 2cb1da3d5..fa1ab3cce 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum, auto import logging from typing import Final @@ -280,12 +280,14 @@ def update( """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) if self._energy_id in ENERGY_HOUR_COUNTERS: - # No syncing needed for the hour-counters, they reset when the device pulsecounter(s) reset last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - # Postpone the daily last_reset time-change until a device pulsecounter resets - if pulse_collection.pulse_counter_reset: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + # Postpone the last_reset time-changes at day-end until a device pulsecounter resets + if last_reset.hour == 0 and not pulse_collection.pulse_counter_reset: + last_reset = (last_reset - timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From 97fdc52847284cac87466c648095e5954e756370 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 5 Apr 2025 12:51:39 +0200 Subject: [PATCH 696/979] Bump to a79 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43b9a9ac1..e61222d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a78" +version = "v0.40.0a79" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 8c2083c62ee1d3433f071e5dde22d84ea0fd89c1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 5 Apr 2025 13:26:55 +0200 Subject: [PATCH 697/979] Last_reset: handle state after pulse_counter reset returns back to False --- plugwise_usb/nodes/helpers/counter.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index fa1ab3cce..58b7d746c 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -189,6 +189,7 @@ def __init__( ) -> None: """Initialize energy counter based on energy id.""" self._mac = mac + self._midnight_reset_passed = False if energy_id not in ENERGY_COUNTERS: raise EnergyError(f"Invalid energy id '{energy_id}' for Energy counter") self._calibration: EnergyCalibration | None = None @@ -279,15 +280,22 @@ def update( ) -> tuple[float | None, datetime | None]: """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) + if self._midnight_reset_passed and last_reset.hour == 1: + self._midnight_reset_passed = False + if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) # Postpone the last_reset time-changes at day-end until a device pulsecounter resets - if last_reset.hour == 0 and not pulse_collection.pulse_counter_reset: + if last_reset.hour == 0 and ( + not pulse_collection.pulse_counter_reset + and not self._midnight_reset_passed + ): last_reset = (last_reset - timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ) + self._midnight_reset_passed = True pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption @@ -307,3 +315,4 @@ def update( energy = self.energy _LOGGER.debug("energy=%s on last_update=%s", energy, last_update) return (energy, last_reset) + From 9d348da4a2582ec6f51c6e21ab96444a438e94b1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 5 Apr 2025 13:30:14 +0200 Subject: [PATCH 698/979] Bump to a80 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e61222d6e..e1b7cb66d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a79" +version = "v0.40.0a80" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 587ccd286e51404601ed0fbf9d7b98c21fda037e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 4 Apr 2025 13:25:12 +0200 Subject: [PATCH 699/979] Improve refactoring of _update_rollover() --- plugwise_usb/nodes/helpers/pulses.py | 64 ++++++++++++++-------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 27dd8bcb6..c4cc0b7ee 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -335,11 +335,13 @@ def _update_rollover(self) -> None: return self._rollover_consumption = self._detect_rollover( + self._rollover_consumption, self._last_log_consumption_timestamp, self._next_log_consumption_timestamp, ) if self._log_production: self._rollover_production = self._detect_rollover( + self._rollover_production, self._last_log_production_timestamp, self._next_log_production_timestamp, False, @@ -347,46 +349,46 @@ def _update_rollover(self) -> None: def _detect_rollover( self, + rollover: bool, last_log_timestamp: datetime | None, next_log_timestamp: datetime | None, is_consumption=True, ) -> bool: """Helper function for _update_rollover().""" + if ( - self._pulses_timestamp is None - or last_log_timestamp is None - or next_log_timestamp is None + self._pulses_timestamp is not None + and last_log_timestamp is not None + and next_log_timestamp is not None ): - # Unable to determine rollover - return - - direction = "consumption" - if not is_consumption: - direction = "production" - - if self._pulses_timestamp > next_log_timestamp: - _LOGGER.debug( - "_update_rollover | %s | set %s rollover => pulses newer", - self._mac, - direction, - ) - return True + direction = "consumption" + if not is_consumption: + direction = "production" - if self._pulses_timestamp < last_log_timestamp: - _LOGGER.debug( - "_update_rollover | %s | set %s rollover => log newer", - self._mac, - direction, - ) - return True + if self._pulses_timestamp > next_log_timestamp: + _LOGGER.debug( + "_update_rollover | %s | set %s rollover => pulses newer", + self._mac, + direction, + ) + return True - if last_log_timestamp <= self._pulses_timestamp <= next_log_timestamp: - _LOGGER.debug( - "_update_rollover | %s | reset %s rollover", - self._mac, - direction - ) - return False + if self._pulses_timestamp < last_log_timestamp: + _LOGGER.debug( + "_update_rollover | %s | set %s rollover => log newer", + self._mac, + direction, + ) + return True + + if last_log_timestamp <= self._pulses_timestamp <= next_log_timestamp: + if rollover: + _LOGGER.debug( + "_update_rollover | %s | reset %s rollover", + self._mac, + direction + ) + return False def add_empty_log(self, address: int, slot: int) -> None: """Add empty energy log record to mark any start of beginning of energy log collection.""" From 64dbbb926bf80d0910f665a9f4bbf7c9898de695 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 13:17:44 +0200 Subject: [PATCH 700/979] _update_log_references(): don't sync to pulse-counter reset The timestamps are set by the device --- plugwise_usb/nodes/helpers/pulses.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index c4cc0b7ee..b56592418 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -755,15 +755,6 @@ def _update_log_references(self, address: int, slot: int) -> None: log_timestamp = self._logs[address][slot].timestamp is_consumption = self._logs[address][slot].is_consumption - # Sync log_timestamp with the device pulsecounter reset-time - # This syncs the daily reset of energy counters with the corresponding device pulsecounter reset - if self._last_hourly_reset is not None: - log_timestamp = log_timestamp + timedelta( - minutes=self._last_hourly_reset.minute, - seconds=self._last_hourly_reset.second, - microseconds=self._last_hourly_reset.microsecond, - ) - # Update log references self._update_first_log_reference(address, slot, log_timestamp, is_consumption) self._update_last_log_reference(address, slot, log_timestamp, is_consumption) From b828418a2e746fb5db57dbc43c49723c076fdcb5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 13:32:37 +0200 Subject: [PATCH 701/979] Correct rollover assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 13aa9eb72..035e7c1b0 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1148,7 +1148,7 @@ def test_pulse_collection_consumption( ) == (145 + 2222 + 3333, pulse_update_5) pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) tst_consumption.update_pulse_counter(321, 0, pulse_update_6) - assert tst_consumption.log_rollover + assert not tst_consumption.log_rollover assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True ) == (2222 + 3333 + 321, pulse_update_6) From f802020dbe8964a63ba25f3f64ea8b2bc3f58b25 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 13:39:11 +0200 Subject: [PATCH 702/979] Bump to a81 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1b7cb66d..e5e4d60db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a80" +version = "v0.40.0a81" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 4432085d2e504e21de37b0bf6480baa2512fcc44 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 14:26:55 +0200 Subject: [PATCH 703/979] Add missing line --- plugwise_usb/nodes/helpers/pulses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b56592418..d64b9adc7 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -301,6 +301,7 @@ def update_pulse_counter( and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True + self._last_hourly_reset = timestamp _LOGGER.debug( "update_pulse_counter | production pulses reset | hourly_reset_time=%s", self._last_hourly_reset, From eec72dd0971bfffed92a9cebe9734f90cdce7553 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 08:15:22 +0200 Subject: [PATCH 704/979] counter-update: add debug-logging --- plugwise_usb/nodes/helpers/counter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 58b7d746c..9909ecccc 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -288,6 +288,12 @@ def update( if self._energy_id in ENERGY_DAY_COUNTERS: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) # Postpone the last_reset time-changes at day-end until a device pulsecounter resets + _LOGGER.debug("energycounter_update | last reset hour=%s", last_reset.hour) + _LOGGER.debug( + "energycounter_update | pulse_counter_reset=%s, midnight_reset_passed=%s", + pulse_collection.pulse_counter_reset, + self._midnight_reset_passed, + ) if last_reset.hour == 0 and ( not pulse_collection.pulse_counter_reset and not self._midnight_reset_passed From 867860c7420e7fb9bc86a3e7d57363fe4f506985 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 08:16:12 +0200 Subject: [PATCH 705/979] Bump to a82 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e5e4d60db..e24ffe562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a81" +version = "v0.40.0a82" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 258632c51ed6469d8c5a7e39f0b42b27f69ae5a1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 08:35:49 +0200 Subject: [PATCH 706/979] Improve counter update() --- plugwise_usb/nodes/helpers/counter.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 9909ecccc..89dc17796 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -286,14 +286,14 @@ def update( if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - # Postpone the last_reset time-changes at day-end until a device pulsecounter resets + reset: bool = pulse_collection.pulse_counter_reset _LOGGER.debug("energycounter_update | last reset hour=%s", last_reset.hour) _LOGGER.debug( "energycounter_update | pulse_counter_reset=%s, midnight_reset_passed=%s", - pulse_collection.pulse_counter_reset, + reset, self._midnight_reset_passed, ) + # Postpone the last_reset time-changes at day-end until a device pulsecounter resets if last_reset.hour == 0 and ( not pulse_collection.pulse_counter_reset and not self._midnight_reset_passed @@ -302,6 +302,8 @@ def update( hour=0, minute=0, second=0, microsecond=0 ) self._midnight_reset_passed = True + else: + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From b94815dc8a71392a6f3ecd4d04c2219b0325f79c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 08:36:32 +0200 Subject: [PATCH 707/979] Bump to a83 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e24ffe562..cac3e9acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a82" +version = "v0.40.0a83" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 5328b456152be5a97fcee951eb7309a16602866e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 11:44:48 +0200 Subject: [PATCH 708/979] Add missing @property --- plugwise_usb/nodes/helpers/pulses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d64b9adc7..f285c4def 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -165,6 +165,7 @@ def last_update(self) -> datetime | None: """Return timestamp of last update.""" return self._pulses_timestamp + @property def pulse_counter_reset(self) -> bool: """Return a pulse_counter reset.""" return self._cons_pulsecounter_reset or self._prod_pulsecounter_reset From ef8ad51f7606557737851022febb280deb865311 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 11:49:36 +0200 Subject: [PATCH 709/979] Bump to a84 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cac3e9acf..a20080f1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a83" +version = "v0.40.0a84" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From a0b111a53a3d35bf15183fad9846341cdd105d08 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 19:52:45 +0200 Subject: [PATCH 710/979] Remove hourly-reset related, not used --- plugwise_usb/nodes/helpers/pulses.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index f285c4def..2f2bd2dc2 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -106,13 +106,6 @@ def collected_logs(self) -> int: counter += len(self._logs[address]) return counter - @property - def hourly_reset_time(self) -> datetime | None: - """Provide the device hourly pulse reset time.""" - if (timestamp := self._last_hourly_reset) is not None: - return timestamp - return None - @property def logs(self) -> dict[int, dict[int, PulseLogRecord]]: """Return currently collected pulse logs in reversed order.""" @@ -291,22 +284,15 @@ def update_pulse_counter( and self._pulses_consumption > pulses_consumed ): self._cons_pulsecounter_reset = True - self._last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | consumption pulses reset | hourly_reset_time=%s", - self._last_hourly_reset, - ) + _LOGGER.debug("update_pulse_counter | consumption pulses reset") if ( self._pulses_production is not None and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True - self._last_hourly_reset = timestamp - _LOGGER.debug( - "update_pulse_counter | production pulses reset | hourly_reset_time=%s", - self._last_hourly_reset, - ) + _LOGGER.debug("update_pulse_counter | production pulses reset") + # No rollover based on time, set rollover based on counter reset # Required for special cases like nodes which have been powered off for several days if not (self._rollover_consumption or self._rollover_production): From 48a7c8cb17cef6a91f74c9e17fc391a312c10b83 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 19:54:22 +0200 Subject: [PATCH 711/979] Remove related test-assert --- tests/test_usb.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 035e7c1b0..d10ac122f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1121,7 +1121,6 @@ def test_pulse_collection_consumption( pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover - assert tst_consumption.hourly_reset_time == pulse_update_4 test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=4) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True From 292db2841994370de6320760115b5740169a7219 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 7 Apr 2025 20:02:28 +0200 Subject: [PATCH 712/979] Revert debug-logging related changes --- plugwise_usb/nodes/helpers/pulses.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 2f2bd2dc2..2f7665cc6 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -841,12 +841,11 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: if self._logs[address][slot].timestamp <= from_timestamp: break - _LOGGER.debug( - "_logs_missing | %s | missing in range=%s", self._mac, missing - ) - # return missing logs in range first if len(missing) > 0: + _LOGGER.debug( + "_logs_missing | %s | missing in range=%s", self._mac, missing + ) return missing if first_address not in self._logs: @@ -880,11 +879,6 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: calculated_timestamp = self._logs[first_address][ first_slot ].timestamp - timedelta(minutes=log_interval) - _LOGGER.debug( - "_logs_missing | %s | calculated timestamp=%s", - self._mac, - calculated_timestamp, - ) while from_timestamp < calculated_timestamp: if ( address == self._first_empty_log_address From 1f5069697e43e48714b4253207424bfc0593741c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 8 Apr 2025 08:09:45 +0200 Subject: [PATCH 713/979] Add extra guarding for setting _midnight_reset_passed to True --- plugwise_usb/nodes/helpers/counter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 89dc17796..77f943d4f 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -301,7 +301,8 @@ def update( last_reset = (last_reset - timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ) - self._midnight_reset_passed = True + if pulse_collection.pulse_counter_reset: + self._midnight_reset_passed = True else: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) From c50395c15891baadb0040f7682f1a0e5581cb1e3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 8 Apr 2025 08:18:30 +0200 Subject: [PATCH 714/979] Bump to a85 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a20080f1e..f86ea3e3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a84" +version = "v0.40.0a85" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 00c94e292718ed5621d8304eef9df1bc04dd4545 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 8 Apr 2025 20:35:53 +0200 Subject: [PATCH 715/979] Clean up --- plugwise_usb/nodes/helpers/pulses.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 2f7665cc6..81de2ca1a 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -67,7 +67,6 @@ def __init__(self, mac: str) -> None: self._last_empty_log_address: int | None = None self._last_empty_log_slot: int | None = None - self._last_hourly_reset: datetime | None = None self._last_log_consumption_timestamp: datetime | None = None self._last_log_consumption_address: int | None = None self._last_log_consumption_slot: int | None = None @@ -844,7 +843,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: # return missing logs in range first if len(missing) > 0: _LOGGER.debug( - "_logs_missing | %s | missing in range=%s", self._mac, missing + "_logs_missing | %s | missing in range=%s", self._mac, missing ) return missing @@ -931,7 +930,7 @@ def _missing_addresses_before( if self._log_interval_consumption == 0: pass - if not self._log_production: #False + if not self._log_production: expected_timestamp = ( self._logs[address][slot].timestamp - calc_interval_cons ) @@ -989,7 +988,7 @@ def _missing_addresses_after( # Use consumption interval calc_interval_cons = timedelta(minutes=self._log_interval_consumption) - if not self._log_production: # False + if not self._log_production: expected_timestamp = ( self._logs[address][slot].timestamp + calc_interval_cons ) From 53058e94af91401991e4381283adda5e68c35b79 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 9 Apr 2025 08:13:07 +0200 Subject: [PATCH 716/979] Move setting _midnight_reset_passed --- plugwise_usb/nodes/helpers/counter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 77f943d4f..dcc094e02 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -301,10 +301,10 @@ def update( last_reset = (last_reset - timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ) - if pulse_collection.pulse_counter_reset: - self._midnight_reset_passed = True else: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) + if pulse_collection.pulse_counter_reset: + self._midnight_reset_passed = True pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From 7c6ce7e66337e2fa9944ba7ff4a4f41526410c21 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 9 Apr 2025 08:13:31 +0200 Subject: [PATCH 717/979] Bump to a86 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f86ea3e3f..4a33a9dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a85" +version = "v0.40.0a86" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From adfadeae919185f00d4a739e10ede5a0630ba27a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 08:13:38 +0200 Subject: [PATCH 718/979] Improve midnight reset delaying code further --- plugwise_usb/nodes/helpers/counter.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index dcc094e02..cae4a1c4e 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -280,9 +280,6 @@ def update( ) -> tuple[float | None, datetime | None]: """Get pulse update.""" last_reset = datetime.now(tz=LOCAL_TIMEZONE) - if self._midnight_reset_passed and last_reset.hour == 1: - self._midnight_reset_passed = False - if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: @@ -303,8 +300,10 @@ def update( ) else: last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) - if pulse_collection.pulse_counter_reset: + if last_reset.hour == 0 and pulse_collection.pulse_counter_reset: self._midnight_reset_passed = True + if last_reset.hour == 1 and self._midnight_reset_passed: + self._midnight_reset_passed = False pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From 2c729af2b165e5e0bdcb05900ee5f1d4163e1dcc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 08:17:46 +0200 Subject: [PATCH 719/979] Bump to a87 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4a33a9dcc..b3078b8af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a86" +version = "v0.40.0a87" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 643e0409516631983d8583262138e7c9822a2a8e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 11 Apr 2025 08:11:49 +0200 Subject: [PATCH 720/979] Test last_reset.hour before setting hour to 0 --- plugwise_usb/nodes/helpers/counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index cae4a1c4e..02d8050fe 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -299,11 +299,11 @@ def update( hour=0, minute=0, second=0, microsecond=0 ) else: - last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) if last_reset.hour == 0 and pulse_collection.pulse_counter_reset: self._midnight_reset_passed = True if last_reset.hour == 1 and self._midnight_reset_passed: self._midnight_reset_passed = False + last_reset = last_reset.replace(hour=0, minute=0, second=0, microsecond=0) pulses, last_update = pulse_collection.collected_pulses( last_reset, self._is_consumption From ba7ca2f77d75c2040b43bb0c2e762adc062bb74b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 11 Apr 2025 08:13:09 +0200 Subject: [PATCH 721/979] Bump to a88 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3078b8af..43016dbac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a87" +version = "v0.40.0a88" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From a045e7fda12b733f6bacb758e7973cddb7eb27d5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 12 Apr 2025 07:19:02 +0200 Subject: [PATCH 722/979] Remove debug-logging for testing --- plugwise_usb/nodes/helpers/counter.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 02d8050fe..a8d08a8ea 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -283,13 +283,6 @@ def update( if self._energy_id in ENERGY_HOUR_COUNTERS: last_reset = last_reset.replace(minute=0, second=0, microsecond=0) if self._energy_id in ENERGY_DAY_COUNTERS: - reset: bool = pulse_collection.pulse_counter_reset - _LOGGER.debug("energycounter_update | last reset hour=%s", last_reset.hour) - _LOGGER.debug( - "energycounter_update | pulse_counter_reset=%s, midnight_reset_passed=%s", - reset, - self._midnight_reset_passed, - ) # Postpone the last_reset time-changes at day-end until a device pulsecounter resets if last_reset.hour == 0 and ( not pulse_collection.pulse_counter_reset From bd939163128e74dcdac6d5a662f5607d83aea9c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 12 Apr 2025 07:19:38 +0200 Subject: [PATCH 723/979] Bump to a89 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43016dbac..22906eaf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a88" +version = "v0.40.0a89" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From ff7dead5a86978a26c5ba0650af17384a7ed1935 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 16 Apr 2025 08:34:16 +0200 Subject: [PATCH 724/979] Publish on Pypi for async branch too --- .github/workflows/merge.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index a3f69cecf..185f28d8b 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -13,6 +13,7 @@ on: types: closed branches: - main + -async jobs: publishing: From b56be80bb9d72acf298ce705db9835d09a37db0b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 16 Apr 2025 08:34:49 +0200 Subject: [PATCH 725/979] Set to v0.40.0b0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 22906eaf0..58d1e7d95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a89" +version = "v0.40.0b0" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 3d87acccd0109f277a485511085edbcd07b90ab4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 17 Apr 2025 07:53:39 +0200 Subject: [PATCH 726/979] Fix typo --- .github/workflows/merge.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 185f28d8b..86aef558e 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -13,7 +13,7 @@ on: types: closed branches: - main - -async + - async jobs: publishing: From 1c085b01b844ce106e58060a814369b40eba0d30 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 16 Apr 2025 08:41:13 +0200 Subject: [PATCH 727/979] Bump to v0.40.0b1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 58d1e7d95..fe00292ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0b0" +version = "v0.40.0b1" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From b731cab37aac6ebdeb73fcc672f4019ed5baaa13 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 10:33:55 +0200 Subject: [PATCH 728/979] Set a fixed test-time for test_pulse_collection_consumption() --- tests/test_usb.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d10ac122f..40fda6821 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -946,8 +946,9 @@ def test_pulse_collection_consumption( """Testing pulse collection class.""" monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) - fixed_timestamp_utc = dt.now(UTC) - fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) + # fixed_timestamp_utc = dt.now(UTC) + # fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) + fixed_this_hour = freeze_time("2025-04-04 00:00:00") # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") From 71274058c9dfb930410f63884ca02dd6c56c9478 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 10:45:09 +0200 Subject: [PATCH 729/979] Try --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 40fda6821..3e0e26555 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -939,7 +939,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) await stick.disconnect() - @freeze_time(dt.now()) + @freeze_time("2025-04-04 00:00:00", tz_offset=-2) def test_pulse_collection_consumption( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -948,7 +948,7 @@ def test_pulse_collection_consumption( # fixed_timestamp_utc = dt.now(UTC) # fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) - fixed_this_hour = freeze_time("2025-04-04 00:00:00") + fixed_this_hour = dt.now(UTC) # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") From 3a739235503fbebc102278779ec39ee7dd7417d4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 10:51:53 +0200 Subject: [PATCH 730/979] Try 2 --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 3e0e26555..b23ef6d95 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -939,7 +939,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) await stick.disconnect() - @freeze_time("2025-04-04 00:00:00", tz_offset=-2) + @freeze_time("2025-04-04 00:00:00", tz_offset=0) def test_pulse_collection_consumption( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -948,7 +948,7 @@ def test_pulse_collection_consumption( # fixed_timestamp_utc = dt.now(UTC) # fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) - fixed_this_hour = dt.now(UTC) + fixed_this_hour = dt.now() # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") From e0cb7b53cca6d6492e280df96ec0822844b05b56 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 11:02:08 +0200 Subject: [PATCH 731/979] Try 3 --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index b23ef6d95..8d4c99cf2 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -939,7 +939,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) await stick.disconnect() - @freeze_time("2025-04-04 00:00:00", tz_offset=0) + @freeze_time("2025-04-04 00:00:00") def test_pulse_collection_consumption( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -948,7 +948,7 @@ def test_pulse_collection_consumption( # fixed_timestamp_utc = dt.now(UTC) # fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) - fixed_this_hour = dt.now() + fixed_this_hour = dt.now(UTC) # Test consumption logs tst_consumption = pw_energy_pulses.PulseCollection(mac="0098765432101234") From e975209aa6bfca6a1b8b4464139084835f59de75 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 11:24:33 +0200 Subject: [PATCH 732/979] Fix timestamp for added log --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 8d4c99cf2..3ee81f021 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1126,7 +1126,7 @@ def test_pulse_collection_consumption( assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True ) == (45, pulse_update_4) - tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1, minutes=1, seconds=5)), 2222) + tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert tst_consumption.log_rollover # Test collection of the last full hour assert tst_consumption.collected_pulses( From 8e3a4b4eb900860bb7cb8233ba94e8e242ef100b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 11:35:16 +0200 Subject: [PATCH 733/979] Change frozen time --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 3ee81f021..efa34232f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -939,7 +939,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) await stick.disconnect() - @freeze_time("2025-04-04 00:00:00") + @freeze_time("2025-04-03 23:00:00") def test_pulse_collection_consumption( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 26593c8f49799c8688b0b825a73e572f6a0e19a0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 11:53:36 +0200 Subject: [PATCH 734/979] Fix wrong asserts --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index efa34232f..7f9e08ef1 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1127,7 +1127,7 @@ def test_pulse_collection_consumption( test_timestamp, is_consumption=True ) == (45, pulse_update_4) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) - assert tst_consumption.log_rollover + assert not tst_consumption.log_rollover # Test collection of the last full hour assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True From 62233de907e58a9aef88adc8ed9135ba59d8074b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 19:06:38 +0200 Subject: [PATCH 735/979] Correct timestamps for collected_pulses() --- tests/test_usb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 7f9e08ef1..da9f767e5 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1122,7 +1122,8 @@ def test_pulse_collection_consumption( pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover - test_timestamp = fixed_this_hour + td(hours=1, minutes=1, seconds=4) + assert tst_consumption.hourly_reset_time == pulse_update_4 + test_timestamp = fixed_this_hour + td(hours=1) assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True ) == (45, pulse_update_4) @@ -1133,11 +1134,10 @@ def test_pulse_collection_consumption( fixed_this_hour, is_consumption=True ) == (45 + 2222, pulse_update_4) pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) - test_timestamp_2 = fixed_this_hour + td(hours=1, minutes=1, seconds=20) tst_consumption.update_pulse_counter(145, 0, pulse_update_5) # Test collection of the last new hour assert tst_consumption.collected_pulses( - test_timestamp_2, is_consumption=True + test_timestamp, is_consumption=True ) == (145, pulse_update_5) # Test log rollover by updating log first before updating pulses From 5250b5141052acdfa72f26f7510c9e948c00d3b5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 12:03:32 +0200 Subject: [PATCH 736/979] Lower frozen time --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index da9f767e5..1accde9e0 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -939,7 +939,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: ) await stick.disconnect() - @freeze_time("2025-04-03 23:00:00") + @freeze_time("2025-04-03 22:00:00") def test_pulse_collection_consumption( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 3c10b8bd61a10df86ffceefafe23323c99fec5aa Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 12:18:16 +0200 Subject: [PATCH 737/979] Add asserts, correct from_timestamps --- tests/test_usb.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 1accde9e0..a70503bc3 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1116,8 +1116,13 @@ def test_pulse_collection_consumption( pulse_update_3 = fixed_this_hour + td(hours=0, minutes=0, seconds=30) tst_consumption.update_pulse_counter(2500, 0, pulse_update_3) assert not tst_consumption.log_rollover + # Collected pulses last hour: assert tst_consumption.collected_pulses( - test_timestamp, is_consumption=True + fixed_this_hour, is_consumption=True + ) == (2500, pulse_update_3) + # Collected pulses last day: + assert tst_consumption.collected_pulses( + fixed_this_hour - td(hours=24), is_consumption=True ) == (2500 + 1111 + 1000 + 750, pulse_update_3) pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) From 5f803589d338d642b7a6b0ffab587afcbc227623 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 12:35:50 +0200 Subject: [PATCH 738/979] Fix last-day assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index a70503bc3..12619ebc7 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1123,7 +1123,7 @@ def test_pulse_collection_consumption( # Collected pulses last day: assert tst_consumption.collected_pulses( fixed_this_hour - td(hours=24), is_consumption=True - ) == (2500 + 1111 + 1000 + 750, pulse_update_3) + ) == (2500 + 23861, pulse_update_3) pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover From 4b891105c5b01498eb6f2d28f6fa12b78366e14e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 12:42:04 +0200 Subject: [PATCH 739/979] Add/update collected_pulses asserts for pulse-counter reset --- tests/test_usb.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 12619ebc7..04dff67d3 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1124,14 +1124,19 @@ def test_pulse_collection_consumption( assert tst_consumption.collected_pulses( fixed_this_hour - td(hours=24), is_consumption=True ) == (2500 + 23861, pulse_update_3) - pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1, seconds=3) + pulse_update_4 = fixed_this_hour + td(hours=0, minutes=1) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover assert tst_consumption.hourly_reset_time == pulse_update_4 test_timestamp = fixed_this_hour + td(hours=1) + # Collected pulses last hour: assert tst_consumption.collected_pulses( - test_timestamp, is_consumption=True + fixed_this_hour, is_consumption=True ) == (45, pulse_update_4) + # Collected pulses last day: + assert tst_consumption.collected_pulses( + fixed_this_hour - td(hours=24), is_consumption=True + ) == (2500 + 23861, pulse_update_3) tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover # Test collection of the last full hour From 85eccc2ba752b0d7df63a54d3979ffee4d3a45f6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 12:59:37 +0200 Subject: [PATCH 740/979] Test-updates --- tests/test_usb.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 04dff67d3..c375a34b0 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1113,42 +1113,42 @@ def test_pulse_collection_consumption( # Test rollover by updating pulses before log record - pulse_update_3 = fixed_this_hour + td(hours=0, minutes=0, seconds=30) + pulse_update_3 = fixed_this_hour + td(hours=1, minutes=0, seconds=30) + test_timestamp = fixed_this_hour + td(hours=1) tst_consumption.update_pulse_counter(2500, 0, pulse_update_3) - assert not tst_consumption.log_rollover + assert tst_consumption.log_rollover # Collected pulses last hour: assert tst_consumption.collected_pulses( - fixed_this_hour, is_consumption=True + test_timestamp, is_consumption=True ) == (2500, pulse_update_3) # Collected pulses last day: assert tst_consumption.collected_pulses( - fixed_this_hour - td(hours=24), is_consumption=True - ) == (2500 + 23861, pulse_update_3) - pulse_update_4 = fixed_this_hour + td(hours=0, minutes=1) + test_timestamp - td(hours=24), is_consumption=True + ) == (2500 + 22861, pulse_update_3) + pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover assert tst_consumption.hourly_reset_time == pulse_update_4 - test_timestamp = fixed_this_hour + td(hours=1) # Collected pulses last hour: assert tst_consumption.collected_pulses( - fixed_this_hour, is_consumption=True + test_timestamp, is_consumption=True ) == (45, pulse_update_4) # Collected pulses last day: assert tst_consumption.collected_pulses( - fixed_this_hour - td(hours=24), is_consumption=True - ) == (2500 + 23861, pulse_update_3) - tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) - assert not tst_consumption.log_rollover - # Test collection of the last full hour - assert tst_consumption.collected_pulses( - fixed_this_hour, is_consumption=True - ) == (45 + 2222, pulse_update_4) - pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) - tst_consumption.update_pulse_counter(145, 0, pulse_update_5) - # Test collection of the last new hour - assert tst_consumption.collected_pulses( - test_timestamp, is_consumption=True - ) == (145, pulse_update_5) + test_timestamp - td(hours=24), is_consumption=True + ) == (45 + 2500 + 23861, pulse_update_4) + # tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) + # assert not tst_consumption.log_rollover + ## Test collection of the last full hour + # assert tst_consumption.collected_pulses( + # fixed_this_hour, is_consumption=True + # ) == (45 + 2222, pulse_update_4) + # pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) + # tst_consumption.update_pulse_counter(145, 0, pulse_update_5) + ## Test collection of the last new hour + # assert tst_consumption.collected_pulses( + # test_timestamp, is_consumption=True + # ) == (145, pulse_update_5) # Test log rollover by updating log first before updating pulses tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) From 764ec8b24cc3e3926f1501b49dc0f4cdba0bd317 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 6 Apr 2025 11:40:22 +0200 Subject: [PATCH 741/979] Drop log when exact 24hrs old --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 81de2ca1a..0c0d4d94d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -468,7 +468,7 @@ def _add_log_record( return False # Drop useless log records when we have at least 4 logs - if self.collected_logs > 4 and log_record.timestamp < ( + if self.collected_logs > 4 and log_record.timestamp <= ( datetime.now(tz=UTC) - timedelta(hours=MAX_LOG_HOURS) ): return False From cb079473edc010af149719e46b578d7c98cd6760 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 19:13:01 +0200 Subject: [PATCH 742/979] Remove no longer available hourly_reset assert --- tests/test_usb.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index c375a34b0..3572cbaee 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1128,7 +1128,6 @@ def test_pulse_collection_consumption( pulse_update_4 = fixed_this_hour + td(hours=1, minutes=1) tst_consumption.update_pulse_counter(45, 0, pulse_update_4) assert tst_consumption.log_rollover - assert tst_consumption.hourly_reset_time == pulse_update_4 # Collected pulses last hour: assert tst_consumption.collected_pulses( test_timestamp, is_consumption=True From 03ef20c65304b94be8ab3274a421e8261a7d3659 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 19:14:29 +0200 Subject: [PATCH 743/979] Adapt test-assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 3572cbaee..5cc21b1a8 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1135,7 +1135,7 @@ def test_pulse_collection_consumption( # Collected pulses last day: assert tst_consumption.collected_pulses( test_timestamp - td(hours=24), is_consumption=True - ) == (45 + 2500 + 23861, pulse_update_4) + ) == (45 + 2500 + 20361, pulse_update_4) # tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) # assert not tst_consumption.log_rollover ## Test collection of the last full hour From cbffb73094b436ab7b7e3335e8f34244087e0fc1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 19:37:57 +0200 Subject: [PATCH 744/979] Enable extra tests --- tests/test_usb.py | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 5cc21b1a8..59d78a472 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1135,32 +1135,34 @@ def test_pulse_collection_consumption( # Collected pulses last day: assert tst_consumption.collected_pulses( test_timestamp - td(hours=24), is_consumption=True - ) == (45 + 2500 + 20361, pulse_update_4) - # tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) - # assert not tst_consumption.log_rollover - ## Test collection of the last full hour - # assert tst_consumption.collected_pulses( - # fixed_this_hour, is_consumption=True - # ) == (45 + 2222, pulse_update_4) - # pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) - # tst_consumption.update_pulse_counter(145, 0, pulse_update_5) - ## Test collection of the last new hour - # assert tst_consumption.collected_pulses( - # test_timestamp, is_consumption=True - # ) == (145, pulse_update_5) + ) == (45 + 23861, pulse_update_4) + # pulse-count of 2500 is ignored, the code does not export this incorrect value - # Test log rollover by updating log first before updating pulses - tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) - assert tst_consumption.log_rollover - assert tst_consumption.collected_pulses( - fixed_this_hour, is_consumption=True - ) == (145 + 2222 + 3333, pulse_update_5) - pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) - tst_consumption.update_pulse_counter(321, 0, pulse_update_6) + tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) assert not tst_consumption.log_rollover + # Test collection of the last full hour assert tst_consumption.collected_pulses( fixed_this_hour, is_consumption=True - ) == (2222 + 3333 + 321, pulse_update_6) + ) == (45 + 2222, pulse_update_4) + pulse_update_5 = fixed_this_hour + td(hours=1, minutes=1, seconds=18) + tst_consumption.update_pulse_counter(145, 0, pulse_update_5) + # Test collection of the last new hour + assert tst_consumption.collected_pulses( + test_timestamp, is_consumption=True + ) == (145, pulse_update_5) + + # Test log rollover by updating log first before updating pulses + # tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) + # assert tst_consumption.log_rollover + # assert tst_consumption.collected_pulses( + # fixed_this_hour, is_consumption=True + # ) == (145 + 2222 + 3333, pulse_update_5) + # pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) + # tst_consumption.update_pulse_counter(321, 0, pulse_update_6) + # assert not tst_consumption.log_rollover + # assert tst_consumption.collected_pulses( + # fixed_this_hour, is_consumption=True + # ) == (2222 + 3333 + 321, pulse_update_6) @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( From 5a1fac152f1fb77b19449344f724aed0bf694447 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 10 Apr 2025 19:51:13 +0200 Subject: [PATCH 745/979] Fix assert --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 59d78a472..1ab7fde51 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1135,7 +1135,7 @@ def test_pulse_collection_consumption( # Collected pulses last day: assert tst_consumption.collected_pulses( test_timestamp - td(hours=24), is_consumption=True - ) == (45 + 23861, pulse_update_4) + ) == (45 + 22861, pulse_update_4) # pulse-count of 2500 is ignored, the code does not export this incorrect value tst_consumption.add_log(100, 2, (fixed_this_hour + td(hours=1)), 2222) From efebab1e4389b0d19d2d513e7de135b2bc0792e4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 11 Apr 2025 16:58:49 +0200 Subject: [PATCH 746/979] Uncomment disabled test-asserts --- tests/test_usb.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 1ab7fde51..53a8d932e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -1152,17 +1152,17 @@ def test_pulse_collection_consumption( ) == (145, pulse_update_5) # Test log rollover by updating log first before updating pulses - # tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) - # assert tst_consumption.log_rollover - # assert tst_consumption.collected_pulses( - # fixed_this_hour, is_consumption=True - # ) == (145 + 2222 + 3333, pulse_update_5) - # pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) - # tst_consumption.update_pulse_counter(321, 0, pulse_update_6) - # assert not tst_consumption.log_rollover - # assert tst_consumption.collected_pulses( - # fixed_this_hour, is_consumption=True - # ) == (2222 + 3333 + 321, pulse_update_6) + tst_consumption.add_log(100, 3, (fixed_this_hour + td(hours=2)), 3333) + assert tst_consumption.log_rollover + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (145 + 2222 + 3333, pulse_update_5) + pulse_update_6 = fixed_this_hour + td(hours=2, seconds=10) + tst_consumption.update_pulse_counter(321, 0, pulse_update_6) + assert not tst_consumption.log_rollover + assert tst_consumption.collected_pulses( + fixed_this_hour, is_consumption=True + ) == (2222 + 3333 + 321, pulse_update_6) @freeze_time(dt.now()) def test_pulse_collection_consumption_empty( From f7165b5d5674bf6d0e433e4b337370d437a1197f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 18 Apr 2025 09:50:32 +0200 Subject: [PATCH 747/979] Cleanup --- tests/test_usb.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 53a8d932e..1a2b99bc3 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -945,9 +945,6 @@ def test_pulse_collection_consumption( ) -> None: """Testing pulse collection class.""" monkeypatch.setattr(pw_energy_pulses, "MAX_LOG_HOURS", 24) - - # fixed_timestamp_utc = dt.now(UTC) - # fixed_this_hour = fixed_timestamp_utc.replace(minute=0, second=0, microsecond=0) fixed_this_hour = dt.now(UTC) # Test consumption logs @@ -1086,7 +1083,6 @@ def test_pulse_collection_consumption( assert not tst_consumption.log_rollover # add missing logs - test_timestamp = fixed_this_hour - td(hours=3) tst_consumption.add_log(99, 2, (fixed_this_hour - td(hours=3)), 1000) tst_consumption.add_log(99, 1, (fixed_this_hour - td(hours=4)), 1000) tst_consumption.add_log(98, 4, (fixed_this_hour - td(hours=5)), 1000) From f3b108f0b5d7db7625c2bb1db67fc52f11ce68be Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 09:47:00 +0200 Subject: [PATCH 748/979] HOI debug --- plugwise_usb/network/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index cab52a073..b256ebd65 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -506,6 +506,7 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" + _LOGGER.debug("HOI try setting allow_join_requests to True") request = CirclePlusAllowJoiningRequest(self._controller.send, state) response = await request.send() if (response := await request.send()) is None: From 03881d7c11ad121832047e2d25948cfe61ed89d8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 09:35:42 +0200 Subject: [PATCH 749/979] Full test-output --- scripts/tests_and_coverage.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 884a5221b..72753aaef 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -23,7 +23,8 @@ set +u if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then # Python tests (rerun with debug if failures) - PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ + #PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || + PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ fi if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then From e7542796f51e20300722267dc191a56272db4bdf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 10:04:14 +0200 Subject: [PATCH 750/979] Call allow_join_request() via accept_join_request setter --- plugwise_usb/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a00ca68da..f23f3e2f4 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -191,19 +191,22 @@ def accept_join_request(self) -> bool | None: return self._network.accept_join_request @accept_join_request.setter - def accept_join_request(self, state: bool) -> None: + async def accept_join_request(self, state: bool) -> None: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( "Cannot accept joining node" + " without an active USB-Stick connection." ) + if self._network is None or not self._network.is_running: raise StickError( "Cannot accept joining node" + "without node discovery be activated. Call discover() first." ) + self._network.accept_join_request = state + await self._network.allow_join_requests(state) async def clear_cache(self) -> None: """Clear current cache.""" From 2149fd9993e45bcaf7da751725d58eece525bf61 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 13:02:39 +0200 Subject: [PATCH 751/979] Revert --- plugwise_usb/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index f23f3e2f4..efc3a5747 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -191,7 +191,7 @@ def accept_join_request(self) -> bool | None: return self._network.accept_join_request @accept_join_request.setter - async def accept_join_request(self, state: bool) -> None: + def accept_join_request(self, state: bool) -> None: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( @@ -206,7 +206,6 @@ async def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - await self._network.allow_join_requests(state) async def clear_cache(self) -> None: """Clear current cache.""" From 95a6019a38c10ed61e16af3ad91af3a4a6334387 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 13:38:55 +0200 Subject: [PATCH 752/979] Enable/disable automatic joining based on accept_join_request --- plugwise_usb/network/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index b256ebd65..074c9061b 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -318,7 +318,12 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: ): if load: return await self._load_node(self._controller.mac_coordinator) + if self.accept_join_request: + await self.allow_join_requests(True) + else: + await self.allow_join_requests(False) return True + return False # endregion From 44d4602de0752977bdb2a2be577540659cac1ca5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 13:51:00 +0200 Subject: [PATCH 753/979] Limit sending allow_join_request() --- plugwise_usb/network/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 074c9061b..b07f4d79e 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -40,6 +40,7 @@ class StickNetwork: accept_join_request = False _event_subscriptions: dict[StickEvent, int] = {} + _old_acc_join_req = False def __init__( self, @@ -318,9 +319,9 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: ): if load: return await self._load_node(self._controller.mac_coordinator) - if self.accept_join_request: + if self.accept_join_request and not self._old_acc_join_req: await self.allow_join_requests(True) - else: + if not self.accept_join_request and self._old_acc_join_req: await self.allow_join_requests(False) return True From b96b74efe27c687f9309b987b5886c5d441edf09 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 13:54:53 +0200 Subject: [PATCH 754/979] Bump to a90 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fe00292ad..a261da725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0b1" +version = "v0.40.0a90" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 4c457ef721e3ce89c48b70d3c6c856ac03db269f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 14:07:22 +0200 Subject: [PATCH 755/979] Update _old_acc_join_req --- plugwise_usb/network/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index b07f4d79e..8a2ea4244 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -321,8 +321,10 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: return await self._load_node(self._controller.mac_coordinator) if self.accept_join_request and not self._old_acc_join_req: await self.allow_join_requests(True) + self._old_acc_join_req = True if not self.accept_join_request and self._old_acc_join_req: await self.allow_join_requests(False) + self._old_acc_join_req = False return True return False From c17f1009320250b877d001d741b193f6c662248d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 14:08:30 +0200 Subject: [PATCH 756/979] Bump to a91 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a261da725..55dfdc287 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a90" +version = "v0.40.0a91" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From f8d78a6f219e60f4df10f48cd525e21012f17572 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 21 Apr 2025 14:39:56 +0200 Subject: [PATCH 757/979] Execute allow_join_requests() after network.is_running --- plugwise_usb/network/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 8a2ea4244..7f86b65f6 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -319,12 +319,6 @@ async def discover_network_coordinator(self, load: bool = False) -> bool: ): if load: return await self._load_node(self._controller.mac_coordinator) - if self.accept_join_request and not self._old_acc_join_req: - await self.allow_join_requests(True) - self._old_acc_join_req = True - if not self.accept_join_request and self._old_acc_join_req: - await self.allow_join_requests(False) - self._old_acc_join_req = False return True return False @@ -493,9 +487,18 @@ async def discover_nodes(self, load: bool = True) -> bool: await self.discover_network_coordinator(load=load) if not self._is_running: await self.start() + await self._discover_registered_nodes() if load: return await self._load_discovered_nodes() + + if self.accept_join_request and not self._old_acc_join_req: + await self.allow_join_requests(True) + self._old_acc_join_req = True + if not self.accept_join_request and self._old_acc_join_req: + await self.allow_join_requests(False) + self._old_acc_join_req = False + return True async def stop(self) -> None: From c6317f7d6641a2b68ff8a81c8afac8e779b31b1d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 08:12:24 +0200 Subject: [PATCH 758/979] Break out --- plugwise_usb/network/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7f86b65f6..a5ff63f44 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -492,13 +492,6 @@ async def discover_nodes(self, load: bool = True) -> bool: if load: return await self._load_discovered_nodes() - if self.accept_join_request and not self._old_acc_join_req: - await self.allow_join_requests(True) - self._old_acc_join_req = True - if not self.accept_join_request and self._old_acc_join_req: - await self.allow_join_requests(False) - self._old_acc_join_req = False - return True async def stop(self) -> None: @@ -515,6 +508,15 @@ async def stop(self) -> None: # endregion + async def test123(self) -> None: + if self.accept_join_request and not self._old_acc_join_req: + await self.allow_join_requests(True) + self._old_acc_join_req = True + if not self.accept_join_request and self._old_acc_join_req: + await self.allow_join_requests(False) + self._old_acc_join_req = False + + async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" _LOGGER.debug("HOI try setting allow_join_requests to True") From 61a068121e792a626ddced9dc2202fa74504ee7b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 11:56:01 +0200 Subject: [PATCH 759/979] Execute allow_join_requests() with accept_join_request state being set --- plugwise_usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index efc3a5747..e89c3ee22 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -206,6 +206,7 @@ def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state + self._network.allow_join_requests(state) async def clear_cache(self) -> None: """Clear current cache.""" From 479816fce8c532e63be206c400650a26f83ec7ef Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 11:58:36 +0200 Subject: [PATCH 760/979] Clean up, add logger --- plugwise_usb/network/__init__.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index a5ff63f44..a8f8c24be 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -508,27 +508,20 @@ async def stop(self) -> None: # endregion - async def test123(self) -> None: - if self.accept_join_request and not self._old_acc_join_req: - await self.allow_join_requests(True) - self._old_acc_join_req = True - if not self.accept_join_request and self._old_acc_join_req: - await self.allow_join_requests(False) - self._old_acc_join_req = False - - async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" - _LOGGER.debug("HOI try setting allow_join_requests to True") request = CirclePlusAllowJoiningRequest(self._controller.send, state) response = await request.send() if (response := await request.send()) is None: raise NodeError("No response to get notifications for join request.") + if response.response_type != NodeResponseType.JOIN_ACCEPTED: raise MessageError( f"Unknown NodeResponseType '{response.response_type.name}' received" ) + _LOGGER.debug("Send AllowJoiningRequest to Circle+ with state=%s", state) + def subscribe_to_node_events( self, node_event_callback: Callable[[NodeEvent, str], Coroutine[Any, Any, None]], From 96231383d2d99bf7d4339e7ec8439748ae5ee84a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 12:50:41 +0200 Subject: [PATCH 761/979] Bump to a92 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 55dfdc287..cd8d9d960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a91" +version = "v0.40.0a92" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 20452421c854990c1f0c01793ab978dee89d052a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 13:52:10 +0200 Subject: [PATCH 762/979] Handle async function-call properly --- plugwise_usb/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index e89c3ee22..a0eabd880 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from asyncio import get_running_loop +from asyncio import create_task, get_running_loop from collections.abc import Callable, Coroutine from functools import wraps import logging @@ -206,7 +206,7 @@ def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - self._network.allow_join_requests(state) + create_task(self._network.allow_join_requests(state)) async def clear_cache(self) -> None: """Clear current cache.""" From c6a9ef016cb0dbfc871f1d5bb00842d6082d1554 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 14:24:32 +0200 Subject: [PATCH 763/979] Correct syntax --- plugwise_usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a0eabd880..efe96fee6 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -206,7 +206,7 @@ def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - create_task(self._network.allow_join_requests(state)) + task = create_task(self._network.allow_join_requests(state)) async def clear_cache(self) -> None: """Clear current cache.""" From e458c11c1e55cc193fe105261eb08bbb6f764aa5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 14:45:25 +0200 Subject: [PATCH 764/979] Remove double code --- plugwise_usb/network/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index a8f8c24be..5d5e416d0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -511,7 +511,6 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" request = CirclePlusAllowJoiningRequest(self._controller.send, state) - response = await request.send() if (response := await request.send()) is None: raise NodeError("No response to get notifications for join request.") From ef84f9877c5af57c5097bbb0129d1886c262fe39 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 14:51:20 +0200 Subject: [PATCH 765/979] Try async setter construct --- plugwise_usb/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index efe96fee6..fbae25e5a 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -191,7 +191,7 @@ def accept_join_request(self) -> bool | None: return self._network.accept_join_request @accept_join_request.setter - def accept_join_request(self, state: bool) -> None: + async def accept_join_request(self, state: bool) -> None: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( @@ -206,7 +206,7 @@ def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - task = create_task(self._network.allow_join_requests(state)) + await self._network.allow_join_requests(state) async def clear_cache(self) -> None: """Clear current cache.""" From bbf65a963d75567fac354d9e027d74b544980048 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 19:05:17 +0200 Subject: [PATCH 766/979] Fix testing --- tests/test_usb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 1a2b99bc3..6566f285a 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -469,7 +469,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.accept_join_request is None # test failing of join requests without active discovery with pytest.raises(pw_exceptions.StickError): - stick.accept_join_request = True + await setattr(stick.accept_join_request, "attrib", True) unsub_connect() await stick.disconnect() assert not stick.network_state @@ -572,7 +572,7 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - stick.accept_join_request = True + await setattr(stick.accept_join_request, "attrib", True) self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, From 887f3071a555982ca86bb945cb5c889bbeb1047c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 19:13:07 +0200 Subject: [PATCH 767/979] Try calling allow_join_requests via create_task() --- plugwise_usb/__init__.py | 4 ++-- tests/test_usb.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index fbae25e5a..64366b548 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -191,7 +191,7 @@ def accept_join_request(self) -> bool | None: return self._network.accept_join_request @accept_join_request.setter - async def accept_join_request(self, state: bool) -> None: + def accept_join_request(self, state: bool) -> None: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( @@ -206,7 +206,7 @@ async def accept_join_request(self, state: bool) -> None: ) self._network.accept_join_request = state - await self._network.allow_join_requests(state) + _ = create_task(self._network.allow_join_requests(state)) async def clear_cache(self) -> None: """Clear current cache.""" diff --git a/tests/test_usb.py b/tests/test_usb.py index 6566f285a..1a2b99bc3 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -469,7 +469,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.accept_join_request is None # test failing of join requests without active discovery with pytest.raises(pw_exceptions.StickError): - await setattr(stick.accept_join_request, "attrib", True) + stick.accept_join_request = True unsub_connect() await stick.disconnect() assert not stick.network_state @@ -572,7 +572,7 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - await setattr(stick.accept_join_request, "attrib", True) + stick.accept_join_request = True self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, From 7808bac19ebf8f2bac5c89fb98012a2ca9ca337e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 22 Apr 2025 19:53:13 +0200 Subject: [PATCH 768/979] Bump to a93 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cd8d9d960..21748e6b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a92" +version = "v0.40.0a93" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 9142104f546aafa8dd08489329c45e014b1d05ac Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 09:55:24 +0200 Subject: [PATCH 769/979] Remove unused --- plugwise_usb/network/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 5d5e416d0..caa00b886 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -40,7 +40,6 @@ class StickNetwork: accept_join_request = False _event_subscriptions: dict[StickEvent, int] = {} - _old_acc_join_req = False def __init__( self, From b84d1c458d650c1559f97cae84273e7596fd581d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 11:03:07 +0200 Subject: [PATCH 770/979] Register node in node_join_available_message() --- plugwise_usb/network/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index caa00b886..18f7dfee0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -247,8 +247,12 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) - return True + if self.register_node(mac): + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + return True + + _LOGGER.debug("Failed to register available Node with mac=%s", mac) + return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: """Handle NodeRejoinResponse messages.""" From 4fcd0ebd509d24f7b1620b924466b9809cccb225 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 11:17:35 +0200 Subject: [PATCH 771/979] Bump to a94 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 21748e6b4..70b19b14c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a93" +version = "v0.40.0a94" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 0e3192a0eb985e95a40f405c1c4a5bcded732a38 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 11:55:49 +0200 Subject: [PATCH 772/979] Improve debug-message --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 18f7dfee0..5a59dff73 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -251,7 +251,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True - _LOGGER.debug("Failed to register available Node with mac=%s", mac) + _LOGGER.debug("Joining of available Node (%s) failed", mac) return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: From 0b836e93776877976ed1956a60809fd8075e0250 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 13:05:03 +0200 Subject: [PATCH 773/979] Add missing await --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 5a59dff73..087860111 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -247,7 +247,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - if self.register_node(mac): + if await self.register_node(mac): await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True From 7f706d9f46cf003355b004540a5acf0b061f2a79 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 13:05:25 +0200 Subject: [PATCH 774/979] Bump to a95 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 70b19b14c..beca00487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a94" +version = "v0.40.0a95" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From bff21f00a5e8bc95b7bd2adc1bdfb09b31d4ede8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 15:36:53 +0200 Subject: [PATCH 775/979] Change debug-message --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 087860111..d720f39a3 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -251,7 +251,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True - _LOGGER.debug("Joining of available Node (%s) failed", mac) + _LOGGER.debug("Joining of available Node %s failed", mac) return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: From 235d448de4b76cbf655b264aa1d8d2ccd03f8d0e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 16:18:18 +0200 Subject: [PATCH 776/979] Set asyncio_default_fixture_loop_scope to "session" --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index beca00487..2beeae4b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,7 @@ overgeneral-exceptions = [ ] [tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "session" asyncio_mode = "strict" markers = [ # mark a test as a asynchronous io test. From 8b99c71b8193dcf050808a27826c888527870851 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 20:38:48 +0200 Subject: [PATCH 777/979] Update actions/* versions --- .github/workflows/merge.yml | 4 +-- .github/workflows/verify.yml | 62 ++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 86aef558e..efdb23047 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -23,10 +23,10 @@ jobs: if: github.event.pull_request.merged == true steps: - name: Check out committed code - uses: actions/checkout@v4 + uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install pypa/build diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f5b8540c9..c31554a67 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -22,15 +22,15 @@ jobs: name: Prepare steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -52,7 +52,7 @@ jobs: pip install -r requirements_test.txt -r requirements_commit.txt - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -71,17 +71,17 @@ jobs: needs: prepare steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -124,15 +124,15 @@ jobs: - dependencies_check steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -147,7 +147,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -175,15 +175,15 @@ jobs: python-version: ["3.13"] steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -215,15 +215,15 @@ jobs: steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -253,17 +253,17 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master @@ -303,7 +303,7 @@ jobs: name: Dependency steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Run dependency checker run: scripts/dependencies_check.sh debug @@ -313,15 +313,15 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -358,15 +358,15 @@ jobs: needs: [coverage, mypy] steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -401,15 +401,15 @@ jobs: needs: coverage steps: - name: Check out committed code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4 + uses: actions/cache@v4.2.3 with: path: venv key: >- From 6266dde8392c22c0ceb1fcfb2cac4edd7b381106 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 23 Apr 2025 20:53:06 +0200 Subject: [PATCH 778/979] More version-updates --- .github/workflows/verify.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c31554a67..ecb8a0ed2 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -240,7 +240,7 @@ jobs: . venv/bin/activate pytest --log-level info tests/*.py --cov='.' - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }} path: .coverage @@ -335,7 +335,7 @@ jobs: echo "Failed to restore Python virtual environment from cache" exit 1 - name: Download all coverage artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v4.2.1 - name: Combine coverage results run: | . venv/bin/activate @@ -348,7 +348,7 @@ jobs: echo "***" coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v5.4.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From df7c234be5013ba25285fd688fe43d8c27705af6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 24 Apr 2025 20:22:50 +0200 Subject: [PATCH 779/979] Disable testcase --- tests/test_usb.py | 80 +++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 1a2b99bc3..c35dfdb1f 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -643,46 +643,46 @@ async def test_stick_node_discovered_subscription( await stick.disconnect() - async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] - """Handle join event callback.""" - if event == pw_api.NodeEvent.JOIN: - self.test_node_join.set_result(mac) - else: - self.test_node_join.set_exception( - BaseException( - f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.JOIN}" - ) - ) - - @pytest.mark.asyncio - async def test_stick_node_join_subscription( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Testing "new_node" subscription.""" - mock_serial = MockSerial(None) - monkeypatch.setattr( - pw_connection_manager, - "create_serial_connection", - mock_serial.mock_connection, - ) - monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) - stick = pw_stick.Stick("test_port", cache_enabled=False) - await stick.connect() - await stick.initialize() - await stick.discover_nodes(load=False) - self.test_node_join = asyncio.Future() - unusb_join = stick.subscribe_to_node_events( - node_event_callback=self.node_join, - events=(pw_api.NodeEvent.JOIN,), - ) - - # Inject node join request message - mock_serial.inject_message(b"00069999999999999999", b"FFFC") - mac_join_node = await self.test_node_join - assert mac_join_node == "9999999999999999" - unusb_join() - await stick.disconnect() +# async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] +# """Handle join event callback.""" +# if event == pw_api.NodeEvent.JOIN: +# self.test_node_join.set_result(mac) +# else: +# self.test_node_join.set_exception( +# BaseException( +# f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.JOIN}" +# ) +# ) +# +# @pytest.mark.asyncio +# async def test_stick_node_join_subscription( +# self, monkeypatch: pytest.MonkeyPatch +# ) -> None: +# """Testing "new_node" subscription.""" +# mock_serial = MockSerial(None) +# monkeypatch.setattr( +# pw_connection_manager, +# "create_serial_connection", +# mock_serial.mock_connection, +# ) +# monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) +# monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) +# stick = pw_stick.Stick("test_port", cache_enabled=False) +# await stick.connect() +# await stick.initialize() +# await stick.discover_nodes(load=False) +# self.test_node_join = asyncio.Future() +# unusb_join = stick.subscribe_to_node_events( +# node_event_callback=self.node_join, +# events=(pw_api.NodeEvent.JOIN,), +# ) +# +# # Inject node join request message +# mock_serial.inject_message(b"00069999999999999999", b"FFFC") +# mac_join_node = await self.test_node_join +# assert mac_join_node == "9999999999999999" +# unusb_join() +# await stick.disconnect() @pytest.mark.asyncio async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: From bf01dd40af3127894d3babf88b6b92d24891dcfe Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 09:07:06 +0200 Subject: [PATCH 780/979] Bump to a96 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2beeae4b2..5c8b87ee4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a95" +version = "v0.40.0a96" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From d92a806db37c0b533869fade9004ae58824aac44 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 09:21:42 +0200 Subject: [PATCH 781/979] Base NodeAddRequest on StickResponse --- plugwise_usb/messages/requests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 4cf6dd3ed..ecebe53b7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -409,13 +409,13 @@ def serialize(self) -> bytes: return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER -class PlugwiseRequestWithNodeAckResponse(PlugwiseRequest): +class PlugwiseRequestWithStickResponse(PlugwiseRequest): """Base class of a plugwise request with a NodeAckResponse.""" - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self, suppress_node_errors: bool = False) -> StickResponse | None: """Send request.""" result = await self._send_request(suppress_node_errors) - if isinstance(result, NodeAckResponse): + if isinstance(result, StickResponse): return result if result is None: return None @@ -424,7 +424,7 @@ async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | No ) -class NodeAddRequest(PlugwiseRequestWithNodeAckResponse): +class NodeAddRequest(PlugwiseRequestWithStickResponse): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 @@ -432,7 +432,7 @@ class NodeAddRequest(PlugwiseRequestWithNodeAckResponse): """ _identifier = b"0007" - _reply_identifier = b"0005" + _reply_identifier = b"0000" #"0005" def __init__( self, From e3a9a5afa1eb9a12c02e36228bec33a2925377ee Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 11:09:26 +0200 Subject: [PATCH 782/979] Add new found firmware version --- plugwise_usb/nodes/helpers/firmware.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index 471f1bb81..cc47ac656 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -46,6 +46,7 @@ class SupportedVersions(NamedTuple): # Proto release datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2015, 9, 18, 8, 53, 15, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), # New Flash Update datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } From 96cae345ca5d0699eeecf54f9fab77fd3538abfa Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 11:10:18 +0200 Subject: [PATCH 783/979] Bump to a97 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c8b87ee4..710d850ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a96" +version = "v0.40.0a97" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 64aefd9aa339e1559e3122c2d949547b9391bf74 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 12:10:04 +0200 Subject: [PATCH 784/979] Change max version to 2.5 for added firware Nor response received for CircleRelayInitStateRequest --- plugwise_usb/nodes/helpers/firmware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/firmware.py b/plugwise_usb/nodes/helpers/firmware.py index cc47ac656..e0e1600f7 100644 --- a/plugwise_usb/nodes/helpers/firmware.py +++ b/plugwise_usb/nodes/helpers/firmware.py @@ -46,7 +46,7 @@ class SupportedVersions(NamedTuple): # Proto release datetime(2015, 6, 16, 21, 9, 10, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), datetime(2015, 6, 18, 14, 0, 54, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), - datetime(2015, 9, 18, 8, 53, 15, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), + datetime(2015, 9, 18, 8, 53, 15, tzinfo=UTC): SupportedVersions(min=2.0, max=2.5), # New Flash Update datetime(2017, 7, 11, 16, 6, 59, tzinfo=UTC): SupportedVersions(min=2.0, max=2.6), } From c66fd0e02d3358ecc1c4d700a7889918d60796b7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 12:13:00 +0200 Subject: [PATCH 785/979] Bump to a98 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 710d850ed..2b049ecb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a97" +version = "v0.40.0a98" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 5cea70d126c53bb975a171bc594995698a55a7e8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 16:58:02 +0200 Subject: [PATCH 786/979] Correct corrected_pulses to 0 when negative corrected_pulses should always be >= 0 --- plugwise_usb/nodes/circle.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index dde3c5098..11226df77 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1039,20 +1039,23 @@ def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | No negative = True pulses_per_s = abs(pulses_per_s) - corrected_pulses = seconds * ( - ( + corrected_pulses = max( + 0, + seconds * ( ( - ((pulses_per_s + self._calibration.off_noise) ** 2) - * self._calibration.gain_b - ) - + ( - (pulses_per_s + self._calibration.off_noise) - * self._calibration.gain_a + ( + ((pulses_per_s + self._calibration.off_noise) ** 2) + * self._calibration.gain_b + ) + + ( + (pulses_per_s + self._calibration.off_noise) + * self._calibration.gain_a + ) ) + + self._calibration.off_tot ) - + self._calibration.off_tot ) - if negative: + if negative and corrected_pulses != 0: corrected_pulses = -corrected_pulses return corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) From b3ed80b47568dbbd1ed1ab129a4ae928afe172be Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 25 Apr 2025 16:59:18 +0200 Subject: [PATCH 787/979] Bump to a99 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2b049ecb7..8d7b82e02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a98" +version = "v0.40.0a99" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From d8bc95997197e1d865a5f747cc039478b32c08a7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 26 Apr 2025 08:05:20 +0200 Subject: [PATCH 788/979] Revert "Correct corrected_pulses to 0 when negative" This reverts commit 14c7c3d373d21f1257cca9be99b86c762ac4a898. --- plugwise_usb/nodes/circle.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 11226df77..dde3c5098 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1039,23 +1039,20 @@ def _calc_watts(self, pulses: int, seconds: int, nano_offset: int) -> float | No negative = True pulses_per_s = abs(pulses_per_s) - corrected_pulses = max( - 0, - seconds * ( + corrected_pulses = seconds * ( + ( ( - ( - ((pulses_per_s + self._calibration.off_noise) ** 2) - * self._calibration.gain_b - ) - + ( - (pulses_per_s + self._calibration.off_noise) - * self._calibration.gain_a - ) + ((pulses_per_s + self._calibration.off_noise) ** 2) + * self._calibration.gain_b + ) + + ( + (pulses_per_s + self._calibration.off_noise) + * self._calibration.gain_a ) - + self._calibration.off_tot ) + + self._calibration.off_tot ) - if negative and corrected_pulses != 0: + if negative: corrected_pulses = -corrected_pulses return corrected_pulses / PULSES_PER_KW_SECOND / seconds * (1000) From f7fedb1aea6e3a9320c25b47b7dd7488dec84ea2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:27:41 +0200 Subject: [PATCH 789/979] Update __init__.py --- plugwise_usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 64366b548..4487b8808 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -365,3 +365,4 @@ async def disconnect(self) -> None: if self._network is not None: await self._network.stop() await self._controller.disconnect_from_stick() + From 71a9501811296d4f0ac729f276d923dbc7ff33b1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:32:25 +0200 Subject: [PATCH 790/979] Update connection-files --- plugwise_usb/connection/__init__.py | 6 ++++-- plugwise_usb/connection/queue.py | 2 ++ plugwise_usb/connection/receiver.py | 1 - plugwise_usb/connection/sender.py | 5 +++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index bb2559b3c..beee04725 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -214,7 +214,7 @@ async def get_node_details( self.send, bytes(mac, UTF8), retries=1 ) try: - ping_response = await ping_request.send(suppress_node_errors=True) + ping_response = await ping_request.send() except StickError: return (None, None) if ping_response is None: @@ -230,7 +230,9 @@ async def get_node_details( return (info_response, ping_response) async def send( - self, request: PlugwiseRequest, suppress_node_errors: bool = True + self, + request: PlugwiseRequest, + suppress_node_errors=True, ) -> PlugwiseResponse | None: """Submit request to queue and return response.""" if not suppress_node_errors: diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 4aa075a9f..4dc5f7556 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -90,6 +90,8 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: ) await self._add_request_to_queue(request) try: + if not request.node_response_expected: + return None response: PlugwiseResponse = await request.response_future() return response except (NodeTimeout, StickTimeout) as e: diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 7ea263038..2ca1154a6 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -511,5 +511,4 @@ async def _notify_node_response_subscribers( name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) - # endregion diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 007103a25..3551ea9d6 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -81,7 +81,8 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: serialized_data = request.serialize() _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) - request.start_response_timeout() + if request.node_response_expected: + request.start_response_timeout() # Wait for USB stick to accept request try: @@ -118,7 +119,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._receiver.subscribe_to_stick_responses, self._receiver.subscribe_to_node_responses, ) - _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) + _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning( "USB-Stick directly responded with communication timeout for %s", From 7e9cf84dd33c2b3c7bcf5be6b609071b13833fc9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:41:21 +0200 Subject: [PATCH 791/979] Update messages files --- plugwise_usb/messages/requests.py | 219 +++++++++++------------------ plugwise_usb/messages/responses.py | 2 +- 2 files changed, 87 insertions(+), 134 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index ecebe53b7..7ddc63111 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -68,6 +68,7 @@ def __init__( send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]] | None, mac: bytes | None, + node_response=True, ) -> None: """Initialize request message.""" super().__init__() @@ -104,9 +105,13 @@ def __init__( self._unsubscribe_stick_response: Callable[[], None] | None = None self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None - self._response_future: Future[PlugwiseResponse] = self._loop.create_future() + self._response_future: Future[PlugwiseResponse] | None = None + if node_response: + self._response_future = self._loop.create_future() self._waiting_for_response = False + self.node_response_expected: bool = node_response + def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: @@ -171,12 +176,13 @@ async def subscribe_to_response( self._unsubscribe_stick_response = await stick_subscription_fn( self._process_stick_response, self._seq_id, None ) - self._unsubscribe_node_response = await node_subscription_fn( - self.process_node_response, - self._mac, - (self._reply_identifier,), - self._seq_id, - ) + if self.node_response_expected: + self._unsubscribe_node_response = await node_subscription_fn( + self.process_node_response, + self._mac, + (self._reply_identifier,), + self._seq_id, + ) def _unsubscribe_from_stick(self) -> None: """Unsubscribe from StickResponse messages.""" @@ -221,7 +227,8 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) self._seq_id = None self._unsubscribe_from_stick() - self._unsubscribe_from_node() + if self.node_response_expected: + self._unsubscribe_from_node() if stick_timeout: self._response_future.set_exception( StickTimeout(f"USB-stick responded with time out to {self}") @@ -237,7 +244,8 @@ def assign_error(self, error: BaseException) -> None: """Assign error for this request.""" self.stop_response_timeout() self._unsubscribe_from_stick() - self._unsubscribe_from_node() + if self.node_response_expected: + self._unsubscribe_from_node() if self._response_future.done(): return self._waiting_for_response = False @@ -266,7 +274,8 @@ async def process_node_response(self, response: PlugwiseResponse) -> bool: self._response = copy(response) self.stop_response_timeout() self._unsubscribe_from_stick() - self._unsubscribe_from_node() + if self.node_response_expected: + self._unsubscribe_from_node() if self._send_counter > 1: _LOGGER.debug( "Received %s after %s retries as reply to %s", @@ -303,9 +312,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: self, ) - async def _send_request( - self, suppress_node_errors: bool = False - ) -> PlugwiseResponse | None: + async def _send_request(self, suppress_node_errors=False) -> PlugwiseResponse | None: """Send request.""" if self._send_fn is None: return None @@ -355,11 +362,9 @@ class StickNetworkInfoRequest(PlugwiseRequest): _identifier = b"0001" _reply_identifier = b"0002" - async def send( - self, suppress_node_errors: bool = False - ) -> StickNetworkInfoResponse | None: + async def send(self) -> StickNetworkInfoResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, StickNetworkInfoResponse): return result if result is None: @@ -379,11 +384,9 @@ class CirclePlusConnectRequest(PlugwiseRequest): _identifier = b"0004" _reply_identifier = b"0005" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusConnectResponse | None: + async def send(self) -> CirclePlusConnectResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusConnectResponse): return result if result is None: @@ -409,30 +412,14 @@ def serialize(self) -> bytes: return MESSAGE_HEADER + msg + checksum + MESSAGE_FOOTER -class PlugwiseRequestWithStickResponse(PlugwiseRequest): - """Base class of a plugwise request with a NodeAckResponse.""" - - async def send(self, suppress_node_errors: bool = False) -> StickResponse | None: - """Send request.""" - result = await self._send_request(suppress_node_errors) - if isinstance(result, StickResponse): - return result - if result is None: - return None - raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" - ) - - -class NodeAddRequest(PlugwiseRequestWithStickResponse): +class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : TODO check if response is NodeAckResponse + Response message : (@bouwew) no Response """ _identifier = b"0007" - _reply_identifier = b"0000" #"0005" def __init__( self, @@ -441,10 +428,19 @@ def __init__( accept: bool, ) -> None: """Initialize NodeAddRequest message object.""" - super().__init__(send_fn, mac) + super().__init__(send_fn, mac, node_response=False) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) + async def send(self) -> None: + """Send request.""" + if ( + result := await self._send_request() + ) is not None: + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected no Response" + ) + # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method def serialize(self) -> bytes: @@ -468,7 +464,6 @@ class CirclePlusAllowJoiningRequest(PlugwiseRequest): """ _identifier = b"0008" - _reply_identifier = b"0000" def __init__( self, @@ -480,9 +475,9 @@ def __init__( val = 1 if enable else 0 self._args.append(Int(val, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -516,11 +511,9 @@ def __init__( Int(timeout, length=2), ] - async def send( - self, suppress_node_errors: bool = False - ) -> NodeSpecificResponse | None: + async def send(self) -> NodeSpecificResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeSpecificResponse): return result if result is None: @@ -548,13 +541,11 @@ def __init__( super().__init__(send_fn, None) self._max_retries = 1 - async def send( - self, suppress_node_errors: bool = False - ) -> StickInitResponse | None: + async def send(self) -> StickInitResponse | None: """Send request.""" if self._send_fn is None: raise MessageError("Send function missing") - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, StickInitResponse): return result if result is None: @@ -574,11 +565,9 @@ class NodeImagePrepareRequest(PlugwiseRequest): _identifier = b"000B" _reply_identifier = b"0003" - async def send( - self, suppress_node_errors: bool = False - ) -> NodeSpecificResponse | None: + async def send(self) -> NodeSpecificResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeSpecificResponse): return result if result is None: @@ -598,11 +587,9 @@ class NodeImageValidateRequest(PlugwiseRequest): _identifier = b"000C" _reply_identifier = b"0010" - async def send( - self, suppress_node_errors: bool = False - ) -> NodeImageValidationResponse | None: + async def send(self) -> NodeImageValidationResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeImageValidationResponse): return result if result is None: @@ -633,9 +620,9 @@ def __init__( self._reply_identifier = b"000E" self._max_retries = retries - async def send(self, suppress_node_errors: bool = False) -> NodePingResponse | None: + async def send(self) -> NodePingResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodePingResponse): return result if result is None: @@ -679,11 +666,9 @@ class CirclePowerUsageRequest(PlugwiseRequest): _identifier = b"0012" _reply_identifier = b"0013" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePowerUsageResponse | None: + async def send(self) -> CirclePowerUsageResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePowerUsageResponse): return result if result is None: @@ -733,11 +718,9 @@ def __init__( to_abs = DateTime(end.year, end.month, month_minutes_end) self._args += [from_abs, to_abs] - async def send( - self, suppress_node_errors: bool = False - ) -> CircleLogDataResponse | None: + async def send(self) -> CircleLogDataResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleLogDataResponse): return result if result is None: @@ -757,7 +740,6 @@ class CircleClockSetRequest(PlugwiseRequest): """ _identifier = b"0016" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -792,9 +774,9 @@ def __init__( else: self._args += [this_date, String("FFFFFFFF", 8), this_time, day_of_week] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -812,7 +794,6 @@ class CircleRelaySwitchRequest(PlugwiseRequest): """ _identifier = b"0017" - _reply_identifier = b"0000" def __init__( self, @@ -826,9 +807,9 @@ def __init__( val = 1 if on else 0 self._args.append(Int(val, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -866,11 +847,9 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, network_address={self.network_address})" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusScanResponse | None: + async def send(self) -> CirclePlusScanResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusScanResponse): return result if result is None: @@ -900,11 +879,9 @@ def __init__( super().__init__(send_fn, mac_circle_plus) self._args.append(String(mac_to_unjoined, length=16)) - async def send( - self, suppress_node_errors: bool = False - ) -> NodeRemoveResponse | None: + async def send(self) -> NodeRemoveResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeRemoveResponse): return result if result is None: @@ -934,9 +911,9 @@ def __init__( super().__init__(send_fn, mac) self._max_retries = retries - async def send(self, suppress_node_errors: bool = False) -> NodeInfoResponse | None: + async def send(self) -> NodeInfoResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeInfoResponse): return result if result is None: @@ -956,11 +933,9 @@ class EnergyCalibrationRequest(PlugwiseRequest): _identifier = b"0026" _reply_identifier = b"0027" - async def send( - self, suppress_node_errors: bool = False - ) -> EnergyCalibrationResponse | None: + async def send(self) -> EnergyCalibrationResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, EnergyCalibrationResponse): return result if result is None: @@ -978,7 +953,6 @@ class CirclePlusRealTimeClockSetRequest(PlugwiseRequest): """ _identifier = b"0028" - _reply_identifier = b"0000" def __init__( self, @@ -994,9 +968,9 @@ def __init__( this_date = RealClockDate(dt.day, dt.month, dt.year) self._args += [this_time, day_of_week, this_date] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -1016,11 +990,9 @@ class CirclePlusRealTimeClockGetRequest(PlugwiseRequest): _identifier = b"0029" _reply_identifier = b"003A" - async def send( - self, suppress_node_errors: bool = False - ) -> CirclePlusRealTimeClockResponse | None: + async def send(self) -> CirclePlusRealTimeClockResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CirclePlusRealTimeClockResponse): return result if result is None: @@ -1046,11 +1018,9 @@ class CircleClockGetRequest(PlugwiseRequest): _identifier = b"003E" _reply_identifier = b"003F" - async def send( - self, suppress_node_errors: bool = False - ) -> CircleClockResponse | None: + async def send(self) -> CircleClockResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleClockResponse): return result if result is None: @@ -1068,7 +1038,6 @@ class CircleActivateScheduleRequest(PlugwiseRequest): """ _identifier = b"0040" - _reply_identifier = b"0000" def __init__( self, @@ -1091,7 +1060,6 @@ class NodeAddToGroupRequest(PlugwiseRequest): """ _identifier = b"0045" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -1109,9 +1077,9 @@ def __init__( port_mask_val = String(port_mask, length=16) self._args += [group_mac_val, task_id_val, port_mask_val] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeResponse): return result if result is None: @@ -1128,7 +1096,6 @@ class NodeRemoveFromGroupRequest(PlugwiseRequest): """ _identifier = b"0046" - _reply_identifier = b"0000" def __init__( self, @@ -1149,7 +1116,6 @@ class NodeBroadcastGroupSwitchRequest(PlugwiseRequest): """ _identifier = b"0047" - _reply_identifier = b"0000" def __init__( self, @@ -1188,11 +1154,9 @@ def __repr__(self) -> str: """Convert request into writable str.""" return f"{super().__repr__()[:-1]}, log_address={self._log_address})" - async def send( - self, suppress_node_errors: bool = False - ) -> CircleEnergyLogsResponse | None: + async def send(self) -> CircleEnergyLogsResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleEnergyLogsResponse): return result if result is None: @@ -1209,7 +1173,6 @@ class CircleHandlesOffRequest(PlugwiseRequest): """ _identifier = b"004D" - _reply_identifier = b"0000" class CircleHandlesOnRequest(PlugwiseRequest): @@ -1219,7 +1182,6 @@ class CircleHandlesOnRequest(PlugwiseRequest): """ _identifier = b"004E" - _reply_identifier = b"0000" class NodeSleepConfigRequest(PlugwiseRequest): @@ -1240,7 +1202,6 @@ class NodeSleepConfigRequest(PlugwiseRequest): """ _identifier = b"0050" - _reply_identifier = b"0000" # pylint: disable=too-many-arguments def __init__( @@ -1269,9 +1230,9 @@ def __init__( self.clock_interval_val, ] - async def send(self, suppress_node_errors: bool = False) -> NodeResponse | None: + async def send(self) -> NodeResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() _LOGGER.warning("NodeSleepConfigRequest result: %s", result) if isinstance(result, NodeResponse): return result @@ -1298,7 +1259,6 @@ class NodeSelfRemoveRequest(PlugwiseRequest): """ _identifier = b"0051" - _reply_identifier = b"0000" class CircleMeasureIntervalRequest(PlugwiseRequest): @@ -1310,7 +1270,6 @@ class CircleMeasureIntervalRequest(PlugwiseRequest): """ _identifier = b"0057" - _reply_identifier = b"0000" def __init__( self, @@ -1332,7 +1291,6 @@ class NodeClearGroupMacRequest(PlugwiseRequest): """ _identifier = b"0058" - _reply_identifier = b"0000" def __init__( self, @@ -1352,7 +1310,6 @@ class CircleSetScheduleValueRequest(PlugwiseRequest): """ _identifier = b"0059" - _reply_identifier = b"0000" def __init__( self, @@ -1384,11 +1341,9 @@ def __init__( super().__init__(send_fn, mac) self._args.append(SInt(val, length=4)) - async def send( - self, suppress_node_errors: bool = False - ) -> NodeFeaturesResponse | None: + async def send(self) -> NodeFeaturesResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeFeaturesResponse): return result if result is None: @@ -1436,9 +1391,9 @@ def __init__( reset_timer_value, ] - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1457,9 +1412,9 @@ class ScanLightCalibrateRequest(PlugwiseRequest): _identifier = b"0102" _reply_identifier = b"0100" - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1490,9 +1445,9 @@ def __init__( super().__init__(send_fn, mac) self._args.append(Int(interval, length=2)) - async def send(self, suppress_node_errors: bool = False) -> NodeAckResponse | None: + async def send(self) -> NodeAckResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, NodeAckResponse): return result if result is None: @@ -1526,11 +1481,9 @@ def __init__( self.relay = Int(1 if relay_state else 0, length=2) self._args += [self.set_or_get, self.relay] - async def send( - self, suppress_node_errors: bool = False - ) -> CircleRelayInitStateResponse | None: + async def send(self) -> CircleRelayInitStateResponse | None: """Send request.""" - result = await self._send_request(suppress_node_errors) + result = await self._send_request() if isinstance(result, CircleRelayInitStateResponse): return result if result is None: diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 50dedbccc..ed9e7310f 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -56,6 +56,7 @@ class StickResponseType(bytes, Enum): class NodeResponseType(bytes, Enum): """Response types of a 'NodeResponse' reply message.""" + CIRCLE_PLUS = b"00DD" CLOCK_ACCEPTED = b"00D7" JOIN_ACCEPTED = b"00D9" RELAY_SWITCHED_OFF = b"00DE" @@ -69,7 +70,6 @@ class NodeResponseType(bytes, Enum): SED_CONFIG_FAILED = b"00F7" POWER_LOG_INTERVAL_ACCEPTED = b"00F8" POWER_CALIBRATION_ACCEPTED = b"00DA" - CIRCLE_PLUS = b"00DD" class NodeAckResponseType(bytes, Enum): From 3059b5052abd81346bdc5e5ce789bbbabcd69cc8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:43:36 +0200 Subject: [PATCH 792/979] Update network files --- plugwise_usb/network/__init__.py | 12 ++++++------ plugwise_usb/network/registry.py | 13 ++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index d720f39a3..92f6b4609 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -14,7 +14,6 @@ from ..connection import StickController from ..constants import UTF8 from ..exceptions import CacheError, MessageError, NodeError, StickError, StickTimeout -from ..helpers.util import validate_mac from ..messages.requests import CirclePlusAllowJoiningRequest, NodePingRequest from ..messages.responses import ( NODE_AWAKE_RESPONSE_ID, @@ -149,8 +148,6 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" - if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to register") address = await self._register.register_node(mac) return await self._discover_node(address, mac, None) @@ -261,7 +258,6 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeRejoinResponse" ) mac = response.mac_decoded - address = self._register.network_address(mac) if (address := self._register.network_address(mac)) is not None: if self._nodes.get(mac) is None: if self._discover_sed_tasks.get(mac) is None: @@ -513,11 +509,14 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" + _LOGGER.debug("Send AllowJoiningRequest to Circle+ with state=%s", state) request = CirclePlusAllowJoiningRequest(self._controller.send, state) if (response := await request.send()) is None: - raise NodeError("No response to get notifications for join request.") + raise NodeError("No response for CirclePlusAllowJoiningRequest.") - if response.response_type != NodeResponseType.JOIN_ACCEPTED: + if response.response_type not in ( + NodeResponseType.JOIN_ACCEPTED, NodeResponseType.CIRCLE_PLUS + ): raise MessageError( f"Unknown NodeResponseType '{response.response_type.name}' received" ) @@ -553,3 +552,4 @@ async def _notify_node_event_subscribers(self, event: NodeEvent, mac: str) -> No callback_list.append(callback(event, mac)) if len(callback_list) > 0: await gather(*callback_list) + diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 70f478298..48a93ca5e 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import NodeResponseType, PlugwiseResponse +from ..messages.responses import PlugwiseResponse #, StickResponseType from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) @@ -246,12 +246,12 @@ async def save_registry_to_cache(self) -> None: async def register_node(self, mac: str) -> int: """Register node to Plugwise network and return network address.""" if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to register") + raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - response = await request.send() - if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: - raise NodeError(f"Failed to register node {mac}") + await request.send() + # if response is None or response.ack_id != StickResponseType.ACCEPT: + # raise NodeError(f"Failed to register node {mac}") self.update_network_registration(self._first_free_address, mac, None) self._first_free_address += 1 return self._first_free_address - 1 @@ -259,7 +259,7 @@ async def register_node(self, mac: str) -> int: async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): - raise NodeError(f"Invalid mac '{mac}' to unregister") + raise NodeError(f"MAC '{mac}' invalid") mac_registered = False for registration in self._registry.values(): @@ -270,7 +270,6 @@ async def unregister_node(self, mac: str) -> None: raise NodeError(f"No existing registration '{mac}' found to unregister") request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) - response = await request.send() if (response := await request.send()) is None: raise NodeError( f"The Zigbee network coordinator '{self._mac_nc!r}'" From 4e10f93e591d5fe872c57cbdbfd5bc26f2c5ee02 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:49:39 +0200 Subject: [PATCH 793/979] Update node-helper files --- plugwise_usb/nodes/helpers/counter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index a8d08a8ea..5026ec494 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -316,4 +316,3 @@ def update( energy = self.energy _LOGGER.debug("energy=%s on last_update=%s", energy, last_update) return (energy, last_reset) - From 5587db05349c82dca6cbc0dfce9c7cefe7da0cea Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 09:51:33 +0200 Subject: [PATCH 794/979] Update test-files --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index c35dfdb1f..64a96b9da 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -252,7 +252,7 @@ def dummy_method() -> None: async def send( self, request: pw_requests.PlugwiseRequest, # type: ignore[name-defined] - suppress_node_errors: bool = True, + suppress_node_errors=True, ) -> pw_responses.PlugwiseResponse | None: # type: ignore[name-defined] """Submit request to queue and return response.""" return self.send_response From 007712f716df50bfb15687915696c228bc5d972a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:03:01 +0200 Subject: [PATCH 795/979] Fix formatting --- plugwise_usb/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 4487b8808..64366b548 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -365,4 +365,3 @@ async def disconnect(self) -> None: if self._network is not None: await self._network.stop() await self._controller.disconnect_from_stick() - From 1d4c5907c1c4ea3ec46c0f80d5cc480b98c9b45e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:07:16 +0200 Subject: [PATCH 796/979] Fix double logging --- plugwise_usb/network/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 92f6b4609..96eac8969 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -509,7 +509,6 @@ async def stop(self) -> None: async def allow_join_requests(self, state: bool) -> None: """Enable or disable Plugwise network.""" - _LOGGER.debug("Send AllowJoiningRequest to Circle+ with state=%s", state) request = CirclePlusAllowJoiningRequest(self._controller.send, state) if (response := await request.send()) is None: raise NodeError("No response for CirclePlusAllowJoiningRequest.") @@ -521,7 +520,7 @@ async def allow_join_requests(self, state: bool) -> None: f"Unknown NodeResponseType '{response.response_type.name}' received" ) - _LOGGER.debug("Send AllowJoiningRequest to Circle+ with state=%s", state) + _LOGGER.debug("Sent AllowJoiningRequest to Circle+ with state=%s", state) def subscribe_to_node_events( self, From 9a6855eebb69d32bb524b4987b91f205ad5f03c5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:08:30 +0200 Subject: [PATCH 797/979] Clean up --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 48a93ca5e..b2bf17301 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import PlugwiseResponse #, StickResponseType +from ..messages.responses import PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) From 426dcd88288de09001f729953043735a3a63a8f5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:16:10 +0200 Subject: [PATCH 798/979] Update network-register_node fault handling --- plugwise_usb/network/registry.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index b2bf17301..362f32da4 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -9,7 +9,7 @@ from ..api import NodeType from ..constants import UTF8 -from ..exceptions import CacheError, NodeError +from ..exceptions import CacheError, MessageError, NodeError from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, @@ -249,9 +249,11 @@ async def register_node(self, mac: str) -> int: raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - await request.send() - # if response is None or response.ack_id != StickResponseType.ACCEPT: - # raise NodeError(f"Failed to register node {mac}") + try: + await request.send() + except MessageError as exc: + raise MessageError(f"Failed to register Node with {mac}") from exc + self.update_network_registration(self._first_free_address, mac, None) self._first_free_address += 1 return self._first_free_address - 1 From 9b8ef1e40707bdcd772a9e07fe21c58f874ffdcb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:18:13 +0200 Subject: [PATCH 799/979] Back to normal test output --- scripts/tests_and_coverage.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 72753aaef..884a5221b 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -23,8 +23,7 @@ set +u if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "test_and_coverage" ] ; then # Python tests (rerun with debug if failures) - #PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || - PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ + PYTHONPATH=$(pwd) pytest -qx tests/ --cov='.' --no-cov-on-fail --cov-report term-missing || PYTHONPATH=$(pwd) pytest -xrpP --log-level debug tests/ fi if [ -z "${GITHUB_ACTIONS}" ] || [ "$1" == "linting" ] ; then From fa92146eeea5916572666ae6bd6a91f8ade5486a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 10:21:12 +0200 Subject: [PATCH 800/979] Set to latest a-version on testpypi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8d7b82e02..beeaaa955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a99" +version = "v0.40.0a111" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 4299d195895e11a953050f4eb860e9f17421e51c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 11:29:02 +0200 Subject: [PATCH 801/979] Revert back to standard uses: actions notation --- .github/workflows/merge.yml | 4 +-- .github/workflows/verify.yml | 68 ++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index efdb23047..86aef558e 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -23,10 +23,10 @@ jobs: if: github.event.pull_request.merged == true steps: - name: Check out committed code - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install pypa/build diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ecb8a0ed2..41d8b0539 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -22,15 +22,15 @@ jobs: name: Prepare steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -52,7 +52,7 @@ jobs: pip install -r requirements_test.txt -r requirements_commit.txt - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -71,17 +71,17 @@ jobs: needs: prepare steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -124,15 +124,15 @@ jobs: - dependencies_check steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -147,7 +147,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: ${{ env.PRE_COMMIT_HOME }} key: | @@ -175,15 +175,15 @@ jobs: python-version: ["3.13"] steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -215,15 +215,15 @@ jobs: steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -240,7 +240,7 @@ jobs: . venv/bin/activate pytest --log-level info tests/*.py --cov='.' - name: Upload coverage artifact - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} path: .coverage @@ -253,17 +253,17 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master @@ -303,7 +303,7 @@ jobs: name: Dependency steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Run dependency checker run: scripts/dependencies_check.sh debug @@ -313,15 +313,15 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -335,7 +335,7 @@ jobs: echo "Failed to restore Python virtual environment from cache" exit 1 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.1 + uses: actions/download-artifact@v4 - name: Combine coverage results run: | . venv/bin/activate @@ -348,7 +348,7 @@ jobs: echo "***" coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -358,15 +358,15 @@ jobs: needs: [coverage, mypy] steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- @@ -401,15 +401,15 @@ jobs: needs: coverage steps: - name: Check out committed code - uses: actions/checkout@4.2.2 + uses: actions/checkout@4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4 with: path: venv key: >- From 7770fe86e6d82dbb6818a8bec1342e9d30017f67 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 12:53:31 +0200 Subject: [PATCH 802/979] Fix typo --- .github/workflows/verify.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 41d8b0539..7125fdf31 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -22,7 +22,7 @@ jobs: name: Prepare steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -71,7 +71,7 @@ jobs: needs: prepare steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - dependencies_check steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -175,7 +175,7 @@ jobs: python-version: ["3.13"] steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -215,7 +215,7 @@ jobs: steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5 @@ -253,7 +253,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master @@ -303,7 +303,7 @@ jobs: name: Dependency steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Run dependency checker run: scripts/dependencies_check.sh debug @@ -313,7 +313,7 @@ jobs: needs: pytest steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -358,7 +358,7 @@ jobs: needs: [coverage, mypy] steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 @@ -401,7 +401,7 @@ jobs: needs: coverage steps: - name: Check out committed code - uses: actions/checkout@4 + uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5 From fdc55238a62bde2606c3a18687a72785aeab9fb5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 13:04:46 +0200 Subject: [PATCH 803/979] Improve queue - submit() --- plugwise_usb/connection/queue.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 4dc5f7556..51a0ee0a0 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -75,48 +75,52 @@ async def stop(self) -> None: _LOGGER.debug("queue stopped") async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: - """Add request to queue and return the response of node. Raises an error when something fails.""" + """Add request to queue and return the received node-response when applicable. + + Raises an error when something fails. + """ if request.waiting_for_response: raise MessageError( f"Cannot send message {request} which is currently waiting for response." ) - while request.resend and not request.waiting_for_response: + while request.resend: _LOGGER.debug("submit | start (%s) %s", request.retries_left, request) if not self._running or self._stick is None: raise StickError( f"Cannot send message {request.__class__.__name__} for" + f"{request.mac_decoded} because queue manager is stopped" ) + await self._add_request_to_queue(request) try: if not request.node_response_expected: return None response: PlugwiseResponse = await request.response_future() return response - except (NodeTimeout, StickTimeout) as e: + except (NodeTimeout, StickTimeout) as exc: if isinstance(request, NodePingRequest): # For ping requests it is expected to receive timeouts, so lower log level _LOGGER.debug( - "%s, cancel because timeout is expected for NodePingRequests", e + "%s, cancel because timeout is expected for NodePingRequests", exc ) elif request.resend: - _LOGGER.debug("%s, retrying", e) + _LOGGER.debug("%s, retrying", exc) else: - _LOGGER.warning("%s, cancel request", e) # type: ignore[unreachable] - except StickError as exception: - _LOGGER.error(exception) + _LOGGER.warning("%s, cancel request", exc) # type: ignore[unreachable] + except StickError as exc: + _LOGGER.error(exc) self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" - ) from exception - except BaseException as exception: + ) from exc + except BaseException as exc: self._stick.correct_received_messages(1) raise StickError( f"No response received for {request.__class__.__name__} " + f"to {request.mac_decoded}" - ) from exception + ) from exc return None From 38cc0732f252dc09f92b98fab04f51518242dae5 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 1 May 2025 13:25:26 +0200 Subject: [PATCH 804/979] Bump to a112 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index beeaaa955..fc8dbcf09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a111" +version = "v0.40.0a112" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 620e42bc28f9c86c0579dff77d9a0a34ed5205dc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 2 May 2025 20:11:43 +0200 Subject: [PATCH 805/979] Add CirclePlusAllowJoiningRequest with response to RESPONSE_MESSAGES --- tests/stick_test_data.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 8c7c57293..6e40a7377 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -64,6 +64,12 @@ + b"4E0843A9" # fw_ver + b"01", # node_type (Circle+) ), + b"\x05\x05\x03\x030008014068\r\n":( + " reply to CirclePlusAllowJoiningRequest", + b"000000C1", # Success ack + b"000000D9" # JOIN_ACCEPTED + + b"0098765432101234", # mac + ), b"\x05\x05\x03\x03000D0098765432101234C208\r\n": ( "ping reply for 0098765432101234", b"000000C1", # Success ack From 75c9efa40ebf927b127427f1c57e7f8f622e6273 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 2 May 2025 19:06:49 +0200 Subject: [PATCH 806/979] Alternative method of getting/setting accept_joiN_request Remove get_ Fix --- plugwise_usb/__init__.py | 8 ++++---- tests/test_usb.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 64366b548..473147611 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -190,8 +190,7 @@ def accept_join_request(self) -> bool | None: return None return self._network.accept_join_request - @accept_join_request.setter - def accept_join_request(self, state: bool) -> None: + async def set_accept_join_request(self, state: bool) -> None: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( @@ -205,8 +204,9 @@ def accept_join_request(self, state: bool) -> None: + "without node discovery be activated. Call discover() first." ) - self._network.accept_join_request = state - _ = create_task(self._network.allow_join_requests(state)) + if self._network.accept_join_request != state: + self._network.accept_join_request = state + await self._network.allow_join_requests(state) async def clear_cache(self) -> None: """Clear current cache.""" diff --git a/tests/test_usb.py b/tests/test_usb.py index 64a96b9da..d097d5513 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -469,7 +469,7 @@ async def test_stick_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: assert stick.accept_join_request is None # test failing of join requests without active discovery with pytest.raises(pw_exceptions.StickError): - stick.accept_join_request = True + await stick.set_accept_join_request(True) unsub_connect() await stick.disconnect() assert not stick.network_state @@ -572,7 +572,7 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - stick.accept_join_request = True + await stick.set_accept_join_request(True) self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, From 305d4b7688fa053dd557d8f52a63ba5051d80ef0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 10:28:51 +0200 Subject: [PATCH 807/979] Test: move unneeded line --- tests/test_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d097d5513..d81212eb7 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -572,7 +572,6 @@ async def test_stick_node_discovered_subscription( await stick.connect() await stick.initialize() await stick.discover_nodes(load=False) - await stick.set_accept_join_request(True) self.test_node_awake = asyncio.Future() unsub_awake = stick.subscribe_to_node_events( node_event_callback=self.node_awake, @@ -671,6 +670,7 @@ async def test_stick_node_discovered_subscription( # await stick.connect() # await stick.initialize() # await stick.discover_nodes(load=False) +# await stick.set_accept_join_request(True) # self.test_node_join = asyncio.Future() # unusb_join = stick.subscribe_to_node_events( # node_event_callback=self.node_join, From 0099e730596fe5a1f1aa2b3e14cbce453ad38cea Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 10:29:57 +0200 Subject: [PATCH 808/979] Bump to a113 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fc8dbcf09..753558e43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a112" +version = "v0.40.0a113" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", From 528f88f31c21a81dddf491cae3c55b57c727678c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 10:39:31 +0200 Subject: [PATCH 809/979] Update pyproject.toml - fix license --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 753558e43..d100dc4c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" version = "v0.40.0a113" +license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", From e2a3404b8d55967c10bbf39f68706eaddba2c3ad Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 10:46:43 +0200 Subject: [PATCH 810/979] Clean up packaging config --- pyproject.toml | 4 +--- requirements_commit.txt | 2 +- requirements_test.txt | 2 +- setup.cfg | 5 ----- setup.py | 5 ----- 5 files changed, 3 insertions(+), 15 deletions(-) delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index d100dc4c7..07d757bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=80.0", "wheel~=0.45.0"] +requires = ["setuptools~=80.0"] build-backend = "setuptools.build_meta" [project] @@ -37,7 +37,6 @@ dependencies = [ "Bug Reports" = "https://github.com/plugwise/python-plugwise-usb/issues" [tool.setuptools] -platforms = ["any"] include-package-data = true [tool.setuptools.package-data] @@ -212,7 +211,6 @@ exclude = [] source = [ "plugwise" ] omit= [ "*/venv/*", - "setup.py", ] [tool.ruff] diff --git a/requirements_commit.txt b/requirements_commit.txt index f8f9b05df..703b892eb 100644 --- a/requirements_commit.txt +++ b/requirements_commit.txt @@ -1,4 +1,4 @@ -# Import from setup.py +# Import from project.dependencies -e . # Minimal requirements for committing # Versioning omitted (leave this up to HA-core) diff --git a/requirements_test.txt b/requirements_test.txt index 37a5e6d77..2b52f7418 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -# Import from setup.py +# Import from project.dependencies -e . # Versioning omitted (leave this up to HA-core) pytest-asyncio diff --git a/setup.cfg b/setup.cfg index e71dcd50c..2c3d87f13 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,4 @@ -# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). # Added Codespell since pre-commit doesn't process args correctly (and python3.11 and toml prevent using pyproject.toml) check #277 (/#278) for details -# Keep this file until it does! - -[metadata] -url = https://github.com/plugwise/python-plugwise-usb [codespell] # Most of the ignores from HA-Core upstream diff --git a/setup.py b/setup.py deleted file mode 100644 index ee7d67fe5..000000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Plugwise USB module setup.""" - -from setuptools import setup - -setup() From 4015c484b97c8c3bcb460007bcaa6ce1f6bd7283 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:06:13 +0200 Subject: [PATCH 811/979] Bump verify CACHE_VERSION --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 7125fdf31..4f93489f5 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,7 +4,7 @@ name: Latest commit env: - CACHE_VERSION: 22 + CACHE_VERSION: 23 DEFAULT_PYTHON: "3.13" PRE_COMMIT_HOME: ~/.cache/pre-commit From 4d2c54a1e03ac770573734efea1aa4ee91e18869 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:17:58 +0200 Subject: [PATCH 812/979] Update CODEOWNERS --- CODEOWNERS | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 953f4907d..ffdf2d7d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,13 +3,11 @@ # Specific files setup.cfg @plugwise/plugwise-usb -setup.py @plugwise/plugwise-usb pyproject.toml @plugwise/plugwise-usb requirements*.txt @plugwise/plugwise-usb # Main code -/plugwise/ @plugwise/plugwise-usb -/userdata/ @plugwise/plugwise-usb +/plugwise_usb/ @plugwise/plugwise-usb # Tests and development support /tests/ @plugwise/plugwise-usb From 2a6379986ce3b4a51bd6dcb71ae3d72c19c07427 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:25:06 +0200 Subject: [PATCH 813/979] pyproject corrections --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 07d757bcf..f5dc0914b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ include-package-data = true "plugwise" = ["py.typed"] [tool.setuptools.packages.find] -include = ["plugwise*"] +include = ["plugwise_usb*"] [tool.black] target-version = ["py313"] @@ -208,7 +208,7 @@ warn_unreachable = true exclude = [] [tool.coverage.run] -source = [ "plugwise" ] +source = [ "plugwise_usb" ] omit= [ "*/venv/*", ] From 94ba2365536a0216dd529e6d0318f86828bbf8fe Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:29:49 +0200 Subject: [PATCH 814/979] Set setuptools to v80.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f5dc0914b..fc040a76d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=80.0"] +requires = ["setuptools~=80.3"] build-backend = "setuptools.build_meta" [project] From 24aa452ad4a69404cbcc1f9f24d1c01fada48818 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:34:57 +0200 Subject: [PATCH 815/979] Try v80.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fc040a76d..6d3e80139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=80.3"] +requires = ["setuptools~=80.2.0"] build-backend = "setuptools.build_meta" [project] From d050dbba96527a47b8c3848bade159b45ae665eb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:40:07 +0200 Subject: [PATCH 816/979] Hard-fix to v80.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6d3e80139..6f34b9dc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools~=80.2.0"] +requires = ["setuptools==80.2.0"] build-backend = "setuptools.build_meta" [project] From a524e8cf84cd4134bd8f2cb676619e4feb0f57dd Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 13:42:23 +0200 Subject: [PATCH 817/979] Bump CACHE_VERSION --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4f93489f5..34b2dd675 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,7 +4,7 @@ name: Latest commit env: - CACHE_VERSION: 23 + CACHE_VERSION: 24 DEFAULT_PYTHON: "3.13" PRE_COMMIT_HOME: ~/.cache/pre-commit From bc0185b8d36bb0941d4176e5546c3fd4e112fe6d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 4 May 2025 17:03:40 +0200 Subject: [PATCH 818/979] Force update --- tests/test_usb.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index d81212eb7..5bb93778e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -642,17 +642,17 @@ async def test_stick_node_discovered_subscription( await stick.disconnect() -# async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] -# """Handle join event callback.""" -# if event == pw_api.NodeEvent.JOIN: -# self.test_node_join.set_result(mac) -# else: -# self.test_node_join.set_exception( -# BaseException( -# f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.JOIN}" -# ) -# ) -# + async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: ignore[name-defined] + """Handle join event callback.""" + if event == pw_api.NodeEvent.JOIN: + self.test_node_join.set_result(mac) + else: + self.test_node_join.set_exception( + BaseException( + f"Invalid {event} event, expected " + f"{pw_api.NodeEvent.JOIN}" + ) + ) + # @pytest.mark.asyncio # async def test_stick_node_join_subscription( # self, monkeypatch: pytest.MonkeyPatch From 5c5dc1dd404605f02960aad2524f38a13c7f52d2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 09:32:13 +0200 Subject: [PATCH 819/979] Bump setuptools to v80.3.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6f34b9dc9..8c83a4569 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.2.0"] +requires = ["setuptools==80.3.1"] build-backend = "setuptools.build_meta" [project] From c700ff876da95f2dbbc531b7219ceb260788ef08 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 11:34:56 +0200 Subject: [PATCH 820/979] Guard subscribing to NODE_JOIN_ID --- plugwise_usb/network/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 96eac8969..701820c39 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -177,9 +177,10 @@ async def _subscribe_to_node_events(self) -> None: (NODE_AWAKE_RESPONSE_ID,), None, ) - self._unsubscribe_node_join = await self._controller.subscribe_to_messages( - self.node_join_available_message, None, (NODE_JOIN_ID,), None - ) + if self.accept_join_request: + self._unsubscribe_node_join = await self._controller.subscribe_to_messages( + self.node_join_available_message, None, (NODE_JOIN_ID,), None + ) self._unsubscribe_node_rejoin = await self._controller.subscribe_to_messages( self.node_rejoin_message, None, (NODE_REJOIN_ID,), None ) From f8f1252211823fd42a016ed5eafdbbdc61fff5c6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 11:37:06 +0200 Subject: [PATCH 821/979] Clean up --- plugwise_usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 473147611..3f970a085 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from asyncio import create_task, get_running_loop +from asyncio import get_running_loop from collections.abc import Callable, Coroutine from functools import wraps import logging From 403d50358828e5f7c0b2b0c517578aa67f052179 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 11:50:05 +0200 Subject: [PATCH 822/979] Fix subscription-guarding --- plugwise_usb/network/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 701820c39..6d0eb6da5 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -177,10 +177,9 @@ async def _subscribe_to_node_events(self) -> None: (NODE_AWAKE_RESPONSE_ID,), None, ) - if self.accept_join_request: - self._unsubscribe_node_join = await self._controller.subscribe_to_messages( - self.node_join_available_message, None, (NODE_JOIN_ID,), None - ) + self._unsubscribe_node_join = await self._controller.subscribe_to_messages( + self.node_join_available_message, None, (NODE_JOIN_ID,), None + ) self._unsubscribe_node_rejoin = await self._controller.subscribe_to_messages( self.node_rejoin_message, None, (NODE_REJOIN_ID,), None ) @@ -245,7 +244,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - if await self.register_node(mac): + if self.accept_join_request: await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True From 5b1b533addaabad7309664c0bc9b0e8bfbbf7012 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 12:06:51 +0200 Subject: [PATCH 823/979] Re-enable test_stick_node_join_subscription test case --- tests/test_usb.py | 60 +++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 5bb93778e..ba0c4d1e6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -653,36 +653,36 @@ async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: i ) ) -# @pytest.mark.asyncio -# async def test_stick_node_join_subscription( -# self, monkeypatch: pytest.MonkeyPatch -# ) -> None: -# """Testing "new_node" subscription.""" -# mock_serial = MockSerial(None) -# monkeypatch.setattr( -# pw_connection_manager, -# "create_serial_connection", -# mock_serial.mock_connection, -# ) -# monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) -# monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) -# stick = pw_stick.Stick("test_port", cache_enabled=False) -# await stick.connect() -# await stick.initialize() -# await stick.discover_nodes(load=False) -# await stick.set_accept_join_request(True) -# self.test_node_join = asyncio.Future() -# unusb_join = stick.subscribe_to_node_events( -# node_event_callback=self.node_join, -# events=(pw_api.NodeEvent.JOIN,), -# ) -# -# # Inject node join request message -# mock_serial.inject_message(b"00069999999999999999", b"FFFC") -# mac_join_node = await self.test_node_join -# assert mac_join_node == "9999999999999999" -# unusb_join() -# await stick.disconnect() + @pytest.mark.asyncio + async def test_stick_node_join_subscription( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Testing "new_node" subscription.""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + await stick.set_accept_join_request(True) + self.test_node_join = asyncio.Future() + unusb_join = stick.subscribe_to_node_events( + node_event_callback=self.node_join, + events=(pw_api.NodeEvent.JOIN,), + ) + + # Inject node join request message + mock_serial.inject_message(b"00069999999999999999", b"FFFC") + mac_join_node = await self.test_node_join + assert mac_join_node == "9999999999999999" + unusb_join() + await stick.disconnect() @pytest.mark.asyncio async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: From fa7549853d2ff66ed2a87f209f1837428a3e13fe Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 12:20:58 +0200 Subject: [PATCH 824/979] Re-fix guarding --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 6d0eb6da5..9f8386f78 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -244,7 +244,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - if self.accept_join_request: + if self.accept_join_request and await self.register_node(mac): await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True From 24ef2df354cd11427a4aed7c72aa1c1341186145 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 12:30:47 +0200 Subject: [PATCH 825/979] Revert "Re-enable test_stick_node_join_subscription test case" This reverts commit f272e62c82e48b3eef2a48082ece38b8092171c5. --- tests/test_usb.py | 60 +++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ba0c4d1e6..5bb93778e 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -653,36 +653,36 @@ async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: i ) ) - @pytest.mark.asyncio - async def test_stick_node_join_subscription( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Testing "new_node" subscription.""" - mock_serial = MockSerial(None) - monkeypatch.setattr( - pw_connection_manager, - "create_serial_connection", - mock_serial.mock_connection, - ) - monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) - monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) - stick = pw_stick.Stick("test_port", cache_enabled=False) - await stick.connect() - await stick.initialize() - await stick.discover_nodes(load=False) - await stick.set_accept_join_request(True) - self.test_node_join = asyncio.Future() - unusb_join = stick.subscribe_to_node_events( - node_event_callback=self.node_join, - events=(pw_api.NodeEvent.JOIN,), - ) - - # Inject node join request message - mock_serial.inject_message(b"00069999999999999999", b"FFFC") - mac_join_node = await self.test_node_join - assert mac_join_node == "9999999999999999" - unusb_join() - await stick.disconnect() +# @pytest.mark.asyncio +# async def test_stick_node_join_subscription( +# self, monkeypatch: pytest.MonkeyPatch +# ) -> None: +# """Testing "new_node" subscription.""" +# mock_serial = MockSerial(None) +# monkeypatch.setattr( +# pw_connection_manager, +# "create_serial_connection", +# mock_serial.mock_connection, +# ) +# monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) +# monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) +# stick = pw_stick.Stick("test_port", cache_enabled=False) +# await stick.connect() +# await stick.initialize() +# await stick.discover_nodes(load=False) +# await stick.set_accept_join_request(True) +# self.test_node_join = asyncio.Future() +# unusb_join = stick.subscribe_to_node_events( +# node_event_callback=self.node_join, +# events=(pw_api.NodeEvent.JOIN,), +# ) +# +# # Inject node join request message +# mock_serial.inject_message(b"00069999999999999999", b"FFFC") +# mac_join_node = await self.test_node_join +# assert mac_join_node == "9999999999999999" +# unusb_join() +# await stick.disconnect() @pytest.mark.asyncio async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: From d85c23f2f50b28a8bb2d0cecdd7a738adc9be3e4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 5 May 2025 12:44:30 +0200 Subject: [PATCH 826/979] Improve network.register_node() --- plugwise_usb/network/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 9f8386f78..45d9ca22d 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -148,8 +148,10 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" - address = await self._register.register_node(mac) - return await self._discover_node(address, mac, None) + if (address := await self._register.register_node(mac)): + return await self._discover_node(address, mac, None) + + return False async def clear_cache(self) -> None: """Clear register cache.""" From 38d693daac029840af40c9946e646d21530c4898 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 08:13:46 +0200 Subject: [PATCH 827/979] Add NodeAddRequest with NodeJoinAckResponse --- tests/stick_test_data.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index 6e40a7377..fc8a54dc4 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -65,11 +65,18 @@ + b"01", # node_type (Circle+) ), b"\x05\x05\x03\x030008014068\r\n":( - " reply to CirclePlusAllowJoiningRequest", + "Reply to CirclePlusAllowJoiningRequest", b"000000C1", # Success ack b"000000D9" # JOIN_ACCEPTED + b"0098765432101234", # mac ), + b"\x05\x05\x03\x030007019999999999999999\r\n":( + "Reply to NodeAddRequest", + b"000000C1", # Success ack + b"0061" # NODE_REJOIN_ID + + b"FFFD" # REJOIN_RESPONSE_SEQ_ID + + b"9999999999999999", # mac + ), b"\x05\x05\x03\x03000D0098765432101234C208\r\n": ( "ping reply for 0098765432101234", b"000000C1", # Success ack From 59a68079ca87223c598c94d5c6fbb9a48d2151e8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 08:18:41 +0200 Subject: [PATCH 828/979] Disable no_response_expected for NodeAddRequest --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 7ddc63111..e55ff65d7 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -428,7 +428,7 @@ def __init__( accept: bool, ) -> None: """Initialize NodeAddRequest message object.""" - super().__init__(send_fn, mac, node_response=False) + super().__init__(send_fn, mac) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) From 913d9e4e9a9f311383d213021c0b6bc9020a57cb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 08:25:21 +0200 Subject: [PATCH 829/979] Add back response-code to register_node() --- plugwise_usb/network/registry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 362f32da4..6d44fda36 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -250,7 +250,10 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: - await request.send() + response = await request.send() + _LOGGER.debug("NodeAddReq response: %s, %s", response.name, response.ack_id) + if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: + raise NodeError(f"Failed to register node {mac}") except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From 34c6cbe5c289be656564351417f00be26a9d10ea Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 08:29:33 +0200 Subject: [PATCH 830/979] Revert part of node_join_available_message() changes --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 45d9ca22d..ac63fca94 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -246,7 +246,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - if self.accept_join_request and await self.register_node(mac): + if self.accept_join_request: await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True From ecf131e6677a4d4a7acad11bc639ea304963c077 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 12:08:24 +0200 Subject: [PATCH 831/979] Add NODE_REJOIN_ID indentifier and corresponding response --- plugwise_usb/messages/responses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index ed9e7310f..22ea1b5da 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -1018,6 +1018,8 @@ def get_message_object( # noqa: C901 return NodeSwitchGroupResponse() if identifier == b"0060": return NodeFeaturesResponse() + if identifier == NODE_REJOIN_ID: + return NodeRejoinResponse() if identifier == b"0100": return NodeAckResponse() if identifier == SENSE_REPORT_ID: From 79a127669112602e895db03a6b0fd6c590f5fdfc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 15:05:16 +0200 Subject: [PATCH 832/979] Enable test_stick_node_join_subscription test case --- tests/test_usb.py | 60 +++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index 5bb93778e..ba0c4d1e6 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -653,36 +653,36 @@ async def node_join(self, event: pw_api.NodeEvent, mac: str) -> None: # type: i ) ) -# @pytest.mark.asyncio -# async def test_stick_node_join_subscription( -# self, monkeypatch: pytest.MonkeyPatch -# ) -> None: -# """Testing "new_node" subscription.""" -# mock_serial = MockSerial(None) -# monkeypatch.setattr( -# pw_connection_manager, -# "create_serial_connection", -# mock_serial.mock_connection, -# ) -# monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) -# monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) -# stick = pw_stick.Stick("test_port", cache_enabled=False) -# await stick.connect() -# await stick.initialize() -# await stick.discover_nodes(load=False) -# await stick.set_accept_join_request(True) -# self.test_node_join = asyncio.Future() -# unusb_join = stick.subscribe_to_node_events( -# node_event_callback=self.node_join, -# events=(pw_api.NodeEvent.JOIN,), -# ) -# -# # Inject node join request message -# mock_serial.inject_message(b"00069999999999999999", b"FFFC") -# mac_join_node = await self.test_node_join -# assert mac_join_node == "9999999999999999" -# unusb_join() -# await stick.disconnect() + @pytest.mark.asyncio + async def test_stick_node_join_subscription( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Testing "new_node" subscription.""" + mock_serial = MockSerial(None) + monkeypatch.setattr( + pw_connection_manager, + "create_serial_connection", + mock_serial.mock_connection, + ) + monkeypatch.setattr(pw_sender, "STICK_TIME_OUT", 0.1) + monkeypatch.setattr(pw_requests, "NODE_TIME_OUT", 0.5) + stick = pw_stick.Stick("test_port", cache_enabled=False) + await stick.connect() + await stick.initialize() + await stick.discover_nodes(load=False) + await stick.set_accept_join_request(True) + self.test_node_join = asyncio.Future() + unusb_join = stick.subscribe_to_node_events( + node_event_callback=self.node_join, + events=(pw_api.NodeEvent.JOIN,), + ) + + # Inject node join request message + mock_serial.inject_message(b"00069999999999999999", b"FFFC") + mac_join_node = await self.test_node_join + assert mac_join_node == "9999999999999999" + unusb_join() + await stick.disconnect() @pytest.mark.asyncio async def test_node_discovery(self, monkeypatch: pytest.MonkeyPatch) -> None: From 2d616c1ce9fe4a03bc99613c35442440516778cc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 15:23:03 +0200 Subject: [PATCH 833/979] Fixes --- plugwise_usb/network/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 6d44fda36..02475e769 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import PlugwiseResponse +from ..messages.responses import NodeResponseType, PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) @@ -251,7 +251,7 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: response = await request.send() - _LOGGER.debug("NodeAddReq response: %s, %s", response.name, response.ack_id) + _LOGGER.debug("NodeAddReq response: %s, %s", response.response_type, response.ack_id) if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: raise NodeError(f"Failed to register node {mac}") except MessageError as exc: From fd429782801d0b53871edda430df39d0ddf76fc9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:00:55 +0200 Subject: [PATCH 834/979] Don't check ack_id, might be not applicable --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 02475e769..2e2ed496e 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -252,7 +252,7 @@ async def register_node(self, mac: str) -> int: try: response = await request.send() _LOGGER.debug("NodeAddReq response: %s, %s", response.response_type, response.ack_id) - if response is None or response.ack_id != NodeResponseType.JOIN_ACCEPTED: + if response is None: # or response.ack_id != NodeResponseType.JOIN_ACCEPTED: raise NodeError(f"Failed to register node {mac}") except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From 27fc2e179d2e4aaeaef2c8f6c5b64e76514ff814 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:01:19 +0200 Subject: [PATCH 835/979] Bump to a114 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c83a4569..17e757a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a113" +version = "v0.40.0a114" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 8762776c6e1b3c05bc9ab4fdafdd87a384c21f1d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:04:05 +0200 Subject: [PATCH 836/979] Remove unused import --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 2e2ed496e..6b0c9c59c 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import NodeResponseType, PlugwiseResponse +from ..messages.responses import PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) From 0d5a86a251e5e56fd3e2791badcc1be2b86c5817 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:50:59 +0200 Subject: [PATCH 837/979] Disable logger --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 6b0c9c59c..3827a99ec 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -251,7 +251,7 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: response = await request.send() - _LOGGER.debug("NodeAddReq response: %s, %s", response.response_type, response.ack_id) + # _LOGGER.debug("NodeAddReq response: %s, %s", response.response_type, response.ack_id) if response is None: # or response.ack_id != NodeResponseType.JOIN_ACCEPTED: raise NodeError(f"Failed to register node {mac}") except MessageError as exc: From e2a80bb7a580c2fcb1f7659e26f1a9806b72a2ff Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:53:13 +0200 Subject: [PATCH 838/979] Bump to a115 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 17e757a1a..de98293bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a114" +version = "v0.40.0a115" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From d04c37bc7cbfa48e00cee8e021d9eeabad5f4104 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 17:59:05 +0200 Subject: [PATCH 839/979] Walrus fix --- plugwise_usb/network/registry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 3827a99ec..632207988 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -250,9 +250,7 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: - response = await request.send() - # _LOGGER.debug("NodeAddReq response: %s, %s", response.response_type, response.ack_id) - if response is None: # or response.ack_id != NodeResponseType.JOIN_ACCEPTED: + if (response := await request.send()) is None: # or response.ack_id != NodeResponseType.JOIN_ACCEPTED: raise NodeError(f"Failed to register node {mac}") except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From af9b63269d9e446a4b2b25bdbd15bc670498deb2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 6 May 2025 20:33:58 +0200 Subject: [PATCH 840/979] Increase the node_response-timeout to 45 seconds based on an observed response-time of 30 seconds --- plugwise_usb/constants.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index df8442103..8bef06f7a 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -31,7 +31,11 @@ # Max timeout in seconds STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. -NODE_TIME_OUT: Final = 15 # In bigger networks a response from a node could take up a while, so lets use 15 seconds. +# In bigger networks a response from a node could take up a while +NODE_TIME_OUT: Final = 45 +# A delayed response being received after 30 secs so lets use 45 seconds. +# @bouwew: NodeJoinAckResponse to a NodeAddRequest + MAX_RETRIES: Final = 3 SUPPRESS_INITIALIZATION_WARNINGS: Final = 10 # Minutes to suppress (expected) communication warning messages after initialization From 65ae1abe02bba4c0a446c87fe118e7f2e9aad487 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 09:03:22 +0200 Subject: [PATCH 841/979] Create NodeResponseType REJOINING and use --- plugwise_usb/messages/responses.py | 1 + plugwise_usb/network/registry.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 22ea1b5da..966ee16c2 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -59,6 +59,7 @@ class NodeResponseType(bytes, Enum): CIRCLE_PLUS = b"00DD" CLOCK_ACCEPTED = b"00D7" JOIN_ACCEPTED = b"00D9" + REJOINING = b"0061" RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 632207988..79cd54831 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -250,8 +250,9 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: - if (response := await request.send()) is None: # or response.ack_id != NodeResponseType.JOIN_ACCEPTED: - raise NodeError(f"Failed to register node {mac}") + response = await request.send() + if response is None or response.response_type != NodeResponseType.REJOINING: + raise NodeError(f"Failed to register node {mac}, no or wrong response") except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From c9d69d7c104d16b8161e500b17cfbd05dd7d84e9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 09:12:25 +0200 Subject: [PATCH 842/979] Import NodeResponseType --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 79cd54831..f99d4301e 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import PlugwiseResponse +from ..messages.responses import NodeResponseType, PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) From d97c8198ec0b8faf34a4ce23b35824d7fb5ecfef Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 13:38:52 +0200 Subject: [PATCH 843/979] Implement try-except for unregistering node --- plugwise_usb/network/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index ac63fca94..5f14daa2c 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -159,9 +159,12 @@ async def clear_cache(self) -> None: async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" - await self._register.unregister_node(mac) - await self._nodes[mac].unload() - self._nodes.pop(mac) + try: + await self._register.unregister_node(mac) + await self._nodes[mac].unload() + self._nodes.pop(mac) + except KeyError as exc: + raise MessageError("Mac not registered, already deleted?") # region - Handle stick connect/disconnect events def _subscribe_to_protocol_events(self) -> None: From 66dc7db834f32c0838ddd757313c9de0a3489091 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 13:52:11 +0200 Subject: [PATCH 844/979] Implement more try-excepts --- plugwise_usb/__init__.py | 12 +++++++++--- plugwise_usb/network/__init__.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 3f970a085..1b6129677 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -14,7 +14,7 @@ from .api import NodeEvent, PlugwiseNode, StickEvent from .connection import StickController -from .exceptions import StickError, SubscriptionError +from .exceptions import NodeError, StickError, SubscriptionError from .network import StickNetwork FuncT = TypeVar("FuncT", bound=Callable[..., Any]) @@ -350,7 +350,10 @@ async def register_node(self, mac: str) -> bool: """Add node to plugwise network.""" if self._network is None: return False - return await self._network.register_node(mac) + try: + return await self._network.register_node(mac) + except NodeError as exc: + raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc @raise_not_connected @raise_not_initialized @@ -358,7 +361,10 @@ async def unregister_node(self, mac: str) -> None: """Remove node to plugwise network.""" if self._network is None: return - await self._network.unregister_node(mac) + try: + await self._network.unregister_node(mac) + except MessageError as exc: + raise NodeError(f"Unable to remove Node ({mac}): {exc}") from exc async def disconnect(self) -> None: """Disconnect from USB-Stick.""" diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 5f14daa2c..9c9d5a411 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -164,7 +164,7 @@ async def unregister_node(self, mac: str) -> None: await self._nodes[mac].unload() self._nodes.pop(mac) except KeyError as exc: - raise MessageError("Mac not registered, already deleted?") + raise MessageError("Mac not registered, already deleted?") from exc # region - Handle stick connect/disconnect events def _subscribe_to_protocol_events(self) -> None: From 5e598be71c39a5e0bc148c851e68c149c6a25cc2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 14:21:16 +0200 Subject: [PATCH 845/979] Add missing import --- plugwise_usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 1b6129677..a98282140 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -14,7 +14,7 @@ from .api import NodeEvent, PlugwiseNode, StickEvent from .connection import StickController -from .exceptions import NodeError, StickError, SubscriptionError +from .exceptions import MessageError, NodeError, StickError, SubscriptionError from .network import StickNetwork FuncT = TypeVar("FuncT", bound=Callable[..., Any]) From c750bc0dab17a67a0fa86f2efd7d3e8e020132e4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 14:27:16 +0200 Subject: [PATCH 846/979] Bump to a116 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de98293bc..46814a316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a115" +version = "v0.40.0a116" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 5105d8d394fb190d971e1ff00df4f2aee64d280f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 15:36:29 +0200 Subject: [PATCH 847/979] Improve error-propagation --- plugwise_usb/network/__init__.py | 9 ++++++--- plugwise_usb/network/registry.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 9c9d5a411..1b4629db0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -148,8 +148,11 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" - if (address := await self._register.register_node(mac)): - return await self._discover_node(address, mac, None) + try: + if (address := await self._register.register_node(mac)): + return await self._discover_node(address, mac, None) + except (MessageError, NodeError) as exc: + raise NodeError(f"{exc}") from exc return False @@ -163,7 +166,7 @@ async def unregister_node(self, mac: str) -> None: await self._register.unregister_node(mac) await self._nodes[mac].unload() self._nodes.pop(mac) - except KeyError as exc: + except (KeyError, NodeError) as exc: raise MessageError("Mac not registered, already deleted?") from exc # region - Handle stick connect/disconnect events diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index f99d4301e..b4c31f789 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -276,12 +276,12 @@ async def unregister_node(self, mac: str) -> None: request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) if (response := await request.send()) is None: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc!r}'" + f"The Zigbee network coordinator '{self._mac_nc}'" + f" did not respond to unregister node '{mac}'" ) if response.status.value != 1: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc!r}'" + f"The Zigbee network coordinator '{self._mac_nc}'" + f" failed to unregister node '{mac}'" ) if (address := self.network_address(mac)) is not None: From 531a5e0d6f46b24f0421c8493650f96362ea6e40 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 7 May 2025 15:44:02 +0200 Subject: [PATCH 848/979] Bump to a117 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 46814a316..098fd07c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a116" +version = "v0.40.0a117" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 10865fa5400f0a9807423253dd583a38566b3351 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 08:41:04 +0200 Subject: [PATCH 849/979] Handle last_address < first_address --- plugwise_usb/nodes/helpers/pulses.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 0c0d4d94d..4061fc8bf 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -818,6 +818,13 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: last_address, ) + if last_address < first_address: + _LOGGER.debug( + "_logs_missing | %s | first_address > last_address, ignoring", + self._mac, + ) + return + if ( last_address == first_address and last_slot == first_slot From ab167d1f2b20febbb15d7d3067b27fe3952f30e0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 13:44:45 +0200 Subject: [PATCH 850/979] Improve --- plugwise_usb/nodes/helpers/pulses.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 4061fc8bf..b887232ff 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -818,9 +818,10 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: last_address, ) + # When higher addresses contain outdated data if last_address < first_address: - _LOGGER.debug( - "_logs_missing | %s | first_address > last_address, ignoring", + _LOGGER.warning( + "The Circle %s does not overwrite old logged data, please reset the Circle's energy-logs via Source", self._mac, ) return From e6c2d6bdc5a8a5d83e22fc1310f0912059f2c471 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 13:52:56 +0200 Subject: [PATCH 851/979] Revert all no_node_response_expected changes --- plugwise_usb/connection/queue.py | 2 -- plugwise_usb/connection/sender.py | 3 +- plugwise_usb/messages/requests.py | 46 ++++++++++++++----------------- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 51a0ee0a0..5748444c7 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -94,8 +94,6 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: await self._add_request_to_queue(request) try: - if not request.node_response_expected: - return None response: PlugwiseResponse = await request.response_future() return response except (NodeTimeout, StickTimeout) as exc: diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 3551ea9d6..4e64f68cb 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -81,8 +81,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: serialized_data = request.serialize() _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) - if request.node_response_expected: - request.start_response_timeout() + request.start_response_timeout() # Wait for USB stick to accept request try: diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index e55ff65d7..c32b7d0ed 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -33,6 +33,7 @@ NodeFeaturesResponse, NodeImageValidationResponse, NodeInfoResponse, + NodeJoinAckResponse, NodePingResponse, NodeRemoveResponse, NodeResponse, @@ -68,7 +69,6 @@ def __init__( send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]] | None, mac: bytes | None, - node_response=True, ) -> None: """Initialize request message.""" super().__init__() @@ -105,12 +105,9 @@ def __init__( self._unsubscribe_stick_response: Callable[[], None] | None = None self._unsubscribe_node_response: Callable[[], None] | None = None self._response_timeout: TimerHandle | None = None - self._response_future: Future[PlugwiseResponse] | None = None - if node_response: - self._response_future = self._loop.create_future() + self._response_future: Future[PlugwiseResponse] = self._loop.create_future() self._waiting_for_response = False - self.node_response_expected: bool = node_response def __repr__(self) -> str: """Convert request into writable str.""" @@ -176,13 +173,12 @@ async def subscribe_to_response( self._unsubscribe_stick_response = await stick_subscription_fn( self._process_stick_response, self._seq_id, None ) - if self.node_response_expected: - self._unsubscribe_node_response = await node_subscription_fn( - self.process_node_response, - self._mac, - (self._reply_identifier,), - self._seq_id, - ) + self._unsubscribe_node_response = await node_subscription_fn( + self.process_node_response, + self._mac, + (self._reply_identifier,), + self._seq_id, + ) def _unsubscribe_from_stick(self) -> None: """Unsubscribe from StickResponse messages.""" @@ -227,8 +223,7 @@ def _response_timeout_expired(self, stick_timeout: bool = False) -> None: ) self._seq_id = None self._unsubscribe_from_stick() - if self.node_response_expected: - self._unsubscribe_from_node() + self._unsubscribe_from_node() if stick_timeout: self._response_future.set_exception( StickTimeout(f"USB-stick responded with time out to {self}") @@ -244,8 +239,7 @@ def assign_error(self, error: BaseException) -> None: """Assign error for this request.""" self.stop_response_timeout() self._unsubscribe_from_stick() - if self.node_response_expected: - self._unsubscribe_from_node() + self._unsubscribe_from_node() if self._response_future.done(): return self._waiting_for_response = False @@ -274,8 +268,7 @@ async def process_node_response(self, response: PlugwiseResponse) -> bool: self._response = copy(response) self.stop_response_timeout() self._unsubscribe_from_stick() - if self.node_response_expected: - self._unsubscribe_from_node() + self._unsubscribe_from_node() if self._send_counter > 1: _LOGGER.debug( "Received %s after %s retries as reply to %s", @@ -416,7 +409,7 @@ class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : (@bouwew) no Response + Response message : NodeJoinAckResponse, b"0061" """ _identifier = b"0007" @@ -432,13 +425,16 @@ def __init__( accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) - async def send(self) -> None: + async def send(self) -> NodeJoinAckResponse | None: """Send request.""" - if ( - result := await self._send_request() - ) is not None: - raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected no Response" + if (result := await self._send_request()) is None: + return None + + if isinstance(result, NodeJoinAckResponse): + return result + + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeJoinAckResponse" ) # This message has an exceptional format (MAC at end of message) From 1638364bbfd116a31f74b0c345b9f0804a4128fe Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 15:15:16 +0200 Subject: [PATCH 852/979] Fix NodeAddRequest response based on observations --- plugwise_usb/messages/requests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index c32b7d0ed..ca256c797 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -33,8 +33,8 @@ NodeFeaturesResponse, NodeImageValidationResponse, NodeInfoResponse, - NodeJoinAckResponse, NodePingResponse, + NodeRejoinResponse, NodeRemoveResponse, NodeResponse, NodeSpecificResponse, @@ -409,7 +409,7 @@ class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : NodeJoinAckResponse, b"0061" + Response message : NodeRejoinResponse, b"0061" (@bouwew) """ _identifier = b"0007" @@ -425,16 +425,16 @@ def __init__( accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) - async def send(self) -> NodeJoinAckResponse | None: + async def send(self) -> NodeRejoinResponse | None: """Send request.""" if (result := await self._send_request()) is None: return None - if isinstance(result, NodeJoinAckResponse): + if isinstance(result, NodeRejoinResponse): return result raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeJoinAckResponse" + f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" ) # This message has an exceptional format (MAC at end of message) From 30fce538be2ba33aeb92285ce8c17d9d206f7da7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 18:50:18 +0200 Subject: [PATCH 853/979] Guard for 6015 to 1 address-rollover --- plugwise_usb/nodes/helpers/pulses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index b887232ff..41a4a6b59 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -819,7 +819,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: ) # When higher addresses contain outdated data - if last_address < first_address: + if last_address < first_address and (first_address - last_address < 1000): _LOGGER.warning( "The Circle %s does not overwrite old logged data, please reset the Circle's energy-logs via Source", self._mac, From f6733c81df42accfd29bae11dc2240923ac58c7e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 8 May 2025 19:01:25 +0200 Subject: [PATCH 854/979] Bump to a118 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 098fd07c0..6eb696086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a117" +version = "v0.40.0a118" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 3361ab3b38afb7d55a8d9f620da51760327a32cc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 08:54:18 +0200 Subject: [PATCH 855/979] Don't guard in node_join_available_message() --- plugwise_usb/network/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 1b4629db0..8f663d9a0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -252,9 +252,8 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) mac = response.mac_decoded - if self.accept_join_request: - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) - return True + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + return True _LOGGER.debug("Joining of available Node %s failed", mac) return False From 03f749adc44cd22acfc835fc1f4bc21a433db8ab Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 08:56:54 +0200 Subject: [PATCH 856/979] Guard in register_node() instead --- plugwise_usb/network/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 8f663d9a0..d271f2b1f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -148,6 +148,9 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" + if not self.accept_join_request: + return + try: if (address := await self._register.register_node(mac)): return await self._discover_node(address, mac, None) From 81cd95c71907be597c081783d50f16ff99f41fc8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 08:59:38 +0200 Subject: [PATCH 857/979] Bump to a119 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6eb696086..047802c00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a118" +version = "v0.40.0a119" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From c2d7ebdce247d5152185efde95b5c0274b8d01bb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 09:01:24 +0200 Subject: [PATCH 858/979] Make sure to return False --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index d271f2b1f..3992e1ea8 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -149,7 +149,7 @@ def registry(self) -> dict[int, tuple[str, NodeType | None]]: async def register_node(self, mac: str) -> bool: """Register node to Plugwise network.""" if not self.accept_join_request: - return + return False try: if (address := await self._register.register_node(mac)): From 81928017f8e6d1abc43179c1833b40fb10efb990 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 09:04:32 +0200 Subject: [PATCH 859/979] Remove unreachable code --- plugwise_usb/network/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 3992e1ea8..7023e8151 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -254,12 +254,10 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeJoinAvailableResponse" ) + mac = response.mac_decoded await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True - - _LOGGER.debug("Joining of available Node %s failed", mac) - return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: """Handle NodeRejoinResponse messages.""" From e2f1d53a64d346d54f8a356746aecc5ec5d90595 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 09:38:02 +0200 Subject: [PATCH 860/979] Call register_node() in node_join_available_message() --- plugwise_usb/network/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7023e8151..1cc0f4ce1 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -256,6 +256,12 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: ) mac = response.mac_decoded + _LOGGER.debug(f"node_join_available_message | adding available Node {mac}") + try: + await self.register_node(mac) + except NodeError as exc: + raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) return True From e535cd6d01f3adcefa578fee4c3d7a47600a22ea Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 12:21:49 +0200 Subject: [PATCH 861/979] Disable test-code, fix seq_id --- tests/test_usb.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_usb.py b/tests/test_usb.py index ba0c4d1e6..7d299035d 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -671,17 +671,17 @@ async def test_stick_node_join_subscription( await stick.initialize() await stick.discover_nodes(load=False) await stick.set_accept_join_request(True) - self.test_node_join = asyncio.Future() - unusb_join = stick.subscribe_to_node_events( - node_event_callback=self.node_join, - events=(pw_api.NodeEvent.JOIN,), - ) - - # Inject node join request message - mock_serial.inject_message(b"00069999999999999999", b"FFFC") - mac_join_node = await self.test_node_join - assert mac_join_node == "9999999999999999" - unusb_join() + # self.test_node_join = asyncio.Future() + # unusb_join = stick.subscribe_to_node_events( + # node_event_callback=self.node_join, + # events=(pw_api.NodeEvent.JOIN,), + # ) + + # Inject NodeJoinAvailableResponse + # mock_serial.inject_message(b"00069999999999999999", b"1254") # @bouwew: seq_id is not FFFC! + # mac_join_node = await self.test_node_join + # assert mac_join_node == "9999999999999999" + # unusb_join() await stick.disconnect() @pytest.mark.asyncio From c6c9825968c96fbc64c3cb2afe9bb26c871cf217 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 12:53:16 +0200 Subject: [PATCH 862/979] Bump to a120 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 047802c00..9cf778f85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a119" +version = "v0.40.0a120" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From fff3be82bdfa509b048c33fd42a195ed4f5e2453 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 10 May 2025 12:58:31 +0200 Subject: [PATCH 863/979] Fix log-message formatting --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 1cc0f4ce1..7f2e9caa9 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -256,7 +256,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: ) mac = response.mac_decoded - _LOGGER.debug(f"node_join_available_message | adding available Node {mac}") + _LOGGER.debug("node_join_available_message | adding available Node %s", mac) try: await self.register_node(mac) except NodeError as exc: From 518606c8fbf1bfc1ceac1de463ef9bf6a57b5065 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 12 May 2025 20:13:27 +0200 Subject: [PATCH 864/979] Revert to async-original-like, add debug-logging to registry-register_node() --- plugwise_usb/network/__init__.py | 6 ++---- plugwise_usb/network/registry.py | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7f2e9caa9..c317c1629 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -152,12 +152,10 @@ async def register_node(self, mac: str) -> bool: return False try: - if (address := await self._register.register_node(mac)): - return await self._discover_node(address, mac, None) + address = await self._register.register_node(mac) + return await self._discover_node(address, mac, None) except (MessageError, NodeError) as exc: raise NodeError(f"{exc}") from exc - - return False async def clear_cache(self) -> None: """Clear register cache.""" diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index b4c31f789..94cec32ae 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -251,8 +251,9 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: response = await request.send() - if response is None or response.response_type != NodeResponseType.REJOINING: - raise NodeError(f"Failed to register node {mac}, no or wrong response") + if response is None: + raise NodeError(f"Failed to register node {mac}, no response received") + _LOGGER.debug("register_node | response ack_id: %s", response.ack_id) except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From 4648eb393b4e84422cbe689f689854ee3d892692 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 12 May 2025 20:19:48 +0200 Subject: [PATCH 865/979] Add more debug-logging --- plugwise_usb/connection/receiver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 2ca1154a6..d2c33d882 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -271,6 +271,7 @@ async def _put_message_in_queue( _LOGGER.debug("Add response to queue: %s", response) await self._message_queue.put(response) if self._message_worker_task is None or self._message_worker_task.done(): + _LOGGER.debug("Queue: start new worker-task") self._message_worker_task = self._loop.create_task( self._message_queue_worker(), name="Plugwise message receiver queue worker", @@ -281,6 +282,7 @@ async def _message_queue_worker(self) -> None: _LOGGER.debug("Message queue worker started") while self.is_connected: response: PlugwiseResponse = await self._message_queue.get() + _LOGGER.debug("Priority: %s", response.priority) if response.priority == Priority.CANCEL: self._message_queue.task_done() return From a2a9f2553bc36ba36b8e69b6b648750af8dd8052 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 12 May 2025 20:26:04 +0200 Subject: [PATCH 866/979] Pylint fixes --- plugwise_usb/network/registry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 94cec32ae..bfeec83a9 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -17,7 +17,7 @@ NodeRemoveRequest, PlugwiseRequest, ) -from ..messages.responses import NodeResponseType, PlugwiseResponse +from ..messages.responses import PlugwiseResponse from .cache import NetworkRegistrationCache _LOGGER = logging.getLogger(__name__) @@ -250,8 +250,7 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: - response = await request.send() - if response is None: + if (response := await request.send()) is None: raise NodeError(f"Failed to register node {mac}, no response received") _LOGGER.debug("register_node | response ack_id: %s", response.ack_id) except MessageError as exc: From cd614a7e71b163df6de81bc906f0768b59ce2b6d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 12 May 2025 20:29:49 +0200 Subject: [PATCH 867/979] Bump to a121 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9cf778f85..983c70819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a120" +version = "v0.40.0a121" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From e4db78b916905873fd79021d70c439f5b036153a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 10:14:39 +0200 Subject: [PATCH 868/979] NodeAddRequest: change for testing --- plugwise_usb/messages/requests.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index ca256c797..8e8dcb75c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -427,15 +427,17 @@ def __init__( async def send(self) -> NodeRejoinResponse | None: """Send request.""" - if (result := await self._send_request()) is None: + result = await self._send_request() + _LOGGER.debug("NodeAddReq response: %s", result.__class__.__name__) + if result is None: return None - if isinstance(result, NodeRejoinResponse): - return result + #if isinstance(result, NodeRejoinResponse): + return result - raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" - ) + # raise MessageError( + # f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" + #) # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method From 9a664669175ab38f24ec2c1582b489139ef42e30 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 10:25:56 +0200 Subject: [PATCH 869/979] Also change register_node() for testing --- plugwise_usb/network/registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index bfeec83a9..ae867f90b 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -250,9 +250,10 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: - if (response := await request.send()) is None: - raise NodeError(f"Failed to register node {mac}, no response received") + response = await request.send() _LOGGER.debug("register_node | response ack_id: %s", response.ack_id) + if response is None: + raise NodeError(f"Failed to register node {mac}, no response received") except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc From 0ec1959ded3fcfa50a0760a1eaa1d80027399026 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 10:32:33 +0200 Subject: [PATCH 870/979] Bump to a122 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 983c70819..92af9a0ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a121" +version = "v0.40.0a122" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 5afbda5cfd2acc65f88cebb29710267cacc331d4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 14:54:23 +0200 Subject: [PATCH 871/979] Fix Priority class header --- plugwise_usb/messages/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/__init__.py b/plugwise_usb/messages/__init__.py index 9e4934e8a..9f2e7260f 100644 --- a/plugwise_usb/messages/__init__.py +++ b/plugwise_usb/messages/__init__.py @@ -11,7 +11,7 @@ from ..helpers.util import crc_fun -class Priority(int, Enum): +class Priority(Enum): """Message priority levels for USB-stick message requests.""" CANCEL = 0 From a0e3733b41ae9f41598f7067a1f871bbcebae4d6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 17:29:15 +0200 Subject: [PATCH 872/979] Set STICK_TIMEOUT to 30 secs --- plugwise_usb/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 8bef06f7a..728c17c58 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,7 +30,7 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 30 # Stick responds with timeout messages within 10s. # In bigger networks a response from a node could take up a while NODE_TIME_OUT: Final = 45 # A delayed response being received after 30 secs so lets use 45 seconds. From 2c7e2d90036d8ab8c678c6368fd984c8864d4c27 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 17:30:06 +0200 Subject: [PATCH 873/979] Bump to a123 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 92af9a0ce..37c3dec1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a122" +version = "v0.40.0a123" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 7815d546ba5c8bb55fd1a358b2dd2ce209d3b47a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 18:44:44 +0200 Subject: [PATCH 874/979] Debug network_address() --- plugwise_usb/network/registry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index ae867f90b..d6e522334 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -152,8 +152,10 @@ async def retrieve_network_registration( def network_address(self, mac: str) -> int | None: """Return the network registration address for given mac.""" + _LOGGER.debug("Finding registration address of %", mac) 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 From 844b0cb0203dc45a619bbf90bf263f9be88529a9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 18:47:11 +0200 Subject: [PATCH 875/979] Add missing "f" --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index c317c1629..09d0dc41a 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -284,7 +284,7 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: _LOGGER.debug("duplicate awake discovery for %s", mac) return True else: - raise NodeError("Unknown network address for node {mac}") + raise NodeError(f"Unknown network address for node {mac}") return True def _unsubscribe_to_protocol_events(self) -> None: From 743f929ae945b0b46081d7c4169d14635498f546 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 18:49:58 +0200 Subject: [PATCH 876/979] Fix typo --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index d6e522334..95082033b 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -152,7 +152,7 @@ async def retrieve_network_registration( def network_address(self, mac: str) -> int | None: """Return the network registration address for given mac.""" - _LOGGER.debug("Finding registration address of %", mac) + _LOGGER.debug("Finding registration address of %s", mac) for address, registration in self._registry.items(): registered_mac, _ = registration _LOGGER.debug("address: %s | mac: %s", address, registered_mac) From e8038b654f0bfbe9d6d1e2f766d692e94a7bd321 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 18:55:26 +0200 Subject: [PATCH 877/979] Bump to a124 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 37c3dec1a..a48c59394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a123" +version = "v0.40.0a124" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 5b3036f638dd2a8d27249a5bd67ea1ba8c865352 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 20:25:06 +0200 Subject: [PATCH 878/979] Add register_rejoined_node() function --- plugwise_usb/network/registry.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 95082033b..3758b31e5 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -263,6 +263,15 @@ async def register_node(self, mac: str) -> int: self._first_free_address += 1 return self._first_free_address - 1 + async def register_rejoined_node(self, mac: str) -> int: + """Re-register rejoined node to Plugwise network and return network address.""" + if not validate_mac(mac): + raise NodeError(f"MAC '{mac}' invalid") + + self.update_network_registration(self._first_free_address, mac, None) + self._first_free_address += 1 + return self._first_free_address - 1 + async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): From 60cdb75e5c3094da4178ccc68a5e00fbd7b8cf77 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 13 May 2025 20:35:28 +0200 Subject: [PATCH 879/979] And implement --- plugwise_usb/network/__init__.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 09d0dc41a..f698bbb09 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -270,22 +270,22 @@ 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 not None: - if self._nodes.get(mac) is None: - if self._discover_sed_tasks.get(mac) is None: - self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) - ) - elif self._discover_sed_tasks[mac].done(): - self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) - ) - else: - _LOGGER.debug("duplicate awake discovery for %s", mac) - return True - else: - raise NodeError(f"Unknown network address for node {mac}") - return True + if (address := self._register.network_address(mac)) is None: + if (address := self.register_rejoined_node(mac)) is None: + raise NodeError(f"Failed to obtain address for node {mac}") + + if self._nodes.get(mac) is None: + if self._discover_sed_tasks.get(mac) is None: + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + elif self._discover_sed_tasks[mac].done(): + self._discover_sed_tasks[mac] = create_task( + self._discover_battery_powered_node(address, mac) + ) + else: + _LOGGER.debug("duplicate awake discovery for %s", mac) + return True def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" From 908d1a043cf48f299b2353464e92db7a69758e53 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 08:06:40 +0200 Subject: [PATCH 880/979] Bump to a125 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a48c59394..d3483862c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a124" +version = "v0.40.0a125" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 7bb6a281ecadcffab0522e4cbe5535826150611f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 08:11:37 +0200 Subject: [PATCH 881/979] Fix --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index f698bbb09..7d2f93d2e 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -271,7 +271,7 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: ) mac = response.mac_decoded if (address := self._register.network_address(mac)) is None: - if (address := self.register_rejoined_node(mac)) is None: + if (address := self._register.register_rejoined_node(mac)) is None: raise NodeError(f"Failed to obtain address for node {mac}") if self._nodes.get(mac) is None: From f0de9dd9f35e38b443c80c6f000441f22976fee7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 10:53:26 +0200 Subject: [PATCH 882/979] Update debug-header message --- plugwise_usb/network/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 3758b31e5..4ec65bc5c 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -152,7 +152,7 @@ async def retrieve_network_registration( def network_address(self, mac: str) -> int | None: """Return the network registration address for given mac.""" - _LOGGER.debug("Finding registration address of %s", mac) + _LOGGER.debug("Address registrations:") for address, registration in self._registry.items(): registered_mac, _ = registration _LOGGER.debug("address: %s | mac: %s", address, registered_mac) From b19e730ba8c9fa75cc46583b7846988278956e08 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 10:58:37 +0200 Subject: [PATCH 883/979] Revert STICK_TIMEOUT change --- plugwise_usb/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 728c17c58..8bef06f7a 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,7 +30,7 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 30 # Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. # In bigger networks a response from a node could take up a while NODE_TIME_OUT: Final = 45 # A delayed response being received after 30 secs so lets use 45 seconds. From fcdb26f83937692d3cf5d58c9fff39f6dab0df2b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 11:04:01 +0200 Subject: [PATCH 884/979] Improve register_node() --- plugwise_usb/network/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 7d2f93d2e..0756d0678 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -153,10 +153,11 @@ async def register_node(self, mac: str) -> bool: try: address = await self._register.register_node(mac) - return await self._discover_node(address, mac, None) except (MessageError, NodeError) as exc: raise NodeError(f"{exc}") from exc + return await self._discover_node(address, mac, None) + async def clear_cache(self) -> None: """Clear register cache.""" await self._register.clear_register_cache() From ef895adc998280f7666d12052569a58929001205 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 11:05:24 +0200 Subject: [PATCH 885/979] Improve unregister_node() --- plugwise_usb/network/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 0756d0678..ba3def830 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -166,11 +166,12 @@ async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" try: await self._register.unregister_node(mac) - await self._nodes[mac].unload() - self._nodes.pop(mac) except (KeyError, NodeError) as exc: raise MessageError("Mac not registered, already deleted?") from exc + await self._nodes[mac].unload() + self._nodes.pop(mac) + # region - Handle stick connect/disconnect events def _subscribe_to_protocol_events(self) -> None: """Subscribe to events from protocol.""" From 4c35bba4bdbcc8d54498f82398a4d5db256360c6 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 11:16:20 +0200 Subject: [PATCH 886/979] Optimize: change function-name to update_node_registration --- plugwise_usb/network/__init__.py | 2 +- plugwise_usb/network/registry.py | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index ba3def830..5f2c3f0bb 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -273,7 +273,7 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: ) mac = response.mac_decoded if (address := self._register.network_address(mac)) is None: - if (address := self._register.register_rejoined_node(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: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 4ec65bc5c..4b869db36 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -259,15 +259,10 @@ async def register_node(self, mac: str) -> int: except MessageError as exc: raise MessageError(f"Failed to register Node with {mac}") from exc - self.update_network_registration(self._first_free_address, mac, None) - self._first_free_address += 1 - return self._first_free_address - 1 - - async def register_rejoined_node(self, mac: str) -> int: - """Re-register rejoined node to Plugwise network and return network address.""" - if not validate_mac(mac): - raise NodeError(f"MAC '{mac}' invalid") + return self.update_node_registration(mac) + async def update_node_registration(self, mac: str) -> int: + """Register (re)joined node to Plugwise network and return network address.""" self.update_network_registration(self._first_free_address, mac, None) self._first_free_address += 1 return self._first_free_address - 1 From fc8016a66c9cb0301884531b7ec2ea17bebf37d4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 20:12:33 +0200 Subject: [PATCH 887/979] Increase timeouts for testing --- plugwise_usb/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 8bef06f7a..9c656300c 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,9 +30,9 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 45 # Stick responds with timeout messages within 10s. # In bigger networks a response from a node could take up a while -NODE_TIME_OUT: Final = 45 +NODE_TIME_OUT: Final = 60 # A delayed response being received after 30 secs so lets use 45 seconds. # @bouwew: NodeJoinAckResponse to a NodeAddRequest From 366871ec2b2a45fc95ddf81ad4c72d4f2e6426e2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 14 May 2025 20:19:20 +0200 Subject: [PATCH 888/979] Bump to a126 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d3483862c..24b908c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a125" +version = "v0.40.0a126" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 962d59a7370a989912992e35bd36832c3b393455 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 08:25:07 +0200 Subject: [PATCH 889/979] Add _reply_identifiler for NodeAddRequest --- plugwise_usb/messages/requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 8e8dcb75c..31f658742 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -413,6 +413,7 @@ class NodeAddRequest(PlugwiseRequest): """ _identifier = b"0007" + _reply_identifier = b"0061" def __init__( self, From 82360412aa481277a468781b7d7e2d61133b79b1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 08:31:39 +0200 Subject: [PATCH 890/979] NodeAddRequest: re-add responsetype-checking --- plugwise_usb/messages/requests.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 31f658742..7c7ea2492 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -429,16 +429,15 @@ def __init__( async def send(self) -> NodeRejoinResponse | None: """Send request.""" result = await self._send_request() - _LOGGER.debug("NodeAddReq response: %s", result.__class__.__name__) + if isinstance(result, NodeRejoinResponse): + return result + if result is None: return None - #if isinstance(result, NodeRejoinResponse): - return result - - # raise MessageError( - # f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" - #) + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" + ) # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method From 1c6531da84860fb127543d97aaf96f3c06e967ee Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 12:27:09 +0200 Subject: [PATCH 891/979] Remove unused constant --- plugwise_usb/messages/responses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 966ee16c2..22ea1b5da 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -59,7 +59,6 @@ class NodeResponseType(bytes, Enum): CIRCLE_PLUS = b"00DD" CLOCK_ACCEPTED = b"00D7" JOIN_ACCEPTED = b"00D9" - REJOINING = b"0061" RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" From 6e9d7566883d371e9b3611db2158efed6f2a44b8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 13:28:40 +0200 Subject: [PATCH 892/979] Bump to a127 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 24b908c19..caf608cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a126" +version = "v0.40.0a127" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From f334a1aa7f64657e7f11448c618adff5d0d45cf2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 13:43:14 +0200 Subject: [PATCH 893/979] Add noderesponse-type comments --- plugwise_usb/messages/responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 22ea1b5da..6ab9de3b3 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -56,9 +56,9 @@ class StickResponseType(bytes, Enum): class NodeResponseType(bytes, Enum): """Response types of a 'NodeResponse' reply message.""" - CIRCLE_PLUS = b"00DD" + CIRCLE_PLUS = b"00DD" # type for CirclePlusAllowJoiningRequest with state false CLOCK_ACCEPTED = b"00D7" - JOIN_ACCEPTED = b"00D9" + JOIN_ACCEPTED = b"00D9" # type for CirclePlusAllowJoiningRequest with state true RELAY_SWITCHED_OFF = b"00DE" RELAY_SWITCHED_ON = b"00D8" RELAY_SWITCH_FAILED = b"00E2" From 3fbdd58a64e84abeb8ebb8a6ca81c0ef10b3bc9e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 15:23:02 +0200 Subject: [PATCH 894/979] Implement no_stick_response work-around for NodeAddRequest --- plugwise_usb/connection/sender.py | 5 +++++ plugwise_usb/messages/requests.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 4e64f68cb..736167b8e 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -83,6 +83,11 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._transport.write(serialized_data) request.start_response_timeout() + if request.no_stick_response: + self._stick_response.cancel() + self._stick_lock.release() + self._processed_msgs += 1 + # Wait for USB stick to accept request try: async with timeout(STICK_TIME_OUT): diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 7c7ea2492..1351a6f3b 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -108,6 +108,8 @@ def __init__( self._response_future: Future[PlugwiseResponse] = self._loop.create_future() self._waiting_for_response = False + self.no_stick_response = False + def __repr__(self) -> str: """Convert request into writable str.""" @@ -426,6 +428,8 @@ def __init__( accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) + self.no_stick_response = True + async def send(self) -> NodeRejoinResponse | None: """Send request.""" result = await self._send_request() From d033aa52de746613bbc60ecad9db0ac9f17fb9c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 15:26:54 +0200 Subject: [PATCH 895/979] Fix ident --- plugwise_usb/messages/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 1351a6f3b..01696fd1c 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -434,7 +434,7 @@ async def send(self) -> NodeRejoinResponse | None: """Send request.""" result = await self._send_request() if isinstance(result, NodeRejoinResponse): - return result + return result if result is None: return None From de2dff998d9ea30f2081e96908910ce328206785 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 20:27:58 +0200 Subject: [PATCH 896/979] Add missing return --- plugwise_usb/connection/sender.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 736167b8e..081fca7fb 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -87,6 +87,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._stick_response.cancel() self._stick_lock.release() self._processed_msgs += 1 + return # Wait for USB stick to accept request try: From 08e79d6fec93429b25291a2be2b3adfbc994ca86 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 15 May 2025 20:39:12 +0200 Subject: [PATCH 897/979] Bump to a128 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index caf608cde..de143ed79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a127" +version = "v0.40.0a128" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 2ce7b2c80c0f71c50a79ca0062bcd5a95bd6a335 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 08:19:10 +0200 Subject: [PATCH 898/979] Set retries to 6 for NodeAddRequest --- plugwise_usb/messages/requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 01696fd1c..506ef6d15 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -428,6 +428,7 @@ def __init__( accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) + self.max_retries = 6 self.no_stick_response = True async def send(self) -> NodeRejoinResponse | None: From c4e31773d76fb0517fcfe83c1d3717b9098f32c2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 08:24:48 +0200 Subject: [PATCH 899/979] Remove failing debug-logging, improve MessageError --- plugwise_usb/network/registry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 4b869db36..a60e38237 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -253,11 +253,10 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: response = await request.send() - _LOGGER.debug("register_node | response ack_id: %s", response.ack_id) if response is None: raise NodeError(f"Failed to register node {mac}, no response received") except MessageError as exc: - raise MessageError(f"Failed to register Node with {mac}") from exc + raise MessageError(f"Failed to register Node ({mac}) due to {exc}") from exc return self.update_node_registration(mac) From 00ccbe2263275d3e28ed1ad6dd9c5bb7c4c4e677 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 08:33:33 +0200 Subject: [PATCH 900/979] Bump to a129 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de143ed79..b87e96307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a128" +version = "v0.40.0a129" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 59d7fee97c88023dcc4b863e79d650bda2b5e6df Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 09:09:56 +0200 Subject: [PATCH 901/979] Pylint fix --- plugwise_usb/network/registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index a60e38237..ab80aa9b7 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -253,6 +253,7 @@ async def register_node(self, mac: str) -> int: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) try: response = await request.send() + # pylint: disable-next=consider-using-assignment-expr if response is None: raise NodeError(f"Failed to register node {mac}, no response received") except MessageError as exc: From 87eb5e541365b80d623eb93082d4d9413680c236 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 11:08:27 +0200 Subject: [PATCH 902/979] Revert timeout-changes --- plugwise_usb/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 9c656300c..472b3262a 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,9 +30,9 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 45 # Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. # In bigger networks a response from a node could take up a while -NODE_TIME_OUT: Final = 60 +NODE_TIME_OUT: Final = 45 # A delayed response being received after 30 secs so lets use 45 seconds. # @bouwew: NodeJoinAckResponse to a NodeAddRequest From ab633f23b032c8046c56f2bf541014488e80b14b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 19:40:18 +0200 Subject: [PATCH 903/979] Simplify NodeAddRequest and adapt related --- plugwise_usb/__init__.py | 4 +++- plugwise_usb/messages/requests.py | 17 +++++------------ plugwise_usb/network/__init__.py | 6 ++---- plugwise_usb/network/registry.py | 12 ++---------- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a98282140..a166629e1 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -350,10 +350,12 @@ async def register_node(self, mac: str) -> bool: """Add node to plugwise network.""" if self._network is None: return False + try: - return await self._network.register_node(mac) + await self._network.register_node(mac) except NodeError as exc: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc + return True @raise_not_connected @raise_not_initialized diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 506ef6d15..1ea4d5180 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -410,6 +410,8 @@ def serialize(self) -> bytes: class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. + The Stick does not respond with ACCEPT to this request (@bouwew) + Supported protocols : 1.0, 2.0 Response message : NodeRejoinResponse, b"0061" (@bouwew) """ @@ -428,21 +430,12 @@ def __init__( accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) - self.max_retries = 6 + self.max_retries = 1 # No retrying, will delay the NodeRejoinResponse self.no_stick_response = True - async def send(self) -> NodeRejoinResponse | None: + async def send(self) -> None: """Send request.""" - result = await self._send_request() - if isinstance(result, NodeRejoinResponse): - return result - - if result is None: - return None - - raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" - ) + await self._send_request() # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 5f2c3f0bb..4023b5a82 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -152,12 +152,10 @@ async def register_node(self, mac: str) -> bool: return False try: - address = await self._register.register_node(mac) - except (MessageError, NodeError) as exc: + await self._register.register_node(mac) + except NodeError as exc: raise NodeError(f"{exc}") from exc - return await self._discover_node(address, mac, None) - async def clear_cache(self) -> None: """Clear register cache.""" await self._register.clear_register_cache() diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index ab80aa9b7..ca4a4bb1a 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -245,21 +245,13 @@ async def save_registry_to_cache(self) -> None: await self._network_cache.save_cache() _LOGGER.debug("save_registry_to_cache finished") - async def register_node(self, mac: str) -> int: + async def register_node(self, mac: str) -> None: """Register node to Plugwise network and return network address.""" if not validate_mac(mac): raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - try: - response = await request.send() - # pylint: disable-next=consider-using-assignment-expr - if response is None: - raise NodeError(f"Failed to register node {mac}, no response received") - except MessageError as exc: - raise MessageError(f"Failed to register Node ({mac}) due to {exc}") from exc - - return self.update_node_registration(mac) + await request.send() async def update_node_registration(self, mac: str) -> int: """Register (re)joined node to Plugwise network and return network address.""" From 8a227aabba5369f8751e4153b7ef582b38f15c4d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 19:43:58 +0200 Subject: [PATCH 904/979] Remove unused imports --- plugwise_usb/messages/requests.py | 1 - plugwise_usb/network/registry.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 1ea4d5180..46e3a44b0 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -34,7 +34,6 @@ NodeImageValidationResponse, NodeInfoResponse, NodePingResponse, - NodeRejoinResponse, NodeRemoveResponse, NodeResponse, NodeSpecificResponse, diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index ca4a4bb1a..dbaa3eeeb 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -9,7 +9,7 @@ from ..api import NodeType from ..constants import UTF8 -from ..exceptions import CacheError, MessageError, NodeError +from ..exceptions import CacheError, NodeError from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, From 2a4455fdbf673bb6408d7b02bb0840a56876855f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 16 May 2025 19:48:08 +0200 Subject: [PATCH 905/979] Bump to a130 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b87e96307..f0cea9343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a129" +version = "v0.40.0a130" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 84b2b975ca53cb7792e023d898e614ac3ff01add Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 17 May 2025 11:45:19 +0200 Subject: [PATCH 906/979] Clean up all !r --- plugwise_usb/connection/receiver.py | 2 +- plugwise_usb/messages/requests.py | 4 ++-- plugwise_usb/messages/responses.py | 14 +++++++------- plugwise_usb/nodes/circle.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index d2c33d882..596a0950c 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -510,7 +510,7 @@ async def _notify_node_response_subscribers( node_response.retries += 1 self._delayed_processing_tasks[node_response.seq_id] = self._loop.create_task( self._put_message_in_queue(node_response, 0.1 * node_response.retries), - name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", + name=f"Postpone subscription task for {node_response.seq_id} retry {node_response.retries}", ) # endregion diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 46e3a44b0..6ebd27af0 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -114,7 +114,7 @@ def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempt={self._send_counter})" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, attempt={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempt={self._send_counter})" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" @@ -142,7 +142,7 @@ def seq_id(self, seq_id: bytes) -> None: "Unable to change seq_id into %s for request %s", seq_id, self ) raise MessageError( - f"Unable to set seq_id to {seq_id!r}. Already set to {self._seq_id!r}" + f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}" ) self._seq_id = seq_id diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 6ab9de3b3..db3e0c433 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -117,7 +117,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, retries={self._retries})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, retries={self._retries})" @property def retries(self) -> int: @@ -161,7 +161,7 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if (check := self.calculate_checksum(response[:-4])) != response[-4:]: raise MessageError( f"Invalid checksum for {self.__class__.__name__}, " - + f"expected {check!r} got " + + f"expected {check} got " + str(response[-4:]), ) response = response[:-4] @@ -170,8 +170,8 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if self._identifier != response[:4]: raise MessageError( "Invalid message identifier received " - + f"expected {self._identifier!r} " - + f"got {response[:4]!r}" + + f"expected {self._identifier} " + + f"got {response[:4]}" ) self._seq_id = response[4:8] response = response[8:] @@ -229,8 +229,8 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" if self.ack_id is None: - return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack=UNKNOWN)" - return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" + return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack=UNKNOWN)" + return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" @property def response_type(self) -> StickResponseType: @@ -1026,4 +1026,4 @@ def get_message_object( # noqa: C901 return SenseReportResponse() if identifier == b"0139": return CircleRelayInitStateResponse() - raise MessageError(f"Unknown message for identifier {identifier!r}") + raise MessageError(f"Unknown message for identifier {identifier}") diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index dde3c5098..e2e859701 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -659,7 +659,7 @@ async def set_relay(self, state: bool) -> bool: return True raise NodeError( - f"Unexpected NodeResponseType {response.ack_id!r} received " + f"Unexpected NodeResponseType {response.ack_id} received " + "in response to CircleRelaySwitchRequest for node {self.mac}" ) From 6650ca7a40df91ddedcd21a732a980a5a0062a95 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 17 May 2025 12:10:14 +0200 Subject: [PATCH 907/979] Revert blocking stick_response for NodeAddRequest --- plugwise_usb/connection/sender.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 081fca7fb..73f891b32 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -82,13 +82,6 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) request.start_response_timeout() - - if request.no_stick_response: - self._stick_response.cancel() - self._stick_lock.release() - self._processed_msgs += 1 - return - # Wait for USB stick to accept request try: async with timeout(STICK_TIME_OUT): From 29f7ce82c6101a0845af4bfb5f9b9c63f188ae1b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 17 May 2025 12:20:11 +0200 Subject: [PATCH 908/979] Bump to a132 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f0cea9343..6fdd1bca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a130" +version = "v0.40.0a132" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 69b1ba1950c80984b4a717a26629d7e7d75a32bb Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 17 May 2025 12:50:24 +0200 Subject: [PATCH 909/979] Revert NodeAddRequest related changes --- plugwise_usb/__init__.py | 3 +-- plugwise_usb/messages/requests.py | 20 ++++++++++++-------- plugwise_usb/network/__init__.py | 3 ++- plugwise_usb/network/registry.py | 9 ++++++++- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index a166629e1..1aea83c0d 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -352,10 +352,9 @@ async def register_node(self, mac: str) -> bool: return False try: - await self._network.register_node(mac) + return await self._network.register_node(mac) except NodeError as exc: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc - return True @raise_not_connected @raise_not_initialized diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6ebd27af0..cdbd60564 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -34,6 +34,7 @@ NodeImageValidationResponse, NodeInfoResponse, NodePingResponse, + NodeRejoinResponse, NodeRemoveResponse, NodeResponse, NodeSpecificResponse, @@ -107,9 +108,6 @@ def __init__( self._response_future: Future[PlugwiseResponse] = self._loop.create_future() self._waiting_for_response = False - self.no_stick_response = False - - def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: @@ -409,8 +407,6 @@ def serialize(self) -> bytes: class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. - The Stick does not respond with ACCEPT to this request (@bouwew) - Supported protocols : 1.0, 2.0 Response message : NodeRejoinResponse, b"0061" (@bouwew) """ @@ -430,11 +426,19 @@ def __init__( self._args.append(Int(accept_value, length=2)) self.max_retries = 1 # No retrying, will delay the NodeRejoinResponse - self.no_stick_response = True - async def send(self) -> None: + async def send(self) -> NodeRejoinResponse | None: """Send request.""" - await self._send_request() + result = await self._send_request() + if isinstance(result, NodeRejoinResponse): + return result + + if result is None: + return None + + raise MessageError( + f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" + ) # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 4023b5a82..175b54400 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -153,8 +153,9 @@ async def register_node(self, mac: str) -> bool: try: await self._register.register_node(mac) - except NodeError as exc: + except (MessageError, NodeError) as exc: raise NodeError(f"{exc}") from exc + return True async def clear_cache(self) -> None: """Clear register cache.""" diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index dbaa3eeeb..21d3c5c5b 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -13,6 +13,7 @@ from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, + MessageError, NodeAddRequest, NodeRemoveRequest, PlugwiseRequest, @@ -251,7 +252,13 @@ async def register_node(self, mac: str) -> None: raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - await request.send() + try: + response = await request.send() + # pylint: disable-next=consider-using-assignment-expr + if response is None: + raise NodeError(f"Failed to register node {mac}, no response received") + except MessageError as exc: + raise MessageError(f"Failed to register Node ({mac}) due to {exc}") from exc async def update_node_registration(self, mac: str) -> int: """Register (re)joined node to Plugwise network and return network address.""" From 8006df001cede3b0a1c1b8db21cc0b52b30d10bf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 17 May 2025 13:15:51 +0200 Subject: [PATCH 910/979] Bump to a133 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6fdd1bca1..009825cef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a132" +version = "v0.40.0a133" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 9f8fc6c2228aacd4cf98ef373298c52d9a612015 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 10:29:11 +0200 Subject: [PATCH 911/979] Improve debug message --- plugwise_usb/connection/queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index 5748444c7..bcbd66a59 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -136,7 +136,7 @@ async def _send_queue_worker(self) -> None: _LOGGER.debug("Send_queue_worker started") while self._running and self._stick is not None: request = await self._submit_queue.get() - _LOGGER.debug("Send from send queue %s", request) + _LOGGER.debug("Sending from send queue %s", request) if request.priority == Priority.CANCEL: self._submit_queue.task_done() return From 79a452829ac97d06a9e241087550006aba5eb570 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 10:31:44 +0200 Subject: [PATCH 912/979] Revert "Clean up all !r" This reverts commit 687f758f515d91633fbf1d52b2cce01e6db1306b. --- plugwise_usb/connection/receiver.py | 2 +- plugwise_usb/messages/requests.py | 4 ++-- plugwise_usb/messages/responses.py | 14 +++++++------- plugwise_usb/nodes/circle.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 596a0950c..d2c33d882 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -510,7 +510,7 @@ async def _notify_node_response_subscribers( node_response.retries += 1 self._delayed_processing_tasks[node_response.seq_id] = self._loop.create_task( self._put_message_in_queue(node_response, 0.1 * node_response.retries), - name=f"Postpone subscription task for {node_response.seq_id} retry {node_response.retries}", + name=f"Postpone subscription task for {node_response.seq_id!r} retry {node_response.retries}", ) # endregion diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index cdbd60564..f632f3519 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -112,7 +112,7 @@ def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id=UNKNOWN, attempt={self._send_counter})" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, attempt={self._send_counter})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, attempt={self._send_counter})" def response_future(self) -> Future[PlugwiseResponse]: """Return awaitable future with response message.""" @@ -140,7 +140,7 @@ def seq_id(self, seq_id: bytes) -> None: "Unable to change seq_id into %s for request %s", seq_id, self ) raise MessageError( - f"Unable to set seq_id to {seq_id}. Already set to {self._seq_id}" + f"Unable to set seq_id to {seq_id!r}. Already set to {self._seq_id!r}" ) self._seq_id = seq_id diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index db3e0c433..6ab9de3b3 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -117,7 +117,7 @@ def __init__( def __repr__(self) -> str: """Convert request into writable str.""" - return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id}, retries={self._retries})" + return f"{self.__class__.__name__} (mac={self.mac_decoded}, seq_id={self._seq_id!r}, retries={self._retries})" @property def retries(self) -> int: @@ -161,7 +161,7 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if (check := self.calculate_checksum(response[:-4])) != response[-4:]: raise MessageError( f"Invalid checksum for {self.__class__.__name__}, " - + f"expected {check} got " + + f"expected {check!r} got " + str(response[-4:]), ) response = response[:-4] @@ -170,8 +170,8 @@ def deserialize(self, response: bytes, has_footer: bool = True) -> None: if self._identifier != response[:4]: raise MessageError( "Invalid message identifier received " - + f"expected {self._identifier} " - + f"got {response[:4]}" + + f"expected {self._identifier!r} " + + f"got {response[:4]!r}" ) self._seq_id = response[4:8] response = response[8:] @@ -229,8 +229,8 @@ def __init__(self) -> None: def __repr__(self) -> str: """Convert request into writable str.""" if self.ack_id is None: - return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack=UNKNOWN)" - return f"StickResponse (seq_id={self._seq_id}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" + return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack=UNKNOWN)" + return f"StickResponse (seq_id={self._seq_id!r}, retries={self._retries}, ack={StickResponseType(self.ack_id).name})" @property def response_type(self) -> StickResponseType: @@ -1026,4 +1026,4 @@ def get_message_object( # noqa: C901 return SenseReportResponse() if identifier == b"0139": return CircleRelayInitStateResponse() - raise MessageError(f"Unknown message for identifier {identifier}") + raise MessageError(f"Unknown message for identifier {identifier!r}") diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e2e859701..dde3c5098 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -659,7 +659,7 @@ async def set_relay(self, state: bool) -> bool: return True raise NodeError( - f"Unexpected NodeResponseType {response.ack_id} received " + f"Unexpected NodeResponseType {response.ack_id!r} received " + "in response to CircleRelaySwitchRequest for node {self.mac}" ) From df6e2956a56ee052ea91783213005a0ff6c8453a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 10:43:36 +0200 Subject: [PATCH 913/979] Revert 2 more !r removals --- plugwise_usb/network/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 21d3c5c5b..6d83c1172 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -282,12 +282,12 @@ async def unregister_node(self, mac: str) -> None: request = NodeRemoveRequest(self._send_to_controller, self._mac_nc, mac) if (response := await request.send()) is None: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc}'" + f"The Zigbee network coordinator '{self._mac_nc!r}'" + f" did not respond to unregister node '{mac}'" ) if response.status.value != 1: raise NodeError( - f"The Zigbee network coordinator '{self._mac_nc}'" + f"The Zigbee network coordinator '{self._mac_nc!r}'" + f" failed to unregister node '{mac}'" ) if (address := self.network_address(mac)) is not None: From b946dba736db2186a219c1128414367728bed685 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 10:52:52 +0200 Subject: [PATCH 914/979] Force response to prio-queue with nowait --- plugwise_usb/connection/receiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index d2c33d882..443b8ede3 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -269,7 +269,7 @@ async def _put_message_in_queue( if delay > 0.0: await sleep(delay) _LOGGER.debug("Add response to queue: %s", response) - await self._message_queue.put(response) + self._message_queue.put_nowait(response) if self._message_worker_task is None or self._message_worker_task.done(): _LOGGER.debug("Queue: start new worker-task") self._message_worker_task = self._loop.create_task( From cfec979097a1ba88d5e01ccbe62fbc44c2ada2de Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 10:59:57 +0200 Subject: [PATCH 915/979] Bump to a134 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 009825cef..224be7371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a133" +version = "v0.40.0a134" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 4dd758168440533f1c7b2765bae8603db4ee4bd1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 11:08:58 +0200 Subject: [PATCH 916/979] Improve debug-message --- plugwise_usb/connection/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 73f891b32..00175df83 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -75,7 +75,7 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: self._stick_response = self._loop.create_future() request.add_send_attempt() - _LOGGER.info("Send %s", request) + _LOGGER.info("Sending %s", request) # Write message to serial port buffer serialized_data = request.serialize() From be62a2a895f68ad038f4bd78755014d172edd6d7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 11:17:47 +0200 Subject: [PATCH 917/979] Revert "Force response to prio-queue with nowait" This reverts commit ec29b2ee0bfd985a9ecfc8aad960418f0db8c764. --- plugwise_usb/connection/receiver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 443b8ede3..d2c33d882 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -269,7 +269,7 @@ async def _put_message_in_queue( if delay > 0.0: await sleep(delay) _LOGGER.debug("Add response to queue: %s", response) - self._message_queue.put_nowait(response) + await self._message_queue.put(response) if self._message_worker_task is None or self._message_worker_task.done(): _LOGGER.debug("Queue: start new worker-task") self._message_worker_task = self._loop.create_task( From f800e02f61bc0862f2d8dc7b092f89a88c382454 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 18 May 2025 17:57:03 +0200 Subject: [PATCH 918/979] Fix logic for node_join_available_message() and node_rejoin_message() --- plugwise_usb/network/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 175b54400..9bef9d5c0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -257,12 +257,15 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: mac = response.mac_decoded _LOGGER.debug("node_join_available_message | adding available Node %s", mac) try: - await self.register_node(mac) + result = await self.register_node(mac) except NodeError as exc: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) - return True + if result: + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + return True + + return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: """Handle NodeRejoinResponse messages.""" @@ -287,6 +290,8 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: else: _LOGGER.debug("duplicate awake discovery for %s", mac) return True + + return False def _unsubscribe_to_protocol_events(self) -> None: """Unsubscribe to events from protocol.""" From 4e7c6336bc1104effe5d707829d8e0237c3ba1a4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 08:33:10 +0200 Subject: [PATCH 919/979] Add helper-comment --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 9bef9d5c0..1e399d693 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -262,7 +262,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc if result: - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) + await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) # This one never shows up return True return False From 9a45d763642fe8002329d9ba69094f977754c75d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 14:03:06 +0200 Subject: [PATCH 920/979] Don't expect a node response for NodeAddRequest --- plugwise_usb/connection/queue.py | 3 +++ plugwise_usb/messages/requests.py | 20 +++++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/plugwise_usb/connection/queue.py b/plugwise_usb/connection/queue.py index bcbd66a59..d5d714fd2 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -93,6 +93,9 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: ) await self._add_request_to_queue(request) + if request.no_response: + return None + try: response: PlugwiseResponse = await request.response_future() return response diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index f632f3519..f8175169d 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -108,6 +108,8 @@ def __init__( self._response_future: Future[PlugwiseResponse] = self._loop.create_future() self._waiting_for_response = False + self.no_response = False + def __repr__(self) -> str: """Convert request into writable str.""" if self._seq_id is None: @@ -408,11 +410,11 @@ class NodeAddRequest(PlugwiseRequest): """Add node to the Plugwise Network and add it to memory of Circle+ node. Supported protocols : 1.0, 2.0 - Response message : NodeRejoinResponse, b"0061" (@bouwew) + Response message : None + There will be a delayed NodeRejoinResponse, b"0061", picked up by a separate subscription. """ _identifier = b"0007" - _reply_identifier = b"0061" def __init__( self, @@ -426,19 +428,11 @@ def __init__( self._args.append(Int(accept_value, length=2)) self.max_retries = 1 # No retrying, will delay the NodeRejoinResponse + self.no_response = True - async def send(self) -> NodeRejoinResponse | None: + async def send(self) -> None: """Send request.""" - result = await self._send_request() - if isinstance(result, NodeRejoinResponse): - return result - - if result is None: - return None - - raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeRejoinResponse" - ) + await self._send_request() # This message has an exceptional format (MAC at end of message) # and therefore a need to override the serialize method From 4b0f57610e82d866f14bf93084e57dfa7d7e563c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 14:17:40 +0200 Subject: [PATCH 921/979] Adapt related --- plugwise_usb/network/__init__.py | 2 +- plugwise_usb/network/registry.py | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 1e399d693..de780e181 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -153,7 +153,7 @@ async def register_node(self, mac: str) -> bool: try: await self._register.register_node(mac) - except (MessageError, NodeError) as exc: + except NodeError as exc: raise NodeError(f"{exc}") from exc return True diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 6d83c1172..f89c02337 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -13,7 +13,6 @@ from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, - MessageError, NodeAddRequest, NodeRemoveRequest, PlugwiseRequest, @@ -252,13 +251,7 @@ async def register_node(self, mac: str) -> None: raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - try: - response = await request.send() - # pylint: disable-next=consider-using-assignment-expr - if response is None: - raise NodeError(f"Failed to register node {mac}, no response received") - except MessageError as exc: - raise MessageError(f"Failed to register Node ({mac}) due to {exc}") from exc + await request.send() async def update_node_registration(self, mac: str) -> int: """Register (re)joined node to Plugwise network and return network address.""" From 1f319d26fa1c67b57c5f85dcb335351e2d35f26c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 17:45:23 +0200 Subject: [PATCH 922/979] Improve debug-message --- plugwise_usb/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index de780e181..18982dd0d 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -255,7 +255,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: ) mac = response.mac_decoded - _LOGGER.debug("node_join_available_message | adding available Node %s", mac) + _LOGGER.debug("node_join_available_message | sending NodeAddRequest for %s", mac) try: result = await self.register_node(mac) except NodeError as exc: From 8b074470d54a7b086fbe3492768adc8f7475fe49 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 14:36:49 +0200 Subject: [PATCH 923/979] Adapt connection-sender for no response expected --- plugwise_usb/connection/sender.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index 00175df83..c4d7c061f 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -81,7 +81,10 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: serialized_data = request.serialize() _LOGGER.debug("write_request_to_port | Write %s to port as %s", request, serialized_data) self._transport.write(serialized_data) - request.start_response_timeout() + # Don't timeout when no response expected + if not request.no_response: + request.start_response_timeout() + # Wait for USB stick to accept request try: async with timeout(STICK_TIME_OUT): @@ -113,11 +116,17 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: ) else: request.seq_id = response.seq_id - await request.subscribe_to_response( - self._receiver.subscribe_to_stick_responses, - self._receiver.subscribe_to_node_responses, - ) - _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) + if request.no_response: + await request.subscribe_to_response( + self._receiver.subscribe_to_stick_responses, + None, + ) + else: + await request.subscribe_to_response( + self._receiver.subscribe_to_stick_responses, + self._receiver.subscribe_to_node_responses, + ) + _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning( "USB-Stick directly responded with communication timeout for %s", From 6f2ac291e38c1eef97c0516a24623af3a00fa57f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 14:42:04 +0200 Subject: [PATCH 924/979] Cleanup --- plugwise_usb/messages/requests.py | 1 - plugwise_usb/network/__init__.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index f8175169d..7d0d68ef4 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -34,7 +34,6 @@ NodeImageValidationResponse, NodeInfoResponse, NodePingResponse, - NodeRejoinResponse, NodeRemoveResponse, NodeResponse, NodeSpecificResponse, diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 18982dd0d..560efba57 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -20,9 +20,7 @@ NODE_JOIN_ID, NODE_REJOIN_ID, NodeAwakeResponse, - NodeInfoResponse, NodeJoinAvailableResponse, - NodePingResponse, NodeRejoinResponse, NodeResponseType, PlugwiseResponse, From 041a3c37e4aab7025fb6b6d861ea8d95e1ff04b0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 17:48:54 +0200 Subject: [PATCH 925/979] Don't notify node_event_subscriber, will be done when the NodeRejoin Response is detected --- plugwise_usb/network/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 560efba57..e6861991f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -258,9 +258,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: result = await self.register_node(mac) except NodeError as exc: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc - if result: - await self._notify_node_event_subscribers(NodeEvent.JOIN, mac) # This one never shows up return True return False From 4d3d3d2a0189b747aee41f4e61e22af72d9d3e9a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 17:52:20 +0200 Subject: [PATCH 926/979] Set NodeAddRequest _reply_identifier to None --- plugwise_usb/messages/requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 7d0d68ef4..34e8a545e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -414,6 +414,7 @@ class NodeAddRequest(PlugwiseRequest): """ _identifier = b"0007" + _reply_identifier = None def __init__( self, From c69689d374f5bc012ab721caeb3e78b8bbe438c3 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 17:57:20 +0200 Subject: [PATCH 927/979] Bump to a135 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 224be7371..77ffbc4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a134" +version = "v0.40.0a135" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From f7b400ca1f58eed9cec815165950233477a03835 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 20:07:17 +0200 Subject: [PATCH 928/979] Revert use of None in subscribe to response --- plugwise_usb/connection/sender.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index c4d7c061f..a2dbe8f17 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -116,17 +116,11 @@ async def write_request_to_port(self, request: PlugwiseRequest) -> None: ) else: request.seq_id = response.seq_id - if request.no_response: - await request.subscribe_to_response( - self._receiver.subscribe_to_stick_responses, - None, - ) - else: - await request.subscribe_to_response( - self._receiver.subscribe_to_stick_responses, - self._receiver.subscribe_to_node_responses, - ) - _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) + await request.subscribe_to_response( + self._receiver.subscribe_to_stick_responses, + self._receiver.subscribe_to_node_responses, + ) + _LOGGER.debug("write_request_to_port | request has subscribed : %s", request) elif response.response_type == StickResponseType.TIMEOUT: _LOGGER.warning( "USB-Stick directly responded with communication timeout for %s", From b7fff92a03a7e4ad0ca77ae31d3a3057d5cdf19f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Mon, 19 May 2025 20:10:26 +0200 Subject: [PATCH 929/979] Bump to a136 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 77ffbc4d1..acc7b4673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a135" +version = "v0.40.0a136" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 3a11f86c275b8eb15a7a335e9183331b8c0e89f8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 14:29:09 +0200 Subject: [PATCH 930/979] Improve set_accept_join_request() --- plugwise_usb/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 1aea83c0d..183a0fa23 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -204,9 +204,12 @@ async def set_accept_join_request(self, state: bool) -> None: + "without node discovery be activated. Call discover() first." ) - if self._network.accept_join_request != state: - self._network.accept_join_request = state + # Observation: joining is only temporarily possible after a HA (re)start or + # Integration reload, force the setting when used otherwise + try: await self._network.allow_join_requests(state) + except (MessageError, NodeError) as exc: + raise NodeError(f"Failed setting accept joining: {exc}") async def clear_cache(self) -> None: """Clear current cache.""" From d2133990cabbf0db858ca0fc1ad3652d5768f65e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 19:08:20 +0200 Subject: [PATCH 931/979] Bump to a137 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index acc7b4673..8e0a05ae2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a136" +version = "v0.40.0a137" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 3806bac2cc71e0187552825769a1af31f8912763 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 19:32:53 +0200 Subject: [PATCH 932/979] Fix --- plugwise_usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 183a0fa23..9ba85bda1 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -209,7 +209,7 @@ async def set_accept_join_request(self, state: bool) -> None: try: await self._network.allow_join_requests(state) except (MessageError, NodeError) as exc: - raise NodeError(f"Failed setting accept joining: {exc}") + raise NodeError(f"Failed setting accept joining: {exc}") from exc async def clear_cache(self) -> None: """Clear current cache.""" From a8654ddc4d12a6e1a53edbe820004d9fe94dff27 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 19:35:02 +0200 Subject: [PATCH 933/979] Revert NODE_TIMEOUT back to 15 --- plugwise_usb/constants.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/constants.py b/plugwise_usb/constants.py index 472b3262a..eb99c232c 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -30,11 +30,10 @@ MESSAGE_HEADER: Final = b"\x05\x05\x03\x03" # Max timeout in seconds -STICK_TIME_OUT: Final = 11 # Stick responds with timeout messages within 10s. -# In bigger networks a response from a node could take up a while -NODE_TIME_OUT: Final = 45 -# A delayed response being received after 30 secs so lets use 45 seconds. -# @bouwew: NodeJoinAckResponse to a NodeAddRequest +# Stick responds with timeout messages within 10s. +STICK_TIME_OUT: Final = 11 +# In bigger networks a response from a Node could take up a while, so lets use 15 seconds. +NODE_TIME_OUT: Final = 15 MAX_RETRIES: Final = 3 SUPPRESS_INITIALIZATION_WARNINGS: Final = 10 # Minutes to suppress (expected) communication warning messages after initialization From 95fafb6f25670a3c88d0121727d4b35005cb978a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 20:00:16 +0200 Subject: [PATCH 934/979] Remove unused request-response pair --- tests/stick_test_data.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/stick_test_data.py b/tests/stick_test_data.py index fc8a54dc4..0f5cd92bc 100644 --- a/tests/stick_test_data.py +++ b/tests/stick_test_data.py @@ -70,13 +70,6 @@ b"000000D9" # JOIN_ACCEPTED + b"0098765432101234", # mac ), - b"\x05\x05\x03\x030007019999999999999999\r\n":( - "Reply to NodeAddRequest", - b"000000C1", # Success ack - b"0061" # NODE_REJOIN_ID - + b"FFFD" # REJOIN_RESPONSE_SEQ_ID - + b"9999999999999999", # mac - ), b"\x05\x05\x03\x03000D0098765432101234C208\r\n": ( "ping reply for 0098765432101234", b"000000C1", # Success ack From aa468f1c8a571564cf8db9effda6f5016b91cb33 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 20:21:03 +0200 Subject: [PATCH 935/979] Make sure to set accept_join_request --- plugwise_usb/network/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index e6861991f..15de610e3 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -535,6 +535,7 @@ async def allow_join_requests(self, state: bool) -> None: ) _LOGGER.debug("Sent AllowJoiningRequest to Circle+ with state=%s", state) + self.accept_join_request = state def subscribe_to_node_events( self, From 4a8bd7f3c28510c9958a7a64c37a523eab129299 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Tue, 20 May 2025 20:25:34 +0200 Subject: [PATCH 936/979] Bump to a138 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e0a05ae2..34c84e3e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a137" +version = "v0.40.0a138" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 96ddeeb3d74fd60fd2a511caed97c407fc868b8e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 21 May 2025 08:28:40 +0200 Subject: [PATCH 937/979] Output bool from set_accept_join_request() --- plugwise_usb/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 9ba85bda1..e375e79a3 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -190,7 +190,7 @@ def accept_join_request(self) -> bool | None: return None return self._network.accept_join_request - async def set_accept_join_request(self, state: bool) -> None: + async def set_accept_join_request(self, state: bool) -> bool: """Configure join request setting.""" if not self._controller.is_connected: raise StickError( @@ -210,6 +210,7 @@ async def set_accept_join_request(self, state: bool) -> None: await self._network.allow_join_requests(state) except (MessageError, NodeError) as exc: raise NodeError(f"Failed setting accept joining: {exc}") from exc + return True async def clear_cache(self) -> None: """Clear current cache.""" From 9aaa9fa55683976b6f638da2100fad0d8cc0446a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 21 May 2025 10:01:25 +0200 Subject: [PATCH 938/979] Bump to a139 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 34c84e3e1..4c318b854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a138" +version = "v0.40.0a139" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From e8091cbae37e9e3ac9652078faa5c08af5c35eb4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 21 May 2025 14:52:49 +0200 Subject: [PATCH 939/979] Update_node_registration() should not be async --- plugwise_usb/network/registry.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index f89c02337..ec6f80fac 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -223,6 +223,12 @@ async def update_missing_registrations(self, quick: bool = False) -> None: await self._full_scan_finished() self._full_scan_finished = None + def update_node_registration(self, mac: str) -> int: + """Register (re)joined node to Plugwise network and return network address.""" + self.update_network_registration(self._first_free_address, mac, None) + self._first_free_address += 1 + return self._first_free_address - 1 + def _stop_registration_task(self) -> None: """Stop the background registration task.""" if self._registration_task is None: @@ -253,12 +259,6 @@ async def register_node(self, mac: str) -> None: request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) await request.send() - async def update_node_registration(self, mac: str) -> int: - """Register (re)joined node to Plugwise network and return network address.""" - self.update_network_registration(self._first_free_address, mac, None) - self._first_free_address += 1 - return self._first_free_address - 1 - async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): From c057027cb907e221cc5aebf31956f50ea6a47f51 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Wed, 21 May 2025 14:55:17 +0200 Subject: [PATCH 940/979] Bump to a140 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4c318b854..9ab8d248d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a139" +version = "v0.40.0a140" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From d074dcb779a91f405cee88c322b25b9420bd2bda Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 22 May 2025 08:11:06 +0200 Subject: [PATCH 941/979] NodeAddRequest: return to default retrying --- plugwise_usb/messages/requests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 34e8a545e..c2c138cb2 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -426,8 +426,6 @@ def __init__( super().__init__(send_fn, mac) accept_value = 1 if accept else 0 self._args.append(Int(accept_value, length=2)) - - self.max_retries = 1 # No retrying, will delay the NodeRejoinResponse self.no_response = True async def send(self) -> None: From 434f853e3c00b0a1557eb81b2b3d875f2caf928f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 22 May 2025 08:15:32 +0200 Subject: [PATCH 942/979] Add fault-handling in registry-register_node() --- plugwise_usb/network/registry.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index ec6f80fac..23f285783 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -9,7 +9,7 @@ from ..api import NodeType from ..constants import UTF8 -from ..exceptions import CacheError, NodeError +from ..exceptions import CacheError, NodeError, StickError from ..helpers.util import validate_mac from ..messages.requests import ( CirclePlusScanRequest, @@ -257,7 +257,10 @@ async def register_node(self, mac: str) -> None: raise NodeError(f"MAC '{mac}' invalid") request = NodeAddRequest(self._send_to_controller, bytes(mac, UTF8), True) - await request.send() + try: + await request.send() + except StickError as exc: + raise NodeError("{exc}") from exc async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" From b92ed965985ce565bec013473822c2bf25bcb653 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Thu, 22 May 2025 08:37:24 +0200 Subject: [PATCH 943/979] Bump to a141 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ab8d248d..5e6c11dfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a140" +version = "v0.40.0a141" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 0d86054a0cdae3c976f02e8e35e4ef0cb64fbc8a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 11:04:00 +0200 Subject: [PATCH 944/979] Bump to v0.40.0b2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5e6c11dfa..633ba0085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0a141" +version = "v0.40.0b2" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From 190caf95aa434be6445bf7f60d5c96a859e7f7f1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 12:59:14 +0200 Subject: [PATCH 945/979] Fix network-node_awake_message() --- plugwise_usb/network/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 15de610e3..e29ac6a0e 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -208,30 +208,34 @@ async def _handle_stick_event(self, event: StickEvent) -> None: await gather(*[node.disconnect() for node in self._nodes.values()]) self._is_running = False - async def node_awake_message(self, response: PlugwiseResponse) -> bool: + async def node_awake_message(self, response: PlugwiseResponse) -> None: """Handle NodeAwakeResponse message.""" if not isinstance(response, NodeAwakeResponse): raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse" ) + mac = response.mac_decoded if self._awake_discovery.get(mac) is None: self._awake_discovery[mac] = response.timestamp - timedelta(seconds=15) + if mac in self._nodes: if self._awake_discovery[mac] < ( response.timestamp - timedelta(seconds=10) ): await self._notify_node_event_subscribers(NodeEvent.AWAKE, mac) self._awake_discovery[mac] = response.timestamp - return True + return + if (address := self._register.network_address(mac)) is None: if self._register.scan_completed: - return True + return + _LOGGER.debug( "Skip node awake message for %s because network registry address is unknown", mac, ) - return True + return if self._nodes.get(mac) is None: if ( @@ -243,7 +247,6 @@ async def node_awake_message(self, response: PlugwiseResponse) -> bool: ) else: _LOGGER.debug("duplicate maintenance awake discovery for %s", mac) - return True async def node_join_available_message(self, response: PlugwiseResponse) -> bool: """Handle NodeJoinAvailableResponse messages.""" From 76d360c8c014ba0aef969fcc244f9eb3ecda0a9f Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 13:53:10 +0200 Subject: [PATCH 946/979] Fix node-initialize() --- plugwise_usb/nodes/circle.py | 6 +++++- plugwise_usb/nodes/node.py | 6 +++--- plugwise_usb/nodes/scan.py | 4 +++- plugwise_usb/nodes/sed.py | 4 +++- plugwise_usb/nodes/sense.py | 4 +++- plugwise_usb/nodes/switch.py | 4 +++- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index dde3c5098..81836c782 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -849,12 +849,14 @@ async def initialize(self) -> bool: ) self._initialized = False return False + if not self._calibration and not await self.calibration_update(): _LOGGER.debug( "Failed to initialized node %s, no calibration", self._mac_in_str ) self._initialized = False return False + if ( self.skip_update(self._node_info, 30) and await self.node_info_update() is None @@ -869,7 +871,9 @@ async def initialize(self) -> bool: ) self._initialized = False return False - return await super().initialize() + + await super().initialize() + return True async def node_info_update( self, node_info: NodeInfoResponse | None = None diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 1f937b99c..8d32c6c56 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -398,15 +398,15 @@ async def _load_from_cache(self) -> bool: return False return True - async def initialize(self) -> bool: + async def initialize(self) -> None: """Initialize node configuration.""" if self._initialized: - return True + return + self._initialization_delay_expired = datetime.now(tz=UTC) + timedelta( minutes=SUPPRESS_INITIALIZATION_WARNINGS ) self._initialized = True - return True async def _available_update_state( self, available: bool, timestamp: datetime | None = None diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 4137d66aa..cd8782bcb 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -110,12 +110,14 @@ async def initialize(self) -> bool: """Initialize Scan node.""" if self._initialized: return True + self._unsubscribe_switch_group = await self._message_subscribe( self._switch_group, self._mac_in_bytes, (NODE_SWITCH_GROUP_ID,), ) - return await super().initialize() + await super().initialize() + return True async def unload(self) -> None: """Unload node.""" diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 69197c19b..faa862b5d 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -144,12 +144,14 @@ async def initialize(self) -> bool: """Initialize SED node.""" if self._initialized: return True + self._awake_subscription = await self._message_subscribe( self._awake_response, self._mac_in_bytes, (NODE_AWAKE_RESPONSE_ID,), ) - return await super().initialize() + await super().initialize() + return True def _load_defaults(self) -> None: """Load default configuration settings.""" diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index e73724ef0..1e4f0ac20 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -72,12 +72,14 @@ async def initialize(self) -> bool: """Initialize Sense node.""" if self._initialized: return True + self._sense_subscription = await self._message_subscribe( self._sense_report, self._mac_in_bytes, (SENSE_REPORT_ID,), ) - return await super().initialize() + await super().initialize() + return True async def unload(self) -> None: """Unload node.""" diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index bf84fb10a..80b1fc191 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -67,12 +67,14 @@ async def initialize(self) -> bool: """Initialize Switch node.""" if self._initialized: return True + self._switch_subscription = await self._message_subscribe( self._switch_group, self._mac_in_bytes, (NODE_SWITCH_GROUP_ID,), ) - return await super().initialize() + await super().initialize() + return True async def unload(self) -> None: """Unload node.""" From e245c8472fb12798b0f5099f63c15cbaf6e20a86 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 14:05:38 +0200 Subject: [PATCH 947/979] Sed format fixes --- plugwise_usb/nodes/sed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index faa862b5d..bc29bc652 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -500,6 +500,7 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected NodeAwakeResponse" ) + _LOGGER.debug("Device %s is awake for %s", self.name, response.awake_type) self._set_cache(CACHE_AWAKE_TIMESTAMP, response.timestamp) await self._available_update_state(True, response.timestamp) @@ -507,7 +508,6 @@ async def _awake_response(self, response: PlugwiseResponse) -> bool: # Pre populate the last awake timestamp if self._last_awake.get(response.awake_type) is None: self._last_awake[response.awake_type] = response.timestamp - # Skip awake messages when they are shortly after each other elif ( self._last_awake[response.awake_type] + timedelta(seconds=AWAKE_RETRY) From d735f8e6de5ab1c4f2b9c709666d40c6b550d2d2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 14:11:22 +0200 Subject: [PATCH 948/979] Fix taskId --- plugwise_usb/messages/requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index c2c138cb2..cca4a8c74 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -1287,11 +1287,11 @@ def __init__( self, send_fn: Callable[[PlugwiseRequest, bool], Awaitable[PlugwiseResponse | None]], mac: bytes, - taskId: int, + task_id: int, ) -> None: """Initialize NodeClearGroupMacRequest message object.""" super().__init__(send_fn, mac) - self._args.append(Int(taskId, length=2)) + self._args.append(Int(task_id, length=2)) class CircleSetScheduleValueRequest(PlugwiseRequest): From f3fc3d354d88a88a632f98ad0e2b0c7d13d2dae9 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 14:21:02 +0200 Subject: [PATCH 949/979] Nodes-helpers-pulses: remove unused lines --- plugwise_usb/nodes/helpers/pulses.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 41a4a6b59..7421a3f0d 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -935,8 +935,6 @@ def _missing_addresses_before( ): # Use consumption interval calc_interval_cons = timedelta(minutes=self._log_interval_consumption) - if self._log_interval_consumption == 0: - pass if not self._log_production: expected_timestamp = ( From 4b6641a9e730262933e7211d7425c074178bf524 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 14:28:57 +0200 Subject: [PATCH 950/979] Test_usb: add missing switch-asserts --- tests/test_usb.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_usb.py b/tests/test_usb.py index 7d299035d..7a8542dc4 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -2630,6 +2630,12 @@ async def test_node_discovery_and_load( pw_api.NodeFeature.SWITCH, ) ) + assert state[pw_api.NodeFeature.AVAILABLE].state + assert state[pw_api.NodeFeature.BATTERY].maintenance_interval == 60 + assert state[pw_api.NodeFeature.BATTERY].awake_duration == 10 + assert not state[pw_api.NodeFeature.BATTERY].clock_sync + assert state[pw_api.NodeFeature.BATTERY].clock_interval == 25200 + assert state[pw_api.NodeFeature.BATTERY].sleep_duration == 60 # endregion # test disable cache From 35e1e0bbc770e3dd394e41469b516496970e9a44 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 15:02:03 +0200 Subject: [PATCH 951/979] Merge if statements with enclosing one --- plugwise_usb/connection/__init__.py | 5 ++--- plugwise_usb/nodes/circle.py | 17 ++++++++--------- plugwise_usb/nodes/helpers/counter.py | 5 ++--- plugwise_usb/nodes/node.py | 16 ++++++++-------- plugwise_usb/nodes/scan.py | 14 ++++++-------- 5 files changed, 26 insertions(+), 31 deletions(-) diff --git a/plugwise_usb/connection/__init__.py b/plugwise_usb/connection/__init__.py index beee04725..06879100b 100644 --- a/plugwise_usb/connection/__init__.py +++ b/plugwise_usb/connection/__init__.py @@ -156,9 +156,8 @@ async def _handle_stick_event(self, event: StickEvent) -> None: if not self._queue.is_running: self._queue.start(self._manager) await self.initialize_stick() - elif event == StickEvent.DISCONNECTED: - if self._queue.is_running: - await self._queue.stop() + elif event == StickEvent.DISCONNECTED and self._queue.is_running: + await self._queue.stop() async def initialize_stick(self) -> None: """Initialize connection to the USB-stick.""" diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index 81836c782..f1db155ea 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -1087,15 +1087,14 @@ def _correct_power_pulses(self, pulses: int, offset: int) -> float: async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: """Update latest state for given feature.""" states: dict[NodeFeature, Any] = {} - if not self._available: - if not await self.is_online(): - _LOGGER.debug( - "Node %s did not respond, unable to update state", self._mac_in_str - ) - for feature in features: - states[feature] = None - states[NodeFeature.AVAILABLE] = self.available_state - return states + if not self._available and not await self.is_online(): + _LOGGER.debug( + "Node %s did not respond, unable to update state", self._mac_in_str + ) + for feature in features: + states[feature] = None + states[NodeFeature.AVAILABLE] = self.available_state + return states for feature in features: if feature not in self._features: diff --git a/plugwise_usb/nodes/helpers/counter.py b/plugwise_usb/nodes/helpers/counter.py index 5026ec494..8f64a3efb 100644 --- a/plugwise_usb/nodes/helpers/counter.py +++ b/plugwise_usb/nodes/helpers/counter.py @@ -82,9 +82,8 @@ def add_pulse_log( # pylint: disable=too-many-arguments """Add pulse log.""" if self._pulse_collection.add_log( address, slot, timestamp, pulses, import_only - ): - if not import_only: - self.update() + ) and not import_only: + self.update() def get_pulse_logs(self) -> dict[int, dict[int, PulseLogRecord]]: """Return currently collected pulse logs.""" diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 8d32c6c56..95a53f231 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -337,14 +337,14 @@ def _setup_protocol( for feature in node_features: if ( required_version := FEATURE_SUPPORTED_AT_FIRMWARE.get(feature) - ) is not None: - if ( - self._node_protocols.min - <= required_version - <= self._node_protocols.max - and feature not in self._features - ): - self._features += (feature,) + ) is not None and ( + self._node_protocols.min + <= required_version + <= self._node_protocols.max + and feature not in self._features + ): + self._features += (feature,) + self._node_info.features = self._features async def reconnect(self) -> None: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index cd8782bcb..cce83b064 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -166,14 +166,12 @@ def _daylight_mode_from_cache(self) -> bool: 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 == "True": - if ( - motion_timestamp := self._motion_timestamp_from_cache() - ) is not None: - if ( - datetime.now(tz=UTC) - motion_timestamp - ).seconds < self._reset_timer_from_cache() * 60: - return True + if ( + cached_motion_state == "True" + and (motion_timestamp := self._motion_timestamp_from_cache()) is not None + and (datetime.now(tz=UTC) - motion_timestamp).seconds < self._reset_timer_from_cache() * 60 + ): + return True return False return SCAN_DEFAULT_MOTION_STATE From 5e0a2d03805bbf2c5940395b4a2e12efa85a1acc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 15:38:37 +0200 Subject: [PATCH 952/979] Responses: optimize --- plugwise_usb/messages/responses.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/messages/responses.py b/plugwise_usb/messages/responses.py index 6ab9de3b3..16e6aa637 100644 --- a/plugwise_usb/messages/responses.py +++ b/plugwise_usb/messages/responses.py @@ -590,25 +590,17 @@ def __init__(self, protocol_version: str = "2.0") -> None: """Initialize NodeInfoResponse message object.""" super().__init__(b"0024") + self.datetime = DateTime() self._logaddress_pointer = LogAddr(0, length=8) - if protocol_version == "1.0": - # FIXME: Define "absoluteHour" variable - self.datetime = DateTime() + if protocol_version in ("1.0", "2.0"): + # FIXME 1.0: Define "absoluteHour" variable self._relay_state = Int(0, length=2) self._params += [ self.datetime, self._logaddress_pointer, self._relay_state, ] - elif protocol_version == "2.0": - self.datetime = DateTime() - self._relay_state = Int(0, length=2) - self._params += [ - self.datetime, - self._logaddress_pointer, - self._relay_state, - ] - elif protocol_version == "2.3": + if protocol_version == "2.3": # FIXME: Define "State_mask" variable self.state_mask = Int(0, length=2) self._params += [ From d69a677817eb6160cade331dfc33896b9667f169 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 15:55:14 +0200 Subject: [PATCH 953/979] Network-init: optimize --- plugwise_usb/network/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index e29ac6a0e..29553f0e0 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -278,11 +278,8 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: raise NodeError(f"Failed to obtain address for node {mac}") if self._nodes.get(mac) is None: - if self._discover_sed_tasks.get(mac) is None: - self._discover_sed_tasks[mac] = create_task( - self._discover_battery_powered_node(address, mac) - ) - elif self._discover_sed_tasks[mac].done(): + 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) ) From aa84271b6ab3adb398d8e47ec303ca124821c7ec Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 16:03:21 +0200 Subject: [PATCH 954/979] Fix doubles --- plugwise_usb/network/registry.py | 1 - plugwise_usb/nodes/circle.py | 2 -- plugwise_usb/nodes/sed.py | 1 - 3 files changed, 4 deletions(-) diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 23f285783..331d83581 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -145,7 +145,6 @@ async def retrieve_network_registration( return await self.retrieve_network_registration(address, retry=False) return None address = response.network_address - mac_of_node = response.registered_mac if (mac_of_node := response.registered_mac) == "FFFFFFFFFFFFFFFF": mac_of_node = "" return (address, mac_of_node) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index f1db155ea..d562ba83e 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -532,7 +532,6 @@ async def energy_log_update(self, address: int | None) -> bool: async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" - cache_data = self._get_cache(CACHE_ENERGY_COLLECTION) if (cache_data := self._get_cache(CACHE_ENERGY_COLLECTION)) is None: _LOGGER.warning( "Failed to restore energy log records from cache for node %s", self.name @@ -733,7 +732,6 @@ async def clock_synchronize(self) -> bool: datetime.now(tz=UTC), self._node_protocols.max, ) - node_response: NodeResponse | None = await set_clock_request.send() if (node_response := await set_clock_request.send()) is None: _LOGGER.warning( "Failed to (re)set the internal clock of %s", diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index bc29bc652..b0c15fb51 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -686,7 +686,6 @@ async def sed_configure( # pylint: disable=too-many-arguments maintenance_interval, sleep_duration, ) - response = await request.send() if (response := await request.send()) is None: self._new_battery_config = BatteryConfig() _LOGGER.warning( From 1b572aa093133affa072d80576fea2f0a9364d7c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 16:09:11 +0200 Subject: [PATCH 955/979] Add comment for pass --- plugwise_usb/messages/requests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index cca4a8c74..6d94eec82 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -285,8 +285,10 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" if self._response_future.done(): return + if self._seq_id is None or self._seq_id != stick_response.seq_id: return + if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) elif stick_response.ack_id == StickResponseType.FAILED: @@ -296,7 +298,7 @@ async def _process_stick_response(self, stick_response: StickResponse) -> None: NodeError(f"Stick failed request {self._seq_id}") ) elif stick_response.ack_id == StickResponseType.ACCEPT: - pass + pass # Nothing to do for ACCEPT else: _LOGGER.debug( "Unknown StickResponseType %s at %s for request %s", From d7912a15566bf4623759adb7e4fcc464fbbd35d1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 16:18:56 +0200 Subject: [PATCH 956/979] Use negative to avoid pass --- plugwise_usb/nodes/celsius.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 815c0f059..3d1dde1b0 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -27,14 +27,14 @@ async def load(self) -> bool: """Load and activate node features.""" if self._loaded: return True - self._node_info.is_battery_powered = True + self._node_info.is_battery_powered = True if self._cache_enabled: _LOGGER.debug( "Load Celsius node %s from cache", self._node_info.mac ) - if await self._load_from_cache(): - pass + if not await self._load_from_cache(): + _LOGGER.debug("Loading from cache failed") self._loaded = True self._setup_protocol( @@ -44,5 +44,6 @@ async def load(self) -> bool: if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) return True + _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) return False From fcb01c3599cc9675618eabcd246b6000c2536c90 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 16:20:01 +0200 Subject: [PATCH 957/979] Clean up --- plugwise_usb/nodes/circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index d562ba83e..e9314637f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -38,7 +38,7 @@ EnergyCalibrationRequest, NodeInfoRequest, ) -from ..messages.responses import NodeInfoResponse, NodeResponse, NodeResponseType +from ..messages.responses import NodeInfoResponse, NodeResponseType from .helpers import EnergyCalibration, raise_not_loaded from .helpers.counter import EnergyCounters from .helpers.firmware import CIRCLE_FIRMWARE_SUPPORT From 1d300f02f61af77864db53cf16d6402d0851c962 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:11:11 +0200 Subject: [PATCH 958/979] Improve _process_stick_response() --- plugwise_usb/messages/requests.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index 6d94eec82..fcae17d5e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -283,29 +283,34 @@ async def process_node_response(self, response: PlugwiseResponse) -> bool: async def _process_stick_response(self, stick_response: StickResponse) -> None: """Process incoming stick response.""" - if self._response_future.done(): + if ( + self._response_future.done() + or self._seq_id is None + or self._seq_id != stick_response.seq_id + ): return - if self._seq_id is None or self._seq_id != stick_response.seq_id: + if stick_response.ack_id == StickResponseType.ACCEPT: return if stick_response.ack_id == StickResponseType.TIMEOUT: self._response_timeout_expired(stick_timeout=True) - elif stick_response.ack_id == StickResponseType.FAILED: + return + + if stick_response.ack_id == StickResponseType.FAILED: self._unsubscribe_from_node() self._seq_id = None self._response_future.set_exception( NodeError(f"Stick failed request {self._seq_id}") ) - elif stick_response.ack_id == StickResponseType.ACCEPT: - pass # Nothing to do for ACCEPT - else: - _LOGGER.debug( - "Unknown StickResponseType %s at %s for request %s", - str(stick_response.ack_id), - stick_response, - self, - ) + return + + _LOGGER.debug( + "Unknown StickResponseType %s at %s for request %s", + str(stick_response.ack_id), + stick_response, + self, + ) async def _send_request(self, suppress_node_errors=False) -> PlugwiseResponse | None: """Send request.""" From e94f0c80cb4584eb9cff28b780e9950b6f85f6d7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:15:29 +0200 Subject: [PATCH 959/979] Improve PlugwiseCelsius class --- plugwise_usb/nodes/celsius.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/nodes/celsius.py b/plugwise_usb/nodes/celsius.py index 3d1dde1b0..c8245a747 100644 --- a/plugwise_usb/nodes/celsius.py +++ b/plugwise_usb/nodes/celsius.py @@ -29,12 +29,11 @@ async def load(self) -> bool: return True self._node_info.is_battery_powered = True + mac = self._node_info.mac if self._cache_enabled: - _LOGGER.debug( - "Load Celsius node %s from cache", self._node_info.mac - ) + _LOGGER.debug("Loading Celsius node %s from cache", mac) if not await self._load_from_cache(): - _LOGGER.debug("Loading from cache failed") + _LOGGER.debug("Loading Celsius node %s from cache failed", mac) self._loaded = True self._setup_protocol( @@ -42,8 +41,8 @@ async def load(self) -> bool: (NodeFeature.INFO, NodeFeature.TEMPERATURE), ) if await self.initialize(): - await self._loaded_callback(NodeEvent.LOADED, self.mac) + await self._loaded_callback(NodeEvent.LOADED, mac) return True - _LOGGER.debug("Load of Celsius node %s failed", self._node_info.mac) + _LOGGER.debug("Loading of Celsius node %s failed", mac) return False From 448217d9b2e8ccb59416b29b49135374358aa036 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:49:26 +0200 Subject: [PATCH 960/979] Implement switch improvements --- plugwise_usb/nodes/switch.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index 80b1fc191..d20b97ab6 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -40,7 +40,7 @@ def __init__( super().__init__(mac, address, controller, loaded_callback) self._switch_subscription: Callable[[], None] | None = None self._switch_state: bool | None = None - self._switch: bool | None = None + self._switch: bool = False async def load(self) -> bool: """Load and activate Switch node features.""" @@ -107,7 +107,7 @@ async def _switch_group(self, response: PlugwiseResponse) -> bool: async def _switch_state_update( self, switch_state: bool, timestamp: datetime ) -> None: - """Process motion state update.""" + """Process switch state update.""" _LOGGER.debug( "_switch_state_update for %s: %s -> %s", self.name, @@ -115,18 +115,13 @@ async def _switch_state_update( switch_state, ) state_update = False - # Switch on - if switch_state: - self._set_cache(CACHE_SWITCH_STATE, "True") - if self._switch_state is None or not self._switch: - self._switch_state = True - state_update = True - else: - # Switch off - self._set_cache(CACHE_SWITCH_STATE, "False") - if self._switch is None or self._switch: - self._switch_state = False - state_update = True + # Update cache + self._set_cache(CACHE_SWITCH_STATE, str(switch_state)) + # Check for a state change + if self._switch_state != switch_state: + self._switch_state = switch_state + state_update = True + self._set_cache(CACHE_SWITCH_TIMESTAMP, timestamp) if state_update: self._switch = switch_state From 9b4da7aee7af12806925e6fade995c6fa7461f56 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 19:56:18 +0200 Subject: [PATCH 961/979] Implement another two improvement suggestions --- plugwise_usb/nodes/node.py | 2 +- plugwise_usb/nodes/sed.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 95a53f231..b1c8e3a85 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -416,7 +416,7 @@ async def _available_update_state( if ( self._last_seen is not None and timestamp is not None - and (timestamp - self._last_seen).seconds > 5 + and (timestamp - self._last_seen).total_seconds > 5 ): self._last_seen = timestamp diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index b0c15fb51..50ee5facb 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -453,23 +453,23 @@ async def _configure_sed_task(self) -> bool: self.name, ) self._sed_config_task_scheduled = False - change_required = True - if self._new_battery_config.awake_duration is not None: - change_required = True - if self._new_battery_config.clock_interval is not None: - change_required = True - if self._new_battery_config.clock_sync is not None: - change_required = True - if self._new_battery_config.maintenance_interval is not None: - change_required = True - if self._new_battery_config.sleep_duration is not None: + change_required = False + if ( + self._new_battery_config.awake_duration is not None + or self._new_battery_config.clock_interval is not None + or self._new_battery_config.clock_sync is not None + or self._new_battery_config.maintenance_interval is not None + or self._new_battery_config.sleep_duration is not None + ): change_required = True + if not change_required: _LOGGER.debug( "_configure_sed_task | Device %s | no change", self.name, ) return True + _LOGGER.debug( "_configure_sed_task | Device %s | request change", self.name, From 35ee5ee3c6713aeaf9fad1152dfc1bcc36558fa0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 20:01:53 +0200 Subject: [PATCH 962/979] More improvements --- plugwise_usb/messages/requests.py | 6 +++--- plugwise_usb/network/registry.py | 6 ++++-- plugwise_usb/nodes/circle.py | 4 ++-- plugwise_usb/nodes/helpers/pulses.py | 2 +- plugwise_usb/nodes/node.py | 25 ++++++++++++++++--------- plugwise_usb/nodes/scan.py | 20 +++++++++++++------- plugwise_usb/nodes/sense.py | 14 ++++++++++---- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/plugwise_usb/messages/requests.py b/plugwise_usb/messages/requests.py index fcae17d5e..818c8c71e 100644 --- a/plugwise_usb/messages/requests.py +++ b/plugwise_usb/messages/requests.py @@ -202,7 +202,7 @@ def start_response_timeout(self) -> None: def stop_response_timeout(self) -> None: """Stop timeout for node response.""" - self._waiting_for_response = True + self._waiting_for_response = False if self._response_timeout is not None: self._response_timeout.cancel() @@ -1231,13 +1231,13 @@ def __init__( async def send(self) -> NodeResponse | None: """Send request.""" result = await self._send_request() - _LOGGER.warning("NodeSleepConfigRequest result: %s", result) + _LOGGER.debug("NodeSleepConfigRequest result: %s", result) if isinstance(result, NodeResponse): return result if result is None: return None raise MessageError( - f"Invalid response message. Received {result.__class__.__name__}, expected NodeAckResponse" + f"Invalid response message. Received {result.__class__.__name__}, expected NodeResponse" ) def __repr__(self) -> str: diff --git a/plugwise_usb/network/registry.py b/plugwise_usb/network/registry.py index 331d83581..144c92ab3 100644 --- a/plugwise_usb/network/registry.py +++ b/plugwise_usb/network/registry.py @@ -128,8 +128,10 @@ async def load_registry_from_cache(self) -> None: "Unable to restore network registry because cache is not initialized" ) return + if self._cache_restored: return + for address, registration in self._network_cache.registrations.items(): mac, node_type = registration if self._registry.get(address) is None: @@ -259,12 +261,12 @@ async def register_node(self, mac: str) -> None: try: await request.send() except StickError as exc: - raise NodeError("{exc}") from exc + raise NodeError(f"{exc}") from exc async def unregister_node(self, mac: str) -> None: """Unregister node from current Plugwise network.""" if not validate_mac(mac): - raise NodeError(f"MAC '{mac}' invalid") + raise NodeError(f"MAC {mac} invalid") mac_registered = False for registration in self._registry.values(): diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index e9314637f..f8aa358f7 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -822,14 +822,14 @@ async def _load_from_cache(self) -> bool: # Relay if await self._relay_load_from_cache(): _LOGGER.debug( - "Node %s failed to load relay state from cache", + "Node %s successfully loaded relay state from cache", self._mac_in_str, ) # Relay init config if feature is enabled if NodeFeature.RELAY_INIT in self._features: if await self._relay_init_load_from_cache(): _LOGGER.debug( - "Node %s failed to load relay_init state from cache", + "Node %s successfully loaded relay_init state from cache", self._mac_in_str, ) return True diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 7421a3f0d..9b9fa2c07 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -824,7 +824,7 @@ def _logs_missing(self, from_timestamp: datetime) -> list[int] | None: "The Circle %s does not overwrite old logged data, please reset the Circle's energy-logs via Source", self._mac, ) - return + return None if ( last_address == first_address diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index b1c8e3a85..2844a9c4d 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -618,15 +618,22 @@ def _get_cache_as_datetime(self, setting: str) -> datetime | None: if (timestamp_str := self._get_cache(setting)) is not None: data = timestamp_str.split("-") if len(data) == 6: - return datetime( - year=int(data[0]), - month=int(data[1]), - day=int(data[2]), - hour=int(data[3]), - minute=int(data[4]), - second=int(data[5]), - tzinfo=UTC, - ) + try: + return datetime( + year=int(data[0]), + month=int(data[1]), + day=int(data[2]), + hour=int(data[3]), + minute=int(data[4]), + second=int(data[5]), + tzinfo=UTC, + ) + except ValueError: + _LOGGER.warning( + "Invalid datetime format in cache for setting %s: %s", + setting, + timestamp_str, + ) return None def _set_cache(self, setting: str, value: Any) -> None: diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index cce83b064..ee54538b2 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -47,6 +47,11 @@ # Light override SCAN_DEFAULT_DAYLIGHT_MODE: Final = False +# Sensitivity values for motion sensor configuration +SENSITIVITY_HIGH_VALUE = 20 # 0x14 +SENSITIVITY_MEDIUM_VALUE = 30 # 0x1E +SENSITIVITY_OFF_VALUE = 255 # 0xFF + # endregion @@ -169,7 +174,7 @@ def _motion_from_cache(self) -> bool: if ( cached_motion_state == "True" and (motion_timestamp := self._motion_timestamp_from_cache()) is not None - and (datetime.now(tz=UTC) - motion_timestamp).seconds < self._reset_timer_from_cache() * 60 + and (datetime.now(tz=UTC) - motion_timestamp).total_seconds < self._reset_timer_from_cache() * 60 ): return True return False @@ -378,7 +383,7 @@ async def _motion_state_update( self._set_cache(CACHE_MOTION_STATE, "False") if self._motion_state.state is None or self._motion_state.state: if self._reset_timer_motion_on is not None: - reset_timer = (timestamp - self._reset_timer_motion_on).seconds + reset_timer = (timestamp - self._reset_timer_motion_on).total_seconds if self._motion_config.reset_timer is None: self._motion_config = replace( self._motion_config, @@ -465,11 +470,12 @@ async def scan_configure( ) -> bool: """Configure Scan device settings. Returns True if successful.""" # Default to medium: - sensitivity_value = 30 # b'1E' - if sensitivity_level == MotionSensitivity.HIGH: - sensitivity_value = 20 # b'14' - if sensitivity_level == MotionSensitivity.OFF: - sensitivity_value = 255 # b'FF' + sensitivity_value = SENSITIVITY_MEDIUM_VALUE + sensitivity_map = { + MotionSensitivity.HIGH: SENSITIVITY_HIGH_VALUE, + MotionSensitivity.OFF: SENSITIVITY_OFF_VALUE, + } + sensitivity_value = sensitivity_map.get(sensitivity_level, SENSITIVITY_MEDIUM_VALUE) request = ScanConfigureRequest( self._send, self._mac_in_bytes, diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index 1e4f0ac20..438ce6b0f 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -52,9 +52,10 @@ async def load(self) -> bool: """Load and activate Sense node features.""" if self._loaded: return True + self._node_info.is_battery_powered = True if self._cache_enabled: - _LOGGER.debug("Load Sense node %s from cache", self._node_info.mac) + _LOGGER.debug("Loading Sense node %s from cache", self._node_info.mac) if await self._load_from_cache(): self._loaded = True self._setup_protocol( @@ -64,7 +65,8 @@ async def load(self) -> bool: if await self.initialize(): await self._loaded_callback(NodeEvent.LOADED, self.mac) return True - _LOGGER.debug("Load of Sense node %s failed", self._node_info.mac) + + _LOGGER.debug("Loading of Sense node %s failed", self._node_info.mac) return False @raise_not_loaded @@ -94,6 +96,7 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: raise MessageError( f"Invalid response message type ({response.__class__.__name__}) received, expected SenseReportResponse" ) + report_received = False await self._available_update_state(True, response.timestamp) if response.temperature.value != 65535: self._temperature = int( @@ -103,6 +106,8 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: await self.publish_feature_update_to_subscribers( NodeFeature.TEMPERATURE, self._temperature ) + report_received = True + if response.humidity.value != 65535: self._humidity = int( SENSE_HUMIDITY_MULTIPLIER * (response.humidity.value / 65536) @@ -111,8 +116,9 @@ async def _sense_report(self, response: PlugwiseResponse) -> bool: await self.publish_feature_update_to_subscribers( NodeFeature.HUMIDITY, self._humidity ) - return True - return False + report_received = True + + return report_received @raise_not_loaded async def get_state(self, features: tuple[NodeFeature]) -> dict[NodeFeature, Any]: From b7a1e13a7b03000ddfe6e51edeb4375b2ac11415 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Fri, 23 May 2025 20:32:30 +0200 Subject: [PATCH 963/979] More improvements 2 --- plugwise_usb/network/__init__.py | 6 +++--- plugwise_usb/nodes/helpers/pulses.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 29553f0e0..9a5a42b2f 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -263,7 +263,7 @@ async def node_join_available_message(self, response: PlugwiseResponse) -> bool: raise NodeError(f"Unable to add Node ({mac}): {exc}") from exc if result: return True - + return False async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: @@ -286,7 +286,7 @@ async def node_rejoin_message(self, response: PlugwiseResponse) -> bool: else: _LOGGER.debug("duplicate awake discovery for %s", mac) return True - + return False def _unsubscribe_to_protocol_events(self) -> None: @@ -299,7 +299,7 @@ def _unsubscribe_to_protocol_events(self) -> None: self._unsubscribe_stick_event = None # endregion - + # region - Coordinator async def discover_network_coordinator(self, load: bool = False) -> bool: """Discover the Zigbee network coordinator (Circle+/Stealth+).""" diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 9b9fa2c07..ef539c3ae 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -271,7 +271,7 @@ def update_pulse_counter( self, pulses_consumed: int, pulses_produced: int, timestamp: datetime ) -> None: """Update pulse counter. - + Both device consumption and production counters reset after the beginning of a new hour. """ self._cons_pulsecounter_reset = False @@ -287,7 +287,7 @@ def update_pulse_counter( if ( self._pulses_production is not None - and self._pulses_production < pulses_produced + and self._pulses_production < pulses_produced ): self._prod_pulsecounter_reset = True _LOGGER.debug("update_pulse_counter | production pulses reset") @@ -313,9 +313,9 @@ def update_pulse_counter( def _update_rollover(self) -> None: """Update rollover states. - + When the last found timestamp is outside the interval `_last_log_timestamp` - to `_next_log_timestamp` the pulses should not be counted as part of the + to `_next_log_timestamp` the pulses should not be counted as part of the ongoing collection-interval. """ if self._log_addresses_missing is not None and self._log_addresses_missing: @@ -341,7 +341,7 @@ def _detect_rollover( next_log_timestamp: datetime | None, is_consumption=True, ) -> bool: - """Helper function for _update_rollover().""" + """Detect rollover based on timestamp comparisons.""" if ( self._pulses_timestamp is not None From 23a515a766c1fc5ffa27c772413d2987a1ea46e1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 09:36:46 +0200 Subject: [PATCH 964/979] Format total_seconds to int --- plugwise_usb/nodes/node.py | 2 +- plugwise_usb/nodes/scan.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index 2844a9c4d..1c0d74efb 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -416,7 +416,7 @@ async def _available_update_state( if ( self._last_seen is not None and timestamp is not None - and (timestamp - self._last_seen).total_seconds > 5 + and int((timestamp - self._last_seen).total_seconds()) > 5 ): self._last_seen = timestamp diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index ee54538b2..669df5434 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -174,7 +174,7 @@ def _motion_from_cache(self) -> bool: if ( cached_motion_state == "True" and (motion_timestamp := self._motion_timestamp_from_cache()) is not None - and (datetime.now(tz=UTC) - motion_timestamp).total_seconds < self._reset_timer_from_cache() * 60 + and int((datetime.now(tz=UTC) - motion_timestamp).total_seconds()) < self._reset_timer_from_cache() * 60 ): return True return False @@ -383,7 +383,7 @@ async def _motion_state_update( self._set_cache(CACHE_MOTION_STATE, "False") if self._motion_state.state is None or self._motion_state.state: if self._reset_timer_motion_on is not None: - reset_timer = (timestamp - self._reset_timer_motion_on).total_seconds + reset_timer = int((timestamp - self._reset_timer_motion_on).total_seconds()) if self._motion_config.reset_timer is None: self._motion_config = replace( self._motion_config, From 6e8694ec5ebbbbad3fcf7c96de0c70709085dbb1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 10:08:53 +0200 Subject: [PATCH 965/979] Scan: sensitivity-fixes --- plugwise_usb/nodes/scan.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index 669df5434..2beed8cf9 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -469,12 +469,12 @@ async def scan_configure( daylight_mode: bool, ) -> bool: """Configure Scan device settings. Returns True if successful.""" - # Default to medium: - sensitivity_value = SENSITIVITY_MEDIUM_VALUE sensitivity_map = { MotionSensitivity.HIGH: SENSITIVITY_HIGH_VALUE, + MotionSensitivity.MEDIUM: SENSITIVITY_MEDIUM_VALUE, MotionSensitivity.OFF: SENSITIVITY_OFF_VALUE, } + # Default to medium sensitivity_value = sensitivity_map.get(sensitivity_level, SENSITIVITY_MEDIUM_VALUE) request = ScanConfigureRequest( self._send, @@ -490,17 +490,20 @@ async def scan_configure( self._new_daylight_mode = None _LOGGER.warning("Failed to configure scan settings for %s", self.name) return False + if response.node_ack_type == NodeAckResponseType.SCAN_CONFIG_ACCEPTED: await self._scan_configure_update( motion_reset_timer, sensitivity_level, daylight_mode ) return True + _LOGGER.warning( "Unexpected response ack type %s for %s", response.node_ack_type, self.name, ) return False + self._new_reset_timer = None self._new_sensitivity_level = None self._new_daylight_mode = None From 3b5e551eaba2354261a611ece678fea98a71b3da Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 10:23:37 +0200 Subject: [PATCH 966/979] Switch: remove unused self --- plugwise_usb/nodes/switch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index d20b97ab6..1a2fed77e 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -40,7 +40,6 @@ def __init__( super().__init__(mac, address, controller, loaded_callback) self._switch_subscription: Callable[[], None] | None = None self._switch_state: bool | None = None - self._switch: bool = False async def load(self) -> bool: """Load and activate Switch node features.""" @@ -124,7 +123,6 @@ async def _switch_state_update( self._set_cache(CACHE_SWITCH_TIMESTAMP, timestamp) if state_update: - self._switch = switch_state await gather( *[ self.publish_feature_update_to_subscribers( From efbbfd53ad4afcb33f0f3dfd903b870f18f5185e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 10:27:19 +0200 Subject: [PATCH 967/979] Pulses: fix wrong logic --- plugwise_usb/nodes/helpers/pulses.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index ef539c3ae..3ad85b31b 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -905,18 +905,22 @@ def _last_known_duration(self) -> timedelta: """Duration for last known logs.""" if self._logs is None: raise EnergyError("Unable to return last known duration without any logs") + if len(self._logs) < 2: return timedelta(hours=1) + address, slot = self._last_log_reference() if address is None or slot is None: raise EnergyError("Unable to return last known duration without any logs") + last_known_timestamp = self._logs[address][slot].timestamp address, slot = calc_log_address(address, slot, -1) while ( self._log_exists(address, slot) - or self._logs[address][slot].timestamp == last_known_timestamp + and self._logs[address][slot].timestamp == last_known_timestamp ): address, slot = calc_log_address(address, slot, -1) + return self._logs[address][slot].timestamp - last_known_timestamp def _missing_addresses_before( From ed503f890dc7998a2c883e25127dadf0c1b218c7 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 10:30:26 +0200 Subject: [PATCH 968/979] Sed: remove unneeded code, implement suggested lock-fixes --- plugwise_usb/nodes/sed.py | 58 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index 50ee5facb..66ff9eaca 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -236,11 +236,10 @@ async def set_awake_duration(self, seconds: int) -> bool: raise ValueError( f"Invalid awake duration ({seconds}). It must be between 1 and 255 seconds." ) + if self._battery_config.awake_duration == seconds: - self._new_battery_config = replace( - self._new_battery_config, awake_duration=seconds - ) - return False + return False + self._new_battery_config = replace( self._new_battery_config, awake_duration=seconds ) @@ -251,6 +250,7 @@ async def set_awake_duration(self, seconds: int) -> bool: "set_awake_duration | Device %s | config scheduled", self.name, ) + return True @raise_not_loaded @@ -266,11 +266,10 @@ async def set_clock_interval(self, minutes: int) -> bool: raise ValueError( f"Invalid clock interval ({minutes}). It must be between 1 and 65535 minutes." ) + if self.battery_config.clock_interval == minutes: - self._new_battery_config = replace( - self._new_battery_config, clock_interval=minutes - ) return False + self._new_battery_config = replace( self._new_battery_config, clock_interval=minutes ) @@ -281,6 +280,7 @@ async def set_clock_interval(self, minutes: int) -> bool: "set_clock_interval | Device %s | config scheduled", self.name, ) + return True @raise_not_loaded @@ -293,10 +293,8 @@ async def set_clock_sync(self, sync: bool) -> bool: sync, ) if self._battery_config.clock_sync == sync: - self._new_battery_config = replace( - self._new_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()) @@ -305,6 +303,7 @@ async def set_clock_sync(self, sync: bool) -> bool: "set_clock_sync | Device %s | config scheduled", self.name, ) + return True @raise_not_loaded @@ -320,11 +319,10 @@ async def set_maintenance_interval(self, minutes: int) -> bool: raise ValueError( f"Invalid maintenance interval ({minutes}). It must be between 1 and 1440 minutes." ) + if self.battery_config.maintenance_interval == minutes: - self._new_battery_config = replace( - self._new_battery_config, maintenance_interval=minutes - ) return False + self._new_battery_config = replace( self._new_battery_config, maintenance_interval=minutes ) @@ -335,6 +333,7 @@ async def set_maintenance_interval(self, minutes: int) -> bool: "set_maintenance_interval | Device %s | config scheduled", self.name, ) + return True @raise_not_loaded @@ -353,11 +352,10 @@ async def set_sleep_duration(self, minutes: int) -> bool: raise ValueError( f"Invalid sleep duration ({minutes}). It must be between 1 and 65535 minutes." ) + if self._battery_config.sleep_duration == minutes: - self._new_battery_config = replace( - self._new_battery_config, sleep_duration=minutes - ) return False + self._new_battery_config = replace( self._new_battery_config, sleep_duration=minutes ) @@ -368,6 +366,7 @@ async def set_sleep_duration(self, minutes: int) -> bool: "set_sleep_duration | Device %s | config scheduled", self.name, ) + return True # endregion @@ -639,25 +638,24 @@ async def _send_tasks(self) -> None: """Send all tasks in queue.""" if len(self._send_task_queue) == 0: return - await self._send_task_lock.acquire() - 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 = [] - self._send_task_lock.release() + + 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 schedule_task_when_awake( self, task_fn: Coroutine[Any, Any, bool] ) -> None: """Add task to queue to be executed when node is awake.""" - await self._send_task_lock.acquire() - self._send_task_queue.append(task_fn) - self._send_task_lock.release() + async with self._send_task_lock: + self._send_task_queue.append(task_fn) async def sed_configure( # pylint: disable=too-many-arguments self, From a1d179a7b737ead8a099782b9c4e58c2d4c84ab4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 11:02:25 +0200 Subject: [PATCH 969/979] _last_known_duration(): add extra guarding --- plugwise_usb/nodes/helpers/pulses.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index 3ad85b31b..d045e23f6 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -921,6 +921,9 @@ def _last_known_duration(self) -> timedelta: ): address, slot = calc_log_address(address, slot, -1) + if self._logs[address][slot].timestamp == last_known_timestamp: + return timedelta(hours=1) + return self._logs[address][slot].timestamp - last_known_timestamp def _missing_addresses_before( From eef7c48b745fadf6a22a2dd96a704d80e2dbc09b Mon Sep 17 00:00:00 2001 From: ArnoutD Date: Sat, 24 May 2025 11:29:27 +0200 Subject: [PATCH 970/979] define constants for reused errors --- plugwise_usb/__init__.py | 16 +++++---------- plugwise_usb/messages/properties.py | 31 +++++++++++++++-------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index e375e79a3..c69fce361 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -10,7 +10,7 @@ from collections.abc import Callable, Coroutine from functools import wraps import logging -from typing import Any, TypeVar, cast +from typing import Any, TypeVar, cast, Final from .api import NodeEvent, PlugwiseNode, StickEvent from .connection import StickController @@ -19,7 +19,7 @@ FuncT = TypeVar("FuncT", bound=Callable[..., Any]) - +NOT_INITIALIZED_STICK_ERROR: Final[StickError] = StickError("Cannot load nodes when network is not initialized") _LOGGER = logging.getLogger(__name__) @@ -319,9 +319,7 @@ async def start_network(self) -> None: async def load_nodes(self) -> bool: """Load all discovered nodes.""" if self._network is None: - raise StickError( - "Cannot load nodes when network is not initialized" - ) + raise NOT_INITIALIZED_STICK_ERROR if not self._network.is_running: raise StickError( "Cannot load nodes when network is not started" @@ -333,9 +331,7 @@ async def load_nodes(self) -> bool: async def discover_coordinator(self, load: bool = False) -> None: """Discover the network coordinator.""" if self._network is None: - raise StickError( - "Cannot load nodes when network is not initialized" - ) + raise NOT_INITIALIZED_STICK_ERROR await self._network.discover_network_coordinator(load=load) @raise_not_connected @@ -343,9 +339,7 @@ async def discover_coordinator(self, load: bool = False) -> None: async def discover_nodes(self, load: bool = False) -> None: """Discover all nodes.""" if self._network is None: - raise StickError( - "Cannot load nodes when network is not initialized" - ) + raise NOT_INITIALIZED_STICK_ERROR await self._network.discover_nodes(load=load) @raise_not_connected diff --git a/plugwise_usb/messages/properties.py b/plugwise_usb/messages/properties.py index a2a065aa4..9cc51e861 100644 --- a/plugwise_usb/messages/properties.py +++ b/plugwise_usb/messages/properties.py @@ -3,12 +3,13 @@ import binascii from datetime import UTC, date, datetime, time, timedelta import struct -from typing import Any +from typing import Any, Final from ..constants import LOGADDR_OFFSET, PLUGWISE_EPOCH, UTF8 from ..exceptions import MessageError from ..helpers.util import int_to_uint +DESERIALIZE_ERROR: Final[MessageError] = MessageError("Unable to return value. Deserialize data first") class BaseType: """Generic single instance property.""" @@ -72,7 +73,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> bytes: """Return bytes value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -92,7 +93,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> str: """Return converted int value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -121,7 +122,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> int: """Return converted int value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -155,7 +156,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> int: """Return converted datetime value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -183,7 +184,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> datetime: """Return converted datetime value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -203,7 +204,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> int: """Return converted int value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -240,14 +241,14 @@ def deserialize(self, val: bytes) -> None: def value_set(self) -> bool: """True when datetime is converted.""" if not self._deserialized: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value is not None @property def value(self) -> datetime: """Return converted datetime value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -272,7 +273,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> time: """Return converted time value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -297,7 +298,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> str: """Return converted string value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -326,7 +327,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> time: """Return converted time value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -355,7 +356,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> date: """Return converted date value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -376,7 +377,7 @@ def deserialize(self, val: bytes) -> None: def value(self) -> float: """Return converted float value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value @@ -399,5 +400,5 @@ def deserialize(self, val: bytes) -> None: def value(self) -> int: """Return converted time value.""" if self._value is None: - raise MessageError("Unable to return value. Deserialize data first") + raise DESERIALIZE_ERROR return self._value From ccd1493641ab4bd4695e7848e28563a8295f88b0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 13:53:48 +0200 Subject: [PATCH 971/979] Pulses: correct mistake --- plugwise_usb/nodes/helpers/pulses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/nodes/helpers/pulses.py b/plugwise_usb/nodes/helpers/pulses.py index d045e23f6..638e5bfeb 100644 --- a/plugwise_usb/nodes/helpers/pulses.py +++ b/plugwise_usb/nodes/helpers/pulses.py @@ -1008,9 +1008,10 @@ def _missing_addresses_after( address, slot = calc_log_address(address, slot, 1) while expected_timestamp < target: address, slot = calc_log_address(address, slot, 1) - expected_timestamp += timedelta(hours=1) + expected_timestamp += calc_interval_cons if address not in addresses: addresses.append(address) + return addresses # Production logging active From 07459a24718a7bf3681329271f5ebca69471eaf2 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 14:12:08 +0200 Subject: [PATCH 972/979] Implement suggested port-related improvements --- plugwise_usb/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index c69fce361..be9d4e481 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -176,10 +176,8 @@ def port(self, port: str) -> None: raise StickError( "Unable to change port while connected. Disconnect first" ) - if self._port is None: - self._port = port - if port != self._port: - self._port = port + + self._port = port @property def accept_join_request(self) -> bool | None: @@ -278,14 +276,14 @@ async def connect(self, port: str | None = None) -> None: f"Already connected to {self._port}, " + "Close existing connection before (re)connect." ) - if port is not None: - self._port = port if self._port is None: raise StickError( "Unable to connect. " + "Path to USB-Stick is not defined, set port property first" ) + + self._port = port await self._controller.connect_to_stick( self._port, ) From 0fad395325a42c54460654a5298d4758bf069076 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 14:13:26 +0200 Subject: [PATCH 973/979] Add missing space --- plugwise_usb/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index be9d4e481..76d625d94 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -199,7 +199,7 @@ async def set_accept_join_request(self, state: bool) -> bool: if self._network is None or not self._network.is_running: raise StickError( "Cannot accept joining node" - + "without node discovery be activated. Call discover() first." + + " without node discovery be activated. Call discover() first." ) # Observation: joining is only temporarily possible after a HA (re)start or From 0364a36d6e93637e4baccd08041f6be138b9b6aa Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 14:34:58 +0200 Subject: [PATCH 974/979] Revert port-related deletion/change --- plugwise_usb/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugwise_usb/__init__.py b/plugwise_usb/__init__.py index 76d625d94..2e37b5be0 100644 --- a/plugwise_usb/__init__.py +++ b/plugwise_usb/__init__.py @@ -277,13 +277,15 @@ async def connect(self, port: str | None = None) -> None: "Close existing connection before (re)connect." ) + if port is not None: + self._port = port + if self._port is None: raise StickError( "Unable to connect. " + "Path to USB-Stick is not defined, set port property first" ) - self._port = port await self._controller.connect_to_stick( self._port, ) From a482a3590ad012e0eb32008480cc9180a3af7f58 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sat, 24 May 2025 14:59:19 +0200 Subject: [PATCH 975/979] Bump to b3 for testing --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 633ba0085..eb729fd73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0b2" +version = "v0.40.0b3" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ From e6afe02a4fd7957c8ef5bbfd019e69a0e3fb2702 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 25 May 2025 10:04:11 +0200 Subject: [PATCH 976/979] Update CHANGELOG --- CHANGELOG.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deb700187..c7082e067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,27 @@ # Changelog +## v0.40.0 + +- Make auto-joining work: (@bouwew) + - Correct setting of accept_join_request which enables the auto-joining temporarily + - Change NodeAddRequest to not expect a response, the NodeRejoinResponse is often delayed past the NODE_TIMEOUT + - Use the already present response-subscription to capture the NodeJoinAvailableResponse and initialize to joining of a Node +- Improve async task-handling, this should stress the CPU less (@bouwew) +- Limit cache-size to 24hrs instead of to 1 week (@bouwew, @ArnoutD) +- Implement support for devices with production enabled (@bouwew) +- Collect Stick NodeInfo for use in HA (@bouwew) +- Several bugfixes (@ArnoutD) +- Github improvements/fixes (@CoMPaTech) +- Async fixes (@ArnoutD - #141) + ## v0.40.0 (a22) - Correcting messageflow to HA ## v0.40.0 (a4) -Full rewrite of library into async version. Main list of changes: - +Full rewrite of library into async version (@brefra). +Main list of changes: - Full async and typed - Improved protocol handling - Support for local caching of collected data to improve startup and device detection From d708f948bad67e698ee49e651b5940c21d5d0537 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 25 May 2025 10:17:44 +0200 Subject: [PATCH 977/979] CHANGELOG format fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7082e067..524259232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Full rewrite of library into async version (@brefra). Main list of changes: + - Full async and typed - Improved protocol handling - Support for local caching of collected data to improve startup and device detection From 43bfb010509e6a4c208391c2cb4ed28b71e01cfc Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 25 May 2025 17:31:30 +0200 Subject: [PATCH 978/979] Improve/amend CHANGELOG --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524259232..e0d3a5d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,20 @@ ## v0.40.0 - Make auto-joining work: (@bouwew) - - Correct setting of accept_join_request which enables the auto-joining temporarily + - Correct setting of accept_join_request which enables the auto-joining (only temporarily!) - Change NodeAddRequest to not expect a response, the NodeRejoinResponse is often delayed past the NODE_TIMEOUT - - Use the already present response-subscription to capture the NodeJoinAvailableResponse and initialize to joining of a Node + - Use the already present response-subscription to capture the NodeRejoinResponse and initialize to joining of a Node - Improve async task-handling, this should stress the CPU less (@bouwew) - Limit cache-size to 24hrs instead of to 1 week (@bouwew, @ArnoutD) - Implement support for devices with production enabled (@bouwew) - Collect Stick NodeInfo for use in HA (@bouwew) - Several bugfixes (@ArnoutD) -- Github improvements/fixes (@CoMPaTech) -- Async fixes (@ArnoutD - #141) +- Github improvements/fixes, python 3.13 (@CoMPaTech, @bouwew) +- Async fixes - implement queue-based message processing (@ArnoutD - #141) ## v0.40.0 (a22) -- Correcting messageflow to HA +- Correcting messageflow to HA (@ArnoutD) ## v0.40.0 (a4) From cba53b137120af20fe4dd54501c5c3576de8acc1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk Date: Sun, 25 May 2025 17:36:40 +0200 Subject: [PATCH 979/979] Set to v0.40.0 release-version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eb729fd73..cef33e18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "v0.40.0b3" +version = "v0.40.0" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [