diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 86aef558e..6d6af5cd0 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -13,32 +13,49 @@ on: types: closed branches: - main - - async jobs: publishing: name: Build and publish Python 🐍 distributions 📦 to PyPI runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read # Required by actions/checkout + id-token: write # Needed for OIDC-based Trusted Publishing # Only trigger on merges, not just closes if: github.event.pull_request.merged == true steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build + - name: Prepare uv + run: | + pip install uv + uv venv --seed venv + . venv/bin/activate + uv pip install toml + - name: Check for existing package on PyPI + id: check_package + run: | + . venv/bin/activate + PACKAGE_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") + PACKAGE_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['name'])") + + echo "Checking for package: $PACKAGE_NAME==$PACKAGE_VERSION" + + if curl -s "https://pypi.org/pypi/$PACKAGE_NAME/json" | jq -r '.releases | keys[]' | grep -q "^$PACKAGE_VERSION$"; then + echo "Package version already exists. Skipping upload." + echo "should_publish=false" >> $GITHUB_OUTPUT + else + echo "Package version does not exist. Proceeding with upload." + echo "should_publish=true" >> $GITHUB_OUTPUT + fi + - name: Build + if: steps.check_package.outputs.should_publish == 'true' + run: | + . venv/bin/activate + uv build - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.pypi_token }} - skip_existing: true + if: steps.check_package.outputs.should_publish == 'true' + run: | + . venv/bin/activate + uv publish diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 7a78313e7..26a3361cd 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -273,6 +273,10 @@ jobs: test-publishing: name: Build and publish Python 🐍 distributions 📦 to TestPyPI runs-on: ubuntu-latest + environment: testpypi + permissions: + contents: read # Required by actions/checkout + id-token: write # Needed for OIDC-based Trusted Publishing needs: - cache - prepare @@ -281,34 +285,38 @@ jobs: steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Create or reuse cache - id: cache-reuse - uses: ./.github/actions/restore-venv - with: - cache-key: ${{ needs.cache.outputs.cache-key }} - python-version: ${{ steps.python.outputs.python-version }} - venv-dir: ${{ env.VENV }} - precommit-home: ${{ env.PRE_COMMIT_HOME }} - - name: Install pypa/build + - name: Prepare uv run: | + pip install uv + uv venv --seed venv . venv/bin/activate - uv pip install build - - name: Build a binary wheel and a source tarball + uv pip install toml + - name: Check for existing package on TestPyPI + id: check_package run: | . venv/bin/activate - python3 -m build - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - continue-on-error: true - with: - password: ${{ secrets.testpypi_token }} - repository_url: https://test.pypi.org/legacy/ - skip_existing: true + PACKAGE_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") + PACKAGE_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['name'])") + + echo "Checking for package: $PACKAGE_NAME==$PACKAGE_VERSION" + + if curl -s "https://test.pypi.org/pypi/$PACKAGE_NAME/json" | jq -r '.releases | keys[]' | grep -q "^$PACKAGE_VERSION$"; then + echo "Package version already exists. Skipping upload." + echo "should_publish=false" >> $GITHUB_OUTPUT + else + echo "Package version does not exist. Proceeding with upload." + echo "should_publish=true" >> $GITHUB_OUTPUT + fi + - name: Build + if: steps.check_package.outputs.should_publish == 'true' + run: | + . venv/bin/activate + uv build + - name: Publish distribution 📦 to TestPyPI + if: steps.check_package.outputs.should_publish == 'true' + run: | + . venv/bin/activate + uv publish --publish-url https://test.pypi.org/legacy/ complexity: name: Process test complexity diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8688f3907..3b3e17c79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ default_language_version: repos: # Run manually in CI skipping the branch checks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.2 + rev: v0.12.4 hooks: - id: ruff name: "Ruff check" @@ -55,7 +55,7 @@ repos: - id: yamllint name: "YAML linting" - repo: https://github.com/biomejs/pre-commit - rev: v2.0.6 + rev: v2.1.2 hooks: - id: biome-lint name: "Verifying/updating code with biome (improved prettier)" diff --git a/CHANGELOG.md b/CHANGELOG.md index ea852f188..c3ba3cf55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.44.8 + +- PR [291](https://github.com/plugwise/python-plugwise-usb/pull/291): Collect send-queue depth via PriorityQueue.qsize(), this provides a more accurate result +- Fix for [#288](https://github.com/plugwise/plugwise_usb-beta/issues/288) via PR [293](https://github.com/plugwise/python-plugwise-usb/pull/293) +- Chores move module publishing on (test)pypi to Trusted Publishing (and using uv) - released as alpha 0.44.8a0 to demonstrate functionality + ## v0.44.7 - 2025-07-08 - PR [282](https://github.com/plugwise/python-plugwise-usb/pull/282): Finalize switch implementation diff --git a/plugwise_usb/connection/manager.py b/plugwise_usb/connection/manager.py index dbba049ef..7dea480be 100644 --- a/plugwise_usb/connection/manager.py +++ b/plugwise_usb/connection/manager.py @@ -36,15 +36,6 @@ def __init__(self) -> None: ] = {} self._unsubscribe_stick_events: Callable[[], None] | None = None - @property - def queue_depth(self) -> int: - """Return estimated size of pending responses.""" - return self._sender.processed_messages - self._receiver.processed_messages - - def correct_received_messages(self, correction: int) -> None: - """Correct received messages count.""" - 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 614277447..a74d8d430 100644 --- a/plugwise_usb/connection/queue.py +++ b/plugwise_usb/connection/queue.py @@ -8,6 +8,7 @@ import logging from ..api import StickEvent +from ..constants import REPORT_QUEUE_FILLING_UP from ..exceptions import MessageError, NodeTimeout, StickError, StickTimeout from ..messages import Priority from ..messages.requests import NodePingRequest, PlugwiseCancelRequest, PlugwiseRequest @@ -112,13 +113,11 @@ async def submit(self, request: PlugwiseRequest) -> PlugwiseResponse | None: _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 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}" @@ -145,12 +144,12 @@ async def _send_queue_worker(self) -> None: self._submit_queue.task_done() return - if self._stick.queue_depth > 3: + qsize = self._submit_queue.qsize() + if qsize > REPORT_QUEUE_FILLING_UP: + # When the queue size grows, rate-limit the sending of requests to avoid overloading the network await sleep(0.125) - if self._stick.queue_depth > 3: - _LOGGER.warning( - "Awaiting plugwise responses %d", self._stick.queue_depth - ) + if qsize > REPORT_QUEUE_FILLING_UP: + _LOGGER.warning("Awaiting plugwise responses %d", qsize) await self._stick.write_to_stick(request) self._submit_queue.task_done() diff --git a/plugwise_usb/connection/receiver.py b/plugwise_usb/connection/receiver.py index 5caf56266..4651dab6f 100644 --- a/plugwise_usb/connection/receiver.py +++ b/plugwise_usb/connection/receiver.py @@ -99,7 +99,6 @@ 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 @@ -138,20 +137,11 @@ 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: - """Correct 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") @@ -291,7 +281,6 @@ 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") diff --git a/plugwise_usb/connection/sender.py b/plugwise_usb/connection/sender.py index f12709849..36030f13e 100644 --- a/plugwise_usb/connection/sender.py +++ b/plugwise_usb/connection/sender.py @@ -38,17 +38,11 @@ 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. @@ -149,7 +143,6 @@ 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/constants.py b/plugwise_usb/constants.py index de2ee4028..22d66ce92 100644 --- a/plugwise_usb/constants.py +++ b/plugwise_usb/constants.py @@ -19,6 +19,7 @@ # Value limits MAX_UINT_2: Final = 255 # 8-bit unsigned integer max MAX_UINT_4: Final = 65535 # 16-bit unsigned integer max +REPORT_QUEUE_FILLING_UP: Final = 8 # Time DAY_IN_HOURS: Final = 24 diff --git a/plugwise_usb/network/__init__.py b/plugwise_usb/network/__init__.py index 3d05f20b0..660821085 100644 --- a/plugwise_usb/network/__init__.py +++ b/plugwise_usb/network/__init__.py @@ -481,10 +481,9 @@ async def _load_node(self, mac: str) -> bool: return False if self._nodes[mac].is_loaded: return True - if await self._nodes[mac].load(): - await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) - return True - return False + await self._nodes[mac].load() + await self._notify_node_event_subscribers(NodeEvent.LOADED, mac) + return True async def _load_stragglers(self) -> None: """Retry failed load operation.""" diff --git a/plugwise_usb/nodes/__init__.py b/plugwise_usb/nodes/__init__.py index 6022017f2..b8a12e5bf 100644 --- a/plugwise_usb/nodes/__init__.py +++ b/plugwise_usb/nodes/__init__.py @@ -26,6 +26,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseCirclePlus( mac, address, + node_type, controller, loaded_callback, ) @@ -33,6 +34,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseCircle( mac, address, + node_type, controller, loaded_callback, ) @@ -40,6 +42,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseSwitch( mac, address, + node_type, controller, loaded_callback, ) @@ -47,6 +50,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseSense( mac, address, + node_type, controller, loaded_callback, ) @@ -54,6 +58,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseScan( mac, address, + node_type, controller, loaded_callback, ) @@ -61,6 +66,7 @@ def get_plugwise_node( # noqa: PLR0911 return PlugwiseStealth( mac, address, + node_type, controller, loaded_callback, ) diff --git a/plugwise_usb/nodes/circle.py b/plugwise_usb/nodes/circle.py index dabb3051f..682d1193f 100644 --- a/plugwise_usb/nodes/circle.py +++ b/plugwise_usb/nodes/circle.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime from functools import wraps import logging -from typing import Any, TypeVar, cast +from typing import Any, Final, TypeVar, cast from ..api import ( EnergyStatistics, @@ -16,6 +16,7 @@ NodeFeature, NodeInfo, NodeInfoMessage, + NodeType, PowerStatistics, RelayConfig, RelayLock, @@ -58,6 +59,19 @@ CACHE_RELAY_INIT = "relay_init" CACHE_RELAY_LOCK = "relay_lock" +CIRCLE_FEATURES: Final = ( + NodeFeature.CIRCLE, + NodeFeature.RELAY, + NodeFeature.RELAY_INIT, + NodeFeature.RELAY_LOCK, + NodeFeature.ENERGY, + NodeFeature.POWER, +) + + +# Default firmware if not known +DEFAULT_FIRMWARE: Final = datetime(2008, 8, 26, 15, 46, tzinfo=UTC) + FuncT = TypeVar("FuncT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) @@ -81,11 +95,12 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize base class for Sleeping End Device.""" - super().__init__(mac, address, controller, loaded_callback) + super().__init__(mac, address, node_type, controller, loaded_callback) # Relay self._relay_lock: RelayLock = RelayLock() @@ -350,12 +365,12 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 # 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_energy_log_requested, _ = await self.energy_log_update( self._current_log_address ) if self._energy_counters.log_rollover: - # Try updating node_info + # Try updating node_info to collect the updated energy log address if await self.node_info_update() is None: _LOGGER.debug( "async_energy_update | %s | Log rollover | node_info_update failed", @@ -364,7 +379,8 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return None # Try collecting energy-stats for _current_log_address - if not await self.energy_log_update(self._current_log_address): + result, _ = await self.energy_log_update(self._current_log_address) + if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update failed", self._mac_in_str, @@ -377,7 +393,8 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 _prev_log_address, _ = calc_log_address( self._current_log_address, 1, -4 ) - if not await self.energy_log_update(_prev_log_address): + result, _ = await self.energy_log_update(_prev_log_address) + if not result: _LOGGER.debug( "async_energy_update | %s | Log rollover | energy_log_update %s failed", self._mac_in_str, @@ -397,7 +414,8 @@ async def energy_update(self) -> EnergyStatistics | None: # noqa: PLR0911 PLR09 return self._energy_counters.energy_statistics if len(missing_addresses) == 1: - if await self.energy_log_update(missing_addresses[0]): + result, _ = await self.energy_log_update(missing_addresses[0]) + if result: await self.power_update() _LOGGER.debug( "async_energy_update for %s | single energy log is missing | %s", @@ -447,7 +465,8 @@ async def _get_initial_energy_logs(self) -> None: log_address = self._current_log_address prev_address_timestamp: datetime | None = None while total_addresses > 0: - if not await self.energy_log_update(log_address): + result, empty_log = await self.energy_log_update(log_address) + if result and empty_log: # Handle case with None-data in all address slots _LOGGER.debug( "Energy None-data collected from log address %s, stopping collection", @@ -526,10 +545,12 @@ async def get_missing_energy_logs(self) -> None: if self._cache_enabled: await self._energy_log_records_save_to_cache() - async def energy_log_update(self, address: int | None) -> bool: + async def energy_log_update(self, address: int | None) -> tuple[bool, bool]: """Request energy log statistics from node. Returns true if successful.""" + empty_log = False + result = False if address is None: - return False + return result, empty_log _LOGGER.debug( "Request of energy log at address %s for node %s", @@ -543,7 +564,7 @@ async def energy_log_update(self, address: int | None) -> bool: str(address), self._mac_in_str, ) - return False + return result, empty_log _LOGGER.debug("EnergyLogs data from %s, address=%s", self._mac_in_str, address) await self._available_update_state(True, response.timestamp) @@ -560,6 +581,7 @@ async def energy_log_update(self, address: int | None) -> bool: ) if log_timestamp is None or log_pulses is None: self._energy_counters.add_empty_log(response.log_address, _slot) + empty_log = True elif await self._energy_log_record_update_state( response.log_address, _slot, @@ -579,15 +601,15 @@ async def energy_log_update(self, address: int | None) -> bool: ) last_energy_timestamp_collected = True + result = True self._energy_counters.update() if energy_record_update: _LOGGER.debug( "Saving energy record update to cache for %s", self._mac_in_str ) await self.save_cache() - return True - return False + return result, empty_log async def _energy_log_records_load_from_cache(self) -> bool: """Load energy_log_record from cache.""" @@ -875,10 +897,10 @@ async def clock_synchronize(self) -> bool: return True return False - async def load(self) -> bool: + async def load(self) -> None: """Load and activate Circle node features.""" if self._loaded: - return True + return if self._cache_enabled: _LOGGER.debug("Loading Circle node %s from cache", self._mac_in_str) @@ -888,37 +910,20 @@ async def load(self) -> bool: _LOGGER.debug("Retrieving Info For Circle node %s", self._mac_in_str) # Check if node is online - 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, - ) - return False - - # Get node info - if await self.node_info_update() is None: + if ( + not self._available and not await self.is_online() + ) or await self.node_info_update() is None: _LOGGER.debug( - "Failed to load Circle node %s because it is not responding to information request", + "Failed to retrieve NodeInfo for %s, loading defaults", self._mac_in_str, ) - return False + await self._load_defaults() - self._loaded = True + self._loaded = True - self._setup_protocol( - CIRCLE_FIRMWARE_SUPPORT, - ( - NodeFeature.CIRCLE, - NodeFeature.RELAY, - NodeFeature.RELAY_INIT, - NodeFeature.RELAY_LOCK, - NodeFeature.ENERGY, - NodeFeature.POWER, - ), - ) + self._setup_protocol(CIRCLE_FIRMWARE_SUPPORT, CIRCLE_FEATURES) await self._loaded_callback(NodeEvent.LOADED, self.mac) await self.initialize() - return True async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" @@ -965,6 +970,15 @@ async def _load_from_cache(self) -> bool: return result + async def _load_defaults(self) -> None: + """Load default configuration settings.""" + if self._node_info.model is None: + self._node_info.model = "Circle" + if self._node_info.name is None: + self._node_info.name = f"Circle {self._node_info.mac[-5:]}" + if self._node_info.firmware is None: + self._node_info.firmware = DEFAULT_FIRMWARE + @raise_not_loaded async def initialize(self) -> bool: """Initialize node.""" diff --git a/plugwise_usb/nodes/node.py b/plugwise_usb/nodes/node.py index bde5d949c..ce238a2e6 100644 --- a/plugwise_usb/nodes/node.py +++ b/plugwise_usb/nodes/node.py @@ -48,7 +48,6 @@ ) CACHE_FIRMWARE = "firmware" -CACHE_NODE_TYPE = "node_type" CACHE_HARDWARE = "hardware" CACHE_NODE_INFO_TIMESTAMP = "node_info_timestamp" CACHE_RELAY = "relay" @@ -61,16 +60,22 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Plugwise base node class.""" super().__init__() + self.node_type = node_type self._loaded_callback = loaded_callback self._message_subscribe = controller.subscribe_to_messages self._features: tuple[NodeFeature, ...] = NODE_FEATURES self._last_seen = datetime.now(tz=UTC) - self._node_info = NodeInfo(mac, address) + self._node_info = NodeInfo( + mac=mac, + zigbee_address=address, + node_type=self.node_type, + ) self._ping = NetworkStatistics() self._mac_in_bytes = bytes(mac, encoding=UTF8) self._mac_in_str = mac @@ -462,7 +467,6 @@ 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, node_info.timestamp) await self.update_node_details(node_info) return self._node_info @@ -475,16 +479,13 @@ async def _node_info_load_from_cache(self) -> bool: firmware = self._get_cache_as_datetime(CACHE_FIRMWARE) hardware = self._get_cache(CACHE_HARDWARE) - 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)) relay_state = self._get_cache(CACHE_RELAY) == "True" timestamp = self._get_cache_as_datetime(CACHE_NODE_INFO_TIMESTAMP) node_info = NodeInfoMessage( current_logaddress_pointer=None, firmware=firmware, hardware=hardware, - node_type=node_type, + node_type=self.node_type, relay_state=relay_state, timestamp=timestamp, ) @@ -509,9 +510,6 @@ async def update_node_details( complete = True if node_info.node_type is None: complete = False - else: - self._node_info.node_type = NodeType(node_info.node_type) - self._set_cache(CACHE_NODE_TYPE, self._node_info.node_type.value) if node_info.firmware is None: complete = False diff --git a/plugwise_usb/nodes/scan.py b/plugwise_usb/nodes/scan.py index db65d550b..775488363 100644 --- a/plugwise_usb/nodes/scan.py +++ b/plugwise_usb/nodes/scan.py @@ -9,7 +9,14 @@ import logging from typing import Any, Final -from ..api import MotionConfig, MotionSensitivity, MotionState, NodeEvent, NodeFeature +from ..api import ( + MotionConfig, + MotionSensitivity, + MotionState, + NodeEvent, + NodeFeature, + NodeType, +) from ..connection import StickController from ..constants import MAX_UINT_2 from ..exceptions import MessageError, NodeError, NodeTimeout @@ -48,11 +55,20 @@ # Light override SCAN_DEFAULT_DAYLIGHT_MODE: Final = False +# Default firmware if not known +DEFAULT_FIRMWARE: Final = datetime(2010, 11, 4, 16, 58, 46, tzinfo=UTC) + # Sensitivity values for motion sensor configuration SENSITIVITY_HIGH_VALUE = 20 # 0x14 SENSITIVITY_MEDIUM_VALUE = 30 # 0x1E SENSITIVITY_OFF_VALUE = 255 # 0xFF +# Scan Features +SCAN_FEATURES: Final = ( + NodeFeature.MOTION, + NodeFeature.MOTION_CONFIG, +) + # endregion @@ -63,11 +79,12 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, controller, loaded_callback) + super().__init__(mac, address, node_type, controller, loaded_callback) self._unsubscribe_switch_group: Callable[[], None] | None = None self._reset_timer_motion_on: datetime | None = None self._scan_subscription: Callable[[], None] | None = None @@ -85,37 +102,23 @@ def __init__( # region Load & Initialize - async def load(self) -> bool: + async def load(self) -> None: """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) - await self._load_from_cache() - else: - self._load_defaults() - self._loaded = True - self._setup_protocol( - SCAN_FIRMWARE_SUPPORT, - ( - NodeFeature.BATTERY, - NodeFeature.INFO, - NodeFeature.PING, - NodeFeature.MOTION, - NodeFeature.MOTION_CONFIG, - ), - ) - 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 + return + + _LOGGER.debug("Loading Scan node %s", self._node_info.mac) + await super().load() + + self._setup_protocol(SCAN_FIRMWARE_SUPPORT, SCAN_FEATURES) + await self.initialize() + await self._loaded_callback(NodeEvent.LOADED, self.mac) @raise_not_loaded - async def initialize(self) -> bool: + async def initialize(self) -> None: """Initialize Scan node.""" if self._initialized: - return True + return self._unsubscribe_switch_group = await self._message_subscribe( self._switch_group, @@ -123,7 +126,6 @@ async def initialize(self) -> bool: (NODE_SWITCH_GROUP_ID,), ) await super().initialize() - return True async def unload(self) -> None: """Unload node.""" @@ -132,9 +134,9 @@ async def unload(self) -> None: await super().unload() # region Caching - def _load_defaults(self) -> None: + async def _load_defaults(self) -> None: """Load default configuration settings.""" - super()._load_defaults() + await super()._load_defaults() self._motion_state = MotionState( state=SCAN_DEFAULT_MOTION_STATE, timestamp=None, @@ -144,11 +146,21 @@ def _load_defaults(self) -> None: daylight_mode=SCAN_DEFAULT_DAYLIGHT_MODE, sensitivity_level=SCAN_DEFAULT_SENSITIVITY, ) + if self._node_info.model is None: + self._node_info.model = "Scan" + if self._node_info.name is None: + self._node_info.name = f"Scan {self._node_info.mac[-5:]}" + if self._node_info.firmware is None: + self._node_info.firmware = DEFAULT_FIRMWARE + self._new_reset_timer = SCAN_DEFAULT_MOTION_RESET_TIMER + self._new_daylight_mode = SCAN_DEFAULT_DAYLIGHT_MODE + self._new_sensitivity_level = SCAN_DEFAULT_SENSITIVITY + await self.schedule_task_when_awake(self._configure_scan_task()) + self._scan_config_task_scheduled = True 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._motion_state = MotionState( state=self._motion_from_cache(), @@ -530,7 +542,7 @@ async def _scan_configure_update( daylight_mode=daylight_mode, ) self._set_cache(CACHE_MOTION_RESET_TIMER, str(motion_reset_timer)) - self._set_cache(CACHE_SCAN_SENSITIVITY, sensitivity_level.value) + self._set_cache(CACHE_SCAN_SENSITIVITY, sensitivity_level) if daylight_mode: self._set_cache(CACHE_SCAN_DAYLIGHT_MODE, "True") else: diff --git a/plugwise_usb/nodes/sed.py b/plugwise_usb/nodes/sed.py index e45849c5a..d454a76c6 100644 --- a/plugwise_usb/nodes/sed.py +++ b/plugwise_usb/nodes/sed.py @@ -17,7 +17,7 @@ import logging from typing import Any, Final -from ..api import BatteryConfig, NodeEvent, NodeFeature, NodeInfo +from ..api import BatteryConfig, NodeEvent, NodeFeature, NodeInfo, NodeType from ..connection import StickController from ..constants import MAX_UINT_2, MAX_UINT_4 from ..exceptions import MessageError, NodeError @@ -64,6 +64,12 @@ # Time in minutes the SED will sleep SED_DEFAULT_SLEEP_DURATION: Final = 60 +# Default firmware if not known +DEFAULT_FIRMWARE: Final = None + +# SED BaseNode Features +SED_FEATURES: Final = (NodeFeature.BATTERY,) + # Value limits MAX_MINUTE_INTERVAL: Final = 1440 @@ -83,11 +89,12 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize base class for Sleeping End Device.""" - super().__init__(mac, address, controller, loaded_callback) + super().__init__(mac, address, node_type, controller, loaded_callback) self._loop = get_running_loop() self._node_info.is_battery_powered = True @@ -111,18 +118,16 @@ 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() + + _LOGGER.debug("Load SED node %s from cache", self._node_info.mac) + if await self._load_from_cache(): + self._loaded = True + if not self._loaded: + _LOGGER.debug("Load SED node %s defaults", self._node_info.mac) + await 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 + self._features += SED_FEATURES + return self._loaded async def unload(self) -> None: """Deactivate and unload node features.""" @@ -143,10 +148,10 @@ async def unload(self) -> None: await super().unload() @raise_not_loaded - async def initialize(self) -> bool: + async def initialize(self) -> None: """Initialize SED node.""" if self._initialized: - return True + return self._awake_subscription = await self._message_subscribe( self._awake_response, @@ -154,9 +159,8 @@ async def initialize(self) -> bool: (NODE_AWAKE_RESPONSE_ID,), ) await super().initialize() - return True - def _load_defaults(self) -> None: + async def _load_defaults(self) -> None: """Load default configuration settings.""" self._battery_config = BatteryConfig( awake_duration=SED_DEFAULT_AWAKE_DURATION, @@ -165,11 +169,14 @@ def _load_defaults(self) -> None: maintenance_interval=SED_DEFAULT_MAINTENANCE_INTERVAL, sleep_duration=SED_DEFAULT_SLEEP_DURATION, ) + await self.schedule_task_when_awake(self.node_info_update(None)) + self._sed_config_task_scheduled = True + self._new_battery_config = self._battery_config + await self.schedule_task_when_awake(self._configure_sed_task()) async def _load_from_cache(self) -> bool: """Load states from previous cached information. Returns True if successful.""" if not await super()._load_from_cache(): - self._load_defaults() return False self._battery_config = BatteryConfig( awake_duration=self._awake_duration_from_cache(), @@ -240,9 +247,6 @@ async def set_awake_duration(self, seconds: int) -> bool: f"Invalid awake duration ({seconds}). It must be between 1 and 255 seconds." ) - if self._battery_config.awake_duration == seconds: - return False - self._new_battery_config = replace( self._new_battery_config, awake_duration=seconds ) @@ -491,7 +495,7 @@ 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): + if node_info is not None and self.skip_update(self._node_info, 86400): return self._node_info return await super().node_info_update(node_info) @@ -643,7 +647,6 @@ async def _send_tasks(self) -> None: """Send all tasks in queue.""" if len(self._send_task_queue) == 0: return - async with self._send_task_lock: task_result = await gather(*self._send_task_queue) if not all(task_result): diff --git a/plugwise_usb/nodes/sense.py b/plugwise_usb/nodes/sense.py index dad2dd37d..36fc06a6f 100644 --- a/plugwise_usb/nodes/sense.py +++ b/plugwise_usb/nodes/sense.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from datetime import UTC, datetime import logging from typing import Any, Final -from ..api import NodeEvent, NodeFeature, SenseStatistics +from ..api import NodeEvent, NodeFeature, NodeType, SenseStatistics from ..connection import StickController from ..exceptions import MessageError, NodeError from ..messages.responses import SENSE_REPORT_ID, PlugwiseResponse, SenseReportResponse @@ -30,6 +31,9 @@ NodeFeature.SENSE, ) +# Default firmware if not known +DEFAULT_FIRMWARE: Final = datetime(2010, 12, 3, 10, 17, 7, tzinfo=UTC) + class PlugwiseSense(NodeSED): """Plugwise Sense node.""" @@ -38,39 +42,34 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, controller, loaded_callback) + super().__init__(mac, address, node_type, controller, loaded_callback) self._sense_statistics = SenseStatistics() self._sense_subscription: Callable[[], None] | None = None - async def load(self) -> bool: + async def load(self) -> None: """Load and activate Sense node features.""" if self._loaded: - return True + return _LOGGER.debug("Loading Sense node %s", self._node_info.mac) - if not await super().load(): - _LOGGER.debug("Load Sense base node failed") - return False + await super().load() self._setup_protocol(SENSE_FIRMWARE_SUPPORT, SENSE_FEATURES) - if await self.initialize(): - await self._loaded_callback(NodeEvent.LOADED, self.mac) - return True - - _LOGGER.debug("Load Sense node %s failed", self._node_info.mac) - return False + await self.initialize() + await self._loaded_callback(NodeEvent.LOADED, self.mac) @raise_not_loaded - async def initialize(self) -> bool: + async def initialize(self) -> None: """Initialize Sense node.""" if self._initialized: - return True + return self._sense_subscription = await self._message_subscribe( self._sense_report, @@ -78,7 +77,6 @@ async def initialize(self) -> bool: (SENSE_REPORT_ID,), ) await super().initialize() - return True async def unload(self) -> None: """Unload node.""" @@ -87,13 +85,22 @@ async def unload(self) -> None: self._sense_subscription() await super().unload() - def _load_defaults(self) -> None: + # region Caching + async def _load_defaults(self) -> None: """Load default configuration settings.""" - super()._load_defaults() + await super()._load_defaults() self._sense_statistics = SenseStatistics( temperature=0.0, humidity=0.0, ) + if self._node_info.model is None: + self._node_info.model = "Sense" + if self._node_info.name is None: + self._node_info.name = f"Sense {self._node_info.mac[-5:]}" + if self._node_info.firmware is None: + self._node_info.firmware = DEFAULT_FIRMWARE + + # endregion # region properties diff --git a/plugwise_usb/nodes/switch.py b/plugwise_usb/nodes/switch.py index a0f0edec2..048ab72b3 100644 --- a/plugwise_usb/nodes/switch.py +++ b/plugwise_usb/nodes/switch.py @@ -5,11 +5,11 @@ from asyncio import gather from collections.abc import Awaitable, Callable from dataclasses import replace -from datetime import datetime +from datetime import UTC, datetime import logging -from typing import Any +from typing import Any, Final -from ..api import NodeEvent, NodeFeature, SwitchGroup +from ..api import NodeEvent, NodeFeature, NodeType, SwitchGroup from ..connection import StickController from ..exceptions import MessageError, NodeError from ..messages.responses import ( @@ -23,6 +23,12 @@ _LOGGER = logging.getLogger(__name__) +# Switch Features +SWITCH_FEATURES: Final = (NodeFeature.SWITCH,) + +# Default firmware if not known +DEFAULT_FIRMWARE: Final = datetime(2009, 9, 8, 14, 7, 4, tzinfo=UTC) + class PlugwiseSwitch(NodeSED): """Plugwise Switch node.""" @@ -31,44 +37,32 @@ def __init__( self, mac: str, address: int, + node_type: NodeType, controller: StickController, loaded_callback: Callable[[NodeEvent, str], Awaitable[None]], ): """Initialize Scan Device.""" - super().__init__(mac, address, controller, loaded_callback) + super().__init__(mac, address, node_type, controller, loaded_callback) self._switch_subscription: Callable[[], None] | None = None self._switch = SwitchGroup() - async def load(self) -> bool: + async def load(self) -> None: """Load and activate Switch node features.""" if self._loaded: - return True - if self._cache_enabled: - _LOGGER.debug("Load Switch node %s from cache", self._node_info.mac) - 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 + return + + _LOGGER.debug("Loading Switch node %s", self._node_info.mac) + await super().load() + + self._setup_protocol(SWITCH_FIRMWARE_SUPPORT, SWITCH_FEATURES) + await self.initialize() + await self._loaded_callback(NodeEvent.LOADED, self.mac) @raise_not_loaded - async def initialize(self) -> bool: + async def initialize(self) -> None: """Initialize Switch node.""" if self._initialized: - return True + return self._switch_subscription = await self._message_subscribe( self._switch_response, @@ -76,7 +70,6 @@ async def initialize(self) -> bool: (NODE_SWITCH_GROUP_ID,), ) await super().initialize() - return True async def unload(self) -> None: """Unload node.""" @@ -84,6 +77,19 @@ async def unload(self) -> None: self._switch_subscription() await super().unload() + # region Caching + async def _load_defaults(self) -> None: + """Load default configuration settings.""" + await super()._load_defaults() + if self._node_info.model is None: + self._node_info.model = "Switch" + if self._node_info.name is None: + self._node_info.name = f"Switch {self._node_info.mac[-5:]}" + if self._node_info.firmware is None: + self._node_info.firmware = DEFAULT_FIRMWARE + + # endregion + # region Properties @property diff --git a/pyproject.toml b/pyproject.toml index ff8bc48cc..79b23fee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plugwise_usb" -version = "0.44.7" +version = "0.44.8" license = "MIT" keywords = ["home", "automation", "plugwise", "module", "usb"] classifiers = [ diff --git a/tests/test_usb.py b/tests/test_usb.py index 04063deb3..7044c4522 100644 --- a/tests/test_usb.py +++ b/tests/test_usb.py @@ -575,7 +575,7 @@ async def test_stick_node_discovered_subscription( assert mac_awake_node == "5555555555555555" unsub_awake() - assert await stick.nodes["5555555555555555"].load() + await stick.nodes["5555555555555555"].load() assert stick.nodes["5555555555555555"].node_info.firmware == dt( 2011, 6, 27, 8, 55, 44, tzinfo=UTC ) @@ -771,7 +771,7 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No await stick.nodes["0098765432101234"].set_relay_lock(True) # Manually load node - assert await stick.nodes["0098765432101234"].load() + await stick.nodes["0098765432101234"].load() # Check relay_lock is set to False when not in cache assert stick.nodes["0098765432101234"].relay_lock @@ -849,7 +849,7 @@ async def test_node_relay_and_power(self, monkeypatch: pytest.MonkeyPatch) -> No with pytest.raises(pw_exceptions.NodeError): await stick.nodes["2222222222222222"].set_relay_init(True) - assert await stick.nodes["2222222222222222"].load() + 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["2222222222222222"].subscribe_to_feature_update( @@ -904,7 +904,7 @@ async def fake_get_missing_energy_logs(address: int) -> None: assert not stick.nodes["0098765432101234"].calibrated # Manually load node - assert await stick.nodes["0098765432101234"].load() + await stick.nodes["0098765432101234"].load() # Check calibration in loaded state assert stick.nodes["0098765432101234"].calibrated @@ -1691,7 +1691,6 @@ async def makedirs(cache_dir: str, exist_ok: bool) -> None: "FEDCBA9876543210": pw_api.NodeType.CIRCLE, "1298347650AFBECD": pw_api.NodeType.SCAN, } - pw_nw_cache.update_nodetypes("1234ABCD4321FEDC", pw_api.NodeType.STEALTH) with patch("aiofiles.threadpool.sync_open", return_value=mock_file_stream): # await pw_nw_cache.save_cache() @@ -1789,7 +1788,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign """Load callback for event.""" test_node = pw_sed.PlugwiseBaseNode( - "1298347650AFBECD", 1, mock_stick_controller, load_callback + "1298347650AFBECD", + 1, + pw_api.NodeType.CIRCLE, + mock_stick_controller, + load_callback, ) # Validate base node properties which are always set @@ -1928,7 +1931,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign """Load callback for event.""" test_sed = pw_sed.NodeSED( - "1298347650AFBECD", 1, mock_stick_controller, load_callback + "1298347650AFBECD", + 1, + pw_api.NodeType.SCAN, + mock_stick_controller, + load_callback, ) assert not test_sed.cache_enabled @@ -1944,7 +1951,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign assert test_sed.node_info.is_battery_powered assert test_sed.is_battery_powered - assert await test_sed.load() + await test_sed.load() assert sorted(test_sed.features) == sorted( ( pw_api.NodeFeature.AVAILABLE, @@ -1970,8 +1977,8 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign 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(10) + assert 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 @@ -2104,8 +2111,8 @@ def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 PLR0 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_RELAY: + return "True" if setting == pw_node.CACHE_NODE_INFO_TIMESTAMP: return "2024-12-7-1-0-0" if setting == pw_sed.CACHE_AWAKE_DURATION: @@ -2146,7 +2153,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign """Load callback for event.""" test_scan = pw_scan.PlugwiseScan( - "1298347650AFBECD", 1, mock_stick_controller, load_callback + "1298347650AFBECD", + 1, + pw_api.NodeType.SCAN, + mock_stick_controller, + load_callback, ) assert not test_scan.cache_enabled node_info = pw_api.NodeInfoMessage( @@ -2157,7 +2168,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign relay_state=None, ) await test_scan.update_node_details(node_info) - assert await test_scan.load() + await test_scan.load() # test motion reset timer assert test_scan.reset_timer == 10 @@ -2167,7 +2178,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign 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 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 @@ -2251,7 +2262,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign # scan with cache enabled mock_stick_controller.send_response = None test_scan = pw_scan.PlugwiseScan( - "1298347650AFBECD", 1, mock_stick_controller, load_callback + "1298347650AFBECD", + 1, + pw_api.NodeType.SCAN, + mock_stick_controller, + load_callback, ) node_info = pw_api.NodeInfoMessage( current_logaddress_pointer=None, @@ -2262,7 +2277,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign ) await test_scan.update_node_details(node_info) test_scan.cache_enabled = True - assert await test_scan.load() + await test_scan.load() assert sorted(test_scan.features) == sorted( ( pw_api.NodeFeature.AVAILABLE, @@ -2295,10 +2310,10 @@ def fake_cache(dummy: object, setting: str) -> str | None: # noqa: PLR0911 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_node.CACHE_RELAY: + return "True" if setting == pw_sed.CACHE_AWAKE_DURATION: return "15" if setting == pw_sed.CACHE_CLOCK_INTERVAL: @@ -2318,7 +2333,11 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign """Load callback for event.""" test_switch = pw_switch.PlugwiseSwitch( - "1298347650AFBECD", 1, mock_stick_controller, load_callback + "1298347650AFBECD", + 1, + pw_api.NodeType.SWITCH, + mock_stick_controller, + load_callback, ) assert not test_switch.cache_enabled @@ -2337,14 +2356,18 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign relay_state=None, ) await test_switch.update_node_details(node_info) - assert await test_switch.load() + await test_switch.load() # 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 + "1298347650AFBECD", + 1, + pw_api.NodeType.SWITCH, + mock_stick_controller, + load_callback, ) node_info = pw_api.NodeInfoMessage( current_logaddress_pointer=None, @@ -2356,7 +2379,7 @@ async def load_callback(event: pw_api.NodeEvent, mac: str) -> None: # type: ign await test_switch.update_node_details(node_info) test_switch.cache_enabled = True assert test_switch.cache_enabled is True - assert await test_switch.load() + await test_switch.load() assert sorted(test_switch.features) == sorted( ( pw_api.NodeFeature.AVAILABLE, @@ -2627,6 +2650,10 @@ async def test_node_discovery_and_load( # noqa: PLR0915 assert stick.nodes["8888888888888888"].node_info.model_type is None assert stick.nodes["8888888888888888"].available assert stick.nodes["8888888888888888"].node_info.is_battery_powered + assert ( + stick.nodes["8888888888888888"].node_info.node_type + == pw_api.NodeType.SWITCH + ) assert sorted(stick.nodes["8888888888888888"].features) == sorted( ( pw_api.NodeFeature.AVAILABLE,