Skip to content

Commit 997b41a

Browse files
raman325claude
andauthored
Add support for schema 47 state properties and events (1/4) (#1404)
* Add support for schema 47 state properties and events (1/4) Mirrors upstream PR #1509. This is the first of four PRs that together add full schema 47 support; the subsequent three add the new commands (driver, controller/node/endpoint, zniffer/utility/broadcast). Bumps both MIN_SERVER_SCHEMA_VERSION and MAX_SERVER_SCHEMA_VERSION to 47, since HA will rely on the new state properties as load-bearing. State properties added: - Driver.ready / all_nodes_ready / config_version (new Driver.data dict populated from state["driver"], plus Driver.update() symmetric with Controller.update()) - Controller.is_sis / max_payload_size / max_payload_size_lr / zwave_api_version / zwave_chip_type - Node.can_sleep / supports_wake_up_on_demand / hardware_version / has_suc_return_route / manufacturer / dsk Events added: - Driver: bootloader ready, error (with error payload) - Controller: network found (with homeId/ownNodeId), network joined, network left, joining network failed, leaving network failed State mutations on event handlers: - handle_driver_ready -> data["ready"] = True - handle_all_nodes_ready -> data["allNodesReady"] = True - handle_network_found is currently a no-op; whether it should mutate data["homeId"]/["ownNodeId"] from the event payload (vs. waiting for network joined or a fresh state dump) is an open question for upstream review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address Copilot review on #1404 - Rename unused `event` param to `_event` on `handle_error` and `handle_bootloader_ready` for consistency with `handle_driver_ready` / `handle_all_nodes_ready` (which were already renamed in this branch). - Move the `handle_network_found` open design question out of the user-facing docstring and into a TODO code comment. The docstring now documents current behavior succinctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review: unify ZWaveChipType and add test annotations Addresses MartinHjelmare's review comments on #1404: 1. Replace `str | UnknownZWaveChipTypeDataType | None` return type on `Controller.zwave_chip_type` with a unified `ZWaveChipType` frozen dataclass. Known chips set `name`; unknown chips set `type`/`version`. Includes `from_dict` classmethod following the codebase convention. 2. Add complete type annotations (parameter types + `-> None`) to all new test functions in test_driver.py, test_controller.py, and test_node.py. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11c3d1a commit 997b41a

File tree

14 files changed

+436
-15
lines changed

14 files changed

+436
-15
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": 46,
265+
"maxSchemaVersion": 47,
266266
}
267267

268268

test/model/test_controller.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from copy import deepcopy
44
import json
55
import logging
6+
from typing import Any
67
from unittest.mock import patch
78

89
import pytest
910

