From 7fd7541037d4f7bef4a19f54f9bb24663337078c Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 5 Jul 2026 08:23:13 -0700 Subject: [PATCH 1/4] fix: device protocol correctness - Send flat raw protocol payloads for weekly reservation and recirculation schedules; the model dump double-nested the request and leaked computed display fields (incl. unit-converted temperature) into device commands. Add NavienBaseModel.to_protocol_dict() - Preserve command queue order on failed flush (deque with re-insert at front instead of tail) - Discard queued commands older than max_queued_command_age (default 300s) instead of replaying stale control commands after long outages - Use truncated remainder (firmware semantics) instead of Python's floored modulo in the ASYMMETRIC Fahrenheit formula; fixes off-by-one conversions for sub-zero temperatures - Correct freeze protection default limits to raw half-Celsius 12/20 (43F/50F documented fixed range) instead of 43/65 - Emit error_detected when the error code changes between two non-zero codes - Round TOU prices half-up via Decimal (round() banker's rounding under-encoded exact halves); encode bool prices instead of passing them through as pre-encoded ints - Fix inverted enable-flag doc, wrong week-bitfield example (158->84), and int/float doctest values --- CHANGELOG.rst | 45 ++++ src/nwp500/_base.py | 11 + src/nwp500/encoding.py | 36 ++- src/nwp500/models/status.py | 8 +- src/nwp500/mqtt/command_queue.py | 103 ++++---- src/nwp500/mqtt/control.py | 15 +- src/nwp500/mqtt/state_tracker.py | 6 +- src/nwp500/mqtt/utils.py | 5 + src/nwp500/temperature.py | 30 ++- tests/test_protocol_correctness.py | 400 +++++++++++++++++++++++++++++ 10 files changed, 574 insertions(+), 85 deletions(-) create mode 100644 tests/test_protocol_correctness.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89741ba9..7bfae543 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,51 @@ Changelog Unreleased ========== +Bug Fixes +--------- +- **Fix malformed weekly reservation and recirculation schedule + payloads**: ``update_weekly_reservation()`` and + ``configure_recirculation_schedule()`` dumped the schedule models + as-is, double-nesting the request (``request.reservation.reservation`` + / ``request.schedule.schedule``) and leaking pydantic computed display + fields — including a unit-converted ``temperature`` alongside the raw + half-Celsius ``param`` — into device commands. Both now send the flat, + raw protocol shape used by ``update_reservations()``. A new + ``NavienBaseModel.to_protocol_dict()`` dumps only declared protocol + fields. +- **Preserve command order when a queued flush fails**: a command that + failed mid-flush was re-queued at the tail, behind commands queued + after it, inverting order-sensitive sequences (e.g. ``set_temp`` + replayed before ``power_on``). The queue is now a deque and failed + commands are re-inserted at the front. +- **Expire stale queued commands**: queued commands stored a timestamp + that was never checked, so a multi-hour outage replayed hours-old + control commands (e.g. ``set_power``) to the appliance on reconnect. + Commands older than ``MqttConnectionConfig.max_queued_command_age`` + (default 300 s, ``None`` to disable) are now discarded at send time. +- **Fix Fahrenheit conversion for sub-zero temperatures** (ASYMMETRIC + formula): the rounding used Python's floored ``%``, which is always + non-negative, while the firmware/app uses a truncated remainder. Raw + ``-11`` (-5.5 °C) decoded to 22 °F instead of the app's 23 °F. Now + uses ``math.fmod`` semantics. +- **Fix freeze protection default limits**: the defaults (43/65) were + Fahrenheit display values stored in raw half-Celsius fields, decoding + to 70.7 °F / 90.5 °F when the device omitted them. Corrected to raw + 12/20 (43 °F / 50 °F), matching the documented fixed limits. +- **Emit error_detected when the error code changes**: a transition + between two non-zero error codes (e.g. E799 → E407) emitted no event, + so consumers kept displaying the stale error. +- **Fix TOU price encoding**: ``encode_price()`` used banker's rounding, + under-encoding exact half values at even boundaries (0.125 at + ``decimal_point=2`` encoded to 12 instead of 13) — now uses + ``Decimal`` half-up rounding. ``build_tou_period()`` also treated + ``bool`` prices as pre-encoded integers (``True`` sent as price 1). +- **Fix protocol documentation errors**: ``decode_reservation_hex()`` + documented the enable flag inverted (1=enabled instead of 2=enabled); + the ``build_reservation_entry()`` example showed ``week: 158`` for + Mon/Wed/Fri instead of the correct 84; temperature doctest examples + showed ``int`` raw values where ``float`` is returned. + Version 8.1.3 (2026-06-15) ========================== diff --git a/src/nwp500/_base.py b/src/nwp500/_base.py index 12d88787..faa06742 100644 --- a/src/nwp500/_base.py +++ b/src/nwp500/_base.py @@ -39,6 +39,17 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: converted: dict[str, Any] = self._convert_enums_to_names(result) return converted + def to_protocol_dict(self) -> dict[str, Any]: + """Dump only the declared protocol fields, by alias. + + Excludes pydantic ``computed_field`` properties, which are + display-oriented (formatted times, weekday names, unit-converted + temperatures) and must never be sent to the device. + """ + return self.model_dump( + by_alias=True, include=set(type(self).model_fields) + ) + @staticmethod def _convert_enums_to_names( data: Any, visited: set[int] | None = None diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 2566c215..670a8a40 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -10,6 +10,7 @@ import logging from collections.abc import Iterable +from decimal import ROUND_HALF_UP, Decimal from numbers import Real from .exceptions import ParameterValidationError, RangeValidationError @@ -238,7 +239,7 @@ def decode_season_bitfield(bitfield: int) -> list[int]: # ============================================================================ -def encode_price(value: Real, decimal_point: int) -> int: +def encode_price(value: Real | float, decimal_point: int) -> int: """ Encode a price into the integer representation expected by the device. @@ -274,8 +275,11 @@ def encode_price(value: Real, decimal_point: int) -> int: min_value=0, max_value=10, ) - scale = 10**decimal_point - return int(round(float(value) * scale)) + # Use Decimal with HALF_UP rounding: round() applies banker's + # rounding, which under-encodes exact half values at even + # boundaries (e.g. 0.125 at decimal_point=2 -> 12 instead of 13). + scaled = Decimal(str(float(value))) * (Decimal(10) ** decimal_point) + return int(scaled.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) def decode_price(value: int, decimal_point: int) -> float: @@ -324,7 +328,7 @@ def decode_reservation_hex(hex_string: str) -> list[dict[str, int]]: Decode a hex-encoded reservation string into structured reservation entries. The reservation data is encoded as 6 bytes per entry: - - Byte 0: enable (1=enabled, 2=disabled) + - Byte 0: enable (2=enabled, 1=disabled, device boolean convention) - Byte 1: week bitfield (days of week) - Byte 2: hour (0-23) - Byte 3: minute (0-59) @@ -423,7 +427,7 @@ def build_reservation_entry( ... ) { 'enable': 2, - 'week': 158, + 'week': 84, 'hour': 6, 'min': 30, 'mode': 3, @@ -592,16 +596,26 @@ def build_tou_period( encoded_min: int encoded_max: int + # bool is an int subclass and would otherwise silently pass through + # the "already encoded" branch below as 1/0; treat it as a numeric + # price instead. + price_min_num: Real | float = ( + float(price_min) if isinstance(price_min, bool) else price_min + ) + price_max_num: Real | float = ( + float(price_max) if isinstance(price_max, bool) else price_max + ) + # Encode prices if they're Real numbers (not already encoded integers) - if not isinstance(price_min, int): - encoded_min = encode_price(price_min, decimal_point) + if not isinstance(price_min_num, int): + encoded_min = encode_price(price_min_num, decimal_point) else: - encoded_min = price_min + encoded_min = price_min_num - if not isinstance(price_max, int): - encoded_max = encode_price(price_max, decimal_point) + if not isinstance(price_max_num, int): + encoded_max = encode_price(price_max_num, decimal_point) else: - encoded_max = price_max + encoded_max = price_max_num return { "season": season_bitfield, diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 4315c2b8..d436d608 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -575,12 +575,16 @@ class DeviceStatus(NavienBaseModel): freeze_protection_temp_min_raw: int = temperature_field( "Active freeze protection lower limit", alias="freezeProtectionTempMin", - default=43, + # Raw half-degrees Celsius: 12 = 6 degC = 43 degF (documented + # fixed lower limit in data_conversions.rst) + default=12, ) freeze_protection_temp_max_raw: int = temperature_field( "Active freeze protection upper limit", alias="freezeProtectionTempMax", - default=65, + # Raw half-degrees Celsius: 20 = 10 degC = 50 degF (documented + # fixed upper limit in data_conversions.rst) + default=20, ) def _is_celsius(self) -> bool: diff --git a/src/nwp500/mqtt/command_queue.py b/src/nwp500/mqtt/command_queue.py index dabfa2af..cf88eca5 100644 --- a/src/nwp500/mqtt/command_queue.py +++ b/src/nwp500/mqtt/command_queue.py @@ -7,8 +7,8 @@ from __future__ import annotations -import asyncio import logging +from collections import deque from collections.abc import Callable from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -35,9 +35,11 @@ class MqttCommandQueue: when the connection is restored. This ensures commands are not lost during temporary network interruptions. - The queue uses asyncio.Queue with a fixed maximum size. When the queue - is full, the oldest command is automatically dropped to make room for - new commands (FIFO with overflow dropping). + The queue uses a deque with a fixed maximum size. When the queue is + full, the oldest command is automatically dropped to make room for + new commands (FIFO with overflow dropping). Commands older than + ``config.max_queued_command_age`` seconds are discarded at send time + rather than replayed to the device. """ def __init__(self, config: MqttConnectionConfig): @@ -48,9 +50,10 @@ def __init__(self, config: MqttConnectionConfig): config: MQTT connection configuration with queue settings """ self.config = config - # Python 3.10+ handles asyncio.Queue initialization without running loop - self._queue: asyncio.Queue[QueuedCommand] = asyncio.Queue( - maxsize=config.max_queued_commands + # A deque (not asyncio.Queue) so a command that fails mid-flush + # can be re-inserted at the FRONT, preserving command order. + self._queue: deque[QueuedCommand] = deque( + maxlen=config.max_queued_commands ) def enqueue( @@ -82,26 +85,17 @@ def enqueue( timestamp=datetime.now(UTC), ) - # If queue is full, drop oldest command first - if self._queue.full(): - try: - # Remove oldest command (non-blocking) - dropped = self._queue.get_nowait() - _logger.warning( - f"Command queue full ({self.config.max_queued_commands}), " - f"dropped oldest command to '{redact_topic(dropped.topic)}'" - ) - except asyncio.QueueEmpty: - # Race condition - queue was emptied between check and get - pass - - # Add new command (should never block since we just made room if needed) - try: - self._queue.put_nowait(command) - _logger.info(f"Queued command (queue size: {self._queue.qsize()})") - except asyncio.QueueFull: - _logger.error("Failed to enqueue command - queue unexpectedly full") - raise + # deque(maxlen=N) drops from the opposite end automatically, but + # log the overflow explicitly for visibility. + if len(self._queue) == self._queue.maxlen: + dropped = self._queue.popleft() + _logger.warning( + f"Command queue full ({self.config.max_queued_commands}), " + f"dropped oldest command to '{redact_topic(dropped.topic)}'" + ) + + self._queue.append(command) + _logger.info(f"Queued command (queue size: {len(self._queue)})") async def send_all( self, @@ -121,22 +115,30 @@ async def send_all( Returns: Tuple of (sent_count, failed_count) """ - if self._queue.empty(): + if not self._queue: return (0, 0) - queue_size = self._queue.qsize() + queue_size = len(self._queue) _logger.info(f"Sending {queue_size} queued command(s)...") sent_count = 0 failed_count = 0 + max_age = self.config.max_queued_command_age + now = datetime.now(UTC) - while not self._queue.empty() and is_connected_func(): - try: - # Get command from queue (non-blocking) - command = self._queue.get_nowait() - except asyncio.QueueEmpty: - # Queue was emptied by another task - break + while self._queue and is_connected_func(): + command = self._queue.popleft() + + # Don't replay stale control commands (e.g. an hours-old + # set_power) to a physical appliance after a long outage. + age = (now - command.timestamp).total_seconds() + if max_age is not None and age > max_age: + _logger.warning( + f"Discarding expired queued command to " + f"'{redact_topic(command.topic)}' " + f"(age {age:.0f}s > max {max_age:.0f}s)" + ) + continue try: # Publish the queued command @@ -156,15 +158,10 @@ async def send_all( f"Failed to send queued command to " f"'{redact_topic(command.topic)}': {e}" ) - # Re-queue if there's room - if not self._queue.full(): - try: - self._queue.put_nowait(command) - _logger.warning("Re-queued failed command") - except asyncio.QueueFull: - _logger.error( - "Failed to re-queue command - queue is full" - ) + # Re-queue at the FRONT so command order is preserved on + # the next flush (re-appending would invert order- + # sensitive sequences like power_on -> set_temp). + self._queue.appendleft(command) break # Stop processing on error to avoid cascade failures if sent_count > 0: @@ -182,14 +179,8 @@ def clear(self) -> int: Returns: Number of commands cleared """ - # Drain the queue - cleared = 0 - while not self._queue.empty(): - try: - self._queue.get_nowait() - cleared += 1 - except asyncio.QueueEmpty: - break + cleared = len(self._queue) + self._queue.clear() if cleared > 0: _logger.info(f"Cleared {cleared} queued command(s)") @@ -198,14 +189,14 @@ def clear(self) -> int: @property def count(self) -> int: """Get the number of queued commands.""" - return self._queue.qsize() + return len(self._queue) @property def is_empty(self) -> bool: """Check if the queue is empty.""" - return self._queue.empty() + return not self._queue @property def is_full(self) -> bool: """Check if the queue is full.""" - return self._queue.full() + return len(self._queue) == self._queue.maxlen diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index bab96c35..1ebc7415 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -680,10 +680,18 @@ async def update_weekly_reservation( Returns: Publish packet ID """ + # Match the documented request shape used by update_reservations(): + # reservationUse at the request level and reservation as a flat + # list of raw protocol entries. Dumping the schedule model as-is + # would double-nest the payload and leak computed display fields + # (formatted times, unit-converted temperatures) to the device. return await self._send_command( device=device, command_code=CommandCode.RESERVATION_WEEKLY, - reservation=schedule.model_dump(by_alias=True), + reservationUse=schedule.reservation_use, + reservation=[ + entry.to_protocol_dict() for entry in schedule.reservation + ], ) @requires_capability("program_reservation_use") @@ -709,10 +717,13 @@ async def configure_recirculation_schedule( Returns: Publish packet ID """ + # Send a flat list of raw protocol entries; dumping the schedule + # model as-is would nest the payload as schedule.schedule and + # leak computed display fields to the device. return await self._send_command( device=device, command_code=CommandCode.RECIR_RESERVATION, - schedule=schedule.model_dump(by_alias=True), + schedule=[entry.to_protocol_dict() for entry in schedule.schedule], ) @requires_capability("recirculation_use") diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index ba91e4a7..ea7e8a40 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -135,8 +135,10 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: ) _logger.debug("Heating stopped") - # Error detection / clearance - if status.error_code and not prev.error_code: + # Error detection / clearance. Also emit when the error code + # CHANGES between two non-zero codes (e.g. E799 -> E407), so + # consumers never keep displaying a stale error. + if status.error_code and status.error_code != prev.error_code: await self._event_emitter.emit( "error_detected", ErrorDetectedEvent( diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index 8e126e33..f42ce61c 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -210,6 +210,10 @@ class MqttConnectionConfig: enable_command_queue: Enable command queueing when disconnected max_queued_commands: Maximum number of queued commands + max_queued_command_age: Maximum age in seconds for a queued + command to still be sent when the connection is restored; + older commands are discarded instead of being replayed to + the appliance. ``None`` disables expiry. """ endpoint: str = AWS_IOT_ENDPOINT @@ -231,6 +235,7 @@ class MqttConnectionConfig: # Command queue settings enable_command_queue: bool = True max_queued_commands: int = 100 + max_queued_command_age: float | None = 300.0 # seconds def __post_init__(self) -> None: """Generate client ID if not provided and validate settings.""" diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py index 78ce8220..01524205 100644 --- a/src/nwp500/temperature.py +++ b/src/nwp500/temperature.py @@ -146,7 +146,7 @@ def from_fahrenheit(cls, fahrenheit: float) -> HalfCelsius: Example: >>> temp = HalfCelsius.from_fahrenheit(140.0) >>> temp.raw_value - 120 + 120.0 """ celsius = (fahrenheit - 32) * 5 / 9 raw_value = round(celsius * 2) @@ -165,7 +165,7 @@ def from_celsius(cls, celsius: float) -> HalfCelsius: Example: >>> temp = HalfCelsius.from_celsius(60.0) >>> temp.raw_value - 120 + 120.0 """ raw_value = round(celsius * 2) return cls(raw_value) @@ -215,7 +215,7 @@ def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsius: Example: >>> temp = DeciCelsius.from_fahrenheit(140.0) >>> temp.raw_value - 600 + 600.0 """ celsius = (fahrenheit - 32) * 5 / 9 raw_value = round(celsius * 10) @@ -234,7 +234,7 @@ def from_celsius(cls, celsius: float) -> DeciCelsius: Example: >>> temp = DeciCelsius.from_celsius(60.0) >>> temp.raw_value - 600 + 600.0 """ raw_value = round(celsius * 10) return cls(raw_value) @@ -256,7 +256,7 @@ class RawCelsius(Temperature): >>> temp.to_celsius() 60.0 >>> temp.to_fahrenheit() - 140.0 + 140 """ def to_celsius(self) -> float: @@ -292,8 +292,14 @@ def to_fahrenheit_with_formula( match formula_type: case TempFormulaType.ASYMMETRIC: - # Asymmetric Rounding: check remainder of raw value - remainder = int(self.raw_value) % 10 + # Asymmetric Rounding: check remainder of raw value. + # Use the truncated remainder (like the firmware/app's + # Java % operator), NOT Python's floored modulo: for + # negative raw values Python's % is always non-negative + # (-11 % 10 == 9), which would apply floor where the + # firmware applies ceil, giving off-by-one Fahrenheit + # values for sub-zero temperatures. + remainder = int(math.fmod(int(self.raw_value), 10)) match remainder: case 9: return float(math.floor(fahrenheit_value)) @@ -316,7 +322,7 @@ def from_fahrenheit(cls, fahrenheit: float) -> RawCelsius: Example: >>> temp = RawCelsius.from_fahrenheit(140.0) >>> temp.raw_value - 120 + 120.0 """ celsius = (fahrenheit - 32) * 5 / 9 raw_value = round(celsius * 2) @@ -335,7 +341,7 @@ def from_celsius(cls, celsius: float) -> RawCelsius: Example: >>> temp = RawCelsius.from_celsius(60.0) >>> temp.raw_value - 120 + 120.0 """ raw_value = round(celsius * 2) return cls(raw_value) @@ -389,7 +395,7 @@ class DeciCelsiusDelta(Temperature): >>> temp.to_celsius() 0.5 >>> temp.to_fahrenheit() - 0.9 # 0.5°C * 9/5 = 0.9°F, no +32 offset + 0.9 """ def to_celsius(self) -> float: @@ -422,7 +428,7 @@ def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsiusDelta: Example: >>> temp = DeciCelsiusDelta.from_fahrenheit(0.9) >>> temp.raw_value - 5 + 5.0 """ celsius = fahrenheit * 5 / 9 raw_value = round(celsius * 10) @@ -441,7 +447,7 @@ def from_celsius(cls, celsius: float) -> DeciCelsiusDelta: Example: >>> temp = DeciCelsiusDelta.from_celsius(0.5) >>> temp.raw_value - 5 + 5.0 """ raw_value = round(celsius * 10) return cls(raw_value) diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py new file mode 100644 index 00000000..8a870428 --- /dev/null +++ b/tests/test_protocol_correctness.py @@ -0,0 +1,400 @@ +"""Regression tests for protocol correctness fixes. + +Covers: +- Weekly reservation / recirculation schedule payload shape (no double + nesting, no computed display fields leaked to the device) +- Command queue ordering on failed flush and stale-command expiry +- Negative-temperature ASYMMETRIC Fahrenheit conversion +- Freeze protection default raw values +- error_detected emitted when the error code changes between two errors +- TOU price encoding (half-up rounding, bool rejection) +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from awscrt import mqtt + +from nwp500.encoding import build_tou_period, encode_price +from nwp500.events import EventEmitter +from nwp500.models.schedule import ( + RecirculationSchedule, + RecirculationScheduleEntry, + WeeklyReservationEntry, + WeeklyReservationSchedule, +) +from nwp500.models.status import DeviceStatus +from nwp500.mqtt.command_queue import MqttCommandQueue +from nwp500.mqtt.control import MqttDeviceController +from nwp500.mqtt.state_tracker import DeviceStateTracker +from nwp500.mqtt.utils import MqttConnectionConfig +from nwp500.temperature import RawCelsius, TempFormulaType +from nwp500.unit_system import reset_unit_system + + +@pytest.fixture(autouse=True) +def _reset_units(): + yield + reset_unit_system() + + +def _status(device_status_dict: dict, **overrides) -> DeviceStatus: + data = {**device_status_dict, **overrides} + return DeviceStatus.model_validate(data) + + +@pytest.fixture +def mock_device(): + device = MagicMock() + device.device_info.mac_address = "aa:bb:cc:dd:ee:ff" + device.device_info.device_type = 52 + device.device_info.additional_value = "additional" + return device + + +def _make_controller(): + publish = AsyncMock(return_value=1) + controller = MqttDeviceController( + client_id="test-client", + session_id="test-session", + publish_func=publish, + ) + # Bypass capability lookup (would require a live device) + controller._get_device_features = AsyncMock(return_value=MagicMock()) + return controller, publish + + +class TestSchedulePayloads: + """Schedule commands must send flat, raw protocol payloads.""" + + @pytest.mark.asyncio + async def test_weekly_reservation_payload_shape(self, mock_device): + """Regression: the schedule model was dumped as-is, double-nesting + the payload (request.reservation.reservation) and leaking computed + display fields — including a unit-converted temperature alongside + the raw half-Celsius param — to the device.""" + controller, publish = _make_controller() + + schedule = WeeklyReservationSchedule( + reservationUse=2, + reservation=[ + WeeklyReservationEntry( + enable=2, week=84, hour=6, min=30, mode=3, param=120 + ) + ], + ) + + await controller.update_weekly_reservation(mock_device, schedule) + + publish.assert_awaited_once() + _topic, command = publish.await_args.args + + # Flat shape matching update_reservations() + request = command["request"] + assert request["reservationUse"] == 2 + entries = request["reservation"] + assert isinstance(entries, list) + assert entries[0] == { + "enable": 2, + "week": 84, + "hour": 6, + "min": 30, + "mode": 3, + "param": 120, + } + + @pytest.mark.asyncio + async def test_recirculation_schedule_payload_shape(self, mock_device): + """Regression: payload was nested as schedule.schedule with + computed fields (start_time, days, mode_name...) included.""" + controller, publish = _make_controller() + + schedule = RecirculationSchedule( + schedule=[ + RecirculationScheduleEntry( + enable=2, + week=84, + startHour=6, + startMin=0, + endHour=8, + endMin=30, + mode=2, + ) + ] + ) + + await controller.configure_recirculation_schedule(mock_device, schedule) + + publish.assert_awaited_once() + _topic, command = publish.await_args.args + + entries = command["request"]["schedule"] + assert isinstance(entries, list) + assert entries[0] == { + "enable": 2, + "week": 84, + "startHour": 6, + "startMin": 0, + "endHour": 8, + "endMin": 30, + "mode": 2, + } + + def test_to_protocol_dict_excludes_computed_fields(self): + entry = WeeklyReservationEntry( + enable=2, week=84, hour=6, min=30, mode=3, param=120 + ) + protocol = entry.to_protocol_dict() + assert set(protocol) == { + "enable", + "week", + "hour", + "min", + "mode", + "param", + } + # The display dump still has the computed fields + display = entry.model_dump() + assert "temperature" in display + assert "days" in display + + +def _queue(max_age: float | None = 300.0) -> MqttCommandQueue: + config = MqttConnectionConfig( + client_id="test-client", max_queued_command_age=max_age + ) + return MqttCommandQueue(config) + + +class TestCommandQueueOrdering: + """A failed flush must preserve command order.""" + + @pytest.mark.asyncio + async def test_failed_command_requeued_at_front(self): + """Regression: a failed command was re-queued at the TAIL behind + newer commands, so [power_on, set_temp] became [set_temp, + power_on] on the next flush.""" + queue = _queue() + queue.enqueue( + "topic/power_on", {"cmd": "power"}, mqtt.QoS.AT_LEAST_ONCE + ) + queue.enqueue("topic/set_temp", {"cmd": "temp"}, mqtt.QoS.AT_LEAST_ONCE) + + publish = AsyncMock(side_effect=RuntimeError("still down")) + + sent, failed = await queue.send_all(publish, lambda: True) + + assert (sent, failed) == (0, 1) + # Order preserved for the next flush + assert [c.topic for c in queue._queue] == [ + "topic/power_on", + "topic/set_temp", + ] + + publish_ok = AsyncMock(return_value=1) + sent, failed = await queue.send_all(publish_ok, lambda: True) + assert (sent, failed) == (2, 0) + sent_topics = [ + call.kwargs["topic"] for call in publish_ok.await_args_list + ] + assert sent_topics == ["topic/power_on", "topic/set_temp"] + + +class TestCommandQueueExpiry: + """Stale commands must not be replayed to the appliance.""" + + @pytest.mark.asyncio + async def test_expired_commands_discarded_on_flush(self): + queue = _queue(max_age=300.0) + queue.enqueue("topic/old", {"cmd": "old"}, mqtt.QoS.AT_LEAST_ONCE) + queue.enqueue("topic/new", {"cmd": "new"}, mqtt.QoS.AT_LEAST_ONCE) + # Age the first command past the limit + queue._queue[0].timestamp = datetime.now(UTC) - timedelta(hours=2) + + publish = AsyncMock(return_value=1) + sent, failed = await queue.send_all(publish, lambda: True) + + assert (sent, failed) == (1, 0) + sent_topics = [call.kwargs["topic"] for call in publish.await_args_list] + assert sent_topics == ["topic/new"] + + @pytest.mark.asyncio + async def test_expiry_disabled_with_none(self): + queue = _queue(max_age=None) + queue.enqueue("topic/old", {"cmd": "old"}, mqtt.QoS.AT_LEAST_ONCE) + queue._queue[0].timestamp = datetime.now(UTC) - timedelta(days=1) + + publish = AsyncMock(return_value=1) + sent, failed = await queue.send_all(publish, lambda: True) + + assert (sent, failed) == (1, 0) + + def test_overflow_drops_oldest(self): + config = MqttConnectionConfig( + client_id="test-client", max_queued_commands=2 + ) + queue = MqttCommandQueue(config) + for name in ("a", "b", "c"): + queue.enqueue(f"topic/{name}", {}, mqtt.QoS.AT_LEAST_ONCE) + + assert queue.count == 2 + assert [c.topic for c in queue._queue] == ["topic/b", "topic/c"] + + +class TestAsymmetricNegativeTemperatures: + """Firmware uses truncated remainder; Python's % is floored.""" + + def test_negative_raw_matches_firmware_rounding(self): + """Regression: raw -11 (-5.5degC) gave -11 % 10 == 9 -> floor -> + 22degF, while the firmware/app's truncated remainder (-1) applies + ceil -> 23degF.""" + temp = RawCelsius(-11) + assert ( + temp.to_fahrenheit_with_formula(TempFormulaType.ASYMMETRIC) == 23.0 + ) + + def test_positive_raw_behavior_unchanged(self): + # raw 119 -> 59.5degC -> 139.1degF; remainder 9 -> floor -> 139 + assert ( + RawCelsius(119).to_fahrenheit_with_formula( + TempFormulaType.ASYMMETRIC + ) + == 139.0 + ) + # raw 120 -> 60.0degC -> 140.0degF; remainder 0 -> ceil -> 140 + assert ( + RawCelsius(120).to_fahrenheit_with_formula( + TempFormulaType.ASYMMETRIC + ) + == 140.0 + ) + + def test_negative_raw_with_remainder_nine_equivalent(self): + # raw -19 -> -9.5degC -> 14.9degF; truncated remainder -9 -> ceil -> 15 + assert ( + RawCelsius(-19).to_fahrenheit_with_formula( + TempFormulaType.ASYMMETRIC + ) + == 15.0 + ) + + +class TestFreezeProtectionDefaults: + """Defaults must be raw half-Celsius values (43degF / 50degF).""" + + def test_defaults_decode_to_documented_range(self, device_status_dict): + """Regression: defaults 43/65 were Fahrenheit display values + pasted into raw half-Celsius fields, decoding to 70.7degF/90.5degF + instead of the documented fixed 43degF/50degF limits.""" + from nwp500.unit_system import set_unit_system + + set_unit_system("us_customary") + data = dict(device_status_dict) + data.pop("freezeProtectionTempMin", None) + data.pop("freezeProtectionTempMax", None) + status = DeviceStatus.model_validate(data) + + assert status.freeze_protection_temp_min_raw == 12 + assert status.freeze_protection_temp_max_raw == 20 + assert status.freeze_protection_temp_min == pytest.approx(43, abs=1) + assert status.freeze_protection_temp_max == pytest.approx(50, abs=1) + + +class TestErrorCodeChangeEvents: + """error_detected must fire when the error code changes.""" + + @pytest.mark.asyncio + async def test_error_code_change_emits_error_detected( + self, device_status_dict + ): + """Regression: only 0 -> non-zero emitted error_detected; a + transition E799 -> E407 emitted nothing, so consumers kept + displaying the stale error.""" + emitter = EventEmitter() + tracker = DeviceStateTracker(emitter) + + events = [] + emitter.on("error_detected", lambda e: events.append(int(e.error_code))) + + # Baseline snapshot (no events emitted for the first status) + await tracker.process("mac", _status(device_status_dict, errorCode=0)) + await tracker.process("mac", _status(device_status_dict, errorCode=799)) + await tracker.process("mac", _status(device_status_dict, errorCode=407)) + + assert events == [799, 407] + + @pytest.mark.asyncio + async def test_unchanged_error_code_not_re_emitted( + self, device_status_dict + ): + emitter = EventEmitter() + tracker = DeviceStateTracker(emitter) + + events = [] + emitter.on("error_detected", lambda e: events.append(int(e.error_code))) + + await tracker.process("mac", _status(device_status_dict, errorCode=0)) + await tracker.process("mac", _status(device_status_dict, errorCode=799)) + await tracker.process("mac", _status(device_status_dict, errorCode=799)) + + assert events == [799] + + @pytest.mark.asyncio + async def test_error_cleared_still_emitted(self, device_status_dict): + emitter = EventEmitter() + tracker = DeviceStateTracker(emitter) + + cleared = [] + emitter.on("error_cleared", lambda e: cleared.append(int(e.error_code))) + + await tracker.process("mac", _status(device_status_dict, errorCode=0)) + await tracker.process("mac", _status(device_status_dict, errorCode=799)) + await tracker.process("mac", _status(device_status_dict, errorCode=0)) + + assert cleared == [799] + + +class TestTouPriceEncoding: + """Price encoding must round half-up and reject bool as int.""" + + def test_encode_price_rounds_half_up(self): + """Regression: round() applies banker's rounding, under-encoding + exact halves at even boundaries.""" + assert encode_price(0.125, 2) == 13 + assert encode_price(0.135, 2) == 14 + assert encode_price(12.34, 2) == 1234 + + def test_build_tou_period_bool_price_not_treated_as_encoded(self): + """Regression: bool is an int subclass, so True passed the + 'already encoded' isinstance check and was sent as price 1.""" + period = build_tou_period( + week_days=["Monday"], + season_months=[1], + start_hour=0, + start_minute=0, + end_hour=6, + end_minute=0, + price_min=True, # nonsense input, but must be encoded not passed + price_max=2.5, + decimal_point=2, + ) + assert period["priceMin"] == 100 # encode_price(1.0, 2) + assert period["priceMax"] == 250 + + def test_build_tou_period_int_passthrough(self): + period = build_tou_period( + week_days=["Monday"], + season_months=[1], + start_hour=0, + start_minute=0, + end_hour=6, + end_minute=0, + price_min=500, # pre-encoded + price_max=0.075, + decimal_point=3, + ) + assert period["priceMin"] == 500 + assert period["priceMax"] == 75 From 5a1f8292907077e42b1139ce4761346e17f599a8 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 5 Jul 2026 08:41:50 -0700 Subject: [PATCH 2/4] refactor!: trim public API, remove dead code, deduplicate conversions BREAKING CHANGES (major version bump required): - Remove deprecated NavienMqttClient.control property; use the delegated methods on the client directly - Remove never-raised exception classes: TokenExpiredError, MqttSubscriptionError, DeviceNotFoundError, DeviceOfflineError, DeviceOperationError (catch the parent classes instead) - Stop re-exporting internal plumbing from the top-level namespace: requires_capability, MqttDeviceInfoCache, MqttDeviceCapabilityChecker, log_performance, and the eight bit-encoding helpers (import from nwp500.encoding et al.) Cleanup: - Delete dead nwp500.cli.commands module (never imported, drifted from the real click commands) - Delete unused converters.str_enum_validator and module-level temperature conversion wrappers; simplify div_10/mul_10 - Delete no-op NavienMqttClient._on_message_received placeholder and unused CLI width calculations - Deduplicate the four Temperature classes via a _scale ClassVar on the base (behavior unchanged, ~200 lines removed); doctests pass - Deduplicate field_factory metadata-merge boilerplate - Use converters.device_bool_from_python in mqtt/control.py instead of inline 2/1 literals - Resolve auth.py version from distribution metadata, removing the order-dependent 'from . import __version__' near-circular import - Update docs and examples to import encoding helpers from nwp500.encoding; add public-API surface tests --- CHANGELOG.rst | 68 ++++++ docs/how-to/optimize-tou.rst | 13 +- docs/how-to/schedule-operation.rst | 6 +- docs/reference/python_api/auth_client.rst | 4 - docs/reference/python_api/exceptions.rst | 57 ----- docs/reference/python_api/mqtt_client.rst | 2 +- examples/advanced/tou_openei.py | 2 + src/nwp500/__init__.py | 48 +--- src/nwp500/auth.py | 12 +- src/nwp500/cli/commands.py | 91 ------- src/nwp500/cli/output_formatters.py | 14 -- src/nwp500/converters.py | 35 --- src/nwp500/exceptions.py | 57 ----- src/nwp500/field_factory.py | 122 +++++----- src/nwp500/mqtt/client.py | 31 --- src/nwp500/mqtt/control.py | 5 +- src/nwp500/temperature.py | 284 ++++------------------ tests/test_auth.py | 9 - tests/test_exceptions.py | 39 --- tests/test_public_api.py | 63 +++++ 20 files changed, 259 insertions(+), 703 deletions(-) delete mode 100644 src/nwp500/cli/commands.py create mode 100644 tests/test_public_api.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bfae543..2ba37c6d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,74 @@ Changelog Unreleased ========== +**BREAKING CHANGES**: Public API surface trimmed and dead code removed. +These changes require a major version bump. + +Removed +------- +- **Deprecated ``.control`` property**: removed + ``NavienMqttClient.control`` (deprecated shim slated for v9.0.0; the + project policy is to remove rather than deprecate). Use the delegated + methods on the client directly: + + .. code-block:: python + + # OLD (removed) + await client.control.set_power(device, True) + + # NEW + await client.set_power(device, True) + +- **Unused exception classes**: removed ``TokenExpiredError``, + ``MqttSubscriptionError``, ``DeviceNotFoundError``, + ``DeviceOfflineError``, and ``DeviceOperationError``. None of them was + ever raised by the library, so no working error handling can break; + catch the parent classes (``AuthenticationError``, ``MqttError``, + ``DeviceError``) instead. +- **Internal plumbing removed from the top-level namespace**: the + package no longer re-exports ``requires_capability``, + ``MqttDeviceInfoCache``, ``MqttDeviceCapabilityChecker``, + ``log_performance``, or the bit-encoding helpers + (``encode_week_bitfield``, ``decode_week_bitfield``, + ``encode_season_bitfield``, ``decode_season_bitfield``, + ``encode_price``, ``decode_price``, ``build_reservation_entry``, + ``build_tou_period``). Import them from their owning modules: + + .. code-block:: python + + # OLD (removed) + from nwp500 import build_reservation_entry, encode_price + + # NEW + from nwp500.encoding import build_reservation_entry, encode_price + +- **Dead code**: removed the never-imported ``nwp500.cli.commands`` + module (its metadata had drifted from the real click commands), the + unused ``converters.str_enum_validator``, the unused module-level + ``temperature.half_celsius_to_fahrenheit`` / + ``deci_celsius_to_fahrenheit`` wrappers, the no-op + ``NavienMqttClient._on_message_received`` placeholder, and unused + width calculations in the CLI formatters. + +Changed +------- +- **Temperature classes deduplicated**: ``HalfCelsius``, ``DeciCelsius``, + ``RawCelsius``, and ``DeciCelsiusDelta`` were four near-identical + copies differing only in a scale constant. All conversions are now + implemented once on the ``Temperature`` base class with a per-class + ``_scale``; only special rounding (``RawCelsius``) and delta semantics + (``DeciCelsiusDelta``) are overridden. Behavior is unchanged + (~200 lines removed). +- **field_factory deduplicated**: the four field factories shared a + copy-pasted metadata-merge block, now extracted into a single private + helper. Behavior is unchanged. +- **Device boolean encoding centralized**: ``mqtt/control.py`` now uses + ``converters.device_bool_from_python()`` instead of inline + ``2 if enabled else 1`` literals. +- **Version resolution decoupled**: ``auth.py`` resolves the package + version from distribution metadata instead of ``from . import + __version__``, removing an order-dependent near-circular import. + Bug Fixes --------- - **Fix malformed weekly reservation and recirculation schedule diff --git a/docs/how-to/optimize-tou.rst b/docs/how-to/optimize-tou.rst index bc23a508..a6c936aa 100644 --- a/docs/how-to/optimize-tou.rst +++ b/docs/how-to/optimize-tou.rst @@ -418,7 +418,7 @@ Encodes a floating-point price into an integer for transmission. .. code-block:: python - from nwp500 import encode_price + from nwp500.encoding import encode_price # Encode $0.45000 per kWh encoded = encode_price(0.45, decimal_point=5) @@ -437,7 +437,7 @@ Decodes an integer price back to floating-point. .. code-block:: python - from nwp500 import decode_price + from nwp500.encoding import decode_price # Decode price from device price = decode_price(45000, decimal_point=5) @@ -466,7 +466,7 @@ Encodes a list of day names into a bitfield. .. code-block:: python - from nwp500 import encode_week_bitfield + from nwp500.encoding import encode_week_bitfield # Weekdays only bitfield = encode_week_bitfield([ @@ -487,7 +487,7 @@ Decodes a bitfield back into a list of day names. .. code-block:: python - from nwp500 import decode_week_bitfield + from nwp500.encoding import decode_week_bitfield # Decode weekday bitfield days = decode_week_bitfield(62) @@ -504,7 +504,8 @@ Configure two rate periods - off-peak and peak pricing: .. code-block:: python import asyncio - from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient, build_tou_period + from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + from nwp500.encoding import build_tou_period async def configure_simple_tou(): async with NavienAuthClient("user@example.com", "password") as auth_client: @@ -654,7 +655,7 @@ Query the device for its current TOU configuration: .. code-block:: python - from nwp500 import decode_week_bitfield, decode_price + from nwp500.encoding import decode_week_bitfield, decode_price async def check_tou_settings(): async with NavienAuthClient("user@example.com", "password") as auth_client: diff --git a/docs/how-to/schedule-operation.rst b/docs/how-to/schedule-operation.rst index 12875782..fa5976de 100644 --- a/docs/how-to/schedule-operation.rst +++ b/docs/how-to/schedule-operation.rst @@ -64,8 +64,8 @@ Quick Example NavienAuthClient, NavienAPIClient, NavienMqttClient, - build_reservation_entry, ) + from nwp500.encoding import build_reservation_entry async def main(): async with NavienAuthClient( @@ -279,7 +279,7 @@ Helper Functions .. code-block:: python - from nwp500 import build_reservation_entry + from nwp500.encoding import build_reservation_entry entry = build_reservation_entry( enabled=True, @@ -589,8 +589,8 @@ want to send the whole weekly program as one typed object. from nwp500 import ( WeeklyReservationEntry, WeeklyReservationSchedule, - build_reservation_entry, ) + from nwp500.encoding import build_reservation_entry morning = WeeklyReservationEntry.model_validate( build_reservation_entry( diff --git a/docs/reference/python_api/auth_client.rst b/docs/reference/python_api/auth_client.rst index 546f9167..0b322601 100644 --- a/docs/reference/python_api/auth_client.rst +++ b/docs/reference/python_api/auth_client.rst @@ -529,7 +529,6 @@ Error Handling from nwp500 import ( InvalidCredentialsError, - TokenExpiredError, TokenRefreshError, AuthenticationError ) @@ -543,9 +542,6 @@ Error Handling except InvalidCredentialsError: print("Wrong email or password") - except TokenExpiredError: - print("Token expired and refresh failed") - except TokenRefreshError: print("Could not refresh token - sign in again") diff --git a/docs/reference/python_api/exceptions.rst b/docs/reference/python_api/exceptions.rst index 58818483..b21e886f 100644 --- a/docs/reference/python_api/exceptions.rst +++ b/docs/reference/python_api/exceptions.rst @@ -15,22 +15,17 @@ All library exceptions inherit from ``Nwp500Error``:: Nwp500Error (base) ├── AuthenticationError │ ├── InvalidCredentialsError - │ ├── TokenExpiredError │ └── TokenRefreshError ├── APIError ├── MqttError │ ├── MqttConnectionError │ ├── MqttNotConnectedError │ ├── MqttPublishError - │ ├── MqttSubscriptionError │ └── MqttCredentialsError ├── ValidationError │ ├── ParameterValidationError │ └── RangeValidationError └── DeviceError - ├── DeviceNotFoundError - ├── DeviceOfflineError - ├── DeviceOperationError └── DeviceCapabilityError Base Exception @@ -134,16 +129,6 @@ InvalidCredentialsError print("Please check your email and password") # Prompt user to re-enter credentials -TokenExpiredError ------------------ - -.. py:class:: TokenExpiredError - - Raised when an authentication token has expired. - - Subclass of :py:class:`AuthenticationError`. Tokens have a limited lifetime - and must be refreshed periodically. - TokenRefreshError ----------------- @@ -305,16 +290,6 @@ MqttPublishError else: raise # Not retriable or max retries reached -MqttSubscriptionError ---------------------- - -.. py:class:: MqttSubscriptionError - - Failed to subscribe to MQTT topic. - - Raised when subscription to an MQTT topic fails. This may occur if the - connection is interrupted or if the client lacks permissions for the topic. - MqttCredentialsError -------------------- @@ -409,38 +384,6 @@ DeviceError All device-related errors inherit from this base class. -DeviceNotFoundError -------------------- - -.. py:class:: DeviceNotFoundError - - Requested device not found. - - Raised when a device cannot be found in the user's device list or when - attempting to access a non-existent device. - -DeviceOfflineError ------------------- - -.. py:class:: DeviceOfflineError - - Device is offline or unreachable. - - Raised when a device is offline and cannot respond to commands or status - requests. The device may be powered off, disconnected from the network, - or experiencing connectivity issues. - -DeviceOperationError --------------------- - -.. py:class:: DeviceOperationError - - Device operation failed. - - Raised when a device operation (mode change, temperature setting, etc.) - fails. This may occur due to invalid commands, device restrictions, or - device-side errors. - DeviceCapabilityError --------------------- diff --git a/docs/reference/python_api/mqtt_client.rst b/docs/reference/python_api/mqtt_client.rst index 7eb4ec4b..0b8f5ff1 100644 --- a/docs/reference/python_api/mqtt_client.rst +++ b/docs/reference/python_api/mqtt_client.rst @@ -467,7 +467,7 @@ update_reservations() .. code-block:: python - from nwp500 import build_reservation_entry + from nwp500.encoding import build_reservation_entry reservations = [ build_reservation_entry( diff --git a/examples/advanced/tou_openei.py b/examples/advanced/tou_openei.py index 5d123229..3a33fe4d 100755 --- a/examples/advanced/tou_openei.py +++ b/examples/advanced/tou_openei.py @@ -21,6 +21,8 @@ NavienAuthClient, NavienMqttClient, OpenEIClient, +) +from nwp500.encoding import ( decode_price, decode_week_bitfield, ) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 5c0d8805..01ce3925 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -32,25 +32,6 @@ authenticate, refresh_access_token, ) -from nwp500.command_decorators import ( - requires_capability, -) -from nwp500.device_capabilities import ( - MqttDeviceCapabilityChecker, -) -from nwp500.device_info_cache import ( - MqttDeviceInfoCache, -) -from nwp500.encoding import ( - build_reservation_entry, - build_tou_period, - decode_price, - decode_season_bitfield, - decode_week_bitfield, - encode_price, - encode_season_bitfield, - encode_week_bitfield, -) from nwp500.enums import ( CommandCode, CurrentOperationMode, @@ -79,20 +60,15 @@ AuthenticationError, DeviceCapabilityError, DeviceError, - DeviceNotFoundError, - DeviceOfflineError, - DeviceOperationError, InvalidCredentialsError, MqttConnectionError, MqttCredentialsError, MqttError, MqttNotConnectedError, MqttPublishError, - MqttSubscriptionError, Nwp500Error, ParameterValidationError, RangeValidationError, - TokenExpiredError, TokenRefreshError, ValidationError, ) @@ -154,17 +130,11 @@ reset_unit_system, set_unit_system, ) -from nwp500.utils import ( - log_performance, -) __all__ = [ "__version__", - # Device Capabilities & Caching - "MqttDeviceCapabilityChecker", + # Device Capabilities "DeviceCapabilityError", - "MqttDeviceInfoCache", - "requires_capability", # Factory functions "create_navien_clients", # Models @@ -225,22 +195,17 @@ "Nwp500Error", "AuthenticationError", "InvalidCredentialsError", - "TokenExpiredError", "TokenRefreshError", "APIError", "MqttError", "MqttConnectionError", "MqttNotConnectedError", "MqttPublishError", - "MqttSubscriptionError", "MqttCredentialsError", "ValidationError", "ParameterValidationError", "RangeValidationError", "DeviceError", - "DeviceNotFoundError", - "DeviceOfflineError", - "DeviceOperationError", # API Client "NavienAPIClient", # OpenEI Client @@ -262,17 +227,6 @@ "EventEmitter", "EventListener", "MqttClientEvents", - # Encoding utilities - "encode_week_bitfield", - "decode_week_bitfield", - "encode_season_bitfield", - "decode_season_bitfield", - "encode_price", - "decode_price", - "build_reservation_entry", - "build_tou_period", - # Utilities - "log_performance", # Unit system management "set_unit_system", "get_unit_system", diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index b182c868..da60aa3f 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -16,6 +16,8 @@ import json import logging from datetime import UTC, datetime, timedelta +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version from typing import Any, Self, cast import aiohttp @@ -26,7 +28,6 @@ model_validator, ) -from . import __version__ from ._base import NavienBaseModel from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT from .exceptions import ( @@ -36,6 +37,15 @@ ) from .unit_system import UnitSystemType +# Resolve the package version directly from distribution metadata instead +# of importing it from the package __init__ (which imports this module, +# making `from . import __version__` an order-dependent near-circular +# import). +try: + __version__ = _dist_version("nwp500-python") +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" + __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" __license__ = "MIT" diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py deleted file mode 100644 index 54febc24..00000000 --- a/src/nwp500/cli/commands.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Command registry for NWP500 CLI.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from . import handlers - - -@dataclass -class CliCommand: - """Definition of a CLI command.""" - - name: str - help: str - callback: Callable[..., Any] - args: list[str] # Required arguments - options: list[str] # Optional arguments - examples: list[str] # Usage examples - - -CLI_COMMANDS = [ - CliCommand( - name="status", - help="Show current device status", - callback=handlers.handle_status_request, - args=[], - options=["--format {text,json,csv}"], - examples=["nwp-cli status"], - ), - CliCommand( - name="info", - help="Show device information", - callback=handlers.handle_device_info_request, - args=[], - options=["--raw"], - examples=["nwp-cli info"], - ), - CliCommand( - name="mode", - help="Set operation mode", - callback=handlers.handle_set_mode_request, - args=["MODE"], - options=[], - examples=["nwp-cli mode heat-pump"], - ), - CliCommand( - name="power", - help="Turn device on or off", - callback=handlers.handle_power_request, - args=["STATE"], - options=[], - examples=["nwp-cli power on"], - ), - CliCommand( - name="temp", - help="Set target hot water temperature", - callback=handlers.handle_set_dhw_temp_request, - args=["VALUE"], - options=[], - examples=["nwp-cli temp 120"], - ), - CliCommand( - name="vacation", - help="Enable vacation mode for N days", - callback=handlers.handle_set_vacation_days_request, - args=["DAYS"], - options=[], - examples=["nwp-cli vacation 7"], - ), - CliCommand( - name="recirc", - help="Set recirculation pump mode", - callback=handlers.handle_set_recirculation_mode_request, - args=["MODE"], - options=[], - examples=["nwp-cli recirc 2"], - ), -] - - -def get_command(name: str) -> CliCommand | None: - """Lookup command by name.""" - return next((c for c in CLI_COMMANDS if c.name == name), None) - - -def list_commands() -> list[CliCommand]: - """Get all available commands.""" - return CLI_COMMANDS diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index 70c65c49..5652fd7f 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -897,13 +897,6 @@ def print_device_status(device_status: Any) -> None: ) ) - # Calculate widths dynamically - max_label_len = max((len(label) for _, label, _ in all_items), default=20) - max_value_len = max( - (len(str(value)) for _, _, value in all_items), default=20 - ) - _line_width = max_label_len + max_value_len + 4 # +4 for padding - # Use rich formatter for output formatter = get_formatter() formatter.print_status_table(all_items) @@ -1109,13 +1102,6 @@ def print_device_info(device_feature: Any) -> None: status = "Yes" if value else "No" all_items.append(("SUPPORTED FEATURES", label, status)) - # Calculate widths dynamically - max_label_len = max((len(label) for _, label, _ in all_items), default=20) - max_value_len = max( - (len(str(value)) for _, _, value in all_items), default=20 - ) - _line_width = max_label_len + max_value_len + 4 # +4 for padding - # Use rich formatter for output formatter = get_formatter() formatter.print_status_table(all_items) diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index d1778b2a..d9ec1167 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -19,7 +19,6 @@ "div_10", "mul_10", "enum_validator", - "str_enum_validator", ] @@ -103,8 +102,6 @@ def div_10(value: Any) -> float: >>> div_10(25.5) 2.55 """ - if isinstance(value, (int, float)): - return float(value) / 10.0 return float(value) / 10.0 @@ -126,8 +123,6 @@ def mul_10(value: Any) -> float: >>> mul_10(25.5) 255.0 """ - if isinstance(value, (int, float)): - return float(value) * 10.0 return float(value) * 10.0 @@ -159,33 +154,3 @@ def validate(value: Any) -> Any: return enum_class(int(value)) return validate - - -def str_enum_validator(enum_class: type[Any]) -> Callable[[Any], Any]: - """Create a validator for converting string to str-based Enum. - - Args: - enum_class: The str Enum class to validate against. - - Returns: - A validator function compatible with Pydantic BeforeValidator. - - Example: - >>> from enum import Enum - >>> class Status(str, Enum): - ... ACTIVE = "A" - ... INACTIVE = "I" - >>> validator = str_enum_validator(Status) - >>> validator("A") - - """ - - def validate(value: Any) -> Any: - """Validate and convert value to enum.""" - if isinstance(value, enum_class): - return value - if isinstance(value, str): - return enum_class(value) - return enum_class(str(value)) - - return validate diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index 39e0ae62..ab861efd 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -9,22 +9,17 @@ Nwp500Error (base) ├── AuthenticationError │ ├── InvalidCredentialsError - │ ├── TokenExpiredError │ └── TokenRefreshError ├── APIError ├── MqttError │ ├── MqttConnectionError │ ├── MqttNotConnectedError │ ├── MqttPublishError - │ ├── MqttSubscriptionError │ └── MqttCredentialsError ├── ValidationError │ ├── ParameterValidationError │ └── RangeValidationError └── DeviceError - ├── DeviceNotFoundError - ├── DeviceOfflineError - ├── DeviceOperationError └── DeviceCapabilityError Migration from v4.x @@ -181,16 +176,6 @@ class InvalidCredentialsError(AuthenticationError): pass -class TokenExpiredError(AuthenticationError): - """Raised when an authentication token has expired. - - Tokens have a limited lifetime and must be refreshed periodically. - This exception indicates that a token has passed its expiration time. - """ - - pass - - class TokenRefreshError(AuthenticationError): """Raised when token refresh operation fails. @@ -292,16 +277,6 @@ class MqttPublishError(MqttError): pass -class MqttSubscriptionError(MqttError): - """Failed to subscribe to MQTT topic. - - Raised when subscription to an MQTT topic fails. This may occur if the - connection is interrupted or if the client lacks permissions for the topic. - """ - - pass - - class MqttCredentialsError(MqttError): """AWS credentials invalid or expired. @@ -416,38 +391,6 @@ class DeviceError(Nwp500Error): pass -class DeviceNotFoundError(DeviceError): - """Requested device not found. - - Raised when a device cannot be found in the user's device list or - when attempting to access a non-existent device. - """ - - pass - - -class DeviceOfflineError(DeviceError): - """Device is offline or unreachable. - - Raised when a device is offline and cannot respond to commands or - status requests. The device may be powered off, disconnected from - the network, or experiencing connectivity issues. - """ - - pass - - -class DeviceOperationError(DeviceError): - """Device operation failed. - - Raised when a device operation (mode change, temperature setting, etc.) - fails. This may occur due to invalid commands, device restrictions, - or device-side errors. - """ - - pass - - class DeviceCapabilityError(DeviceError): """Device does not support a requested capability. diff --git a/src/nwp500/field_factory.py b/src/nwp500/field_factory.py index 1c6a8306..234e9b3f 100644 --- a/src/nwp500/field_factory.py +++ b/src/nwp500/field_factory.py @@ -34,6 +34,39 @@ ] +def _metadata_field( + description: str, + metadata: dict[str, Any], + default: Any, + kwargs: dict[str, Any], +) -> Any: + """Build a Pydantic Field with device metadata in json_schema_extra. + + Args: + description: Field description + metadata: Base json_schema_extra metadata for this field type + default: Default value or Pydantic default + kwargs: Additional Pydantic Field arguments; a caller-provided + json_schema_extra dict is merged over the base metadata + + Returns: + Pydantic Field with the merged metadata + """ + json_schema_extra = dict(metadata) + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + # Explicitly cast to dict[str, Any] for type safety + json_schema_extra.update(cast(dict[str, Any], extra)) + + return Field( + default=default, + description=description, + json_schema_extra=json_schema_extra, + **kwargs, + ) + + def temperature_field( description: str, *, @@ -60,23 +93,15 @@ def temperature_field( Returns: Pydantic Field with temperature metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "temperature", - "suggested_display_precision": 1, - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + { + "unit_of_measurement": unit, + "device_class": "temperature", + "suggested_display_precision": 1, + }, + default, + kwargs, ) @@ -98,22 +123,11 @@ def signal_strength_field( Returns: Pydantic Field with signal strength metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "signal_strength", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "signal_strength"}, + default, + kwargs, ) @@ -135,22 +149,11 @@ def energy_field( Returns: Pydantic Field with energy metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "energy", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "energy"}, + default, + kwargs, ) @@ -172,20 +175,9 @@ def power_field( Returns: Pydantic Field with power metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "power", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "power"}, + default, + kwargs, ) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 13fa05e4..6103fc47 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -12,10 +12,8 @@ from __future__ import annotations import asyncio -import json import logging import uuid -import warnings from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, cast @@ -822,25 +820,6 @@ async def disconnect(self) -> None: _logger.error(f"Error during disconnect: {e}") raise - def _on_message_received( - self, topic: str, payload: bytes, **kwargs: Any - ) -> None: - """Internal callback for received messages.""" - try: - # Parse JSON payload and delegate to subscription manager - _logger.debug("Received message on topic: %s", topic) - - # Call registered handlers via subscription manager - if self._subscription_manager: - # The subscription manager will handle matching - # and calling handlers - pass # Subscription manager handles this internally - - except json.JSONDecodeError as e: - _logger.error(f"Failed to parse message payload: {e}") - except (AttributeError, KeyError, TypeError) as e: - _logger.error(f"Error processing message: {e}") - async def subscribe( self, topic: str, @@ -1411,16 +1390,6 @@ def _control(self) -> MqttDeviceController: """ return self._device_controller - @property - @warnings.deprecated( - "The .control attribute is deprecated and will be removed in v9.0.0. " - "Use the delegated methods on NavienMqttClient directly (e.g., " - "client.set_power() instead of client.control.set_power())." - ) - def control(self) -> MqttDeviceController: - """Deprecated access to device controller.""" - return self._device_controller - async def start_periodic_requests( self, device: Device, diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 1ebc7415..038abdd7 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -26,6 +26,7 @@ from ..command_decorators import requires_capability from ..config import MQTT_PROTOCOL_VERSION +from ..converters import device_bool_from_python from ..device_capabilities import MqttDeviceCapabilityChecker from ..device_info_cache import MqttDeviceInfoCache from ..enums import CommandCode, DhwOperationSetting @@ -460,7 +461,7 @@ async def update_reservations( # See docs/protocol/mqtt_protocol.rst "Reservation Management" for the # command code (16777226) and the reservation object fields # (enable, week, hour, min, mode, param). - reservation_use = 2 if enabled else 1 + reservation_use = device_bool_from_python(enabled) reservation_payload = [dict(entry) for entry in reservations] return await self._send_command( @@ -527,7 +528,7 @@ async def configure_tou_schedule( "At least one TOU period must be provided", parameter="periods" ) - reservation_use = 2 if enabled else 1 + reservation_use = device_bool_from_python(enabled) reservation_payload = [dict(period) for period in periods] return await self._send_command( diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py index 01524205..70731389 100644 --- a/src/nwp500/temperature.py +++ b/src/nwp500/temperature.py @@ -10,14 +10,21 @@ from __future__ import annotations import math -from abc import ABC, abstractmethod -from typing import Any +from typing import ClassVar, Self from .enums import TempFormulaType -class Temperature(ABC): - """Base class for temperature conversions with device protocol support.""" +class Temperature: + """Base class for temperature conversions with device protocol support. + + Subclasses set ``_scale`` (raw device units per degree Celsius) and + inherit all conversions; only formats with special rounding or delta + semantics override the Fahrenheit conversions. + """ + + #: Raw device units per degree Celsius (2 = half-degrees, 10 = deci). + _scale: ClassVar[float] = 1.0 def __init__(self, raw_value: int | float): """Initialize with raw device value. @@ -27,21 +34,21 @@ def __init__(self, raw_value: int | float): """ self.raw_value = float(raw_value) - @abstractmethod def to_celsius(self) -> float: """Convert to Celsius. Returns: Temperature in Celsius. """ + return self.raw_value / self._scale - @abstractmethod def to_fahrenheit(self) -> float: """Convert to Fahrenheit. Returns: Temperature in Fahrenheit. """ + return self.to_celsius() * 9 / 5 + 32 def to_preferred(self, is_celsius: bool = False) -> float: """Convert to preferred unit (Celsius or Fahrenheit). @@ -55,8 +62,8 @@ def to_preferred(self, is_celsius: bool = False) -> float: return self.to_celsius() if is_celsius else self.to_fahrenheit() @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> Temperature: - """Create instance from Fahrenheit value (for commands). + def from_fahrenheit(cls, fahrenheit: float) -> Self: + """Create instance from Fahrenheit value (for device commands). Args: fahrenheit: Temperature in Fahrenheit. @@ -64,13 +71,12 @@ def from_fahrenheit(cls, fahrenheit: float) -> Temperature: Returns: Instance with raw value set for device command. """ - raise NotImplementedError( - f"{cls.__name__} does not support creation from Fahrenheit" - ) + celsius = (fahrenheit - 32) * 5 / 9 + return cls(round(celsius * cls._scale)) @classmethod - def from_celsius(cls, celsius: float) -> Temperature: - """Create instance from Celsius value (for commands). + def from_celsius(cls, celsius: float) -> Self: + """Create instance from Celsius value (for device commands). Args: celsius: Temperature in Celsius. @@ -78,14 +84,10 @@ def from_celsius(cls, celsius: float) -> Temperature: Returns: Instance with raw value set for device command. """ - raise NotImplementedError( - f"{cls.__name__} does not support creation from Celsius" - ) + return cls(round(celsius * cls._scale)) @classmethod - def from_preferred( - cls, value: float, is_celsius: bool = False - ) -> Temperature: + def from_preferred(cls, value: float, is_celsius: bool = False) -> Self: """Create instance from preferred unit (C or F). Args: @@ -114,61 +116,13 @@ class HalfCelsius(Temperature): 60.0 >>> temp.to_fahrenheit() 140.0 + >>> HalfCelsius.from_fahrenheit(140.0).raw_value + 120.0 + >>> HalfCelsius.from_celsius(60.0).raw_value + 120.0 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 2.0 - - def to_fahrenheit(self) -> float: - """Convert to Fahrenheit. - - Returns: - Temperature in Fahrenheit. - """ - celsius = self.to_celsius() - return celsius * 9 / 5 + 32 - - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> HalfCelsius: - """Create HalfCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - HalfCelsius instance with raw value for device. - - Example: - >>> temp = HalfCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 120.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 2) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> HalfCelsius: - """Create HalfCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - HalfCelsius instance with raw value for device. - - Example: - >>> temp = HalfCelsius.from_celsius(60.0) - >>> temp.raw_value - 120.0 - """ - raw_value = round(celsius * 2) - return cls(raw_value) + _scale: ClassVar[float] = 2.0 class DeciCelsius(Temperature): @@ -183,61 +137,13 @@ class DeciCelsius(Temperature): 60.0 >>> temp.to_fahrenheit() 140.0 + >>> DeciCelsius.from_fahrenheit(140.0).raw_value + 600.0 + >>> DeciCelsius.from_celsius(60.0).raw_value + 600.0 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 10.0 - - def to_fahrenheit(self) -> float: - """Convert to Fahrenheit. - - Returns: - Temperature in Fahrenheit. - """ - celsius = self.to_celsius() - return celsius * 9 / 5 + 32 - - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsius: - """Create DeciCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - DeciCelsius instance with raw value for device. - - Example: - >>> temp = DeciCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 600.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 10) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> DeciCelsius: - """Create DeciCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - DeciCelsius instance with raw value for device. - - Example: - >>> temp = DeciCelsius.from_celsius(60.0) - >>> temp.raw_value - 600.0 - """ - raw_value = round(celsius * 10) - return cls(raw_value) + _scale: ClassVar[float] = 10.0 class RawCelsius(Temperature): @@ -259,13 +165,7 @@ class RawCelsius(Temperature): 140 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 2.0 + _scale: ClassVar[float] = 2.0 def to_fahrenheit(self) -> float: """Convert to Fahrenheit using standard rounding. @@ -273,8 +173,7 @@ def to_fahrenheit(self) -> float: Returns: Temperature in Fahrenheit. """ - celsius = self.to_celsius() - return round((celsius * 9 / 5) + 32) + return round(super().to_fahrenheit()) def to_fahrenheit_with_formula( self, formula_type: TempFormulaType @@ -287,8 +186,7 @@ def to_fahrenheit_with_formula( Returns: Temperature in Fahrenheit. """ - celsius = self.to_celsius() - fahrenheit_value = (celsius * 9 / 5) + 32 + fahrenheit_value = self.to_celsius() * 9 / 5 + 32 match formula_type: case TempFormulaType.ASYMMETRIC: @@ -309,75 +207,6 @@ def to_fahrenheit_with_formula( # Standard Rounding (default for STANDARD and any future types) return round(fahrenheit_value) - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> RawCelsius: - """Create RawCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - RawCelsius instance with raw value for device. - - Example: - >>> temp = RawCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 120.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 2) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> RawCelsius: - """Create RawCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - RawCelsius instance with raw value for device. - - Example: - >>> temp = RawCelsius.from_celsius(60.0) - >>> temp.raw_value - 120.0 - """ - raw_value = round(celsius * 2) - return cls(raw_value) - - -def half_celsius_to_fahrenheit(value: Any) -> float: - """Convert half-degrees Celsius to Fahrenheit. - - Validator function for Pydantic fields using HalfCelsius format. - - Args: - value: Raw device value in half-Celsius format. - - Returns: - Temperature in Fahrenheit. - """ - if isinstance(value, (int, float)): - return HalfCelsius(value).to_fahrenheit() - return float(value) - - -def deci_celsius_to_fahrenheit(value: Any) -> float: - """Convert decicelsius to Fahrenheit. - - Validator function for Pydantic fields using DeciCelsius format. - - Args: - value: Raw device value in decicelsius format. - - Returns: - Temperature in Fahrenheit. - """ - if isinstance(value, (int, float)): - return DeciCelsius(value).to_fahrenheit() - return float(value) - class DeciCelsiusDelta(Temperature): """Temperature delta in decicelsius (0.1°C precision). @@ -396,15 +225,13 @@ class DeciCelsiusDelta(Temperature): 0.5 >>> temp.to_fahrenheit() 0.9 + >>> DeciCelsiusDelta.from_fahrenheit(0.9).raw_value + 5.0 + >>> DeciCelsiusDelta.from_celsius(0.5).raw_value + 5.0 """ - def to_celsius(self) -> float: - """Convert to Celsius delta. - - Returns: - Temperature delta in Celsius. - """ - return self.raw_value / 10.0 + _scale: ClassVar[float] = 10.0 def to_fahrenheit(self) -> float: """Convert to Fahrenheit delta (without +32 offset). @@ -412,42 +239,17 @@ def to_fahrenheit(self) -> float: Returns: Temperature delta in Fahrenheit. """ - celsius = self.to_celsius() - return celsius * 9 / 5 + return self.to_celsius() * 9 / 5 @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsiusDelta: - """Create DeciCelsiusDelta from Fahrenheit delta (for device commands). + def from_fahrenheit(cls, fahrenheit: float) -> Self: + """Create DeciCelsiusDelta from Fahrenheit delta (no -32 offset). Args: fahrenheit: Temperature delta in Fahrenheit. Returns: DeciCelsiusDelta instance with raw value for device. - - Example: - >>> temp = DeciCelsiusDelta.from_fahrenheit(0.9) - >>> temp.raw_value - 5.0 """ celsius = fahrenheit * 5 / 9 - raw_value = round(celsius * 10) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> DeciCelsiusDelta: - """Create DeciCelsiusDelta from Celsius delta (for device commands). - - Args: - celsius: Temperature delta in Celsius. - - Returns: - DeciCelsiusDelta instance with raw value for device. - - Example: - >>> temp = DeciCelsiusDelta.from_celsius(0.5) - >>> temp.raw_value - 5.0 - """ - raw_value = round(celsius * 10) - return cls(raw_value) + return cls(round(celsius * cls._scale)) diff --git a/tests/test_auth.py b/tests/test_auth.py index 7ad9a8f3..fb8a5dc9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,7 +15,6 @@ from nwp500.exceptions import ( AuthenticationError, InvalidCredentialsError, - TokenExpiredError, TokenRefreshError, ) @@ -346,14 +345,6 @@ def test_invalid_credentials_error(): assert isinstance(error, AuthenticationError) -def test_token_expired_error(): - """Test TokenExpiredError exception.""" - error = TokenExpiredError("Token expired") - - assert str(error) == "Token expired" - assert isinstance(error, AuthenticationError) - - def test_token_refresh_error(): """Test TokenRefreshError exception.""" error = TokenRefreshError("Refresh failed", status_code=403) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 9aec279c..48078ef5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,20 +6,15 @@ APIError, AuthenticationError, DeviceError, - DeviceNotFoundError, - DeviceOfflineError, - DeviceOperationError, InvalidCredentialsError, MqttConnectionError, MqttCredentialsError, MqttError, MqttNotConnectedError, MqttPublishError, - MqttSubscriptionError, Nwp500Error, ParameterValidationError, RangeValidationError, - TokenExpiredError, TokenRefreshError, ValidationError, ) @@ -39,7 +34,6 @@ def test_base_exception_hierarchy(self): def test_authentication_exception_hierarchy(self): """Test authentication exception inheritance.""" assert issubclass(InvalidCredentialsError, AuthenticationError) - assert issubclass(TokenExpiredError, AuthenticationError) assert issubclass(TokenRefreshError, AuthenticationError) def test_mqtt_exception_hierarchy(self): @@ -47,7 +41,6 @@ def test_mqtt_exception_hierarchy(self): assert issubclass(MqttConnectionError, MqttError) assert issubclass(MqttNotConnectedError, MqttError) assert issubclass(MqttPublishError, MqttError) - assert issubclass(MqttSubscriptionError, MqttError) assert issubclass(MqttCredentialsError, MqttError) def test_validation_exception_hierarchy(self): @@ -55,12 +48,6 @@ def test_validation_exception_hierarchy(self): assert issubclass(ParameterValidationError, ValidationError) assert issubclass(RangeValidationError, ValidationError) - def test_device_exception_hierarchy(self): - """Test device exception inheritance.""" - assert issubclass(DeviceNotFoundError, DeviceError) - assert issubclass(DeviceOfflineError, DeviceError) - assert issubclass(DeviceOperationError, DeviceError) - def test_all_inherit_from_base_exception(self): """Test that all custom exceptions inherit from Exception.""" assert issubclass(Nwp500Error, Exception) @@ -161,12 +148,6 @@ def test_invalid_credentials_error(self): assert error.message == "Invalid password" assert error.status_code == 401 - def test_token_expired_error(self): - """Test TokenExpiredError.""" - error = TokenExpiredError("Token has expired") - assert isinstance(error, AuthenticationError) - assert error.message == "Token has expired" - def test_token_refresh_error(self): """Test TokenRefreshError.""" error = TokenRefreshError("Refresh failed", status_code=400) @@ -214,11 +195,6 @@ def test_mqtt_publish_error(self): assert error.retriable is True assert error.error_code == "PUB_001" - def test_mqtt_subscription_error(self): - """Test MqttSubscriptionError.""" - error = MqttSubscriptionError("Subscribe failed") - assert isinstance(error, MqttError) - def test_mqtt_credentials_error(self): """Test MqttCredentialsError.""" error = MqttCredentialsError("AWS credentials expired") @@ -270,21 +246,6 @@ def test_range_validation_error_message(self): class TestDeviceExceptions: """Test device-related exceptions.""" - def test_device_not_found_error(self): - """Test DeviceNotFoundError.""" - error = DeviceNotFoundError("Device ABC123 not found") - assert isinstance(error, DeviceError) - - def test_device_offline_error(self): - """Test DeviceOfflineError.""" - error = DeviceOfflineError("Device is offline") - assert isinstance(error, DeviceError) - - def test_device_operation_error(self): - """Test DeviceOperationError.""" - error = DeviceOperationError("Failed to change mode") - assert isinstance(error, DeviceError) - class TestExceptionChaining: """Test exception chaining with 'from' clause.""" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 00000000..e6438f9e --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,63 @@ +"""Tests for the public API surface of the nwp500 package.""" + +from __future__ import annotations + +import nwp500 + + +def test_all_names_resolve(): + """Every name in __all__ must exist on the package.""" + missing = [name for name in nwp500.__all__ if not hasattr(nwp500, name)] + assert missing == [] + + +def test_internal_plumbing_not_exported(): + """Internal helpers must not be part of the public surface.""" + internal = [ + "requires_capability", + "MqttDeviceInfoCache", + "MqttDeviceCapabilityChecker", + "log_performance", + "encode_week_bitfield", + "decode_week_bitfield", + "encode_season_bitfield", + "decode_season_bitfield", + "encode_price", + "decode_price", + "build_reservation_entry", + "build_tou_period", + ] + exported = [name for name in internal if name in nwp500.__all__] + assert exported == [] + + +def test_removed_exceptions_are_gone(): + """Dead exception classes were removed (never raised anywhere).""" + removed = [ + "TokenExpiredError", + "MqttSubscriptionError", + "DeviceNotFoundError", + "DeviceOfflineError", + "DeviceOperationError", + ] + still_present = [name for name in removed if hasattr(nwp500, name)] + assert still_present == [] + + +def test_encoding_helpers_importable_from_submodule(): + """Encoding helpers remain available from nwp500.encoding.""" + from nwp500.encoding import ( + build_reservation_entry, + build_tou_period, + decode_price, + decode_week_bitfield, + encode_price, + encode_week_bitfield, + ) + + assert callable(build_reservation_entry) + assert callable(build_tou_period) + assert callable(encode_price) + assert callable(decode_price) + assert callable(encode_week_bitfield) + assert callable(decode_week_bitfield) From 38180398429d368b1fd2d8872dad1dc4c83cd547 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 5 Jul 2026 08:52:14 -0700 Subject: [PATCH 3/4] chore: Python 3.14 modernization and lint rule adoption - Remove 'from __future__ import annotations' project-wide (57 files); redundant on a >=3.14-only package (PEP 649 default) - Adopt RUF, DTZ, PTH, ASYNC, PERF ruff rule sets with documented ignores (ASYNC109 public timeout params, RUF001-003 intentional unicode, RUF022 grouped __all__) - Fix all resulting findings: timezone-aware CSV timestamps, Path.open(), itertools.pairwise in the CLI range collapser, ClassVar on the capability map, stored task references in tests - Convert PeriodicRequestType to StrEnum - Add slots=True to frozen event dataclasses (mqtt_events, EventListener) - Use match for periodic request-type dispatch - Write token cache with 0600 permissions (mirrors #90 so the stacked branches merge cleanly); fix deprecated datetime.utcnow() in the diagnostics example likewise - Add unit tests for previously untested modules: topic_builder, field_factory, models/_converters (19 tests) --- CHANGELOG.rst | 29 +++++ examples/advanced/mqtt_diagnostics.py | 2 +- examples/mask.py | 2 - pyproject.toml | 19 +++ src/nwp500/__init__.py | 2 - src/nwp500/_base.py | 2 - src/nwp500/api_client.py | 4 +- src/nwp500/auth.py | 12 +- src/nwp500/cli/__init__.py | 2 - src/nwp500/cli/__main__.py | 2 - src/nwp500/cli/handlers.py | 2 - src/nwp500/cli/monitoring.py | 2 - src/nwp500/cli/output_formatters.py | 9 +- src/nwp500/cli/rich_output.py | 5 +- src/nwp500/cli/token_storage.py | 11 +- src/nwp500/command_decorators.py | 4 +- src/nwp500/config.py | 2 - src/nwp500/converters.py | 2 - src/nwp500/device_capabilities.py | 6 +- src/nwp500/device_info_cache.py | 2 - src/nwp500/encoding.py | 2 - src/nwp500/enums.py | 2 - src/nwp500/events.py | 4 +- src/nwp500/exceptions.py | 2 - src/nwp500/factory.py | 2 - src/nwp500/field_factory.py | 2 - src/nwp500/models/__init__.py | 2 - src/nwp500/models/_converters.py | 2 - src/nwp500/models/device.py | 2 - src/nwp500/models/energy.py | 2 - src/nwp500/models/feature.py | 2 - src/nwp500/models/mqtt_models.py | 2 - src/nwp500/models/schedule.py | 2 - src/nwp500/models/status.py | 2 - src/nwp500/models/tou.py | 2 - src/nwp500/mqtt/__init__.py | 2 - src/nwp500/mqtt/client.py | 2 - src/nwp500/mqtt/command_queue.py | 2 - src/nwp500/mqtt/connection.py | 2 - src/nwp500/mqtt/control.py | 2 - src/nwp500/mqtt/diagnostics.py | 2 - src/nwp500/mqtt/periodic.py | 11 +- src/nwp500/mqtt/reconnection.py | 2 - src/nwp500/mqtt/state_tracker.py | 2 - src/nwp500/mqtt/subscriptions.py | 2 - src/nwp500/mqtt/utils.py | 6 +- src/nwp500/mqtt_events.py | 24 ++-- src/nwp500/openei.py | 2 - src/nwp500/reservations.py | 2 - src/nwp500/temperature.py | 2 - src/nwp500/topic_builder.py | 2 - src/nwp500/unit_system.py | 2 - src/nwp500/utils.py | 2 - tests/test_bug_fixes.py | 5 +- tests/test_events.py | 3 +- tests/test_mqtt_clean_session_resume.py | 2 - tests/test_mqtt_reconnection.py | 2 - tests/test_mqtt_reconnection_storm.py | 4 +- tests/test_multi_device.py | 4 +- tests/test_protocol_correctness.py | 2 - tests/test_public_api.py | 2 - tests/test_reservations.py | 2 - tests/test_utility_modules.py | 149 ++++++++++++++++++++++++ 63 files changed, 245 insertions(+), 154 deletions(-) create mode 100644 tests/test_utility_modules.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2ba37c6d..b915cad6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,35 @@ Unreleased **BREAKING CHANGES**: Public API surface trimmed and dead code removed. These changes require a major version bump. +Modernization (Python 3.14) +--------------------------- +- **Removed** ``from __future__ import annotations`` **project-wide** + (57 files): redundant on a Python >=3.14-only package where lazy + annotation evaluation (PEP 649) is the default. +- **Adopted additional ruff rule sets**: ``RUF`` (Ruff-specific), + ``DTZ`` (naive datetime), ``PTH`` (pathlib), ``ASYNC``, and ``PERF``, + with documented ignores for intentional patterns (grouped ``__all__`` + lists, unicode degree signs, public ``timeout`` parameters). Fixes + applied for all resulting findings, including a timezone-naive + timestamp in CSV exports (now timezone-aware local time), + ``Path.open()`` usage, ``itertools.pairwise()`` in the CLI range + collapser, a ``ClassVar`` annotation on the capability map, and + dangling ``asyncio.create_task`` references in tests. +- **PeriodicRequestType is now a StrEnum** (was a plain ``Enum`` with + string values), matching the enum style used elsewhere. +- **Event payload dataclasses use** ``slots=True``: the frozen event + dataclasses in ``mqtt_events.py`` and ``events.EventListener`` are + created per state change; slots reduce their memory footprint. +- **match statement** for the periodic request-type dispatch. + +Testing +------- +- New unit tests for previously untested modules: + ``topic_builder.py`` (full topic schema), ``field_factory.py`` + (metadata defaults, overrides, merge semantics), and + ``models/_converters.py`` (unit-preference conversions and + round-trips). + Removed ------- - **Deprecated ``.control`` property**: removed diff --git a/examples/advanced/mqtt_diagnostics.py b/examples/advanced/mqtt_diagnostics.py index b2d634ed..49c9f6c1 100755 --- a/examples/advanced/mqtt_diagnostics.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -275,7 +275,7 @@ async def run_example( # Final export _logger.info("Exporting final diagnostics...") - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") final_file = self.output_dir / f"diagnostics_final_{timestamp}.json" with open(final_file, "w") as f: f.write(self.diagnostics.export_json()) diff --git a/examples/mask.py b/examples/mask.py index 94628f57..60685469 100644 --- a/examples/mask.py +++ b/examples/mask.py @@ -4,8 +4,6 @@ these helpers; if that import fails we leave a small fallback in each script. """ -from __future__ import annotations - import re diff --git a/pyproject.toml b/pyproject.toml index 5f056eab..37c1052f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,30 @@ select = [ "C4", # flake8-comprehensions "SIM", # flake8-simplify "S", # flake8-bandit (security) + "RUF", # Ruff-specific rules (incl. unused noqa) + "DTZ", # flake8-datetimez (naive datetime usage) + "PTH", # flake8-use-pathlib + "ASYNC", # flake8-async + "PERF", # perflint ] ignore = [ "E203", # whitespace before ':' (conflicts with black/ruff format) "B904", # raise from - will be addressed in a future update + # Async functions taking a `timeout:` parameter are a deliberate, + # documented public API of this library (e.g. wait_for, command + # helpers); switching callers to asyncio.timeout() would be a + # breaking change with no behavioral benefit. + "ASYNC109", + # Degree signs, en-dashes, and multiplication signs in docstrings + # and user-facing strings are intentional (temperature units, + # ranges), not typos of ASCII look-alikes. + "RUF001", + "RUF002", + "RUF003", + # __all__ lists are deliberately grouped by category with comments, + # not sorted alphabetically. + "RUF022", ] # Allow autofix for all enabled rules (when `--fix` is provided) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 01ce3925..0a0b0812 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -4,8 +4,6 @@ communication for NWP500 heat pump water heaters. """ -from __future__ import annotations - from importlib.metadata import ( PackageNotFoundError, version, diff --git a/src/nwp500/_base.py b/src/nwp500/_base.py index faa06742..298aea28 100644 --- a/src/nwp500/_base.py +++ b/src/nwp500/_base.py @@ -5,8 +5,6 @@ protocol models share a single base class. """ -from __future__ import annotations - from typing import Any from pydantic import BaseModel, ConfigDict diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index c9645c59..9ffabdd4 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -4,8 +4,6 @@ This module provides an async HTTP client for device management and control. """ -from __future__ import annotations - import logging from typing import Any, Self, cast @@ -217,7 +215,7 @@ async def _make_request( except aiohttp.ClientError as e: _logger.error(f"Network error: {e}") - raise APIError(f"Network error: {str(e)}") from e + raise APIError(f"Network error: {e!s}") from e # Device Management Endpoints diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index da60aa3f..b47f83df 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -11,8 +11,6 @@ 4. Refresh tokens when accessToken expires """ -from __future__ import annotations - import json import logging from datetime import UTC, datetime, timedelta @@ -472,14 +470,12 @@ async def sign_in( except aiohttp.ClientError as e: _logger.error(f"Network error during sign-in: {e}") raise AuthenticationError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse authentication response: {e}") - raise AuthenticationError( - f"Invalid response format: {str(e)}" - ) from e + raise AuthenticationError(f"Invalid response format: {e!s}") from e async def refresh_token( self, refresh_token: str | None = None @@ -573,12 +569,12 @@ async def refresh_token( except aiohttp.ClientError as e: _logger.error(f"Network error during token refresh: {e}") raise TokenRefreshError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse refresh response: {e}") - raise TokenRefreshError(f"Invalid response format: {str(e)}") from e + raise TokenRefreshError(f"Invalid response format: {e!s}") from e async def re_authenticate(self) -> AuthenticationResponse: """ diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index 62ad37c2..ca5d6239 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -1,7 +1,5 @@ """CLI package for nwp500-python.""" -from __future__ import annotations - from .__main__ import run from .handlers import ( handle_device_info_request, diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 3d20691f..369491d0 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,7 +1,5 @@ """Navien Water Heater Control CLI - Main Entry Point.""" -from __future__ import annotations - import asyncio import functools import logging diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index c31f65f9..c7a68878 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -1,7 +1,5 @@ """Command handlers for CLI operations.""" -from __future__ import annotations - import asyncio import json import logging diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 51c90868..37128197 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -1,7 +1,5 @@ """Monitoring and periodic status polling.""" -from __future__ import annotations - import asyncio import logging diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index 5652fd7f..bb5fde53 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -1,7 +1,5 @@ """Output formatting utilities for CLI (CSV, JSON).""" -from __future__ import annotations - import csv import json import logging @@ -369,13 +367,14 @@ def write_status_to_csv(file_path: str, status: DeviceStatus) -> None: # Convert status to dict (enums are already converted to names) status_dict = status.model_dump() - # Add a timestamp to the beginning of the data - status_dict["timestamp"] = datetime.now().isoformat() + # Add a timestamp to the beginning of the data (timezone-aware, + # in the local timezone) + status_dict["timestamp"] = datetime.now().astimezone().isoformat() # Check if file exists to determine if we need to write the header file_exists = Path(file_path).exists() - with open(file_path, "a", newline="") as csvfile: + with Path(file_path).open("a", newline="") as csvfile: # Get the field names from the dict keys fieldnames = list(status_dict.keys()) writer = csv.DictWriter(csvfile, fieldnames=fieldnames) diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index c9099f3c..fed9d21c 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -1,7 +1,6 @@ """Rich-enhanced output formatting with graceful fallback.""" -from __future__ import annotations - +import itertools import json import logging import os @@ -142,7 +141,7 @@ def _collapse_ranges( # Build groups of consecutive items groups: list[list[Any]] = [[items[0]]] - for prev, curr in zip(items, items[1:], strict=False): + for prev, curr in itertools.pairwise(items): if isinstance(prev, int): consecutive = (curr - prev) == 1 or ( prev == cycle_size and curr == 1 diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index 4b5b7633..7bdfcaad 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -1,9 +1,8 @@ """Token storage and management for CLI authentication.""" -from __future__ import annotations - import json import logging +import os from pathlib import Path from nwp500.auth import AuthTokens @@ -22,7 +21,11 @@ def save_tokens(tokens: AuthTokens, email: str) -> None: email: User email address """ try: - with open(TOKEN_FILE, "w") as f: + # Tokens grant account access; keep the file owner-readable only. + # O_CREAT mode only applies to new files, so chmod existing ones. + fd = os.open(TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + TOKEN_FILE.chmod(0o600) # Use the built-in to_dict() method for serialization token_data = tokens.to_dict() token_data["email"] = email @@ -42,7 +45,7 @@ def load_tokens() -> tuple[AuthTokens | None, str | None]: if not TOKEN_FILE.exists(): return None, None try: - with open(TOKEN_FILE) as f: + with TOKEN_FILE.open() as f: data = json.load(f) email = data.get("email") if not email: diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py index 56ee072b..fbc28843 100644 --- a/src/nwp500/command_decorators.py +++ b/src/nwp500/command_decorators.py @@ -4,8 +4,6 @@ before command execution, preventing unsupported commands from being sent. """ -from __future__ import annotations - import functools import inspect import logging @@ -84,7 +82,7 @@ async def async_wrapper( # Wrap other errors (timeouts, connection issues, etc) raise DeviceCapabilityError( feature, - f"Cannot execute {func.__name__}: {str(e)}", + f"Cannot execute {func.__name__}: {e!s}", ) from e if cached_features is None: diff --git a/src/nwp500/config.py b/src/nwp500/config.py index fa22ad77..97d825da 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -1,7 +1,5 @@ """Configuration for the Navien API client.""" -from __future__ import annotations - API_BASE_URL = "https://nlus.naviensmartcontrol.com/api/v2.1" SIGN_IN_ENDPOINT = "/user/sign-in" REFRESH_ENDPOINT = "/auth/refresh" diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index d9ec1167..01dec4ee 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -7,8 +7,6 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ -from __future__ import annotations - from collections.abc import Callable from typing import Any diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index d907ddf1..088a8204 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -6,10 +6,8 @@ individual checker functions. """ -from __future__ import annotations - from collections.abc import Callable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from .exceptions import DeviceCapabilityError @@ -34,7 +32,7 @@ class MqttDeviceCapabilityChecker: # Map of controllable features to their check functions # Capability names MUST match DeviceFeature attribute names exactly # for traceability: capability name -> DeviceFeature.{name} - _CAPABILITY_MAP: dict[str, CapabilityCheckFn] = { + _CAPABILITY_MAP: ClassVar[dict[str, CapabilityCheckFn]] = { "power_use": lambda f: bool(f.power_use), "dhw_use": lambda f: bool(f.dhw_use), "dhw_temperature_setting_use": lambda f: _check_dhw_temperature_control( diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index a1f5865c..4d3e3b43 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -4,8 +4,6 @@ with automatic periodic updates to keep data synchronized with the device. """ -from __future__ import annotations - import asyncio import logging from datetime import UTC, datetime, timedelta diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 670a8a40..4b978362 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -6,8 +6,6 @@ These utilities are used by both the API client and MQTT client. """ -from __future__ import annotations - import logging from collections.abc import Iterable from decimal import ROUND_HALF_UP, Decimal diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index 7899f074..ae726672 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -7,8 +7,6 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ -from __future__ import annotations - from enum import IntEnum, StrEnum # ============================================================================ diff --git a/src/nwp500/events.py b/src/nwp500/events.py index b6dc46eb..5974dcc3 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -6,8 +6,6 @@ detection. """ -from __future__ import annotations - import asyncio import inspect import logging @@ -23,7 +21,7 @@ _logger = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class EventListener: """Represents a registered event listener.""" diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index ab861efd..c6a57d91 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -60,8 +60,6 @@ # handle other validation errors """ -from __future__ import annotations - from typing import Any __author__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py index 48526262..378b2bd2 100644 --- a/src/nwp500/factory.py +++ b/src/nwp500/factory.py @@ -18,8 +18,6 @@ ... devices = await api.list_devices() """ -from __future__ import annotations - import asyncio from .api_client import NavienAPIClient diff --git a/src/nwp500/field_factory.py b/src/nwp500/field_factory.py index 234e9b3f..51e9498c 100644 --- a/src/nwp500/field_factory.py +++ b/src/nwp500/field_factory.py @@ -20,8 +20,6 @@ ... temp: float = temperature_field("DHW Temperature", unit="°F") """ -from __future__ import annotations - from typing import Any, cast from pydantic import Field diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py index e23c9cac..2311709e 100644 --- a/src/nwp500/models/__init__.py +++ b/src/nwp500/models/__init__.py @@ -6,8 +6,6 @@ These models are based on the MQTT message formats and API responses. """ -from __future__ import annotations - from .._base import NavienBaseModel from ._converters import ( fahrenheit_to_half_celsius, diff --git a/src/nwp500/models/_converters.py b/src/nwp500/models/_converters.py index b24b572f..c19eae97 100644 --- a/src/nwp500/models/_converters.py +++ b/src/nwp500/models/_converters.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from ..temperature import HalfCelsius from ..unit_system import get_unit_system diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py index ae7a501f..3538743f 100644 --- a/src/nwp500/models/device.py +++ b/src/nwp500/models/device.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated, Self from pydantic import BeforeValidator diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py index d2410bfa..9d3801a7 100644 --- a/src/nwp500/models/energy.py +++ b/src/nwp500/models/energy.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pydantic import Field from .._base import NavienBaseModel diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py index 0dc56094..36bd7d7a 100644 --- a/src/nwp500/models/feature.py +++ b/src/nwp500/models/feature.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated from pydantic import BeforeValidator, Field, computed_field diff --git a/src/nwp500/models/mqtt_models.py b/src/nwp500/models/mqtt_models.py index 151d38f2..50068b97 100644 --- a/src/nwp500/models/mqtt_models.py +++ b/src/nwp500/models/mqtt_models.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any from pydantic import Field diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py index 7666d7f4..6483dcb7 100644 --- a/src/nwp500/models/schedule.py +++ b/src/nwp500/models/schedule.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, cast from pydantic import ConfigDict, Field, computed_field, model_validator diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index d436d608..7865693f 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Annotated from pydantic import BeforeValidator, Field, computed_field diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py index 5f858856..65009e66 100644 --- a/src/nwp500/models/tou.py +++ b/src/nwp500/models/tou.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, cast from pydantic import ConfigDict, Field, computed_field, model_validator diff --git a/src/nwp500/mqtt/__init__.py b/src/nwp500/mqtt/__init__.py index 2a82fd3a..2a0d2058 100644 --- a/src/nwp500/mqtt/__init__.py +++ b/src/nwp500/mqtt/__init__.py @@ -11,8 +11,6 @@ - MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types """ -from __future__ import annotations - from .client import NavienMqttClient from .diagnostics import ( ConnectionDropEvent, diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 6103fc47..828b6217 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -9,8 +9,6 @@ the authentication flow. """ -from __future__ import annotations - import asyncio import logging import uuid diff --git a/src/nwp500/mqtt/command_queue.py b/src/nwp500/mqtt/command_queue.py index cf88eca5..cd918759 100644 --- a/src/nwp500/mqtt/command_queue.py +++ b/src/nwp500/mqtt/command_queue.py @@ -5,8 +5,6 @@ and automatically sends them when the connection is restored. """ -from __future__ import annotations - import logging from collections import deque from collections.abc import Callable diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index f48ecc8d..8ed39341 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -6,8 +6,6 @@ including credential management and connection state tracking. """ -from __future__ import annotations - import asyncio import json import logging diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 038abdd7..79d82ff3 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -17,8 +17,6 @@ - Recirculation pump control and scheduling """ -from __future__ import annotations - import logging from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime diff --git a/src/nwp500/mqtt/diagnostics.py b/src/nwp500/mqtt/diagnostics.py index 4df6a328..dc0c0c55 100644 --- a/src/nwp500/mqtt/diagnostics.py +++ b/src/nwp500/mqtt/diagnostics.py @@ -8,8 +8,6 @@ - Client-side configuration issues (insufficient keep-alive, poor backoff) """ -from __future__ import annotations - import json import logging import time diff --git a/src/nwp500/mqtt/periodic.py b/src/nwp500/mqtt/periodic.py index 24b6f7dd..1fbdb666 100644 --- a/src/nwp500/mqtt/periodic.py +++ b/src/nwp500/mqtt/periodic.py @@ -9,8 +9,6 @@ - Per-device, per-type task management """ -from __future__ import annotations - import asyncio import contextlib import logging @@ -169,10 +167,11 @@ async def periodic_request() -> None: consecutive_skips = 0 # Send appropriate request type - if request_type == PeriodicRequestType.DEVICE_INFO: - await self._request_device_info(device) - elif request_type == PeriodicRequestType.DEVICE_STATUS: - await self._request_device_status(device) + match request_type: + case PeriodicRequestType.DEVICE_INFO: + await self._request_device_info(device) + case PeriodicRequestType.DEVICE_STATUS: + await self._request_device_status(device) _logger.debug( "Sent periodic %s request for %s", diff --git a/src/nwp500/mqtt/reconnection.py b/src/nwp500/mqtt/reconnection.py index 6f30c6ba..0c458158 100644 --- a/src/nwp500/mqtt/reconnection.py +++ b/src/nwp500/mqtt/reconnection.py @@ -5,8 +5,6 @@ the MQTT connection is interrupted. """ -from __future__ import annotations - import asyncio import contextlib import logging diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index ea7e8a40..f0446d3f 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -5,8 +5,6 @@ errors). """ -from __future__ import annotations - import logging from ..events import EventEmitter diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 52cf1d82..06827770 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -9,8 +9,6 @@ - State change detection and event emission """ -from __future__ import annotations - import asyncio import json import logging diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index f42ce61c..898d0fc6 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -5,13 +5,11 @@ configuration classes, and common data structures used across MQTT modules. """ -from __future__ import annotations - import re import uuid from dataclasses import dataclass from datetime import datetime -from enum import Enum +from enum import StrEnum from typing import Any from awscrt import mqtt @@ -264,7 +262,7 @@ class QueuedCommand: timestamp: datetime -class PeriodicRequestType(Enum): +class PeriodicRequestType(StrEnum): """Types of periodic requests that can be sent. Attributes: diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 22aeac63..d047748b 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -28,8 +28,6 @@ def on_temperature_changed(event): print(event_name) """ -from __future__ import annotations - from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -38,7 +36,7 @@ def on_temperature_changed(event): from .models import DeviceFeature, DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ConnectionInterruptedEvent: """Emitted when MQTT connection is interrupted. @@ -49,7 +47,7 @@ class ConnectionInterruptedEvent: error: Exception -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ConnectionResumedEvent: """Emitted when MQTT connection is resumed after interruption. @@ -62,7 +60,7 @@ class ConnectionResumedEvent: session_present: bool -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class StatusReceivedEvent: """Emitted when a device status message is received. @@ -75,7 +73,7 @@ class StatusReceivedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class TemperatureChangedEvent: """Emitted when the DHW temperature changes. @@ -92,7 +90,7 @@ class TemperatureChangedEvent: new_temperature: float -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ModeChangedEvent: """Emitted when the device operation mode changes. @@ -107,7 +105,7 @@ class ModeChangedEvent: new_mode: CurrentOperationMode -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class PowerChangedEvent: """Emitted when instantaneous power consumption changes. @@ -122,7 +120,7 @@ class PowerChangedEvent: new_power: float -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class HeatingStartedEvent: """Emitted when device transitions from idle to heating. @@ -135,7 +133,7 @@ class HeatingStartedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class HeatingStoppedEvent: """Emitted when device transitions from heating to idle. @@ -148,7 +146,7 @@ class HeatingStoppedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ErrorDetectedEvent: """Emitted when a device error is first detected. @@ -163,7 +161,7 @@ class ErrorDetectedEvent: status: DeviceStatus -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ErrorClearedEvent: """Emitted when a device error is resolved. @@ -176,7 +174,7 @@ class ErrorClearedEvent: error_code: ErrorCode -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class FeatureReceivedEvent: """Emitted when device feature information is received. diff --git a/src/nwp500/openei.py b/src/nwp500/openei.py index f556c37f..9c948d1f 100644 --- a/src/nwp500/openei.py +++ b/src/nwp500/openei.py @@ -8,8 +8,6 @@ API key can be obtained for free at https://openei.org/services/api/signup/ """ -from __future__ import annotations - import logging import os from typing import Any diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index 5dd6172e..28bbe455 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -9,8 +9,6 @@ All functions are ``async`` and require a connected :class:`NavienMqttClient`. """ -from __future__ import annotations - import asyncio import logging from collections.abc import Sequence diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py index 70731389..e9b59955 100644 --- a/src/nwp500/temperature.py +++ b/src/nwp500/temperature.py @@ -7,8 +7,6 @@ All values are converted to preferred unit based on device preference. """ -from __future__ import annotations - import math from typing import ClassVar, Self diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py index 41d92596..b9a5677a 100644 --- a/src/nwp500/topic_builder.py +++ b/src/nwp500/topic_builder.py @@ -12,8 +12,6 @@ Event: evt/{device_type}/navilink-{mac}/{suffix} """ -from __future__ import annotations - class MqttTopicBuilder: """Helper to construct standard MQTT topics for Navien devices.""" diff --git a/src/nwp500/unit_system.py b/src/nwp500/unit_system.py index 3babef8b..9c5169d0 100644 --- a/src/nwp500/unit_system.py +++ b/src/nwp500/unit_system.py @@ -8,8 +8,6 @@ during model validation to convert device values to the user's preferred units. """ -from __future__ import annotations - import contextvars import logging from typing import Literal diff --git a/src/nwp500/utils.py b/src/nwp500/utils.py index 964db2e1..5f1be094 100644 --- a/src/nwp500/utils.py +++ b/src/nwp500/utils.py @@ -5,8 +5,6 @@ including performance monitoring decorators and helper functions. """ -from __future__ import annotations - import functools import inspect import logging diff --git a/tests/test_bug_fixes.py b/tests/test_bug_fixes.py index 08765eae..656cdfbe 100644 --- a/tests/test_bug_fixes.py +++ b/tests/test_bug_fixes.py @@ -1,7 +1,5 @@ """Tests for bug fixes: diagnostics, config validation, encoding, cache.""" -from __future__ import annotations - import asyncio import json from unittest.mock import patch @@ -97,8 +95,9 @@ async def emit_soon(): await asyncio.sleep(0.01) await emitter.emit("test_event", "data") - asyncio.create_task(emit_soon()) + task = asyncio.create_task(emit_soon()) result = await emitter.wait_for("test_event", timeout=1.0) + await task assert result == ("data",) diff --git a/tests/test_events.py b/tests/test_events.py index ca50e2e9..03bd1ee1 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -245,10 +245,11 @@ async def emit_later(): await emitter.emit("test_event", "test_value") # Start emitting in background - asyncio.create_task(emit_later()) + task = asyncio.create_task(emit_later()) # Wait for event result = await emitter.wait_for("test_event", timeout=1.0) + await task assert result == ("test_value",) diff --git a/tests/test_mqtt_clean_session_resume.py b/tests/test_mqtt_clean_session_resume.py index cb92a714..80df6016 100644 --- a/tests/test_mqtt_clean_session_resume.py +++ b/tests/test_mqtt_clean_session_resume.py @@ -1,7 +1,5 @@ """Tests for MQTT client clean session reconnection handling.""" -from __future__ import annotations - from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/test_mqtt_reconnection.py b/tests/test_mqtt_reconnection.py index d5f0382f..45ca4eee 100644 --- a/tests/test_mqtt_reconnection.py +++ b/tests/test_mqtt_reconnection.py @@ -1,7 +1,5 @@ """Tests for MQTT reconnection: old connection cleanup.""" -from __future__ import annotations - import concurrent.futures from unittest.mock import MagicMock diff --git a/tests/test_mqtt_reconnection_storm.py b/tests/test_mqtt_reconnection_storm.py index 3b734825..ae4cb026 100644 --- a/tests/test_mqtt_reconnection_storm.py +++ b/tests/test_mqtt_reconnection_storm.py @@ -35,8 +35,6 @@ Task operations are safe. """ -from __future__ import annotations - import asyncio from unittest.mock import AsyncMock, MagicMock, patch @@ -397,7 +395,7 @@ def test_actively_reconnecting_initialises_false(self): assert client._actively_reconnecting is False @pytest.mark.asyncio(loop_scope="function") - async def test_interrupted_internal_skips_handler_when_flag_set( # noqa: E501 + async def test_interrupted_internal_skips_handler_when_flag_set( self, ): """ diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py index f48101cd..e681a74b 100644 --- a/tests/test_multi_device.py +++ b/tests/test_multi_device.py @@ -75,7 +75,7 @@ async def test_state_tracker_emits_with_mac(): await tracker.process(mac1, status1_v2) assert emitter.emit.call_count == 1 - args, kwargs = emitter.emit.call_args + args, _kwargs = emitter.emit.call_args assert args[0] == "temperature_changed" event = args[1] assert isinstance(event, TemperatureChangedEvent) @@ -100,7 +100,7 @@ async def test_state_tracker_emits_with_mac(): # Should have emitted another event for mac2 assert emitter.emit.call_count == 2 - args, kwargs = emitter.emit.call_args + args, _kwargs = emitter.emit.call_args event = args[1] assert event.device_mac == mac2 diff --git a/tests/test_protocol_correctness.py b/tests/test_protocol_correctness.py index 8a870428..0d8af96c 100644 --- a/tests/test_protocol_correctness.py +++ b/tests/test_protocol_correctness.py @@ -10,8 +10,6 @@ - TOU price encoding (half-up rounding, bool rejection) """ -from __future__ import annotations - from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_public_api.py b/tests/test_public_api.py index e6438f9e..6dd02b6d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,7 +1,5 @@ """Tests for the public API surface of the nwp500 package.""" -from __future__ import annotations - import nwp500 diff --git a/tests/test_reservations.py b/tests/test_reservations.py index de155012..45bc5b76 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -1,7 +1,5 @@ """Tests for the nwp500.reservations public helpers.""" -from __future__ import annotations - from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch diff --git a/tests/test_utility_modules.py b/tests/test_utility_modules.py new file mode 100644 index 00000000..f5e699e5 --- /dev/null +++ b/tests/test_utility_modules.py @@ -0,0 +1,149 @@ +"""Tests for previously untested utility modules. + +Covers topic_builder, field_factory, and models/_converters. +""" + +import pytest +from pydantic import BaseModel + +from nwp500.field_factory import ( + energy_field, + power_field, + signal_strength_field, + temperature_field, +) +from nwp500.models._converters import ( + fahrenheit_to_half_celsius, + preferred_to_half_celsius, + reservation_param_to_preferred, +) +from nwp500.topic_builder import MqttTopicBuilder +from nwp500.unit_system import reset_unit_system, set_unit_system + +MAC = "aa:bb:cc:dd:ee:ff" + + +@pytest.fixture(autouse=True) +def _reset_units(): + yield + reset_unit_system() + + +class TestMqttTopicBuilder: + def test_device_topic(self): + assert MqttTopicBuilder.device_topic(MAC) == f"navilink-{MAC}" + + def test_command_topic_default_suffix(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC) + == f"cmd/52/navilink-{MAC}/ctrl" + ) + + def test_command_topic_custom_suffix(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC, "ctrl/rsv/rd") + == f"cmd/52/navilink-{MAC}/ctrl/rsv/rd" + ) + + def test_command_topic_wildcard(self): + assert ( + MqttTopicBuilder.command_topic("52", MAC, "#") + == f"cmd/52/navilink-{MAC}/#" + ) + + def test_response_ack_topic(self): + assert ( + MqttTopicBuilder.response_ack_topic("52", MAC, "client-1") + == f"cmd/52/navilink-{MAC}/client-1/res" + ) + + def test_response_topic(self): + assert ( + MqttTopicBuilder.response_topic("52", "client-1", "rsv/rd") + == "cmd/52/client-1/res/rsv/rd" + ) + + def test_event_topic(self): + assert ( + MqttTopicBuilder.event_topic("52", MAC, "st") + == f"evt/52/navilink-{MAC}/st" + ) + + +class TestFieldFactory: + def test_temperature_field_metadata(self): + class Model(BaseModel): + temp: float = temperature_field("DHW temperature", default=0.0) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra == { + "unit_of_measurement": "°F", + "device_class": "temperature", + "suggested_display_precision": 1, + } + assert Model.model_fields["temp"].description == "DHW temperature" + + def test_temperature_field_custom_unit(self): + class Model(BaseModel): + temp: float = temperature_field("t", unit="°C", default=0.0) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra["unit_of_measurement"] == "°C" + + def test_caller_json_schema_extra_merged(self): + class Model(BaseModel): + temp: float = temperature_field( + "t", + default=0.0, + json_schema_extra={"custom": True, "device_class": "override"}, + ) + + extra = Model.model_fields["temp"].json_schema_extra + assert extra["custom"] is True + assert extra["device_class"] == "override" # caller wins + assert extra["unit_of_measurement"] == "°F" # base preserved + + @pytest.mark.parametrize( + ("factory", "device_class", "unit"), + [ + (signal_strength_field, "signal_strength", "dBm"), + (energy_field, "energy", "kWh"), + (power_field, "power", "W"), + ], + ) + def test_other_factories_metadata(self, factory, device_class, unit): + class Model(BaseModel): + value: float = factory("desc", default=0.0) + + extra = Model.model_fields["value"].json_schema_extra + assert extra["device_class"] == device_class + assert extra["unit_of_measurement"] == unit + + +class TestModelConverters: + def test_fahrenheit_to_half_celsius(self): + assert fahrenheit_to_half_celsius(140.0) == 120 + + def test_preferred_to_half_celsius_us_customary(self): + set_unit_system("us_customary") + assert preferred_to_half_celsius(140.0) == 120 + + def test_preferred_to_half_celsius_metric(self): + set_unit_system("metric") + assert preferred_to_half_celsius(60.0) == 120 + + def test_reservation_param_to_preferred_metric(self): + set_unit_system("metric") + assert reservation_param_to_preferred(120) == 60.0 + + def test_reservation_param_to_preferred_us_customary(self): + set_unit_system("us_customary") + assert reservation_param_to_preferred(120) == 140.0 + + def test_roundtrip_us_customary(self): + set_unit_system("us_customary") + for temp in (100.0, 120.0, 130.0, 140.0, 150.0): + param = preferred_to_half_celsius(temp) + assert reservation_param_to_preferred(param) == pytest.approx( + temp, abs=1.0 + ) From 39320c10e0ddb937adecf414381b8e61401e4e23 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 5 Jul 2026 09:56:04 -0700 Subject: [PATCH 4/4] fix: use Path.open() in diagnostics example; lint fixes after merge Addresses review feedback: two open() calls on Path objects remained in examples/advanced/mqtt_diagnostics.py. --- examples/advanced/mqtt_diagnostics.py | 4 ++-- src/nwp500/auth.py | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/advanced/mqtt_diagnostics.py b/examples/advanced/mqtt_diagnostics.py index 5a96d8b9..cd4eed69 100755 --- a/examples/advanced/mqtt_diagnostics.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -84,7 +84,7 @@ async def export_diagnostics(self, interval: float = 300.0) -> None: output_file = self.output_dir / f"diagnostics_{timestamp}.json" json_data = self.diagnostics.export_json() - with open(output_file, "w") as f: + with output_file.open("w") as f: f.write(json_data) _logger.info(f"Exported diagnostics to {output_file}") @@ -276,7 +276,7 @@ async def run_example( _logger.info("Exporting final diagnostics...") timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") final_file = self.output_dir / f"diagnostics_final_{timestamp}.json" - with open(final_file, "w") as f: + with final_file.open("w") as f: f.write(self.diagnostics.export_json()) _logger.info(f"Final diagnostics saved to {final_file}") diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index f2e72dbf..e38916c8 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -504,14 +504,12 @@ async def sign_in( except aiohttp.ClientError as e: _logger.error(f"Network error during sign-in: {e}") raise AuthenticationError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse authentication response: {e}") - raise AuthenticationError( - f"Invalid response format: {str(e)}" - ) from e + raise AuthenticationError(f"Invalid response format: {e!s}") from e async def refresh_token( self, refresh_token: str | None = None @@ -639,12 +637,12 @@ async def _refresh_token_unlocked( except aiohttp.ClientError as e: _logger.error(f"Network error during token refresh: {e}") raise TokenRefreshError( - f"Network error: {str(e)}", + f"Network error: {e!s}", retriable=True, ) from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse refresh response: {e}") - raise TokenRefreshError(f"Invalid response format: {str(e)}") from e + raise TokenRefreshError(f"Invalid response format: {e!s}") from e async def re_authenticate(self) -> AuthenticationResponse: """