From d6c1b6205f3ea73d80c5372cd1cca69e73271cf4 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 31 Aug 2026 22:15:22 -0700 Subject: [PATCH 1/2] Accept a null errorCode instead of losing the whole device The cloud returns "error": {"errorCode": null} on some devices. DeviceErrorSummary typed the field ErrorCode | int, and neither union branch accepts None, so a null failed validation of the entire Device - taking the whole /device/list response with it. Downstream that meant the device disappeared and the Home Assistant integration could not set up at all (eman/ha_nwp500#131); the block was unmodelled before 9.3.1, which is why rolling back restored service. Type it ErrorCode | int | None, defaulting to None. Mapping null to 0 would assert the device is fault-free, which is not what a null says: the cloud reported no code. Both known consumers already treat a missing code as nothing to report. Fixes #130 Claude-Session: https://claude.ai/code/session_01LhLMPrLpPJ56jUcQmiBh1d --- CHANGELOG.rst | 12 ++++++++++++ docs/openapi.yaml | 1 + src/nwp500/models/device.py | 13 ++++++++----- tests/test_device_rest_models.py | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3377119..8a93ebf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ========================== diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 51d70ba..9e1fcf4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -221,6 +221,7 @@ paths: properties: errorCode: type: integer + nullable: true example: 0 errorOccuredTime: type: string diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py index 9e3e4a9..273edb7 100644 --- a/src/nwp500/models/device.py +++ b/src/nwp500/models/device.py @@ -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" ) #: Spelled "Occured" by the API; the Python name is spelled correctly. error_occurred_time: str | None = Field( diff --git a/tests/test_device_rest_models.py b/tests/test_device_rest_models.py index 978d0ec..b12288a 100644 --- a/tests/test_device_rest_models.py +++ b/tests/test_device_rest_models.py @@ -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}} From 95e2232e54b8c8b71457cf6fdcbc25db2455a304 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 31 Aug 2026 22:21:32 -0700 Subject: [PATCH 2/2] Address review: fix the 3.1 nullability syntax and the model reference Two follow-ups from review: OpenAPI 3.1 dropped `nullable` as a schema keyword, so 3.1 tooling read `errorCode` as integer-only and the annotation did nothing. Express it as a JSON Schema type array instead. The file had 15 of these, all predating this change; converting only the new one would have left the same block inconsistent with itself, so all 15 are converted. `docs/reference/python_api/models.rst` still documented the field as `ErrorCode | int`, and its example tested `error_code != NO_ERROR`, which is true for None - it would have reported "code None" as a fault. Both now describe the nullable contract. Claude-Session: https://claude.ai/code/session_01LhLMPrLpPJ56jUcQmiBh1d --- docs/openapi.yaml | 45 ++++++++++------------------ docs/reference/python_api/models.rst | 13 ++++---- uv.lock | 3 ++ 3 files changed, 26 insertions(+), 35 deletions(-) create mode 100644 uv.lock diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9e1fcf4..1e14b7f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 @@ -220,23 +218,19 @@ paths: description: Last recorded device fault. properties: errorCode: - type: integer - nullable: true + 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: @@ -312,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: @@ -342,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 @@ -399,8 +386,7 @@ paths: type: integer example: 52 deviceGroup: - type: object - nullable: true + type: [object, "null"] curSwCode: type: integer example: 33556241 @@ -408,8 +394,7 @@ paths: type: integer example: 167837696 downloadedVersion: - type: integer - nullable: true + type: [integer, "null"] example: 0 /device/tou: get: diff --git a/docs/reference/python_api/models.rst b/docs/reference/python_api/models.rst index d12631e..8eda598 100644 --- a/docs/reference/python_api/models.rst +++ b/docs/reference/python_api/models.rst @@ -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``. @@ -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}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a5bc514 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14"