11+
from zwave_js_server.client import Client
1012
from zwave_js_server.const import (
1113
AssociationCheckResult,
1214
ControllerStatus,
@@ -29,6 +31,7 @@
2931
controller as controller_pkg,
3032
)
3133
from zwave_js_server.model.controller import Controller
34+
from zwave_js_server.model.controller.data_model import ZWaveChipType
3235
from zwave_js_server.model.controller.rebuild_routes import (
3336
RebuildRoutesOptions,
3437
RebuildRoutesStatus,
@@ -2359,3 +2362,82 @@ async def test_inclusion_state_changed(controller):
23592362
)
23602363
controller.receive_event(event)
23612364
assert controller.inclusion_state == InclusionState.INCLUDING
2365+
2366+
2367+
async def test_schema_47_controller_state_properties(
2368+
client: Client, controller_state: dict[str, Any]
2369+
) -> None:
2370+
"""Schema 47+ controller state properties read from the controller state dict."""
2371+
state = {
2372+
"controller": {
2373+
**controller_state["controller"],
2374+
"isSIS": True,
2375+
"maxPayloadSize": 46,
2376+
"maxPayloadSizeLR": 1280,
2377+
"zwaveApiVersion": {"kind": "official", "version": 11},
2378+
"zwaveChipType": "ZW0700",
2379+
},
2380+
"nodes": [],
2381+
}
2382+
ctl = Controller(client, state)
2383+
assert ctl.is_sis is True
2384+
assert ctl.max_payload_size == 46
2385+
assert ctl.max_payload_size_lr == 1280
2386+
assert ctl.zwave_api_version == {"kind": "official", "version": 11}
2387+
assert ctl.zwave_chip_type == ZWaveChipType(name="ZW0700")
2388+
2389+
# Unknown chip type exposes type/version instead of name.
2390+
state["controller"]["zwaveChipType"] = {"type": 7, "version": 0}
2391+
ctl = Controller(client, state)
2392+
assert ctl.zwave_chip_type == ZWaveChipType(type=7, version=0)
2393+
assert ctl.zwave_chip_type.name is None
2394+
2395+
# All schema-47 properties default to None when absent.
2396+
bare = Controller(
2397+
client, {"controller": controller_state["controller"], "nodes": []}
2398+
)
2399+
assert bare.is_sis is None
2400+
assert bare.max_payload_size is None
2401+
assert bare.max_payload_size_lr is None
2402+
assert bare.zwave_api_version is None
2403+
assert bare.zwave_chip_type is None
2404+
2405+
2406+
async def test_schema_47_network_lifecycle_events(controller: Controller) -> None:
2407+
"""The new schema-47 network events are dispatched and observable."""
2408+
received: list[str] = []
2409+
for evt in (
2410+
"network found",
2411+
"network joined",
2412+
"network left",
2413+
"joining network failed",
2414+
"leaving network failed",
2415+
):
2416+
controller.on(evt, lambda data, name=evt: received.append(name))
2417+
2418+
controller.receive_event(
2419+
Event(
2420+
"network found",
2421+
{
2422+
"source": "controller",
2423+
"event": "network found",
2424+
"homeId": 12345,
2425+
"ownNodeId": 1,
2426+
},
2427+
)
2428+
)
2429+
for evt in (
2430+
"network joined",
2431+
"network left",
2432+
"joining network failed",
2433+
"leaving network failed",
2434+
):
2435+
controller.receive_event(Event(evt, {"source": "controller", "event": evt}))
2436+
2437+
assert received == [
2438+
"network found",
2439+
"network joined",
2440+
"network left",
2441+
"joining network failed",
2442+
"leaving network failed",
2443+
]

