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
33 changes: 33 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Changelog
Unreleased
==========

**BREAKING CHANGE**: ``NavienMqttClient.request_tou_settings()`` is removed.
It could never have worked.

Added
-----
- **REST fields the cloud added since the models were written.**
Expand All @@ -26,6 +29,36 @@ Added
plain int) so an unrecognised code cannot make a whole listing unparseable. ``docs/openapi.yaml`` is
updated to match.

Removed
-------
- **``request_tou_settings()`` removed - the device has no MQTT read for its
TOU schedule.** The method published a ``CommandCode.TOU_RESERVATION``
message to ``ctrl/tou/rd`` carrying only ``controllerSerialNumber``, then
waited for a reply on ``res/tou/rd``. No reply ever comes: a live device
with TOU provisioned (``program_reservation_use`` true, a valid controller
serial, and a plan the REST API returns in full) stayed silent for 45
seconds. ``ctrl/tou/rd`` with that command code is the *write* - it is what
:meth:`~nwp500.NavienMqttClient.configure_tou_schedule` publishes and what
the vendor app publishes from its TOU editor
(``TouScheduleViewmodel.setPublishMgppControlTou``) - and the device answers
on ``res/tou/rd`` only to confirm such a write. The vendor app reads TOU
over REST, which is the only TOU read the protocol has.

Beyond returning nothing, the call published a write-shaped command with no
schedule attached. This device ignored it - ``touStatus`` was unchanged
across repeated calls - but a firmware that took it at face value could read
it as "store an empty TOU schedule".

**Migration**: use :meth:`~nwp500.NavienAPIClient.get_tou_info`, which
returns the stored plan - rate name, utility, ZIP code and the seasonal
pricing intervals. The read itself is pure REST; it is keyed by the
controller serial number, which only the MQTT device-info response
publishes, so fetch that once and cache it.
:meth:`~nwp500.NavienMqttClient.subscribe_tou_response` is unaffected and
still delivers write confirmations. Enabling and disabling TOU
(:meth:`~nwp500.NavienMqttClient.set_tou_enabled`, command codes
``TOU_ON``/``TOU_OFF``) is a separate path and is unaffected.

Fixed
-----
- **CLI ``energy --months`` no longer duplicates ``--month`` output.**
Expand Down
129 changes: 72 additions & 57 deletions docs/how-to/optimize-tou.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,28 +339,38 @@ Enables or disables TOU operation without changing the schedule.
* ``device``: Device object
* ``enabled``: ``True`` to enable TOU, ``False`` to disable

MQTT: Request TOU Settings
~~~~~~~~~~~~~~~~~~~~~~~~~~
REST: Read the Current TOU Schedule
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: python

async def request_tou_settings(
device: Device,
controller_serial_number: str
) -> None
async def get_tou_info(
mac_address: str,
additional_value: str,
controller_id: str,
user_type: str = "O",
) -> TOUInfo

Requests the current TOU configuration from the device.
Returns the stored TOU plan: rate name, utility, ZIP code, and the seasonal
schedule with its pricing intervals.

**Parameters:**

* ``device``: Device object
* ``controller_serial_number``: Controller serial number
* ``mac_address``: Device MAC address
* ``additional_value``: Additional device identifier
* ``controller_id``: Controller serial number. Only the MQTT device-info
response publishes it (``DeviceFeature.controller_serial_number``); it is a
hardware property, so fetch it once and cache it. See Example 3.
* ``user_type``: User type (default ``"O"``)

The device will respond on the topic:
.. note::

.. code-block:: text

cmd/{deviceType}/{deviceId}/res/tou/rd
There is no MQTT read for the TOU schedule. ``ctrl/tou/rd`` with
``CommandCode.TOU_RESERVATION`` is the *write* - it is what
:meth:`~nwp500.NavienMqttClient.configure_tou_schedule` publishes, and what
the vendor app publishes from its TOU editor. The device replies on
``cmd/{deviceType}/{clientId}/res/tou/rd`` to confirm such a write; it does
not answer a request that carries no schedule. Read the plan over REST.

