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

Added
-----
- **REST fields the cloud added since the models were written.**
``/device/list`` now returns an ``error`` block (``errorCode``,
``errorOccuredTime``) and the ``descaling`` block previously seen only
on ``/device/info``, and ``deviceInfo`` gained ``modelTypeCode`` and
``installerId``. None of these were modelled, and ``NavienBaseModel``
ignores unknown keys, so all of them were silently discarded.
:class:`~nwp500.models.Device` gains optional ``error``
(:class:`~nwp500.models.DeviceErrorSummary`) and ``descaling``
(:class:`~nwp500.models.DescalingInfo`) sections, and
:class:`~nwp500.models.DeviceInfo` gains ``model_type_code`` and
``installer_id``. ``error`` makes the device's last recorded fault
readable without an MQTT connection, including while the device is
offline. Every new field is optional, so responses that omit them - such
as ``/device/info``, which carries no ``error`` block - parse unchanged.
``error_code`` is typed ``ErrorCode | int`` (validated left to right, so a
known code becomes an ``ErrorCode`` member and only an unknown one stays a
plain int) so an unrecognised code cannot make a whole listing unparseable. ``docs/openapi.yaml`` is
updated to match.

Fixed
-----
- **CLI ``energy --months`` no longer duplicates ``--month`` output.**
Expand Down
32 changes: 31 additions & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ paths:
connected:
type: integer
example: 2
modelTypeCode:
type: integer
nullable: true
example: null
installerId:
type: string
nullable: true
example: null
location:
type: object
properties:
Expand All @@ -206,7 +214,29 @@ paths:
example: "Anytown"
address:
type: string
example: "123 Main Street"
example: "123 Main Street"
error:
type: object
description: Last recorded device fault.
properties:
errorCode:
type: integer
example: 0
errorOccuredTime:
type: string
nullable: true
example: "2025-12-07T11:58:02"
descaling:
type: object
properties:
descalingStartTime:
type: string
nullable: true
example: null
descalingEndTime:
type: string
nullable: true
example: null
/device/info:
post:
summary: Device Info
Expand Down
52 changes: 52 additions & 0 deletions docs/reference/python_api/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ Complete device representation with info and location.

* ``device_info`` (DeviceInfo) - Device identification and status
* ``location`` (Location) - Physical location information
* ``error`` (DeviceErrorSummary, optional) - Last recorded fault. Returned by
``/device/list`` only; ``None`` on a device fetched through ``/device/info``.
* ``descaling`` (DescalingInfo, optional) - Descaling window, if one is
scheduled or recorded

**Example:**

Expand All @@ -80,6 +84,52 @@ Complete device representation with info and location.
print(f"Location: {loc.city}, {loc.state}")
print(f"Coords: {loc.latitude}, {loc.longitude}")

DeviceErrorSummary
------------------

The device's last recorded fault, as reported by the REST API. Unlike
``DeviceStatus.error_code`` this is readable without an MQTT connection, and
remains readable while the device is offline.

.. py:class:: DeviceErrorSummary

**Fields:**

* ``error_code`` (ErrorCode | int) - ``ErrorCode.NO_ERROR`` when there is no
recorded fault. A code the enum does not know is kept as a plain int rather
than failing the whole response.
* ``error_occurred_time`` (str, optional) - When the fault was recorded, as an
ISO-8601 string. Sent by the API under the misspelled key ``errorOccuredTime``.

**Example:**

.. code-block:: python

devices = await api.list_devices()

for device in devices:
if device.error and device.error.error_code != ErrorCode.NO_ERROR:
code = device.error.error_code
# A code the enum knows arrives as an ErrorCode; anything else
# falls back to a plain int, which has no .name.
label = code.name if isinstance(code, ErrorCode) else f"code {code}"
print(f"{device.device_info.device_name}: {label}"
f" at {device.error.error_occurred_time}")

DescalingInfo
-------------

Descaling window reported by the REST API.

.. py:class:: DescalingInfo

