Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/nwp500/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@
delete_reservation,
fetch_reservations,
update_reservation,
update_reservations_confirmed,
)
from nwp500.tou_schedule import (
configure_tou_schedule_confirmed,
)
from nwp500.unit_system import (
get_unit_system,
Expand Down Expand Up @@ -214,6 +218,9 @@
"add_reservation",
"delete_reservation",
"update_reservation",
"update_reservations_confirmed",
# TOU schedule helpers
"configure_tou_schedule_confirmed",
# MQTT Client
"NavienMqttClient",
"MqttConnectionConfig",
Expand Down
31 changes: 31 additions & 0 deletions src/nwp500/models/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def mode_name(self) -> str:
except ValueError:
return f"Unknown ({self.mode})"

def canonical_key(self) -> tuple[int, int, int, int, int, int]:
"""Raw protocol fields as a stable, hashable tuple.

Used to compare a desired reservation entry against a device
read-back without depending on field order or computed properties.
"""
return (
self.enable,
self.week,
self.hour,
self.min,
self.mode,
self.param,
)


class ReservationSchedule(NavienBaseModel):
"""Complete reservation schedule from the device.
Expand Down Expand Up @@ -132,6 +147,22 @@ def enabled(self) -> bool:
"""
return self.reservation_use == 2

def canonical(self) -> tuple[bool, tuple[tuple[int, ...], ...]]:
"""Normalized, order-independent representation of this schedule.

Entry order in a device read-back is not guaranteed to match the
order a program was written in, so entries are sorted by their raw
field tuple. Two schedules holding the same reservations return
equal (and equally hashable) results from this method regardless of
entry order — the intended way to compare a desired program against
a device read-back (see
:func:`nwp500.reservations.update_reservations_confirmed`).
"""
return (
self.enabled,
tuple(sorted(entry.canonical_key() for entry in self.reservation)),
)


class WeeklyReservationEntry(NavienBaseModel):
"""A single entry in a weekly temperature reservation schedule.
Expand Down
35 changes: 35 additions & 0 deletions src/nwp500/models/tou.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ def decoded_price_max(self) -> float:
divisor: float = 10.0**self.decimal_point
return float(self.price_max) / divisor

def canonical_key(
self,
) -> tuple[int, int, int, int, int, int, int, int, int]:
"""Raw protocol fields as a stable, hashable tuple.

Used to compare a desired TOU period against a device read-back
without depending on field order or computed properties.
"""
return (
self.season,
self.week,
self.start_hour,
self.start_min,
self.end_hour,
self.end_min,
self.price_min,
self.price_max,
self.decimal_point,
)


class TOUReservationSchedule(NavienBaseModel):
"""TOU schedule as returned by the MQTT ``tou/rd`` response topic.
Expand Down Expand Up @@ -166,3 +186,18 @@ def enabled(self) -> bool:
Protocol convention: 0=disabled, 2=enabled.
"""
return self.reservation_use == 2

def canonical(self) -> tuple[bool, tuple[tuple[int, ...], ...]]:
"""Normalized, order-independent representation of this schedule.

Mirrors :meth:`nwp500.models.schedule.ReservationSchedule.canonical`:
periods are sorted by their raw field tuple so two schedules with the
same periods compare equal regardless of the order the device
returned them in.
"""
return (
self.enabled,
tuple(
sorted(period.canonical_key() for period in self.reservation)
),
)
79 changes: 77 additions & 2 deletions src/nwp500/reservations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import asyncio
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from .converters import device_bool_from_python
from .encoding import build_reservation_entry, encode_week_bitfield
from .models import ReservationSchedule
from .models import ReservationEntry, ReservationSchedule

if TYPE_CHECKING:
from .models import Device
Expand Down Expand Up @@ -78,6 +79,79 @@ def on_schedule(schedule: ReservationSchedule) -> None:
)


async def update_reservations_confirmed(
mqtt: NavienMqttClient,
device: Device,
reservations: Sequence[dict[str, Any]],
*,
enabled: bool = True,
timeout: float = 10.0,
) -> ReservationSchedule | None:
"""Write the full reservation list and confirm the device applied it.

Sends ``update_reservations`` and waits for the device's ``rsv/rd``
echo, returning the parsed :class:`ReservationSchedule` the device now
holds. Compare it against the desired program with
:meth:`ReservationSchedule.canonical`, e.g.::

confirmed = await update_reservations_confirmed(mqtt, device, entries)
assert confirmed is not None
assert confirmed.canonical() == desired_schedule.canonical()

Args:
mqtt: Connected MQTT client.
device: Target device.
reservations: List of raw reservation entry dicts to write.
enabled: Whether reservations are enabled (default: True).
timeout: Seconds to wait for the confirming response.

Returns:
The :class:`ReservationSchedule` 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
``rsv/rd``, so a response is only accepted once its
:meth:`~nwp500.models.ReservationSchedule.canonical` form matches
what was just written. This avoids resolving on a stale/unrelated
``rsv/rd`` message (e.g. from a concurrent read or a previous
write) that happens to arrive in the same window.
"""
expected = ReservationSchedule(
reservationUse=device_bool_from_python(enabled),
reservation=[ReservationEntry(**entry) for entry in reservations],
).canonical()

future: asyncio.Future[ReservationSchedule] = (
asyncio.get_running_loop().create_future()
)

def on_schedule(schedule: ReservationSchedule) -> None:
if not future.done() and schedule.canonical() == expected:
future.set_result(schedule)

await mqtt.subscribe_reservation_response(device, on_schedule)
try:
await mqtt.update_reservations(device, reservations, enabled=enabled)
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:
from .mqtt.utils import redact_mac

_logger.warning(
"Failed to unsubscribe reservations response handler for "
"device %s",
redact_mac(device.device_info.mac_address),
Comment thread
eman marked this conversation as resolved.
Dismissed
exc_info=True,
)


async def add_reservation(
mqtt: NavienMqttClient,
device: Device,
Expand Down Expand Up @@ -283,6 +357,7 @@ async def update_reservation(

__all__ = [
"fetch_reservations",
"update_reservations_confirmed",
"add_reservation",
"delete_reservation",
"update_reservation",
Expand Down
105 changes: 105 additions & 0 deletions src/nwp500/tou_schedule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
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__)


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),
Comment thread
eman marked this conversation as resolved.
Dismissed
exc_info=True,
)


__all__ = ["configure_tou_schedule_confirmed"]
Loading
Loading