Building TOU Periods
--------------------
Expand Down Expand Up @@ -527,7 +537,7 @@ Configure two rate periods - off-peak and peak pricing:
await mqtt_client.subscribe_device_feature(device, capture_feature)
await mqtt_client.request_device_info(device)
feature = await asyncio.wait_for(feature_future, timeout=15)
controller_serial = feature.controllerSerialNumber
controller_serial = feature.controller_serial_number

# Define off-peak period (midnight to 2 PM, weekdays)
off_peak = build_tou_period(
Expand Down Expand Up @@ -651,58 +661,63 @@ Configure different rates for summer and winter:
Example 3: Retrieve Current TOU Settings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Query the device for its current TOU configuration:
The stored TOU plan is read over the REST API. The read itself needs no MQTT
connection, but it is keyed by the controller serial number, and the only
place that is published is the MQTT device-info response. The serial is a
hardware property that never changes, so fetch it once, keep it, and every
later read is pure REST:

.. code-block:: python

from nwp500.encoding import decode_week_bitfield, decode_price
import asyncio
from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient
from nwp500.encoding import decode_price, decode_week_bitfield

async def get_controller_serial(auth_client, device) -> str:
"""One-time lookup: the controller serial is only published over MQTT."""
mqtt_client = NavienMqttClient(auth_client)
await mqtt_client.connect()
try:
feature_future = asyncio.Future()

def capture_feature(feature):
if not feature_future.done():
feature_future.set_result(feature)

await mqtt_client.subscribe_device_feature(device, capture_feature)
await mqtt_client.request_device_info(device)
feature = await asyncio.wait_for(feature_future, timeout=15)
return feature.controller_serial_number
finally:
await mqtt_client.disconnect()

async def check_tou_settings():
async def check_tou_settings(controller_serial: str | None = None):
async with NavienAuthClient("user@example.com", "password") as auth_client:
api_client = NavienAPIClient(auth_client=auth_client)
device = await api_client.get_first_device()

mqtt_client = NavienMqttClient(auth_client)
await mqtt_client.connect()

# ... get controller_serial (same as Example 1) ...

# Set up response handler
response_topic = f"cmd/{device.device_info.device_type}/{mqtt_client.config.client_id}/res/tou/rd"

def on_tou_response(topic: str, message: dict):
response = message.get("response", {})
enabled = response.get("reservationUse")
periods = response.get("reservation", [])

print(f"TOU Enabled: {enabled}")
print(f"Number of periods: {len(periods)}")

for i, period in enumerate(periods, 1):
days = decode_week_bitfield(period.get("week", 0))
price_min = decode_price(
period.get("priceMin", 0),
period.get("decimalPoint", 0)
)
price_max = decode_price(
period.get("priceMax", 0),
period.get("decimalPoint", 0)
)


if controller_serial is None:
controller_serial = await get_controller_serial(auth_client, device)

tou_info = await api_client.get_tou_info(
mac_address=device.device_info.mac_address,
additional_value=device.device_info.additional_value,
controller_id=controller_serial,
)
Comment on lines +702 to +706

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves were wrong, and fixed in 6d7fd96.

There is no REST source for the controller serial — I checked the HAR captures, and the vendor app sends /device/tou a controllerId it already holds. The only thing that publishes it is the MQTT device-info response (DeviceFeature.controller_serial_number).

So the section no longer claims the whole flow is MQTT-free. It now says the read itself is REST but is keyed by the controller serial, which is a hardware property: fetch it once, cache it, and every later read is pure REST. Example 3 is self-contained — a get_controller_serial() helper that connects, reads device info and disconnects, and a check_tou_settings(controller_serial=None) that skips it when you pass a cached value. The parameter list for get_tou_info says where the serial comes from.

Separately: Example 1 read it as feature.controllerSerialNumber, which the model has no such attribute for. Corrected to controller_serial_number while in the same file.


print(f"Plan: {tou_info.name} ({tou_info.utility})")

# TOUSchedule.intervals holds the raw interval dicts
for season in tou_info.schedule:
for i, interval in enumerate(season.intervals, 1):
days = decode_week_bitfield(interval["week"])
dp = interval["decimalPoint"]
print(f"\nPeriod {i}:")
print(f" Days: {', '.join(days)}")
print(f" Time: {period['startHour']:02d}:{period['startMinute']:02d} "
f"- {period['endHour']:02d}:{period['endMinute']:02d}")
print(f" Price: ${price_min:.5f} - ${price_max:.5f}/kWh")

await mqtt_client.subscribe(response_topic, on_tou_response)

# Request current settings
await mqtt_client.request_tou_settings(device, controller_serial)

# Wait for response
await asyncio.sleep(5)
await mqtt_client.disconnect()
print(f" Time: {interval['startHour']:02d}:{interval['startMinute']:02d} "
f"- {interval['endHour']:02d}:{interval['endMinute']:02d}")
print(f" Price: ${decode_price(interval['priceMin'], dp):.5f} "
f"- ${decode_price(interval['priceMax'], dp):.5f}/kWh")

asyncio.run(check_tou_settings())

Expand Down
15 changes: 4 additions & 11 deletions docs/reference/python_api/mqtt_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -582,24 +582,17 @@ configure_tou_schedule()

**Capability Required:** ``program_reservation_use``

request_tou_settings()
^^^^^^^^^^^^^^^^^^^^^^

.. py:method:: request_tou_settings(device, controller_serial_number)

Request the current TOU schedule.

subscribe_tou_response()
^^^^^^^^^^^^^^^^^^^^^^^^

.. py:method:: subscribe_tou_response(device, callback)

Subscribe to parsed TOU schedule responses.
Subscribe to parsed TOU schedule write confirmations.

The callback is invoked with a :class:`~nwp500.models.TOUReservationSchedule`
whenever the device responds to a :meth:`request_tou_settings` read or a
:meth:`configure_tou_schedule` write (both use the ``tou/rd`` response
topic).
when the device confirms a :meth:`configure_tou_schedule` write on the
``tou/rd`` response topic. The device has no MQTT read for its TOU schedule;
to read the stored plan, use :meth:`~nwp500.NavienAPIClient.get_tou_info`.

:param callback: Called with the parsed TOU schedule on each response.
:type callback: Callable[[TOUReservationSchedule], None]
Expand Down
15 changes: 3 additions & 12 deletions examples/advanced/firmware_payload_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

Specifically captures:
- Weekly reservations (rsv/rd)
- Time-of-Use schedule (tou/rd)
- Device info (firmware versions, capabilities)
- Device status (current operating state)
- All other response/event topics (via wildcards)
Expand Down Expand Up @@ -37,7 +36,6 @@
from typing import Any

from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient
from nwp500.exceptions import Nwp500Error
from nwp500.models import DeviceFeature
from nwp500.mqtt.utils import redact, redact_topic
from nwp500.topic_builder import MqttTopicBuilder
Expand Down Expand Up @@ -156,16 +154,9 @@ def on_feature(feature: DeviceFeature) -> None:
await mqtt_client.request_reservations(device)
await asyncio.sleep(5)

# --- Step 4: request TOU schedule (requires controller serial number) ---
if device_feature and device_feature.program_reservation_use:
serial = device_feature.controller_serial_number
if serial:
print("Requesting TOU schedule...")
try:
await mqtt_client.request_tou_settings(device, serial)
await asyncio.sleep(5)
except Nwp500Error as exc:
print(f" TOU request failed: {exc}")
# No TOU step: the device has no MQTT read for its TOU schedule.
# ctrl/tou/rd carries the *write*, which a capture tool must not send.
# Read the schedule over REST instead (api_client.get_tou_info).

# --- Step 5: wait a bit more to catch any late-arriving messages ---
print("\nWaiting for any remaining messages...")
Expand Down
8 changes: 4 additions & 4 deletions examples/advanced/tou_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None:
enabled=True,
)