**Fields:**

* ``descaling_start_time`` (str, optional) - Start of the descaling window
* ``descaling_end_time`` (str, optional) - End of the descaling window

Both are ``None`` on a device with no descaling scheduled or recorded.

DeviceInfo
----------

Expand All @@ -99,6 +149,8 @@ Device identification and connection information.
* ``device_name`` (str) - User-assigned device name
* ``connected`` (int) - Connection status (2 = online, 0 = offline)
* ``install_type`` (str, optional) - Installation type
* ``model_type_code`` (int, optional) - Model type code
* ``installer_id`` (str, optional) - Installer identifier

**Example:**

Expand Down
4 changes: 4 additions & 0 deletions src/nwp500/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@
)
from nwp500.models import (
ConvertedTOUPlan,
DescalingInfo,
Device,
DeviceErrorSummary,
DeviceFeature,
DeviceInfo,
DeviceStatus,
Expand Down Expand Up @@ -147,6 +149,8 @@
"DeviceInfo",
"Location",
"Device",
"DeviceErrorSummary",
"DescalingInfo",
"FirmwareInfo",
"ReservationEntry",
"ReservationSchedule",
Expand Down
4 changes: 4 additions & 0 deletions src/nwp500/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
)
from .device import (
ConnectionStatusField,
DescalingInfo,
Device,
DeviceErrorSummary,
DeviceInfo,
FirmwareInfo,
Location,
Expand Down Expand Up @@ -69,6 +71,8 @@
"DeviceInfo",
"Location",
"Device",
"DeviceErrorSummary",
"DescalingInfo",
"FirmwareInfo",
"TOUSchedule",
"ConvertedTOUPlan",
Expand Down
48 changes: 46 additions & 2 deletions src/nwp500/models/device.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from typing import Annotated, Self

from pydantic import BeforeValidator
from pydantic import BeforeValidator, Field

from .._base import NavienBaseModel
from ..converters import enum_validator
from ..enums import ConnectionStatus, DeviceType
from ..enums import ConnectionStatus, DeviceType, ErrorCode

ConnectionStatusField = Annotated[
ConnectionStatus, BeforeValidator(enum_validator(ConnectionStatus))
Expand All @@ -21,6 +21,8 @@ class DeviceInfo(NavienBaseModel):
device_name: str = "Unknown"
connected: ConnectionStatusField = ConnectionStatus.DISCONNECTED
install_type: str | None = None
model_type_code: int | None = None
installer_id: str | None = None


class Location(NavienBaseModel):
Expand All @@ -34,11 +36,53 @@ class Location(NavienBaseModel):
altitude: float | None = None


class DeviceErrorSummary(NavienBaseModel):
"""Last device fault as reported by the REST API.

The cloud keeps this independently of the live MQTT status, so it is
readable without an MQTT connection - including while the device is
offline, where it reports the fault as of the last time the device
was heard from.
"""

#: ``NO_ERROR`` when the device has no recorded fault. Typed to accept a
#: bare int as well, following ``device_type``, so a code the enum does
#: not know cannot make a whole ``/device/list`` response unparseable.
#:
#: ``union_mode`` matters here: pydantic's default smart union matches an
#: incoming int against the ``int`` branch exactly and never reaches the
#: enum, so every code - known or not - would stay a plain int. Trying the
#: branches left to right instead means a known code becomes an
#: ``ErrorCode`` member and only an unknown one falls through to ``int``.
error_code: ErrorCode | int = Field(
default=ErrorCode.NO_ERROR, union_mode="left_to_right"
)
#: Spelled "Occured" by the API; the Python name is spelled correctly.
error_occurred_time: str | None = Field(
default=None, alias="errorOccuredTime"
)


class DescalingInfo(NavienBaseModel):
"""Descaling window reported by the REST API.

Both timestamps are ``None`` on a device with no descaling scheduled
or recorded.
"""

descaling_start_time: str | None = None
descaling_end_time: str | None = None


class Device(NavienBaseModel):
"""Complete device information including location."""

device_info: DeviceInfo
location: Location
#: Present on ``/device/list``; absent from ``/device/info``.
error: DeviceErrorSummary | None = None
#: Present on both ``/device/list`` and ``/device/info``.
descaling: DescalingInfo | None = None

def with_info(self, info: DeviceInfo) -> Self:
"""Return a new Device instance with updated DeviceInfo."""
Expand Down
109 changes: 109 additions & 0 deletions tests/test_device_rest_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Device models against the shapes the REST API actually returns.

The payloads below are trimmed from live ``/device/list`` and
``/device/info`` responses (identifiers replaced).
"""

import pytest

from nwp500.enums import ErrorCode
from nwp500.models import Device

DEVICE_LIST_ENTRY = {
"deviceInfo": {
"installerId": None,
"homeSeq": 25004,
"macAddress": "0123456789ab",
"additionalValue": "5322",
"deviceType": 52,
"modelTypeCode": None,
"deviceName": "NWP500",
"connected": 2,
},
"location": {
"state": "California",
"city": "Anytown",
"address": "123 Main Street",
},
"error": {"errorCode": 0, "errorOccuredTime": "2025-12-07T11:58:02"},
"descaling": {"descalingStartTime": None, "descalingEndTime": None},
}

DEVICE_INFO_ENTRY = {
"deviceInfo": {
"homeSeq": 25004,
"deviceName": "NWP500",
"deviceType": 52,
"modelTypeCode": None,
"installType": "R",
"connected": 2,
},
"location": {
"state": "California",
"city": "Anytown",
"address": "123 Main Street",
"latitude": 38.011845,
"longitude": -122.54772,
"altitude": None,
},
"installer": {"installerId": None},
"alarmInfo": {"isEtcAlarm": 1, "isErrorAlarm": 1},
"descaling": {"descalingStartTime": None, "descalingEndTime": None},
}


class TestDeviceListEntry:
def test_error_summary_is_parsed(self):
device = Device.model_validate(DEVICE_LIST_ENTRY)

assert device.error is not None
assert device.error.error_code == ErrorCode.NO_ERROR
assert device.error.error_occurred_time == "2025-12-07T11:58:02"

def test_descaling_window_is_parsed(self):
device = Device.model_validate(DEVICE_LIST_ENTRY)

assert device.descaling is not None
assert device.descaling.descaling_start_time is None
assert device.descaling.descaling_end_time is None

def test_device_info_extras_are_parsed(self):
info = Device.model_validate(DEVICE_LIST_ENTRY).device_info

assert info.model_type_code is None
assert info.installer_id is None

@pytest.mark.parametrize("code", [0, 96, 326])
def test_known_error_code_becomes_an_enum(self, code):
"""Not just ``== ErrorCode(code)``: an int compares equal to its
member, so only the type tells the two union branches apart."""
payload = DEVICE_LIST_ENTRY | {"error": {"errorCode": code}}

error_code = Device.model_validate(payload).error.error_code

assert isinstance(error_code, ErrorCode)
assert error_code is ErrorCode(code)

def test_unknown_error_code_does_not_break_the_response(self):
"""A code the enum doesn't know must not fail the whole listing."""
payload = DEVICE_LIST_ENTRY | {"error": {"errorCode": 9999}}

error_code = Device.model_validate(payload).error.error_code

assert not isinstance(error_code, ErrorCode)
assert error_code == 9999


class TestDeviceInfoEntry:
def test_missing_error_block_is_none(self):
"""``/device/info`` carries no ``error`` section."""
device = Device.model_validate(DEVICE_INFO_ENTRY)

assert device.error is None
assert device.descaling is not None

def test_unmodelled_sections_are_ignored(self):
"""``installer``/``alarmInfo`` are unmodelled and must not fail."""
device = Device.model_validate(DEVICE_INFO_ENTRY)

assert device.device_info.install_type == "R"
Loading