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

Fixed
-----
- **A null ``errorCode`` no longer makes ``/device/list`` unparseable.**
The cloud returns ``"error": {"errorCode": null}`` on some devices.
:class:`~nwp500.models.DeviceErrorSummary` typed the field
``ErrorCode | int``, so validation of the whole listing failed and every
caller - including the Home Assistant integration, which could no longer
set up - lost the device entirely. ``error_code`` is now
``ErrorCode | int | None`` and defaults to ``None``: a null means the
cloud reported no code, which is not the same claim as ``NO_ERROR``.
Callers that treated the field as always-present should handle ``None``.

Version 9.3.1 (2026-08-29)
==========================

Expand Down
44 changes: 15 additions & 29 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,10 @@ paths:
type: integer
example: 2
modelTypeCode:
type: integer
nullable: true
type: [integer, "null"]
example: null
installerId:
type: string
nullable: true
type: [string, "null"]
example: null
location:
type: object
Expand All @@ -220,22 +218,19 @@ paths:
description: Last recorded device fault.
properties:
errorCode:
type: integer
type: [integer, "null"]
example: 0
errorOccuredTime:
type: string
nullable: true
type: [string, "null"]
example: "2025-12-07T11:58:02"
descaling:
type: object
properties:
descalingStartTime:
type: string
nullable: true
type: [string, "null"]
example: null
descalingEndTime:
type: string
nullable: true
type: [string, "null"]
example: null
/device/info:
post:
Expand Down Expand Up @@ -311,23 +306,18 @@ paths:
type: number
example: -118.243683
altitude:
type: object
nullable: true
type: [object, "null"]
installer:
type: object
properties:
installerId:
type: object
nullable: true
type: [object, "null"]
installerFirstName:
type: object
nullable: true
type: [object, "null"]
installerLastName:
type: object
nullable: true
type: [object, "null"]
installerPhoneNumber:
type: object
nullable: true
type: [object, "null"]
alarmInfo:
type: object
properties:
Expand All @@ -341,11 +331,9 @@ paths:
type: object
properties:
descalingStartTime:
type: object
nullable: true
type: [object, "null"]
descalingEndTime:
type: object
nullable: true
type: [object, "null"]
/device/firmware/info:
post:
summary: Firmware Info
Expand Down Expand Up @@ -398,17 +386,15 @@ paths:
type: integer
example: 52
deviceGroup:
type: object
nullable: true
type: [object, "null"]
curSwCode:
type: integer
example: 33556241
curVersion:
type: integer
example: 167837696
downloadedVersion:
type: integer
nullable: true
type: [integer, "null"]
example: 0
/device/tou:
get:
Expand Down
13 changes: 8 additions & 5 deletions docs/reference/python_api/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ remains readable while the device is offline.

**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_code`` (ErrorCode | int | None) - ``ErrorCode.NO_ERROR`` when the
device has no recorded fault, and ``None`` when the cloud reports no code
at all - it sends ``"errorCode": null`` on some devices, which is not the
same claim as "no error". A code the enum does not know is kept as a plain
int, so neither a null nor an unrecognised code fails 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``.

Expand All @@ -108,8 +110,9 @@ remains readable while the device is offline.
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
code = device.error.error_code if device.error else None
# None is "the cloud told us nothing", not "no fault".
if code is not None and code != ErrorCode.NO_ERROR:
# 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}"
Expand Down
13 changes: 8 additions & 5 deletions src/nwp500/models/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,20 @@ class DeviceErrorSummary(NavienBaseModel):
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.
#: ``NO_ERROR`` when the device has no recorded fault, and ``None`` when
#: the cloud reports no code at all - it sends ``"errorCode": null`` on
#: some devices, which is not the same claim as "no error". Typed to
#: accept a bare int as well, following ``device_type``, so neither a
#: null nor a code the enum does not know can 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"
error_code: ErrorCode | int | None = Field(
default=None, union_mode="left_to_right"
Comment on lines +60 to +61

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 95e2232 — the field list now reads ErrorCode | int | None and explains that a null is "the cloud reported no code", not NO_ERROR. The example was worse than stale: error_code != ErrorCode.NO_ERROR is true for None, so it would have printed code None as a fault. It now pulls the code out first and guards on code is not None.

)
#: Spelled "Occured" by the API; the Python name is spelled correctly.
error_occurred_time: str | None = Field(
Expand Down
19 changes: 19 additions & 0 deletions tests/test_device_rest_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ def test_known_error_code_becomes_an_enum(self, code):
assert isinstance(error_code, ErrorCode)
assert error_code is ErrorCode(code)

def test_null_error_code_does_not_break_the_response(self):
"""The cloud sends ``"errorCode": null`` on some devices, which must
not fail the listing and so make the whole integration unusable."""
payload = DEVICE_LIST_ENTRY | {
"error": {"errorCode": None, "errorOccuredTime": None}
}

device = Device.model_validate(payload)

assert device.error is not None
assert device.error.error_code is None

def test_absent_error_code_is_not_reported(self):
"""No code in the block reads the same as an explicit null: the
cloud has told us nothing, not that the device is fault-free."""
payload = DEVICE_LIST_ENTRY | {"error": {}}

assert Device.model_validate(payload).error.error_code is None

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}}
Expand Down
3 changes: 3 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading