Skip to content

Commit 11c3d1a

Browse files
raman325claude
andauthored
Add support for schema 46 metadata fields (#1403)
* Add support for schema 46 metadata fields Schema 46 (upstream PR #1501) adds two metadata fields: - `allowed`: list of allowed values for numeric value metadata and configuration metadata, where each entry is either a single value (`{value}`) or an inclusive range (`{from, to, step?}`) - `purpose`: free-form string on configuration metadata `AllowedValue` is exposed as a discriminated union of two frozen dataclasses (`AllowedSingleValue`, `AllowedRangeValue`) so consumers use `isinstance()` to discriminate. The `ValueMetadata.allowed` accessor is a `cached_property` because parsing allocates new dataclass instances; the cache is invalidated in `update()` only when the incoming patch contains the `allowed` key. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address Copilot review: use cast instead of dict copy in allowed parser Copilot flagged that `ValueMetadata.allowed` built a new `dict(entry)` on every iteration (plus a `type: ignore`) inside a method explicitly cached to reduce allocations. Replace with `typing.cast`, which is a runtime no-op, so each iteration now touches the original entry dict directly and drops the `type: ignore`. Only the `if` branch needs an explicit cast — mypy narrows the `else` branch automatically from `"value" in entry` being false. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add from_dict classmethods to AllowedSingleValue/AllowedRangeValue Follow the codebase convention of putting dict-to-dataclass conversion into a `from_dict` classmethod rather than inline in the caller. The `allowed` property accessor now delegates to these methods. No `to_dict` needed — `AllowedValue` is read-only server metadata that is never sent back, matching the event model precedent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix ref to self --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 923e726 commit 11c3d1a

File tree

7 files changed

+136
-6
lines changed

7 files changed

+136
-6
lines changed

test/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ def version_data_fixture() -> dict[str, Any]:
262262
"serverVersion": "test_server_version",
263263
"homeId": "test_home_id",
264264
"minSchemaVersion": 0,
265-
"maxSchemaVersion": 45,
265+
"maxSchemaVersion": 46,
266266
}
267267

268268

test/model/test_value.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
from zwave_js_server.const import ConfigurationValueType, SetValueStatus
66
from zwave_js_server.model.node import Node
77
from zwave_js_server.model.value import (
8+
AllowedRangeValue,
9+
AllowedSingleValue,
810
ConfigurationValue,
911
ConfigurationValueFormat,
1012
MetaDataType,
1113
SetValueResult,
1214
ValueDataType,
15+
ValueMetadata,
1316
get_value_id_str,
1417
)
1518

@@ -77,6 +80,55 @@ def test_secret(lock_schlage_be469):
7780
assert zwave_value.metadata.stateful
7881

7982

83+
def test_metadata_purpose():
84+
"""`purpose` is None by default and exposes the raw string when set."""
85+
assert ValueMetadata(MetaDataType(type="number")).purpose is None
86+
metadata = ValueMetadata(MetaDataType(type="number", purpose="dimmer ramp rate"))
87+
assert metadata.purpose == "dimmer ramp rate"
88+
89+
90+
def test_metadata_allowed_parsing():
91+
"""`allowed` returns None by default and parses both shapes via isinstance."""
92+
assert ValueMetadata(MetaDataType(type="number")).allowed is None
93+
94+
metadata = ValueMetadata(
95+
MetaDataType(
96+
type="number",
97+
allowed=[
98+
{"value": 0},
99+
{"from": 10, "to": 20, "step": 2},
100+
{"from": -5, "to": 5},
101+
],
102+
)
103+
)
104+
allowed = metadata.allowed
105+
assert allowed is not None
106+
assert allowed == [
107+
AllowedSingleValue(value=0),
108+
AllowedRangeValue(from_=10, to=20, step=2),
109+
AllowedRangeValue(from_=-5, to=5, step=None),
110+
]
111+
# Discrimination via isinstance is the public API.
112+
assert isinstance(allowed[0], AllowedSingleValue)
113+
assert isinstance(allowed[1], AllowedRangeValue)
114+
115+
116+
def test_metadata_allowed_cache_invalidation():
117+
"""`allowed` is cached and only re-parsed when `update()` touches the key."""
118+
metadata = ValueMetadata(MetaDataType(type="number", allowed=[{"value": 1}]))
119+
first = metadata.allowed
120+
assert metadata.allowed is first # cache hit
121+
122+
# Updating an unrelated field keeps the cached list.
123+
metadata.update(MetaDataType(label="renamed"))
124+
assert metadata.allowed is first
125+
126+
# Updating the `allowed` key invalidates the cache.
127+
metadata.update(MetaDataType(allowed=[{"from": 0, "to": 5}]))
128+
assert metadata.allowed is not first
129+
assert metadata.allowed == [AllowedRangeValue(from_=0, to=5, step=None)]
130+
131+
80132
def test_configuration_value_type(inovelli_switch_state):
81133
"""Test configuration value types."""
82134
value = ConfigurationValue(

test/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ async def test_additional_user_agent_components(client_session, url):
463463
{
464464
"command": "initialize",
465465
"messageId": "initialize",
466-
"schemaVersion": 45,
466+
"schemaVersion": 46,
467467
"additionalUserAgentComponents": {
468468
"zwave-js-server-python": __version__,
469469
"foo": "bar",

test/test_dump.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async def test_dump_additional_user_agent_components(
106106
{
107107
"command": "initialize",
108108
"messageId": "initialize",
109-
"schemaVersion": 45,
109+
"schemaVersion": 46,
110110
"additionalUserAgentComponents": {
111111
"zwave-js-server-python": __version__,
112112
"foo": "bar",

test/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_dump_state(
5858
assert captured.out == (
5959
"{'type': 'version', 'driverVersion': 'test_driver_version', "
6060
"'serverVersion': 'test_server_version', 'homeId': 'test_home_id', "
61-
"'minSchemaVersion': 0, 'maxSchemaVersion': 45}\n"
61+
"'minSchemaVersion': 0, 'maxSchemaVersion': 46}\n"
6262
"{'type': 'result', 'success': True, 'result': {}, 'messageId': 'initialize'}\n"
6363
"test_result\n"
6464
)

zwave_js_server/const/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# minimal server schema version we can handle
1414
MIN_SERVER_SCHEMA_VERSION = 44
1515
# max server schema version we can handle (and our code is compatible with)
16-
MAX_SERVER_SCHEMA_VERSION = 45
16+
MAX_SERVER_SCHEMA_VERSION = 46
1717

1818
VALUE_UNKNOWN = "unknown"
1919

zwave_js_server/model/value.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
from dataclasses import dataclass, field
66
from enum import IntEnum, StrEnum
7-
from typing import TYPE_CHECKING, Any, TypedDict
7+
from functools import cached_property
8+
from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, cast
89

910
from ..const import (
1011
VALUE_UNKNOWN,
@@ -30,6 +31,54 @@ class ValueType(StrEnum):
3031
STRING = "string"
3132

3233

34+
class AllowedSingleValueDataType(TypedDict):
35+
"""Represent a single allowed value entry (`{value: number}`)."""
36+
37+
value: int
38+
39+
40+
# `from` is a Python keyword, so the functional TypedDict form is required.
41+
AllowedRangeValueDataType = TypedDict(
42+
"AllowedRangeValueDataType",
43+
{"from": int, "to": int, "step": NotRequired[int]},
44+
)
45+
46+
47+
# AllowedValue is a discriminated union: either a single value or an inclusive range.
48+
# The TypeScript type is `{ value: number } | { from: number; to: number; step?: number }`.
49+
AllowedValueDataType = AllowedSingleValueDataType | AllowedRangeValueDataType
50+
51+
52+
@dataclass(frozen=True)
53+
class AllowedSingleValue:
54+
"""A single allowed value for a numeric metadata field (schema 46+)."""
55+
56+
value: int
57+
58+
@classmethod
59+
def from_dict(cls, data: AllowedSingleValueDataType) -> AllowedSingleValue:
60+
"""Initialize from dict."""
61+
return cls(value=data["value"])
62+
63+
64+
@dataclass(frozen=True)
65+
class AllowedRangeValue:
66+
"""An inclusive allowed value range for a numeric metadata field (schema 46+)."""
67+
68+
from_: int
69+
to: int
70+
step: int | None = None
71+
72+
@classmethod
73+
def from_dict(cls, data: AllowedRangeValueDataType) -> Self:
74+
"""Initialize from dict."""
75+
return cls(from_=data["from"], to=data["to"], step=data.get("step"))
76+
77+
78+
# Discriminated union — use isinstance() to differentiate.
79+
AllowedValue = AllowedSingleValue | AllowedRangeValue
80+
81+
3382
class MetaDataType(TypedDict, total=False):
3483
"""Represent a metadata data dict type."""
3584

@@ -48,13 +97,15 @@ class MetaDataType(TypedDict, total=False):
4897
stateful: bool
4998
secret: bool
5099
default: int
100+
allowed: list[AllowedValueDataType] # schema 46+
51101
# Configuration Value specific attributes
52102
valueSize: int
53103
format: int
54104
noBulkSupport: bool # deprecated
55105
isAdvanced: bool
56106
requiresReInclusion: bool
57107
isFromConfig: bool
108+
purpose: str # schema 46+, configuration metadata only
58109

59110

60111
class ValueDataType(TypedDict, total=False):
@@ -184,6 +235,25 @@ def secret(self) -> bool | None:
184235
"""Return secret."""
185236
return self.data.get("secret")
186237

238+
@cached_property
239+
def allowed(self) -> list[AllowedValue] | None:
240+
"""Return allowed values.
241+
242+
Each entry is either an `AllowedSingleValue` or an `AllowedRangeValue`;
243+
use ``isinstance`` to discriminate. Cached because parsing allocates
244+
new dataclass instances; the cache is invalidated by `update()`.
245+
"""
246+
if (raw := self.data.get("allowed")) is None:
247+
return None
248+
return [
249+
(
250+
AllowedSingleValue.from_dict(cast("AllowedSingleValueDataType", entry))
251+
if "value" in entry
252+
else AllowedRangeValue.from_dict(entry)
253+
)
254+
for entry in raw
255+
]
256+
187257
@property
188258
def default(self) -> int | None:
189259
"""Return default."""
@@ -216,9 +286,17 @@ def is_from_config(self) -> bool | None:
216286
"""Return isFromConfig."""
217287
return self.data.get("isFromConfig")
218288

289+
@property
290+
def purpose(self) -> str | None:
291+
"""Return purpose."""
292+
return self.data.get("purpose")
293+
219294
def update(self, data: MetaDataType) -> None:
220295
"""Update data."""
221296
self.data.update(data)
297+
# Invalidate cached_property entries when their backing key changed.
298+
if "allowed" in data:
299+
self.__dict__.pop("allowed", None)
222300

223301

224302
class Value:

0 commit comments

Comments
 (0)