"""
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
from .encoding import build_reservation_entry, encode_week_bitfield
from .models import ReservationSchedule
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",
}
[docs]
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()
)
def on_schedule(schedule: ReservationSchedule) -> None:
if not future.done():
future.set_result(schedule)
await mqtt.subscribe_reservation_response(device, on_schedule)
await mqtt.request_reservations(device)
try:
return await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
return None
finally:
try:
await mqtt.unsubscribe_reservation_response(device, on_schedule)
except Exception:
_logger.warning(
"Failed to unsubscribe reservations response handler for "
"device %s",
device.device_info.mac_address,
exc_info=True,
)
[docs]
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.update_reservations(device, current_reservations, enabled=True)
[docs]
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.update_reservations(
device, current_reservations, enabled=still_enabled
)
[docs]
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.update_reservations(
device, current_reservations, enabled=schedule.enabled
)
__all__ = [
"fetch_reservations",
"add_reservation",
"delete_reservation",
"update_reservation",
]