From f7b5f537f0e06ca074daf2804c2e805f1336ed11 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 13:41:17 -0800 Subject: [PATCH 1/7] Document Python helpers for reservation management Add comprehensive documentation covering: - Low-level MQTT client methods (update_reservations, request_reservations) - High-level CLI helpers (reservations add/update/delete/get) - Read-modify-write pattern implementation - Why CLI helpers are recommended for typical use cases - Example code for both SDK and CLI approaches This clarifies that the CLI provides the primary public interface for managing reservations programmatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/guides/scheduling.rst | 109 +++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index 9b46e1d..bbfbb5b 100644 --- a/docs/guides/scheduling.rst +++ b/docs/guides/scheduling.rst @@ -325,13 +325,19 @@ Managing Reservations **Important:** The device protocol requires sending the **full list** of reservations for every update. Individual add/delete/update operations work by fetching the current schedule, modifying it, and -sending the full list back. The CLI and Python helpers handle this -automatically. +sending the full list back. The MQTT client provides low-level methods, +and the CLI includes high-level helpers for common operations. + +Low-Level Methods (``NavienMqttClient``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ **Update the full schedule:** .. code-block:: python + from nwp500.mqtt import NavienMqttClient + from nwp500.encoding import build_reservation_entry + reservations = [ build_reservation_entry( enabled=True, @@ -358,22 +364,105 @@ automatically. device, [], enabled=False ) +**Request current schedule:** + +.. code-block:: python + + await mqtt.control.request_reservations(device) + **Read the current schedule using models:** .. code-block:: python from nwp500 import ReservationSchedule - # Subscribe and request + # Subscribe to responses + def on_reservations(schedule: ReservationSchedule) -> None: + print(f"Enabled: {schedule.enabled}") + for entry in schedule.reservation: + print(f" {entry.time} - {', '.join(entry.days)}" + f" - {entry.temperature}{entry.unit}" + f" - {entry.mode_name}") + + await mqtt.subscribe_device_feature( + device, on_reservations, path="reservations" + ) await mqtt.control.request_reservations(device) - # In the callback, parse with the model: - schedule = ReservationSchedule(**response) - print(f"Enabled: {schedule.enabled}") - for entry in schedule.reservation: - print(f" {entry.time} - {', '.join(entry.days)}" - f" - {entry.temperature}{entry.unit}" - f" - {entry.mode_name}") +High-Level Helpers (CLI - Recommended) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For typical use cases, the CLI provides helper commands that handle +the read-modify-write pattern automatically. These are the recommended +public API for working with reservations: + +**List current reservations:** + +.. code-block:: bash + + nwp-cli reservations get # Formatted table + nwp-cli reservations get --json # JSON output + +**Add a single reservation:** + +.. code-block:: bash + + nwp-cli reservations add --days MO,TU,WE,TH,FR \ + --hour 6 --minute 30 --mode 4 --temperature 60 + +**Update an existing reservation:** + +.. code-block:: bash + + nwp-cli reservations update --mode 3 --temperature 58 1 + +**Delete a reservation:** + +.. code-block:: bash + + nwp-cli reservations delete 1 + +These CLI commands internally: +1. Fetch the current reservation schedule +2. Validate inputs +3. Modify the entry or list +4. Send the full updated list back to the device +5. Parse the response and display results + +**Why Use the CLI Helpers?** + +- ✅ Automatic read-modify-write pattern +- ✅ Input validation (time ranges, mode IDs, temperatures) +- ✅ Helpful error messages +- ✅ Unit system awareness (shows temps in your preferred unit) +- ✅ Formatted output (tables, JSON, human-readable) +- ✅ No need to manually fetch, modify, and send reservations + +**For Python SDK Users:** + +If you need programmatic access, the CLI helpers can be invoked via +the Python API using the handler functions (though the low-level MQTT +methods are usually sufficient for most cases): + +.. code-block:: python + + from nwp500.cli.handlers import ( + handle_add_reservation_request, + handle_get_reservations_request + ) + + # Add via handler + await handle_add_reservation_request( + mqtt, device, + enabled=True, + days="MO,TU,WE,TH,FR", + hour=6, minute=30, + mode=4, temperature=60.0 + ) + + # Get via handler (displays formatted output) + await handle_get_reservations_request(mqtt, device) + Mode Selection Strategy ----------------------- From 53c7c3e43dbe913d17c260cd3efcb42636267545 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 13:48:29 -0800 Subject: [PATCH 2/7] Simplify scheduling guide: document what exists and what's needed Show three levels of abstraction: 1. Low-level update_reservations() method (exists) 2. CLI helpers (exist) 3. Library helpers needed - add_reservation(), update_reservation(), delete_reservation(), get_reservations() Remove prescriptive language about what should be primary interface. --- docs/guides/scheduling.rst | 69 ++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index bbfbb5b..b9e9273 100644 --- a/docs/guides/scheduling.rst +++ b/docs/guides/scheduling.rst @@ -325,13 +325,13 @@ Managing Reservations **Important:** The device protocol requires sending the **full list** of reservations for every update. Individual add/delete/update operations work by fetching the current schedule, modifying it, and -sending the full list back. The MQTT client provides low-level methods, -and the CLI includes high-level helpers for common operations. +sending the full list back. -Low-Level Methods (``NavienMqttClient``) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Low-Level Method (``NavienMqttClient``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Update the full schedule:** +Use ``update_reservations()`` when you need full control or are managing +multiple entries at once: .. code-block:: python @@ -389,12 +389,10 @@ Low-Level Methods (``NavienMqttClient``) ) await mqtt.control.request_reservations(device) -High-Level Helpers (CLI - Recommended) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +CLI Helpers +^^^^^^^^^^^ -For typical use cases, the CLI provides helper commands that handle -the read-modify-write pattern automatically. These are the recommended -public API for working with reservations: +The CLI provides convenience commands: **List current reservations:** @@ -422,46 +420,35 @@ public API for working with reservations: nwp-cli reservations delete 1 -These CLI commands internally: -1. Fetch the current reservation schedule -2. Validate inputs -3. Modify the entry or list -4. Send the full updated list back to the device -5. Parse the response and display results +Library Helpers (Needed) +^^^^^^^^^^^^^^^^^^^^^^^^ -**Why Use the CLI Helpers?** - -- ✅ Automatic read-modify-write pattern -- ✅ Input validation (time ranges, mode IDs, temperatures) -- ✅ Helpful error messages -- ✅ Unit system awareness (shows temps in your preferred unit) -- ✅ Formatted output (tables, JSON, human-readable) -- ✅ No need to manually fetch, modify, and send reservations - -**For Python SDK Users:** - -If you need programmatic access, the CLI helpers can be invoked via -the Python API using the handler functions (though the low-level MQTT -methods are usually sufficient for most cases): +The following convenience methods should exist in the library to abstract +the read-modify-write pattern: .. code-block:: python - from nwp500.cli.handlers import ( - handle_add_reservation_request, - handle_get_reservations_request - ) + from nwp500.mqtt import NavienMqttClient + from nwp500.encoding import build_reservation_entry - # Add via handler - await handle_add_reservation_request( - mqtt, device, + entry = build_reservation_entry( enabled=True, - days="MO,TU,WE,TH,FR", + days=["MO", "TU", "WE", "TH", "FR"], hour=6, minute=30, - mode=4, temperature=60.0 + mode_id=4, temperature=60.0 ) - # Get via handler (displays formatted output) - await handle_get_reservations_request(mqtt, device) + # Add a single reservation (automatically fetches current, modifies, sends) + # await mqtt.add_reservation(device, entry) + + # Update an existing reservation by index + # await mqtt.update_reservation(device, index, mode=3, temperature=58) + + # Delete a reservation by index + # await mqtt.delete_reservation(device, index) + + # Get current schedule as ReservationSchedule model + # schedule = await mqtt.get_reservations(device) Mode Selection Strategy From c138589e739024000faccc10c5c909d9efd41130 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 14:19:07 -0800 Subject: [PATCH 3/7] Move reservation CRUD helpers into core library Extract add_reservation(), delete_reservation(), update_reservation(), and fetch_reservations() from the CLI into a new public module (src/nwp500/reservations.py). - New module raises ValueError/TimeoutError instead of logging errors, which is appropriate for a library API - All four functions are exported from nwp500.__init__ and listed in __all__ - CLI handlers now delegate to the library functions; all inline logic removed - docs/guides/scheduling.rst: replace the 'Library Helpers (Needed)' stub with a real documented section showing all four functions with examples --- docs/guides/scheduling.rst | 69 ++++++--- src/nwp500/__init__.py | 11 ++ src/nwp500/cli/handlers.py | 210 +++++--------------------- src/nwp500/reservations.py | 292 +++++++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 191 deletions(-) create mode 100644 src/nwp500/reservations.py diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index b9e9273..fc2815e 100644 --- a/docs/guides/scheduling.rst +++ b/docs/guides/scheduling.rst @@ -420,35 +420,70 @@ The CLI provides convenience commands: nwp-cli reservations delete 1 -Library Helpers (Needed) -^^^^^^^^^^^^^^^^^^^^^^^^ +Library Helpers +^^^^^^^^^^^^^^^^ -The following convenience methods should exist in the library to abstract -the read-modify-write pattern: +The library provides convenience functions that abstract the +read-modify-write pattern for individual reservation entries. + +**fetch_reservations()** — Retrieve the current schedule: .. code-block:: python - from nwp500.mqtt import NavienMqttClient - from nwp500.encoding import build_reservation_entry + from nwp500 import fetch_reservations - entry = build_reservation_entry( + schedule = await fetch_reservations(mqtt, device) + if schedule is not None: + print(f"Schedule enabled: {schedule.enabled}") + for entry in schedule.reservation: + print(f" {entry.time} {', '.join(entry.days)}" + f" — {entry.temperature}{entry.unit}" + f" — {entry.mode_name}") + +**add_reservation()** — Append a new entry to the schedule: + +.. code-block:: python + + from nwp500 import add_reservation + + await add_reservation( + mqtt, device, enabled=True, days=["MO", "TU", "WE", "TH", "FR"], - hour=6, minute=30, - mode_id=4, temperature=60.0 + hour=6, + minute=30, + mode=4, # High Demand + temperature=60.0, # In user's preferred unit ) - # Add a single reservation (automatically fetches current, modifies, sends) - # await mqtt.add_reservation(device, entry) +**delete_reservation()** — Remove an entry by 1-based index: - # Update an existing reservation by index - # await mqtt.update_reservation(device, index, mode=3, temperature=58) +.. code-block:: python + + from nwp500 import delete_reservation + + await delete_reservation(mqtt, device, index=2) + +**update_reservation()** — Modify specific fields of an existing entry. +Only the keyword arguments you supply are changed; all others are kept: + +.. code-block:: python - # Delete a reservation by index - # await mqtt.delete_reservation(device, index) + from nwp500 import update_reservation + + # Change temperature only + await update_reservation(mqtt, device, 1, temperature=55.0) + + # Change days and time + await update_reservation(mqtt, device, 1, days=["SA", "SU"], hour=8, minute=0) + + # Disable without deleting + await update_reservation(mqtt, device, 1, enabled=False) - # Get current schedule as ReservationSchedule model - # schedule = await mqtt.get_reservations(device) +These helpers raise :class:`ValueError` for out-of-range arguments, +:class:`~nwp500.exceptions.RangeValidationError` or +:class:`~nwp500.exceptions.ValidationError` for device-protocol +violations, and :class:`TimeoutError` if the device does not respond. Mode Selection Strategy diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 361cc9a..20aacc7 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -134,6 +134,12 @@ from nwp500.openei import ( OpenEIClient, ) +from nwp500.reservations import ( + add_reservation, + delete_reservation, + fetch_reservations, + update_reservation, +) from nwp500.unit_system import ( get_unit_system, reset_unit_system, @@ -223,6 +229,11 @@ "NavienAPIClient", # OpenEI Client "OpenEIClient", + # Reservation helpers + "fetch_reservations", + "add_reservation", + "delete_reservation", + "update_reservation", # MQTT Client "NavienMqttClient", "MqttConnectionConfig", diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index cdc2507..9c0ffce 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -23,7 +23,13 @@ ) from nwp500.models import ReservationSchedule from nwp500.mqtt.utils import redact_serial -from nwp500.unit_system import get_unit_system, set_unit_system +from nwp500.reservations import ( + add_reservation, + delete_reservation, + fetch_reservations, + update_reservation, +) +from nwp500.unit_system import get_unit_system from .output_formatters import ( print_device_info, @@ -36,16 +42,6 @@ _logger = logging.getLogger(__name__) _formatter = get_formatter() -# Raw protocol fields for ReservationEntry (used in model_dump include) -_RAW_RESERVATION_FIELDS = { - "enable", - "week", - "hour", - "min", - "mode", - "param", -} - T = TypeVar("T") @@ -275,45 +271,6 @@ async def handle_power_request( ) -async def _fetch_reservations( - mqtt: NavienMqttClient, device: Device -) -> ReservationSchedule | None: - """Fetch current reservations from device and return as a model. - - Returns None on timeout. - """ - future: asyncio.Future[ReservationSchedule] = ( - asyncio.get_running_loop().create_future() - ) - caller_unit_system = get_unit_system() - - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if ( - future.done() - or "response" not in message - or "/res/rsv/" not in topic - ): - return - response = message.get("response", {}) - # Ensure it's actually a reservation response (not some other /res/ msg) - if "reservationUse" not in response and "reservation" not in response: - return - if caller_unit_system: - set_unit_system(caller_unit_system) - schedule = ReservationSchedule(**response) - future.set_result(schedule) - - device_type = str(device.device_info.device_type) - response_pattern = f"cmd/{device_type}/+/#" - await mqtt.subscribe(response_pattern, raw_callback) - await mqtt.control.request_reservations(device) - try: - return await asyncio.wait_for(future, timeout=10) - except TimeoutError: - _logger.error("Timed out waiting for reservations.") - return None - - def _schedule_to_display_list( schedule: ReservationSchedule, ) -> list[dict[str, Any]]: @@ -331,7 +288,7 @@ async def handle_get_reservations_request( mqtt: NavienMqttClient, device: Device, output_json: bool = False ) -> None: """Request current reservation schedule.""" - schedule = await _fetch_reservations(mqtt, device) + schedule = await fetch_reservations(mqtt, device) if schedule is None: return @@ -388,53 +345,21 @@ async def handle_add_reservation_request( temperature: float, ) -> None: """Add a single reservation to the existing schedule.""" - from nwp500.encoding import build_reservation_entry - - # Validate inputs - if not 0 <= hour <= 23: - _logger.error("Hour must be between 0 and 23") - return - if not 0 <= minute <= 59: - _logger.error("Minute must be between 0 and 59") - return - if not 1 <= mode <= 6: - _logger.error("Mode must be between 1 and 6") - return - - # Parse day string (comma-separated: "MO,WE,FR" or full day names) day_list = [d.strip() for d in days.split(",")] - try: - # Build the reservation entry - reservation_entry = build_reservation_entry( + await add_reservation( + mqtt, + device, enabled=enabled, days=day_list, hour=hour, minute=minute, - mode_id=mode, + mode=mode, temperature=temperature, ) - - # Fetch current reservations using shared helper - schedule = await _fetch_reservations(mqtt, device) - if schedule is None: - _logger.error("Timed out fetching current reservations") - return - - # Build raw entry list and append new one - current_reservations = [ - e.model_dump(include=_RAW_RESERVATION_FIELDS) - for e in schedule.reservation - ] - current_reservations.append(reservation_entry) - - # Update the full schedule - await mqtt.control.update_reservations( - device, current_reservations, enabled=True - ) - print("✓ Reservation added successfully") - + except (ValueError, TimeoutError) as e: + _logger.error(str(e)) except (RangeValidationError, ValidationError) as e: _logger.error(f"Failed to add reservation: {e}") @@ -445,34 +370,11 @@ async def handle_delete_reservation_request( index: int, ) -> None: """Delete a single reservation by 1-based index.""" - schedule = await _fetch_reservations(mqtt, device) - if schedule is None: - _logger.error("Timed out fetching current reservations") - return - - count = len(schedule.reservation) - if index < 1 or index > count: - _logger.error( - f"Invalid reservation index {index}. " - f"Valid range: 1-{count} ({count} reservation(s) exist)" - ) - return - - # Build raw entry list and remove the target - current_reservations = [ - e.model_dump(include=_RAW_RESERVATION_FIELDS) - for e in schedule.reservation - ] - removed = current_reservations.pop(index - 1) - _logger.info(f"Removing reservation {index}: {removed}") - - # Determine if reservations should stay enabled - still_enabled = schedule.enabled and len(current_reservations) > 0 - - await mqtt.control.update_reservations( - device, current_reservations, enabled=still_enabled - ) - print(f"✓ Reservation {index} deleted successfully") + try: + await delete_reservation(mqtt, device, index) + print(f"✓ Reservation {index} deleted successfully") + except (ValueError, TimeoutError) as e: + _logger.error(str(e)) async def handle_update_reservation_request( @@ -491,66 +393,26 @@ async def handle_update_reservation_request( Only the provided fields are modified; others are preserved. """ - from nwp500.encoding import build_reservation_entry - - schedule = await _fetch_reservations(mqtt, device) - if schedule is None: - _logger.error("Timed out fetching current reservations") - return - - count = len(schedule.reservation) - if index < 1 or index > count: - _logger.error( - f"Invalid reservation index {index}. " - f"Valid range: 1-{count} ({count} reservation(s) exist)" - ) - return - - existing = schedule.reservation[index - 1] - - # Merge: use provided values or fall back to existing - new_enabled = enabled if enabled is not None else existing.enabled - new_days: list[str] = ( - [d.strip() for d in days.split(",")] if days else existing.days + day_list: list[str] | None = ( + [d.strip() for d in days.split(",")] if days is not None else None ) - new_hour = hour if hour is not None else existing.hour - new_minute = minute if minute is not None else existing.min - new_mode = mode if mode is not None else existing.mode - - # Temperature requires special handling: if user provides a value - # it's in their preferred unit, otherwise keep the raw param. - if temperature is not None: - new_entry = build_reservation_entry( - enabled=new_enabled, - days=new_days, - hour=new_hour, - minute=new_minute, - mode_id=new_mode, + try: + await update_reservation( + mqtt, + device, + index, + enabled=enabled, + days=day_list, + hour=hour, + minute=minute, + mode=mode, temperature=temperature, ) - else: - from nwp500.encoding import encode_week_bitfield - - new_entry = { - "enable": 2 if new_enabled else 1, - "week": encode_week_bitfield(new_days), - "hour": new_hour, - "min": new_minute, - "mode": new_mode, - "param": existing.param, - } - - # Build full list with the replacement - current_reservations = [ - e.model_dump(include=_RAW_RESERVATION_FIELDS) - for e in schedule.reservation - ] - current_reservations[index - 1] = new_entry - - await mqtt.control.update_reservations( - device, current_reservations, enabled=schedule.enabled - ) - print(f"✓ Reservation {index} updated successfully") + print(f"✓ Reservation {index} updated successfully") + except (ValueError, TimeoutError) as e: + _logger.error(str(e)) + except (RangeValidationError, ValidationError) as e: + _logger.error(f"Failed to update reservation: {e}") async def handle_enable_anti_legionella_request( diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py new file mode 100644 index 0000000..d653972 --- /dev/null +++ b/src/nwp500/reservations.py @@ -0,0 +1,292 @@ +""" +Reservation schedule management helpers. + +This module provides high-level helpers for managing individual reservation +entries on a Navien device. The device protocol requires sending the full +schedule for every change, so each helper follows a read-modify-write pattern: +fetch the current schedule, apply the change, then send the updated list back. + +All functions are ``async`` and require a connected :class:`NavienMqttClient`. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from .encoding import build_reservation_entry, encode_week_bitfield +from .models import ReservationSchedule +from .unit_system import get_unit_system, set_unit_system + +if TYPE_CHECKING: + from .models import Device + from .mqtt import NavienMqttClient + +_logger = logging.getLogger(__name__) + +# Raw protocol fields for ReservationEntry (used in model_dump include) +_RAW_RESERVATION_FIELDS = { + "enable", + "week", + "hour", + "min", + "mode", + "param", +} + + +async def fetch_reservations( + mqtt: NavienMqttClient, + device: Device, + *, + timeout: float = 10.0, +) -> ReservationSchedule | None: + """Fetch the current reservation schedule from a device. + + Sends a request to the device and waits for the response. + + Args: + mqtt: Connected MQTT client. + device: Target device. + timeout: Seconds to wait for a response before giving up. + + Returns: + The current :class:`ReservationSchedule`, or ``None`` on timeout. + """ + future: asyncio.Future[ReservationSchedule] = ( + asyncio.get_running_loop().create_future() + ) + caller_unit_system = get_unit_system() + + def raw_callback(topic: str, message: dict[str, Any]) -> None: + if ( + future.done() + or "response" not in message + or "/res/rsv/" not in topic + ): + return + response = message.get("response", {}) + # Ensure it's actually a reservation response (not some other /res/ msg) + if "reservationUse" not in response and "reservation" not in response: + return + if caller_unit_system: + set_unit_system(caller_unit_system) + schedule = ReservationSchedule(**response) + future.set_result(schedule) + + device_type = str(device.device_info.device_type) + response_pattern = f"cmd/{device_type}/+/#" + await mqtt.subscribe(response_pattern, raw_callback) + await mqtt.control.request_reservations(device) + try: + return await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + _logger.error("Timed out waiting for reservations.") + return None + + +async def add_reservation( + mqtt: NavienMqttClient, + device: Device, + *, + enabled: bool, + days: Sequence[str | int], + hour: int, + minute: int, + mode: int, + temperature: float, +) -> None: + """Add a single reservation entry to the device schedule. + + Fetches the current schedule, appends the new entry, and sends the + updated list back to the device. The schedule is automatically enabled + after a successful add. + + Args: + mqtt: Connected MQTT client. + device: Target device. + enabled: Whether the new reservation is active. + days: Days of the week. Accepts full names (``"Monday"``), 2-letter + abbreviations (``"MO"``), or integer indices where 0 = Monday and + 6 = Sunday. + hour: Hour of the day in 24-hour format (0–23). + minute: Minute of the hour (0–59). + mode: DHW operation mode (1–6). + temperature: Target temperature in the user's preferred unit. + + Raises: + ValueError: If ``hour``, ``minute``, or ``mode`` are out of range. + RangeValidationError: If ``temperature`` is out of the device's range. + ValidationError: If the entry fails model validation. + TimeoutError: If the current schedule cannot be fetched. + """ + if not 0 <= hour <= 23: + raise ValueError(f"Hour must be between 0 and 23, got {hour}") + if not 0 <= minute <= 59: + raise ValueError(f"Minute must be between 0 and 59, got {minute}") + if not 1 <= mode <= 6: + raise ValueError(f"Mode must be between 1 and 6, got {mode}") + + reservation_entry = build_reservation_entry( + enabled=enabled, + days=days, + hour=hour, + minute=minute, + mode_id=mode, + temperature=temperature, + ) + + schedule = await fetch_reservations(mqtt, device) + if schedule is None: + raise TimeoutError("Timed out fetching current reservations") + + current_reservations = [ + e.model_dump(include=_RAW_RESERVATION_FIELDS) + for e in schedule.reservation + ] + current_reservations.append(reservation_entry) + + await mqtt.control.update_reservations( + device, current_reservations, enabled=True + ) + + +async def delete_reservation( + mqtt: NavienMqttClient, + device: Device, + index: int, +) -> None: + """Delete a single reservation entry by 1-based index. + + Fetches the current schedule, removes the entry at ``index``, and sends + the updated list back. If the schedule becomes empty, it is automatically + disabled. + + Args: + mqtt: Connected MQTT client. + device: Target device. + index: 1-based position of the reservation to delete. + + Raises: + ValueError: If ``index`` is out of the valid range. + TimeoutError: If the current schedule cannot be fetched. + """ + schedule = await fetch_reservations(mqtt, device) + if schedule is None: + raise TimeoutError("Timed out fetching current reservations") + + count = len(schedule.reservation) + if index < 1 or index > count: + raise ValueError( + f"Invalid reservation index {index}. " + f"Valid range: 1–{count} ({count} reservation(s) exist)" + ) + + current_reservations = [ + e.model_dump(include=_RAW_RESERVATION_FIELDS) + for e in schedule.reservation + ] + removed = current_reservations.pop(index - 1) + _logger.info(f"Removing reservation {index}: {removed}") + + still_enabled = schedule.enabled and len(current_reservations) > 0 + + await mqtt.control.update_reservations( + device, current_reservations, enabled=still_enabled + ) + + +async def update_reservation( + mqtt: NavienMqttClient, + device: Device, + index: int, + *, + enabled: bool | None = None, + days: Sequence[str | int] | None = None, + hour: int | None = None, + minute: int | None = None, + mode: int | None = None, + temperature: float | None = None, +) -> None: + """Update a single reservation entry in-place by 1-based index. + + Only the fields that are explicitly provided are changed; all other fields + are preserved from the existing entry. + + Args: + mqtt: Connected MQTT client. + device: Target device. + index: 1-based position of the reservation to update. + enabled: Set the enabled state, or ``None`` to keep current. + days: Replace the days, or ``None`` to keep current. Accepts full + names, 2-letter abbreviations, or integer indices (see + :func:`add_reservation`). + hour: Replace the hour (0–23), or ``None`` to keep current. + minute: Replace the minute (0–59), or ``None`` to keep current. + mode: Replace the mode (1–6), or ``None`` to keep current. + temperature: Replace the temperature (in the user's preferred unit), + or ``None`` to keep the existing raw ``param`` value unchanged. + + Raises: + ValueError: If ``index`` is out of the valid range. + RangeValidationError: If ``temperature`` is out of the device's range. + ValidationError: If the updated entry fails model validation. + TimeoutError: If the current schedule cannot be fetched. + """ + schedule = await fetch_reservations(mqtt, device) + if schedule is None: + raise TimeoutError("Timed out fetching current reservations") + + count = len(schedule.reservation) + if index < 1 or index > count: + raise ValueError( + f"Invalid reservation index {index}. " + f"Valid range: 1–{count} ({count} reservation(s) exist)" + ) + + existing = schedule.reservation[index - 1] + + new_enabled = enabled if enabled is not None else existing.enabled + new_days = days if days is not None else existing.days + new_hour = hour if hour is not None else existing.hour + new_minute = minute if minute is not None else existing.min + new_mode = mode if mode is not None else existing.mode + + if temperature is not None: + new_entry = build_reservation_entry( + enabled=new_enabled, + days=new_days, + hour=new_hour, + minute=new_minute, + mode_id=new_mode, + temperature=temperature, + ) + else: + new_entry = { + "enable": 2 if new_enabled else 1, + "week": encode_week_bitfield(new_days), + "hour": new_hour, + "min": new_minute, + "mode": new_mode, + "param": existing.param, + } + + current_reservations = [ + e.model_dump(include=_RAW_RESERVATION_FIELDS) + for e in schedule.reservation + ] + current_reservations[index - 1] = new_entry + + await mqtt.control.update_reservations( + device, current_reservations, enabled=schedule.enabled + ) + + +__all__ = [ + "fetch_reservations", + "add_reservation", + "delete_reservation", + "update_reservation", +] From 1c53a504226ab62d2ea79f7a7d0fb99ab6649592 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 14:22:32 -0800 Subject: [PATCH 4/7] Update changelog for reservation CRUD helpers --- CHANGELOG.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b9c851d..f15e717 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,18 @@ Changelog ========= +Unreleased +========== + +Added +----- +- **Reservation CRUD Helpers**: New public functions ``fetch_reservations()``, + ``add_reservation()``, ``delete_reservation()``, and ``update_reservation()`` + in ``nwp500.reservations`` (and exported from ``nwp500``). These abstract the + read-modify-write pattern for single-entry schedule management so library + users no longer need to fetch the full schedule, splice it manually, and send + it back. The CLI now delegates to these library functions. + Version 7.4.7 (2026-02-17) ========================== From 2afddbd22f80eb8e25b01ce1dbca02ace6720fae Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 14:49:04 -0800 Subject: [PATCH 5/7] Address PR review comments - fetch_reservations: use specific response topic instead of wildcard, unsubscribe in finally block, restore previous unit system after parsing - fetch_reservations: remove error logging on timeout (returns None silently) - update_reservation: add range validation for hour/minute/mode when provided - docs: remove non-existent path= kwarg from subscribe_device_feature call - docs: clarify that fetch_reservations returns None on timeout while mutating helpers raise TimeoutError - tests: add test_reservations.py covering fetch success/timeout, add/delete/update success paths, index and range validation, and timeout propagation for all three mutating helpers --- docs/guides/scheduling.rst | 9 +- src/nwp500/reservations.py | 35 +++- tests/test_reservations.py | 382 +++++++++++++++++++++++++++++++++++++ 3 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 tests/test_reservations.py diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index fc2815e..5f25da0 100644 --- a/docs/guides/scheduling.rst +++ b/docs/guides/scheduling.rst @@ -384,9 +384,7 @@ multiple entries at once: f" - {entry.temperature}{entry.unit}" f" - {entry.mode_name}") - await mqtt.subscribe_device_feature( - device, on_reservations, path="reservations" - ) + await mqtt.subscribe_device_feature(device, on_reservations) await mqtt.control.request_reservations(device) CLI Helpers @@ -483,7 +481,10 @@ Only the keyword arguments you supply are changed; all others are kept: These helpers raise :class:`ValueError` for out-of-range arguments, :class:`~nwp500.exceptions.RangeValidationError` or :class:`~nwp500.exceptions.ValidationError` for device-protocol -violations, and :class:`TimeoutError` if the device does not respond. +violations. :func:`fetch_reservations` returns ``None`` on timeout and +logs the failure, while the mutating helpers (:func:`add_reservation`, +:func:`update_reservation`, :func:`delete_reservation`) raise +:class:`TimeoutError` if the device does not respond. Mode Selection Strategy diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index d653972..b7711a4 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -71,20 +71,33 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: # Ensure it's actually a reservation response (not some other /res/ msg) if "reservationUse" not in response and "reservation" not in response: return - if caller_unit_system: - set_unit_system(caller_unit_system) - schedule = ReservationSchedule(**response) + previous = get_unit_system() + try: + if caller_unit_system: + set_unit_system(caller_unit_system) + schedule = ReservationSchedule(**response) + finally: + if previous is not None: + set_unit_system(previous) future.set_result(schedule) device_type = str(device.device_info.device_type) - response_pattern = f"cmd/{device_type}/+/#" - await mqtt.subscribe(response_pattern, raw_callback) + response_topic = f"cmd/{device_type}/{mqtt.client_id}/res/rsv/rd" + await mqtt.subscribe(response_topic, raw_callback) await mqtt.control.request_reservations(device) try: return await asyncio.wait_for(future, timeout=timeout) except TimeoutError: - _logger.error("Timed out waiting for reservations.") return None + finally: + try: + await mqtt.unsubscribe(response_topic) + except Exception: + _logger.warning( + "Failed to unsubscribe reservations response handler for %s", + response_topic, + exc_info=True, + ) async def add_reservation( @@ -230,7 +243,8 @@ async def update_reservation( or ``None`` to keep the existing raw ``param`` value unchanged. Raises: - ValueError: If ``index`` is out of the valid range. + ValueError: If ``index`` is out of the valid range, or if any of + ``hour``, ``minute``, or ``mode`` are provided but out of range. RangeValidationError: If ``temperature`` is out of the device's range. ValidationError: If the updated entry fails model validation. TimeoutError: If the current schedule cannot be fetched. @@ -246,6 +260,13 @@ async def update_reservation( f"Valid range: 1–{count} ({count} reservation(s) exist)" ) + if hour is not None and not 0 <= hour <= 23: + raise ValueError(f"Hour must be between 0 and 23, got {hour}") + if minute is not None and not 0 <= minute <= 59: + raise ValueError(f"Minute must be between 0 and 59, got {minute}") + if mode is not None and not 1 <= mode <= 6: + raise ValueError(f"Mode must be between 1 and 6, got {mode}") + existing = schedule.reservation[index - 1] new_enabled = enabled if enabled is not None else existing.enabled diff --git a/tests/test_reservations.py b/tests/test_reservations.py new file mode 100644 index 0000000..345f411 --- /dev/null +++ b/tests/test_reservations.py @@ -0,0 +1,382 @@ +"""Tests for the nwp500.reservations public helpers.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nwp500.models import ReservationEntry, ReservationSchedule +from nwp500.reservations import ( + add_reservation, + delete_reservation, + fetch_reservations, + update_reservation, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_device() -> MagicMock: + device = MagicMock() + device.device_info.device_type = "NWP500" + return device + + +@pytest.fixture +def mock_mqtt(mock_device: MagicMock) -> MagicMock: + mqtt = MagicMock() + mqtt.client_id = "test-client" + mqtt.subscribe = AsyncMock() + mqtt.unsubscribe = AsyncMock() + mqtt.control.request_reservations = AsyncMock() + mqtt.control.update_reservations = AsyncMock() + return mqtt + + +def _make_schedule( + entries: list[dict[str, Any]], enabled: bool = True +) -> ReservationSchedule: + """Build a ReservationSchedule from raw entry dicts.""" + return ReservationSchedule( + reservationUse=2 if enabled else 1, + reservation=[ReservationEntry(**e) for e in entries], + ) + + +def _entry( + enable: int = 2, + week: int = 124, + hour: int = 6, + min: int = 30, + mode: int = 4, + param: int = 120, +) -> dict[str, Any]: + return { + "enable": enable, + "week": week, + "hour": hour, + "min": min, + "mode": mode, + "param": param, + } + + +# --------------------------------------------------------------------------- +# fetch_reservations +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_reservations_success( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """fetch_reservations returns a ReservationSchedule on success.""" + schedule = _make_schedule([_entry()]) + captured_callback: list[Any] = [] + + async def fake_subscribe(topic: str, cb: Any) -> int: + captured_callback.append(cb) + return 1 + + mock_mqtt.subscribe.side_effect = fake_subscribe + + async def fake_request(device: Any) -> None: + # Simulate the device response arriving after subscribe + topic = "cmd/NWP500/test-client/res/rsv/rd" + msg = { + "response": { + "reservationUse": 2, + "reservation": "023e061e0478", + } + } + for cb in captured_callback: + cb(topic, msg) + + mock_mqtt.control.request_reservations.side_effect = fake_request + + with patch( + "nwp500.reservations.ReservationSchedule", + return_value=schedule, + ): + result = await fetch_reservations(mock_mqtt, mock_device) + + assert result is schedule + mock_mqtt.unsubscribe.assert_called_once_with( + "cmd/NWP500/test-client/res/rsv/rd" + ) + + +@pytest.mark.asyncio +async def test_fetch_reservations_timeout( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """fetch_reservations returns None on timeout and still unsubscribes.""" + mock_mqtt.subscribe = AsyncMock() + mock_mqtt.control.request_reservations = AsyncMock() # never fires callback + + result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) + + assert result is None + mock_mqtt.unsubscribe.assert_called_once_with( + "cmd/NWP500/test-client/res/rsv/rd" + ) + + +@pytest.mark.asyncio +async def test_fetch_reservations_ignores_wrong_topic( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """fetch_reservations ignores messages on non-reservation topics.""" + captured_callback: list[Any] = [] + + async def fake_subscribe(topic: str, cb: Any) -> int: + captured_callback.append(cb) + return 1 + + mock_mqtt.subscribe.side_effect = fake_subscribe + + async def fake_request(device: Any) -> None: + # Wrong topic — should be ignored + for cb in captured_callback: + cb("cmd/NWP500/test-client/res/other/rd", {"response": {"foo": 1}}) + + mock_mqtt.control.request_reservations.side_effect = fake_request + + result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) + assert result is None + + +# --------------------------------------------------------------------------- +# add_reservation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_reservation_success( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """add_reservation appends the new entry and sends the full list.""" + existing = _entry(hour=6, min=0) + schedule = _make_schedule([existing]) + + with patch( + "nwp500.reservations.fetch_reservations", return_value=schedule + ): + await add_reservation( + mock_mqtt, + mock_device, + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=8, + minute=0, + mode=3, + temperature=120.0, + ) + + mock_mqtt.control.update_reservations.assert_called_once() + _, reservations = mock_mqtt.control.update_reservations.call_args.args + assert len(reservations) == 2 + + +@pytest.mark.asyncio +async def test_add_reservation_invalid_hour( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with pytest.raises(ValueError, match="Hour"): + await add_reservation( + mock_mqtt, mock_device, + enabled=True, days=["MO"], hour=25, minute=0, mode=1, + temperature=120.0, + ) + + +@pytest.mark.asyncio +async def test_add_reservation_invalid_minute( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with pytest.raises(ValueError, match="Minute"): + await add_reservation( + mock_mqtt, mock_device, + enabled=True, days=["MO"], hour=6, minute=60, mode=1, + temperature=120.0, + ) + + +@pytest.mark.asyncio +async def test_add_reservation_invalid_mode( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with pytest.raises(ValueError, match="Mode"): + await add_reservation( + mock_mqtt, mock_device, + enabled=True, days=["MO"], hour=6, minute=0, mode=7, + temperature=120.0, + ) + + +@pytest.mark.asyncio +async def test_add_reservation_timeout( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with patch("nwp500.reservations.fetch_reservations", return_value=None): + with pytest.raises(TimeoutError): + await add_reservation( + mock_mqtt, mock_device, + enabled=True, days=["MO"], hour=6, minute=0, mode=1, + temperature=120.0, + ) + + +# --------------------------------------------------------------------------- +# delete_reservation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_reservation_success( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """delete_reservation removes the entry at the given 1-based index.""" + e1 = _entry(hour=6) + e2 = _entry(hour=8) + schedule = _make_schedule([e1, e2]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + await delete_reservation(mock_mqtt, mock_device, index=1) + + mock_mqtt.control.update_reservations.assert_called_once() + _, reservations = mock_mqtt.control.update_reservations.call_args.args + assert len(reservations) == 1 + assert reservations[0]["hour"] == 8 + + +@pytest.mark.asyncio +async def test_delete_reservation_disables_when_empty( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """Deleting the last entry sets enabled=False.""" + schedule = _make_schedule([_entry()], enabled=True) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + await delete_reservation(mock_mqtt, mock_device, index=1) + + enabled = mock_mqtt.control.update_reservations.call_args.kwargs["enabled"] + assert enabled is False + + +@pytest.mark.asyncio +async def test_delete_reservation_invalid_index( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + schedule = _make_schedule([_entry()]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + with pytest.raises(ValueError, match="Invalid reservation index"): + await delete_reservation(mock_mqtt, mock_device, index=5) + + +@pytest.mark.asyncio +async def test_delete_reservation_timeout( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with patch("nwp500.reservations.fetch_reservations", return_value=None): + with pytest.raises(TimeoutError): + await delete_reservation(mock_mqtt, mock_device, index=1) + + +# --------------------------------------------------------------------------- +# update_reservation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_update_reservation_temperature( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """update_reservation with only temperature changes param.""" + schedule = _make_schedule([_entry(param=120)]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + await update_reservation( + mock_mqtt, mock_device, 1, temperature=150.0 + ) + + mock_mqtt.control.update_reservations.assert_called_once() + _, reservations = mock_mqtt.control.update_reservations.call_args.args + # param must differ from the original 120 (150°F = 65.6°C → param=131) + assert reservations[0]["param"] != 120 + + +@pytest.mark.asyncio +async def test_update_reservation_preserves_fields( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + """update_reservation without temperature preserves existing param.""" + schedule = _make_schedule([_entry(hour=6, param=120)]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + await update_reservation( + mock_mqtt, mock_device, 1, hour=8 + ) + + _, reservations = mock_mqtt.control.update_reservations.call_args.args + assert reservations[0]["hour"] == 8 + assert reservations[0]["param"] == 120 + + +@pytest.mark.asyncio +async def test_update_reservation_invalid_index( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + schedule = _make_schedule([_entry()]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + with pytest.raises(ValueError, match="Invalid reservation index"): + await update_reservation(mock_mqtt, mock_device, 99) + + +@pytest.mark.asyncio +async def test_update_reservation_invalid_hour( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + schedule = _make_schedule([_entry()]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + with pytest.raises(ValueError, match="Hour"): + await update_reservation(mock_mqtt, mock_device, 1, hour=25) + + +@pytest.mark.asyncio +async def test_update_reservation_invalid_minute( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + schedule = _make_schedule([_entry()]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + with pytest.raises(ValueError, match="Minute"): + await update_reservation(mock_mqtt, mock_device, 1, minute=60) + + +@pytest.mark.asyncio +async def test_update_reservation_invalid_mode( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + schedule = _make_schedule([_entry()]) + + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): + with pytest.raises(ValueError, match="Mode"): + await update_reservation(mock_mqtt, mock_device, 1, mode=0) + + +@pytest.mark.asyncio +async def test_update_reservation_timeout( + mock_mqtt: MagicMock, mock_device: MagicMock +) -> None: + with patch("nwp500.reservations.fetch_reservations", return_value=None): + with pytest.raises(TimeoutError): + await update_reservation(mock_mqtt, mock_device, 1, hour=8) From 19a502d5782a9b569aa598554ea9f7502b36f703 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 15:01:32 -0800 Subject: [PATCH 6/7] Fix ruff formatting in test_reservations.py --- tests/test_reservations.py | 48 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/tests/test_reservations.py b/tests/test_reservations.py index 345f411..2bf3a25 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -164,9 +164,7 @@ async def test_add_reservation_success( existing = _entry(hour=6, min=0) schedule = _make_schedule([existing]) - with patch( - "nwp500.reservations.fetch_reservations", return_value=schedule - ): + with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await add_reservation( mock_mqtt, mock_device, @@ -189,8 +187,13 @@ async def test_add_reservation_invalid_hour( ) -> None: with pytest.raises(ValueError, match="Hour"): await add_reservation( - mock_mqtt, mock_device, - enabled=True, days=["MO"], hour=25, minute=0, mode=1, + mock_mqtt, + mock_device, + enabled=True, + days=["MO"], + hour=25, + minute=0, + mode=1, temperature=120.0, ) @@ -201,8 +204,13 @@ async def test_add_reservation_invalid_minute( ) -> None: with pytest.raises(ValueError, match="Minute"): await add_reservation( - mock_mqtt, mock_device, - enabled=True, days=["MO"], hour=6, minute=60, mode=1, + mock_mqtt, + mock_device, + enabled=True, + days=["MO"], + hour=6, + minute=60, + mode=1, temperature=120.0, ) @@ -213,8 +221,13 @@ async def test_add_reservation_invalid_mode( ) -> None: with pytest.raises(ValueError, match="Mode"): await add_reservation( - mock_mqtt, mock_device, - enabled=True, days=["MO"], hour=6, minute=0, mode=7, + mock_mqtt, + mock_device, + enabled=True, + days=["MO"], + hour=6, + minute=0, + mode=7, temperature=120.0, ) @@ -226,8 +239,13 @@ async def test_add_reservation_timeout( with patch("nwp500.reservations.fetch_reservations", return_value=None): with pytest.raises(TimeoutError): await add_reservation( - mock_mqtt, mock_device, - enabled=True, days=["MO"], hour=6, minute=0, mode=1, + mock_mqtt, + mock_device, + enabled=True, + days=["MO"], + hour=6, + minute=0, + mode=1, temperature=120.0, ) @@ -302,9 +320,7 @@ async def test_update_reservation_temperature( schedule = _make_schedule([_entry(param=120)]) with patch("nwp500.reservations.fetch_reservations", return_value=schedule): - await update_reservation( - mock_mqtt, mock_device, 1, temperature=150.0 - ) + await update_reservation(mock_mqtt, mock_device, 1, temperature=150.0) mock_mqtt.control.update_reservations.assert_called_once() _, reservations = mock_mqtt.control.update_reservations.call_args.args @@ -320,9 +336,7 @@ async def test_update_reservation_preserves_fields( schedule = _make_schedule([_entry(hour=6, param=120)]) with patch("nwp500.reservations.fetch_reservations", return_value=schedule): - await update_reservation( - mock_mqtt, mock_device, 1, hour=8 - ) + await update_reservation(mock_mqtt, mock_device, 1, hour=8) _, reservations = mock_mqtt.control.update_reservations.call_args.args assert reservations[0]["hour"] == 8 From e2d36110daa132ebf3fd8ffefd11bbe531a25f9d Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 17 Feb 2026 15:08:11 -0800 Subject: [PATCH 7/7] Update changelog for v7.4.8 --- CHANGELOG.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f15e717..7939d09 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,8 +2,8 @@ Changelog ========= -Unreleased -========== +Version 7.4.8 (2026-02-17) +========================== Added -----