diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b9c851d..7939d09 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,18 @@ Changelog ========= +Version 7.4.8 (2026-02-17) +========================== + +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) ========================== diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index 9b46e1d..5f25da0 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. -**Update the full schedule:** +Low-Level Method (``NavienMqttClient``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use ``update_reservations()`` when you need full control or are managing +multiple entries at once: .. code-block:: python + from nwp500.mqtt import NavienMqttClient + from nwp500.encoding import build_reservation_entry + reservations = [ build_reservation_entry( enabled=True, @@ -358,22 +364,128 @@ 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) 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}") +CLI Helpers +^^^^^^^^^^^ + +The CLI provides convenience commands: + +**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 + +Library Helpers +^^^^^^^^^^^^^^^^ + +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 import fetch_reservations + + 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=4, # High Demand + temperature=60.0, # In user's preferred unit + ) + +**delete_reservation()** — Remove an entry by 1-based index: + +.. 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 + + 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) + +These helpers raise :class:`ValueError` for out-of-range arguments, +:class:`~nwp500.exceptions.RangeValidationError` or +:class:`~nwp500.exceptions.ValidationError` for device-protocol +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/__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..b7711a4 --- /dev/null +++ b/src/nwp500/reservations.py @@ -0,0 +1,313 @@ +""" +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 + 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_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: + 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( + 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, 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. + """ + 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)" + ) + + 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 + 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", +] diff --git a/tests/test_reservations.py b/tests/test_reservations.py new file mode 100644 index 0000000..2bf3a25 --- /dev/null +++ b/tests/test_reservations.py @@ -0,0 +1,396 @@ +"""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)