test/model/test_driver.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
from zwave_js_server.client import Client
910
from zwave_js_server.const import LogLevel
1011
from zwave_js_server.event import Event
1112
from zwave_js_server.model import (
@@ -394,14 +395,16 @@ async def test_unknown_event(driver, caplog):
394395
assert caplog.records[0].levelno == logging.INFO
395396

396397

397-
async def test_all_nodes_ready_event(driver):
398-
"""Test that the all nodes ready event is succesfully validated by pydantic."""
398+
async def test_all_nodes_ready_event(driver: Driver) -> None:
399+
"""`all nodes ready` event marks the driver as having all nodes ready."""
400+
assert driver.all_nodes_ready is None # not set yet
399401
event = Event("all nodes ready", {"source": "driver", "event": "all nodes ready"})
400402
driver.receive_event(event)
403+
assert driver.all_nodes_ready is True
401404

402405

403-
async def test_driver_ready_event(driver):
404-
"""Test that the driver ready event is succesfully validated by pydantic."""
406+
async def test_driver_ready_event(driver: Driver) -> None:
407+
"""`driver ready` event marks the driver as ready and fires listeners."""
405408
event_type = "driver ready"
406409
event_data = {"source": "driver", "event": event_type}
407410
event = Event(event_type, event_data)
@@ -410,7 +413,70 @@ def callback(data: dict) -> None:
410413
assert data == event_data
411414

412415
driver.on(event_type, callback)
416+
assert driver.ready is None # not set yet
413417
driver.receive_event(event)
418+
assert driver.ready is True
419+
420+
421+
async def test_schema_47_driver_state_properties(
422+
client: Client, controller_state: dict[str, Any], log_config: dict[str, Any]
423+
) -> None:
424+
"""Schema 47+ driver state properties read from state["driver"]."""
425+
426+
state = {
427+
**controller_state,
428+
"driver": {
429+
"ready": True,
430+
"allNodesReady": False,
431+
"configVersion": "2026.3.0",
432+
},
433+
}
434+
driver = Driver(client, state, log_config)
435+
assert driver.ready is True
436+
assert driver.all_nodes_ready is False
437+
assert driver.config_version == "2026.3.0"
438+
439+
# Properties default to None when state["driver"] is absent.
440+
bare = Driver(client, controller_state, log_config)
441+
assert bare.ready is None
442+
assert bare.all_nodes_ready is None
443+
assert bare.config_version is None
444+
445+
446+
async def test_driver_update_replaces_data(driver: Driver) -> None:
447+
"""`Driver.update()` replaces the data dict, mirroring Controller.update()."""
448+
driver.update({"ready": True, "allNodesReady": True, "configVersion": "2026.4.0"})
449+
assert driver.ready is True
450+
assert driver.all_nodes_ready is True
451+
assert driver.config_version == "2026.4.0"
452+
453+
454+
async def test_error_event(driver: Driver) -> None:
455+
"""`error` event fires listeners and exposes the error string."""
456+
received: list[str] = []
457+
driver.on("error", lambda data: received.append(data["error"]))
458+
driver.receive_event(
459+
Event(
460+
"error",
461+
{"source": "driver", "event": "error", "error": "boom"},
462+
)
463+
)
464+
assert received == ["boom"]
465+
466+
467+
async def test_bootloader_ready_event(driver: Driver) -> None:
468+
"""`bootloader ready` event fires listeners (observable only, no state)."""
469+
fired = False
470+
471+
def cb(_data: dict) -> None:
472+
nonlocal fired
473+
fired = True
474+
475+
driver.on("bootloader ready", cb)
476+
driver.receive_event(
477+
Event("bootloader ready", {"source": "driver", "event": "bootloader ready"})
478+
)
479+
assert fired
414480

415481

416482
def test_config_manager(driver):

test/model/test_node.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pytest
1212

13+
from zwave_js_server.client import Client
1314
from zwave_js_server.const import (
1415
INTERVIEW_FAILED,
1516
CommandClass,
@@ -2836,3 +2837,34 @@ def callback(data: dict) -> None:
28362837

28372838
node.on(event_type, callback)
28382839
node.receive_event(event)
2840+
2841+
2842+
async def test_schema_47_node_state_properties(
2843+
client: Client, multisensor_6_state: dict[str, Any]
2844+
) -> None:
2845+
"""Schema 47+ node state properties read from the node data dict."""
2846+
enriched_state = {
2847+
**multisensor_6_state,
2848+
"canSleep": True,
2849+
"supportsWakeUpOnDemand": False,
2850+
"hardwareVersion": 3,
2851+
"hasSUCReturnRoute": True,
2852+
"manufacturer": "Aeotec",
2853+
"dsk": "12345-67890-12345-67890-12345-67890-12345-67890",
2854+
}
2855+
node = node_pkg.Node(client, enriched_state)
2856+
assert node.can_sleep is True
2857+
assert node.supports_wake_up_on_demand is False
2858+
assert node.hardware_version == 3
2859+
assert node.has_suc_return_route is True
2860+
assert node.manufacturer == "Aeotec"
2861+
assert node.dsk == "12345-67890-12345-67890-12345-67890-12345-67890"
2862+
2863+
# Properties default to None when absent.
2864+
bare = node_pkg.Node(client, multisensor_6_state)
2865+
assert bare.can_sleep is None
2866+
assert bare.supports_wake_up_on_demand is None
2867+
assert bare.hardware_version is None
2868+
assert bare.has_suc_return_route is None
2869+
assert bare.manufacturer is None
2870+
assert bare.dsk is None

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": 46,
466+
"schemaVersion": 47,
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": 46,
109+
"schemaVersion": 47,
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': 46}\n"
61+
"'minSchemaVersion': 0, 'maxSchemaVersion': 47}\n"
6262
"{'type': 'result', 'success': True, 'result': {}, 'messageId': 'initialize'}\n"
6363
"test_result\n"
6464
)

zwave_js_server/const/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
__version__ = "0.69.0"
1212

1313
# minimal server schema version we can handle
14-
MIN_SERVER_SCHEMA_VERSION = 44
14+
MIN_SERVER_SCHEMA_VERSION = 47
1515
# max server schema version we can handle (and our code is compatible with)
16-
MAX_SERVER_SCHEMA_VERSION = 46
16+
MAX_SERVER_SCHEMA_VERSION = 47
1717

1818
VALUE_UNKNOWN = "unknown"
1919

zwave_js_server/model/controller/__init__.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..association import AssociationAddress, AssociationGroup
2727
from ..node import Node
2828
from ..node.firmware import NodeFirmwareUpdateResult
29-
from .data_model import ControllerDataType
29+
from .data_model import ControllerDataType, ZWaveApiVersionDataType, ZWaveChipType
3030
from .event_model import CONTROLLER_EVENT_MODEL_MAP
3131
from .inclusion_and_provisioning import (
3232
InclusionGrant,
@@ -247,6 +247,33 @@ def supports_long_range(self) -> bool | None:
247247
"""Return whether controller supports long range or not."""
248248
return self.data.get("supportsLongRange")
249249

250+
@property
251+
def is_sis(self) -> bool | None:
252+
"""Return whether the controller is the SIS (Static Information Source)."""
253+
return self.data.get("isSIS")
254+
255+
@property
256+
def max_payload_size(self) -> int | None:
257+
"""Return the maximum Z-Wave payload size in bytes."""
258+
return self.data.get("maxPayloadSize")
259+
260+
@property
261+
def max_payload_size_lr(self) -> int | None:
262+
"""Return the maximum Long Range payload size in bytes."""
263+
return self.data.get("maxPayloadSizeLR")
264+
265+
@property
266+
def zwave_api_version(self) -> ZWaveApiVersionDataType | None:
267+
"""Return the Z-Wave API version (kind + version) supported by the controller."""
268+
return self.data.get("zwaveApiVersion")
269+
270+
@property
271+
def zwave_chip_type(self) -> ZWaveChipType | None:
272+
"""Return the Z-Wave chip type."""
273+
if (raw := self.data.get("zwaveChipType")) is None:
274+
return None
275+
return ZWaveChipType.from_dict(raw)
276+
250277
def update(self, data: ControllerDataType) -> None:
251278
"""Update controller data."""
252279
self.data = data
@@ -1014,3 +1041,20 @@ def handle_status_changed(self, event: Event) -> None:
10141041
"""Process a status changed event."""
10151042
self.data["status"] = event.data["status"]
10161043
event.data["status"] = ControllerStatus(event.data["status"])
1044+
1045+
def handle_network_found(self, _event: Event) -> None:
1046+
"""Process a `network found` event without mutating controller identity."""
1047+
# TODO: Revisit whether `homeId` / `ownNodeId` from this event should
1048+
# update `self.data` once upstream semantics are confirmed.
1049+
1050+
def handle_network_joined(self, event: Event) -> None:
1051+
"""Process a `network joined` event (schema 47+)."""
1052+
1053+
def handle_network_left(self, event: Event) -> None:
1054+
"""Process a `network left` event (schema 47+)."""
1055+
1056+
def handle_joining_network_failed(self, event: Event) -> None:
1057+
"""Process a `joining network failed` event (schema 47+)."""
1058+
1059+
def handle_leaving_network_failed(self, event: Event) -> None:
1060+
"""Process a `leaving network failed` event (schema 47+)."""

0 commit comments

Comments
 (0)