fix(python-client): serialize write_attribute struct values by TLV tag, not field name - #958
Conversation
…g, not field name
write_attribute() passed dataclass values (e.g. list[PresetStruct]) straight
through to the outgoing message, relying on orjson's native dataclass
serialization to produce the JSON. orjson keys dataclasses by their Python
field name ("presetHandle"), but the server's WRITE_ATTRIBUTE handler
(convertWebSocketTagBasedToMatter) expects struct members keyed by their
numeric TLV tag ("0"), matching the format already used for attribute
reports. The mismatch made every struct field fall into an "unknown key"
fallback that skips type conversion, leaving binary handle fields as
un-decoded base64 strings and the write rejected with Status 141
(INVALID_DATA_TYPE) -- reproducible even for a byte-for-byte unchanged
round-trip write.
Adds dataclass_to_tag_dict(), which recursively converts dataclass
instances (including nested lists, e.g. Schedule.transitions) to
tag-keyed dicts using each field's ClusterObjectFieldDescriptor.Tag, and
uses it in write_attribute(). dataclass_to_dict() (field-name-keyed, used
for DEVICE_COMMAND payloads via send_device_command) is unchanged, since
the server's command-invoke path expects name-keyed data.
Fixes matter-js#957.
Regression tests for the tag-keyed struct serialization used by write_attribute() (see previous commit): a PresetStruct converts to a dict keyed "0".."5" by TLV tag, nested structs inside lists (e.g. Schedule.transitions) convert recursively, and scalars pass through untouched. Also asserts dataclass_to_dict() (used by send_device_command) keeps its existing field-name keying, so the two helpers don't drift onto the same format.
There was a problem hiding this comment.
Pull request overview
Fixes Python client write_attribute() serialization for struct / list-of-struct values to match the server’s tag-keyed WRITE_ATTRIBUTE wire format (TLV tag numbers as object keys), preventing type-conversion from being skipped (notably octstr handle fields) and avoiding INVALID_DATA_TYPE failures on valid writes.
Changes:
- Add
dataclass_to_tag_dict()helper to recursively convert CHIP cluster dataclass structs into TLV tag-keyed dictionaries. - Use
dataclass_to_tag_dict()inMatterClient.write_attribute()so outgoing WS payloads match the server’s expected struct shape. - Add unit tests validating tag-keyed behavior and recursion through nested list members; update changelog.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python_client/matter_server/common/helpers/util.py | Adds dataclass_to_tag_dict() to produce TLV tag-keyed dicts for struct writes. |
| python_client/matter_server/client/client.py | Routes write_attribute() values through dataclass_to_tag_dict() before sending. |
| python_client/tests/test_util.py | Adds tests covering tag-keying, recursive list handling, and leaving dataclass_to_dict() unchanged. |
| CHANGELOG.md | Documents the Python client fix and its impact on struct writes. |
- Fix CI mypy failure: ScheduleTransitionStruct.transitionTime expects the SDK uint type, not a plain int. - Use cached_fields(type(obj_in)) instead of fields(obj_in): the cache key must be the (hashable) class, not the instance, which can be unhashable for dataclasses holding list fields (e.g. Schedule with a transitions list) -- fields()/cached_fields() accept either the class or an instance and return the same result either way, so this is a pure caching fix, not a behavior change. - Clarify the docstring: the name-keyed fallback only applies to dataclasses missing CHIP's `descriptor` metadata, which real cluster structs (the only values write_attribute() is ever called with) always provide.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
python_client/matter_server/common/helpers/util.py:104
- The docstring claims CHIP cluster dataclasses are "the only kind ever passed to write_attribute()", but
write_attribute()is also used for scalar values (anddataclass_to_tag_dict()explicitly supports non-dataclass inputs). Rewording avoids an inaccurate guarantee while keeping the rationale about descriptors/tags.
Every generated CHIP cluster dataclass (the only kind ever passed to
write_attribute()) exposes a `descriptor` with a Tag per field, so the
tag lookup always succeeds in practice. A dataclass field is only ever
kept name-keyed if `descriptor`/`GetFieldByLabel` is missing/unset for
that field, which should not happen for real cluster structs; this is a
Python clients before matter-server 1.3.0 serialized struct values (e.g. Thermostat Presets/Schedules) keyed by field name instead of TLV tag, so convertWebSocketTagBasedToMatter fell into its unknown-key fallback and skipped type conversion, leaving octstr handles as raw base64 and getting rejected by matter.js as malformed. Add a name-based fallback, keyed by the same wire field name convertMatterToWebSocketNameBased emits, so older clients get correct conversion without waiting on a client upgrade.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/ws-controller/src/server/Converters.ts:110
convertWebSocketTagBasedToMatternow always builds/looks up the wire-name member map for every struct conversion, even when all keys are numeric TLV tags (the common case for tag-based WRITE_ATTRIBUTE). This adds avoidable overhead on a hot path; the wire-name map can be initialized lazily only when a non-numeric key is actually encountered.
const memberById = getStructMembersById(model);
// Python clients before matter-server 1.3.0 serialized struct fields by wire field name
// (e.g. "presetHandle") instead of TLV tag, so unrecognized non-numeric keys are resolved
// against the same name that convertMatterToWebSocketNameBased emits.
const memberByWireName = getStructMembersByWireFieldName(model, clusterModel);
for (const key of valueKeys) {
const tag = parseInt(key);
const member = Number.isNaN(tag) ? memberByWireName.get(key) : memberById.get(tag);
… attribute writes Follow-up hardening on both sides of the struct-write fix: - ws-controller: WRITE_ATTRIBUTE struct conversion skips null for optional non-nullable members (same guard as the command-invoke path) so old name-keyed clients whose serializer emits null for every unset field no longer fail on partial structs; only purely-numeric keys are treated as TLV tags; the wire-name fallback also resolves legacy matter.js property names (e.g. iPv4Addresses) that the name-based converter dual-emits - python client: dataclass_to_tag_dict omits None fields (absent) to match the CHIP TLV encoder while NullValue still travels as explicit null - tests: converter unit tests for the null/key rules, a client wiring test pinning the tag-keyed write payload, and end-to-end struct-list write round-trips in both integration suites via a new UserLabel cluster on the test light device (tag-keyed and name-keyed forms) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a2049b0 to
cdcdb10
Compare
|
Pushed a follow-up commit on top of this PR — thanks @lboue, both sides of the fix were sound. What it adds: Null semantics — the main gap: unset optional fields traveled as explicit
Key parsing hardening — only purely-numeric keys are treated as TLV tags ( Tests — converter unit tests for the null/key rules (incl. a wire-name≠propertyName case via All gates green: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/ws-controller/test/ConvertersTest.ts:40
- This test name hard-codes a version boundary ("pre-1.3.0") which is easy to stale. Consider naming it based on the wire format being accepted (wire-field-name keyed struct members) instead of a specific release number.
it("falls back to wire field names for pre-1.3.0 python clients that serialized by name", () => {
packages/ws-controller/src/server/Converters.ts:107
- The comment hard-codes a version boundary ("before matter-server 1.3.0") that is likely to become inaccurate and make the rationale harder to trust later. Consider rewording it to be version-agnostic (e.g., "Older Python clients serialized struct fields by wire field name"), since the code already detects the format by key shape (numeric vs non-numeric).
// Python clients before matter-server 1.3.0 serialized struct fields by wire field name
// (e.g. "presetHandle") instead of TLV tag, so unrecognized non-numeric keys are resolved
// against the same name that convertMatterToWebSocketNameBased emits.
const memberByWireName = getStructMembersByWireFieldName(model, clusterModel);
packages/ws-controller/test/ConvertersTest.ts:28
- This test name embeds a specific version claim ("matter-server >=1.3.0") that may quickly become wrong. Renaming it to describe the behavior being tested (tag-keyed struct members) keeps the intent accurate over time.
This issue also appears on line 40 of the same file.
it("resolves struct members by numeric TLV tag (matter-server >=1.3.0 python client)", () => {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
CHANGELOG.md:22
- The PR description/checklist says the change “touches only python_client/” and marks
npm run format-verify/npm run lint/npm testas “not applicable”, but this PR also changes TypeScript packages (packages/ws-controller/andpackages/matter-server/). Please update the PR description/checklist (and ensure the TS-side format/lint/build/tests are run) so reviewers/CI expectations match the actual scope.
- Fix: Improves ICD UI handling when deactivating the LIT mode
- Fix: `WRITE_ATTRIBUTE` now also accepts struct members keyed by wire field name, not only by TLV tag
- Fix: (lboue) Python client `write_attribute()` now serializes struct/list-of-struct values keyed by TLV tag instead of field name as the server's `WRITE_ATTRIBUTE` handler expects
packages/ws-controller/src/server/Converters.ts:110
parseIntshould be called with an explicit radix to avoid legacy parsing edge cases (and to match the rest of the codebase’s use ofNumber.parseInt(..., 10)in newer code).
const isTag = /^\d+$/.test(key);
const member = isTag ? memberById.get(parseInt(key)) : memberByWireName.get(key);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
python_client/matter_server/common/helpers/util.py:120
- dataclass_to_tag_dict() currently omits any field whose value is None unconditionally. For non-optional (mandatory) fields this diverges from the CHIP SDK’s own TLV encoding behavior (ClusterObjects.ClusterObjectFieldDescriptor.PutFieldToTLV), which raises when a non-optional field is None. Silently dropping a mandatory field can mask caller bugs and may result in malformed partial structs being sent to the server.
for field in cached_fields(type(obj_in)):
value = getattr(obj_in, field.name)
if value is None:
continue
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/ws-controller/test/ConvertersTest.ts:40
- This test description hard-codes a version boundary ("pre-1.3.0") that may not match actual client/server release versioning. Consider rephrasing to simply describe the legacy behavior (name-keyed struct members).
it("falls back to wire field names for pre-1.3.0 python clients that serialized by name", () => {
packages/ws-controller/src/server/Converters.ts:106
- The comment refers to “Python clients before matter-server 1.3.0”, which is ambiguous (server vs client versioning) and may become inaccurate over time. Consider rephrasing to describe the wire format difference without tying it to a specific version number.
// Python clients before matter-server 1.3.0 serialized struct fields by wire field name
// (e.g. "presetHandle") instead of TLV tag, so unrecognized non-numeric keys are resolved
// against the same name that convertMatterToWebSocketNameBased emits.
packages/ws-controller/test/ConvertersTest.ts:28
- The test name bakes in a specific version relationship ("matter-server >=1.3.0 python client") that isn’t enforced by the code and may become misleading. Prefer describing the behavior (tag-keyed struct members) without a version qualifier.
This issue also appears on line 40 of the same file.
it("resolves struct members by numeric TLV tag (matter-server >=1.3.0 python client)", () => {
Type of change
Description
write_attribute()in the Python client passed dataclass values (e.g.list[PresetStruct]for the ThermostatPresets/Schedulesattributes) straight through to the outgoing WebSocket message, relying onorjson's native dataclass serialization.orjsonkeys a dataclass by its Python field name (e.g."presetHandle"), but the server'sWRITE_ATTRIBUTEhandler (convertWebSocketTagBasedToMatterinws-controller) expects struct members keyed by their numeric TLV tag ("0"), matching the format already used for attribute reports.Because of that mismatch, every struct field falls into an "unknown key, keep as-is" fallback that skips type conversion — most importantly,
octstrhandle fields (presetHandle/scheduleHandle) stay as raw base64 strings instead of being decoded to binary. matter.js then rejects the resulting malformed struct withStatus 141(INVALID_DATA_TYPE) when trying to TLV-encode it. This reproduces even for a byte-for-byte unchanged round-trip write (confirmed against a live device), and is unrelated to theAtomicRequestBegin/Commit sequence, which both report success independently.Fix: adds
dataclass_to_tag_dict()(common/helpers/util.py), which recursively converts a dataclass instance — including nested lists, e.g.Schedule.transitions— into a dict keyed by each field'sClusterObjectFieldDescriptor.Tag, and uses it inwrite_attribute().dataclass_to_dict()(field-name-keyed, used bysend_device_commandforDEVICE_COMMANDpayloads) is untouched, since the server's command-invoke path (convertCommandDataToMatter) intentionally expects name-keyed data — the two message types use different wire conventions on the server side.Backing evidence
Issue #957 contains the exact raw WebSocket request/response JSON captured directly from the wire for the failing write (both the malformed name-keyed request and the server's
Status: 141response), plus the precise server-side source lines (Converters.ts,ControllerCommandHandler.ts) that explain why that shape is rejected. That is the complete, reproducible evidence this fix is based on.Checklist
python_client/tests/test_util.py)npm testpasses — not applicable, this change touches onlypython_client/(pure Python), no TypeScript packagesnpm run format-verifyandnpm run lintpass — not applicable for the same reason; ran the Python equivalents instead:pytest python_client/tests/(130 passed) andruff check/ruff format --checkon all changed files (clean, aside from a pre-existing repo-wideCPY001copyright-notice warning present on every file in the project, unrelated to this change)