diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7939d09..48e32b0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,48 @@ Changelog ========= +Unreleased +========== + +Fixed +----- +- **Anti-Legionella set-period State Preservation**: ``nwp-cli anti-legionella + set-period`` was calling ``enable_anti_legionella()`` in both the enabled and + disabled branches, silently re-enabling the feature when it was off. The + command now informs the user that the period can only be updated while the + feature is enabled and directs them to ``anti-legionella enable``. +- **Subscription State Lost After Failed Resubscription**: ``resubscribe_all()`` + cleared ``_subscriptions`` and ``_message_handlers`` before the re-subscribe + loop. Topics that failed to resubscribe were permanently dropped from internal + state and could not be retried on the next reconnection. Failed topics are now + restored so they are retried automatically. +- **Unit System Detection Returns None on Timeout**: ``_detect_unit_system()`` + declared return type ``UnitSystemType`` but returned ``None`` on + ``TimeoutError``, violating the type contract. Now returns + ``"us_customary"`` consistent with the warning message. +- **Once-Listener Becomes Permanent With Duplicate Callbacks**: ``emit()`` + identified once-listeners via a ``set`` of ``(event, callback)`` tuples. If + the same callback was registered twice with ``once=True``, the set + deduplicated the tuple — after the first emit the second listener lost its + once-status and became permanent. Fixed by checking ``listener.once`` + directly on the ``EventListener`` object. +- **Auth Session Leaked on Client Construction Failure**: In + ``create_navien_clients()``, if ``NavienAPIClient`` or + ``NavienMqttClient`` construction raised after a successful + ``auth_client.__aenter__()``, the auth session and its underlying + ``aiohttp`` session would leak. Client construction is now wrapped in a + ``try/except`` that calls ``auth_client.__aexit__()`` on failure. +- **Hypothesis Tests Broke All Test Collection**: ``test_mqtt_hypothesis.py`` + imported ``hypothesis`` at module level; when it was not installed, pytest + failed to collect every test in the suite. ``hypothesis`` is now mandated + as a ``[testing]`` extra dependency, restoring correct collection behaviour. + +Changed +------- +- **Dependency: awsiotsdk >= 1.28.2**: Bumped minimum ``awsiotsdk`` version + from ``>=1.27.0`` to ``>=1.28.2`` to track the current patch release. + ``awscrt`` 0.31.3 is pulled in transitively. + Version 7.4.8 (2026-02-17) ========================== diff --git a/setup.cfg b/setup.cfg index 89dee02..f77cb33 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ python_requires = >=3.13 # For more information, check out https://semver.org/. install_requires = aiohttp>=3.8.0 - awsiotsdk>=1.27.0 + awsiotsdk>=1.28.2 pydantic>=2.0.0 diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index dd5749a..76a06ff 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -66,7 +66,7 @@ def _on_status(status: DeviceStatus) -> None: _logger.warning( "Timed out detecting unit system, defaulting to us_customary" ) - return None + return "us_customary" def async_command(f: Any) -> Any: diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 9c0ffce..c10c49c 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -452,14 +452,15 @@ def _on_status(status: DeviceStatus) -> None: # Get current enabled state use = getattr(status, "anti_legionella_use", None) - # If enabled, keep it enabled; otherwise, enable it - # (period only, no disable-state for set operation) if use: await mqtt.control.enable_anti_legionella(device, period_days) + print(f"Anti-Legionella period set to {period_days} day(s)") else: - await mqtt.control.enable_anti_legionella(device, period_days) - - print(f"✓ Anti-Legionella period set to {period_days} day(s)") + print( + "Anti-Legionella is currently disabled. " + "Enable it first to set the period, or use " + "'anti-legionella enable' with the desired period." + ) except (RangeValidationError, ValidationError) as e: _logger.error(f"Failed to set Anti-Legionella period: {e}") except DeviceError as e: diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 69b64b2..fe2da2c 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -252,8 +252,8 @@ async def emit(self, event: str, *args: Any, **kwargs: Any) -> int: called_count += 1 - # Check if this is a once listener using O(1) set lookup - if (event, listener.callback) in self._once_callbacks: + # Check if this is a once listener + if listener.once: listeners_to_remove.append(listener) self._once_callbacks.discard((event, listener.callback)) diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py index 9edf6f3..044f3d0 100644 --- a/src/nwp500/factory.py +++ b/src/nwp500/factory.py @@ -81,7 +81,11 @@ async def create_navien_clients( raise # Create API and MQTT clients that share the session - api_client = NavienAPIClient(auth_client=auth_client) - mqtt_client = NavienMqttClient(auth_client=auth_client) + try: + api_client = NavienAPIClient(auth_client=auth_client) + mqtt_client = NavienMqttClient(auth_client=auth_client) + except BaseException: + await auth_client.__aexit__(None, None, None) + raise return auth_client, api_client, mqtt_client diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index fb50bfd..9ee059f 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -322,8 +322,19 @@ async def resubscribe_all(self) -> None: break # Exit handler loop, move to next topic if failed_subscriptions: + # Restore failed subscriptions to internal state so they can be + # retried on the next reconnection cycle. + qos_map = dict(subscriptions_to_restore) + for topic in failed_subscriptions: + self._subscriptions[topic] = qos_map.get( + topic, mqtt.QoS.AT_LEAST_ONCE + ) + self._message_handlers[topic] = handlers_to_restore.get( + topic, [] + ) _logger.warning( - f"Failed to restore {len(failed_subscriptions)} subscription(s)" + f"Failed to restore {len(failed_subscriptions)} " + "subscription(s); will retry on next reconnection" ) else: _logger.info("All subscriptions re-established successfully")