print("Requesting current TOU settings for confirmation...")
await mqtt_client.request_tou_settings(device, controller_serial)

print("Waiting up to 15 seconds for TOU responses...")
# The device has no MQTT read for its TOU schedule; the write above
# is confirmed on the same tou/rd topic. To read the stored schedule
# back, use the REST API: api_client.get_tou_info(...).
print("Waiting up to 15 seconds for the TOU write confirmation...")
await asyncio.sleep(15)

print("Toggling TOU off for quick test...")
Expand Down
11 changes: 6 additions & 5 deletions src/nwp500/models/tou.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,12 @@ def canonical_key(
class TOUReservationSchedule(NavienBaseModel):
"""TOU schedule as returned by the MQTT ``tou/rd`` response topic.

This model matches the raw MQTT payload for both
:meth:`~nwp500.NavienMqttClient.request_tou_settings` read responses
and :meth:`~nwp500.NavienMqttClient.configure_tou_schedule` write
confirmations — both use ``CommandCode.TOU_RESERVATION`` and the
``tou/rd`` response topic.
This model matches the raw MQTT payload the device sends to confirm a
:meth:`~nwp500.NavienMqttClient.configure_tou_schedule` write. The device
has no MQTT read for its TOU schedule - ``ctrl/tou/rd`` with
``CommandCode.TOU_RESERVATION`` is the write - so this payload only ever
arrives as a write confirmation. To read the current schedule, use the
REST :meth:`~nwp500.NavienAPIClient.get_tou_info`.

The payload structure is::

Expand Down
8 changes: 0 additions & 8 deletions src/nwp500/mqtt/_control_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,6 @@ async def configure_tou_schedule(
device, controller_serial_number, periods, enabled=enabled
)

async def request_tou_settings(
self, device: Device, controller_serial_number: str
) -> int:
"""Request the current TOU settings from the device."""
return await self._device_controller.request_tou_settings(
device, controller_serial_number
)

async def set_tou_enabled(self, device: Device, enabled: bool) -> int:
"""Enable or disable Time-of-Use optimization."""
return await self._device_controller.set_tou_enabled(device, enabled)
Expand Down
11 changes: 6 additions & 5 deletions src/nwp500/mqtt/_device_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,15 @@ async def subscribe_tou_response(
device: Device,
callback: Callable[[TOUReservationSchedule], None],
) -> int:
"""Subscribe to Time-of-Use schedule read responses with automatic
parsing.
"""Subscribe to Time-of-Use schedule write confirmations with
automatic parsing.

Subscribes to the ``tou/rd`` response topic for the given device.
The callback receives a fully-parsed
:class:`~nwp500.models.TOUReservationSchedule` whenever the device
responds to a TOU read or configure request (triggered by
:meth:`request_tou_settings` or :meth:`configure_tou_schedule`).
:class:`~nwp500.models.TOUReservationSchedule` when the device
confirms a TOU write (triggered by :meth:`configure_tou_schedule`).
There is no MQTT read to subscribe to; to read the current schedule,
use the REST :meth:`~nwp500.NavienAPIClient.get_tou_info`.
Comment on lines +202 to +205

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6d7fd96 — the summary line now reads "Subscribe to Time-of-Use schedule write confirmations with automatic parsing." Done on both layers, and docs/reference/python_api/mqtt_client.rst had the same stale summary ("Subscribe to parsed TOU schedule responses"), which is corrected too.


Args:
device: Device whose TOU responses to receive.
Expand Down
32 changes: 0 additions & 32 deletions src/nwp500/mqtt/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,38 +539,6 @@ async def configure_tou_schedule(
reservation=reservation_payload,
)

async def request_tou_settings(
self,
device: Device,
controller_serial_number: str,
) -> int:
"""
Request current Time-of-Use schedule from the device.

Args:
device: Device object
controller_serial_number: Controller serial number

Returns:
Publish packet ID

Raises:
ValueError: If controller_serial_number is empty
"""
if not controller_serial_number:
raise ParameterValidationError(
"controller_serial_number is required",
parameter="controller_serial_number",
)

return await self._send_command(
device=device,
command_code=CommandCode.TOU_RESERVATION,
topic_suffix="ctrl/tou/rd",
response_topic_suffix="tou/rd",
controllerSerialNumber=controller_serial_number,
)

@requires_capability("program_reservation_use")
async def set_tou_enabled(self, device: Device, enabled: bool) -> int:
"""Toggle Time-of-Use functionality."""
Expand Down
Loading
Loading