From 2c0794ed0cffb4bf06a6c57062f29d533fac1a57 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Sat, 29 Aug 2026 06:58:56 -0700 Subject: [PATCH 1/2] Model the REST fields the cloud has started returning A live /device/list response carries more than it did when these models were written. Comparing today's responses against the HAR captures in reference/ (September 2025), the cloud has added: - an `error` block: errorCode, errorOccuredTime - `descaling`, previously only on /device/info - deviceInfo.modelTypeCode and deviceInfo.installerId NavienBaseModel sets extra="ignore", so every one of these was being discarded without a trace. The `error` block is the useful one: it makes the device's last recorded fault readable without an MQTT connection, including while the device is offline. Device gains optional `error` and `descaling` sections and DeviceInfo gains `model_type_code`/`installer_id`. All are optional, so a response that omits them - /device/info returns no `error` block - parses exactly as before. `error_code` is typed `ErrorCode | int`, following `device_type`, so a code the enum does not know degrades to a plain int instead of making a whole device listing unparseable. docs/openapi.yaml and the model reference are updated to match. Claude-Session: https://claude.ai/code/session_01XVj9BYvuLj7Th3iFUVoeCn --- CHANGELOG.rst | 20 +++++ docs/openapi.yaml | 32 +++++++- docs/reference/python_api/models.rst | 48 ++++++++++++ src/nwp500/__init__.py | 4 + src/nwp500/models/__init__.py | 4 + src/nwp500/models/device.py | 40 +++++++++- tests/test_device_rest_models.py | 105 +++++++++++++++++++++++++++ 7 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 tests/test_device_rest_models.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index df5c4cf0..cc71c431 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,26 @@ 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`` 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.** diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c5ea832a..51d70ba4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: @@ -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 diff --git a/docs/reference/python_api/models.rst b/docs/reference/python_api/models.rst index 4dd8847a..ec117171 100644 --- a/docs/reference/python_api/models.rst +++ b/docs/reference/python_api/models.rst @@ -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:** @@ -80,6 +84,48 @@ 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: + print(f"{device.device_info.device_name}: {device.error.error_code.name}" + 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 ---------- @@ -99,6 +145,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:** diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 7e98d5ec..eea83a02 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -75,7 +75,9 @@ ) from nwp500.models import ( ConvertedTOUPlan, + DescalingInfo, Device, + DeviceErrorSummary, DeviceFeature, DeviceInfo, DeviceStatus, @@ -147,6 +149,8 @@ "DeviceInfo", "Location", "Device", + "DeviceErrorSummary", + "DescalingInfo", "FirmwareInfo", "ReservationEntry", "ReservationSchedule", diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py index 54270557..f655ed95 100644 --- a/src/nwp500/models/__init__.py +++ b/src/nwp500/models/__init__.py @@ -14,7 +14,9 @@ ) from .device import ( ConnectionStatusField, + DescalingInfo, Device, + DeviceErrorSummary, DeviceInfo, FirmwareInfo, Location, @@ -69,6 +71,8 @@ "DeviceInfo", "Location", "Device", + "DeviceErrorSummary", + "DescalingInfo", "FirmwareInfo", "TOUSchedule", "ConvertedTOUPlan", diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py index 3538743f..3ecb4ceb 100644 --- a/src/nwp500/models/device.py +++ b/src/nwp500/models/device.py @@ -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)) @@ -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): @@ -34,11 +36,45 @@ 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. + error_code: ErrorCode | int = ErrorCode.NO_ERROR + #: 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.""" diff --git a/tests/test_device_rest_models.py b/tests/test_device_rest_models.py new file mode 100644 index 00000000..02f03fb1 --- /dev/null +++ b/tests/test_device_rest_models.py @@ -0,0 +1,105 @@ +"""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", [96, 326]) + def test_known_error_code_becomes_an_enum(self, code): + payload = DEVICE_LIST_ENTRY | {"error": {"errorCode": code}} + + device = Device.model_validate(payload) + + assert device.error.error_code == 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}} + + device = Device.model_validate(payload) + + assert device.error.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" From 1fde090467ddee8833c5c00b67799763b7843707 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Sat, 29 Aug 2026 07:18:17 -0700 Subject: [PATCH 2/2] Try the ErrorCode branch before int 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 - stayed a plain int, and the .name access in the documented example would have raised AttributeError on a code the enum does know. union_mode="left_to_right" makes a known code an ErrorCode member while an unknown one still falls back to int. The test asserted only equality, which an IntEnum satisfies from either branch; it now asserts the type. The doc example handles the int fallback. Claude-Session: https://claude.ai/code/session_01XVj9BYvuLj7Th3iFUVoeCn --- CHANGELOG.rst | 5 +++-- docs/reference/python_api/models.rst | 6 +++++- src/nwp500/models/device.py | 10 +++++++++- tests/test_device_rest_models.py | 14 +++++++++----- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cc71c431..93970d8f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,8 +21,9 @@ Added 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`` so an unrecognised code - cannot make a whole listing unparseable. ``docs/openapi.yaml`` is + ``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 diff --git a/docs/reference/python_api/models.rst b/docs/reference/python_api/models.rst index ec117171..d12631e5 100644 --- a/docs/reference/python_api/models.rst +++ b/docs/reference/python_api/models.rst @@ -109,7 +109,11 @@ remains readable while the device is offline. for device in devices: if device.error and device.error.error_code != ErrorCode.NO_ERROR: - print(f"{device.device_info.device_name}: {device.error.error_code.name}" + 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 diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py index 3ecb4ceb..9e3e4a93 100644 --- a/src/nwp500/models/device.py +++ b/src/nwp500/models/device.py @@ -48,7 +48,15 @@ class DeviceErrorSummary(NavienBaseModel): #: ``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. - error_code: ErrorCode | int = ErrorCode.NO_ERROR + #: + #: ``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" diff --git a/tests/test_device_rest_models.py b/tests/test_device_rest_models.py index 02f03fb1..978d0ec3 100644 --- a/tests/test_device_rest_models.py +++ b/tests/test_device_rest_models.py @@ -73,21 +73,25 @@ def test_device_info_extras_are_parsed(self): assert info.model_type_code is None assert info.installer_id is None - @pytest.mark.parametrize("code", [96, 326]) + @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}} - device = Device.model_validate(payload) + error_code = Device.model_validate(payload).error.error_code - assert device.error.error_code == ErrorCode(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}} - device = Device.model_validate(payload) + error_code = Device.model_validate(payload).error.error_code - assert device.error.error_code == 9999 + assert not isinstance(error_code, ErrorCode) + assert error_code == 9999 class TestDeviceInfoEntry: