Source code for nwp500.tou_schedule
"""
TOU (Time-of-Use) schedule management helpers.
Companion to :mod:`nwp500.reservations`: the device protocol requires
sending the full TOU period list for every change, and confirming that a
write landed requires waiting for the device's ``tou/rd`` echo rather than
just the MQTT publish packet id.
All functions are ``async`` and require a connected :class:`NavienMqttClient`.
"""
import asyncio
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from .converters import device_bool_from_python
from .models import TOUPeriod, TOUReservationSchedule
if TYPE_CHECKING:
from .models import Device
from .mqtt import NavienMqttClient
_logger = logging.getLogger(__name__)
[docs]
async def configure_tou_schedule_confirmed(
mqtt: NavienMqttClient,
device: Device,
controller_serial_number: str,
periods: Sequence[dict[str, Any]],
*,
enabled: bool = True,
timeout: float = 10.0,
) -> TOUReservationSchedule | None:
"""Write the TOU schedule and confirm the device applied it.
Sends ``configure_tou_schedule`` and waits for the device's ``tou/rd``
echo, returning the parsed :class:`TOUReservationSchedule` the device
now holds. Compare it against the desired program with
:meth:`TOUReservationSchedule.canonical`, e.g.::
confirmed = await configure_tou_schedule_confirmed(
mqtt, device, serial, periods
)
assert confirmed is not None
assert confirmed.canonical() == desired_schedule.canonical()
Args:
mqtt: Connected MQTT client.
device: Target device.
controller_serial_number: Controller serial number.
periods: List of raw TOU period dicts to write.
enabled: Whether TOU is enabled (default: True).
timeout: Seconds to wait for the confirming response.
Returns:
The :class:`TOUReservationSchedule` the device echoed back after
the write, or ``None`` if no matching response arrived within
``timeout``.
Note:
The device protocol has no request/response correlation id on
``tou/rd``, so a response is only accepted once its
:meth:`~nwp500.models.TOUReservationSchedule.canonical` form
matches what was just written. This avoids resolving on a
stale/unrelated ``tou/rd`` message (e.g. from a concurrent read or
a previous configure) that happens to arrive in the same window.
"""
expected = TOUReservationSchedule(
reservationUse=device_bool_from_python(enabled),
reservation=[TOUPeriod(**period) for period in periods],
).canonical()
future: asyncio.Future[TOUReservationSchedule] = (
asyncio.get_running_loop().create_future()
)
def on_schedule(schedule: TOUReservationSchedule) -> None:
if not future.done() and schedule.canonical() == expected:
future.set_result(schedule)
await mqtt.subscribe_tou_response(device, on_schedule)
try:
await mqtt.configure_tou_schedule(
device, controller_serial_number, periods, enabled=enabled
)
try:
return await asyncio.wait_for(future, timeout=timeout)
except TimeoutError:
return None
finally:
try:
await mqtt.unsubscribe_tou_response(device, on_schedule)
except Exception:
from .mqtt.utils import redact_mac
_logger.warning(
"Failed to unsubscribe TOU response handler for device %s",
redact_mac(device.device_info.mac_address),
exc_info=True,
)
__all__ = ["configure_tou_schedule_confirmed"]