matter-python-client 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ """Matter node."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
9
+
10
+ from chip.clusters import Objects as Clusters
11
+ from chip.clusters.ClusterObjects import ALL_ATTRIBUTES, ALL_CLUSTERS
12
+
13
+ from matter_server.common.helpers.util import (
14
+ create_attribute_path,
15
+ parse_attribute_path,
16
+ parse_value,
17
+ )
18
+
19
+ from .device_types import (
20
+ ALL_TYPES as DEVICE_TYPES,
21
+ Aggregator,
22
+ BridgedNode,
23
+ DeviceType,
24
+ RootNode,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from matter_server.common.models import MatterNodeData
29
+
30
+ LOGGER = logging.getLogger(__name__)
31
+
32
+ # pylint: disable=invalid-name
33
+ _CLUSTER_T = TypeVar("_CLUSTER_T", bound=Clusters.Cluster)
34
+ _ATTRIBUTE_T = TypeVar("_ATTRIBUTE_T", bound=Clusters.ClusterAttributeDescriptor)
35
+ # pylint: enable=invalid-name
36
+
37
+
38
+ def get_object_params(
39
+ descriptor: Clusters.ClusterObjectDescriptor, object_id: int
40
+ ) -> tuple[str, type]:
41
+ """Parse label/key and type for an object from the descriptors, given the raw object id."""
42
+ for desc in descriptor.Fields:
43
+ if desc.Tag == object_id:
44
+ return (desc.Label, desc.Type)
45
+ raise KeyError(f"No descriptor found for object {object_id}")
46
+
47
+
48
+ @dataclass
49
+ class MatterFabricData:
50
+ """Data about a Matter fabric."""
51
+
52
+ fabric_id: int
53
+ vendor_id: int
54
+ fabric_index: int
55
+ fabric_label: str | None = None
56
+ vendor_name: str | None = None
57
+
58
+
59
+ class MatterEndpoint:
60
+ """Representation of a Matter Endpoint."""
61
+
62
+ def __init__(
63
+ self,
64
+ endpoint_id: int,
65
+ attributes_data: dict[str, Any],
66
+ node: MatterNode,
67
+ ) -> None:
68
+ """Initialize MatterEndpoint."""
69
+ self.node = node
70
+ self.endpoint_id = endpoint_id
71
+ self.clusters: dict[int, Clusters.Cluster] = {}
72
+ self.device_types: set[type[DeviceType]] = set()
73
+ self.update(attributes_data)
74
+
75
+ @property
76
+ def is_bridged_device(self) -> bool:
77
+ """Return if this endpoint represents a Bridged device."""
78
+ return BridgedNode in self.device_types
79
+
80
+ @property
81
+ def is_composed_device(self) -> bool:
82
+ """Return if this endpoint belongs to a composed device."""
83
+ return self.node.get_compose_parent(self.endpoint_id) is not None
84
+
85
+ @property
86
+ def device_info(
87
+ self,
88
+ ) -> Clusters.BasicInformation | Clusters.BridgedDeviceBasicInformation:
89
+ """
90
+ Return device info.
91
+
92
+ If this endpoint represents a BridgedDevice, returns BridgedDeviceBasic.
93
+ If this endpoint represents a ComposedDevice, returns the info of the compose device.
94
+ Otherwise, returns BasicInformation from the Node itself (endpoint 0).
95
+ """
96
+ if self.is_bridged_device:
97
+ return self.get_cluster(Clusters.BridgedDeviceBasicInformation)
98
+ if compose_parent := self.node.get_compose_parent(self.endpoint_id):
99
+ return compose_parent.device_info
100
+ return self.node.device_info
101
+
102
+ def has_cluster(self, cluster: type[_CLUSTER_T] | int) -> bool:
103
+ """
104
+ Check if endpoint has a specific cluster.
105
+
106
+ Provide the cluster to lookup either as the class/type or the id.
107
+ """
108
+ if isinstance(cluster, type):
109
+ return cluster.id in self.clusters
110
+ return cluster in self.clusters
111
+
112
+ def get_cluster(self, cluster: type[_CLUSTER_T] | int) -> _CLUSTER_T | None:
113
+ """
114
+ Get a full Cluster object containing all attributes.
115
+
116
+ Provide the cluster to lookup either as the class/type or the id.
117
+ Return None if the Cluster is not present on the node.
118
+ """
119
+ if isinstance(cluster, type):
120
+ return self.clusters.get(cluster.id)
121
+ return self.clusters.get(cluster)
122
+
123
+ def get_attribute_value(
124
+ self,
125
+ cluster: type[_CLUSTER_T] | int | None,
126
+ attribute: int | type[_ATTRIBUTE_T],
127
+ ) -> Any:
128
+ """
129
+ Return Matter Cluster Attribute object for given parameters.
130
+
131
+ Either supply a cluster id and attribute id or omit cluster
132
+ and supply the Attribute class/type.
133
+ """
134
+ if cluster is None:
135
+ # allow sending None for Cluster to auto resolve it from the Attribute
136
+ if isinstance(attribute, int):
137
+ raise TypeError("Attribute can not be integer if Cluster is omitted")
138
+ cluster = attribute.cluster_id
139
+ # get cluster first, grab value from cluster instance next
140
+ if cluster_obj := self.get_cluster(cluster):
141
+ if isinstance(attribute, type):
142
+ attribute_name, _ = get_object_params(
143
+ cluster_obj.descriptor, attribute.attribute_id
144
+ )
145
+ return getattr(cluster_obj, attribute_name)
146
+ # actual value is just a class attribute on the cluster instance
147
+ # NOTE: do not use the value on the ClusterAttribute
148
+ # instance itself as that is not used!
149
+ attribute_name, _ = get_object_params(cluster_obj.descriptor, attribute)
150
+ return getattr(
151
+ cluster_obj,
152
+ attribute_name,
153
+ )
154
+ return None
155
+
156
+ def has_attribute(
157
+ self,
158
+ cluster: type[_CLUSTER_T] | int | None,
159
+ attribute: int | type[_ATTRIBUTE_T],
160
+ ) -> bool:
161
+ """
162
+ Perform a quick check if the endpoint has a specific attribute.
163
+
164
+ Either supply a cluster id and attribute id or omit cluster
165
+ and supply the Attribute class/type.
166
+ """
167
+ if cluster is None:
168
+ if isinstance(attribute, int):
169
+ raise TypeError("Attribute can not be integer if Cluster is omitted")
170
+ # allow sending None for Cluster to auto resolve it from the Attribute
171
+ cluster = attribute.cluster_id
172
+ cluster_id = cluster if isinstance(cluster, int) else cluster.id
173
+ attribute_id = (
174
+ attribute if isinstance(attribute, int) else attribute.attribute_id
175
+ )
176
+ # the fastest way to check this is just by checking the AttributePath in the raw data...
177
+ attr_path = create_attribute_path(self.endpoint_id, cluster_id, attribute_id)
178
+ return attr_path in self.node.node_data.attributes
179
+
180
+ def set_attribute_value(self, attribute_path: str, attribute_value: Any) -> None:
181
+ """
182
+ Set the value of a Cluster Attribute.
183
+
184
+ May only be called by logic that received data from the server.
185
+ Do not modify the data directly from a consumer.
186
+ """
187
+ _, cluster_id, attribute_id = parse_attribute_path(attribute_path)
188
+ if (
189
+ cluster_id not in ALL_CLUSTERS
190
+ or cluster_id not in ALL_ATTRIBUTES
191
+ or attribute_id not in ALL_ATTRIBUTES[cluster_id]
192
+ ):
193
+ # guard for unknown/custom clusters/attributes
194
+ return
195
+ assert cluster_id is not None # for mypy
196
+ assert attribute_id is not None # for mypy
197
+ cluster_class: type[Clusters.Cluster] = ALL_CLUSTERS[cluster_id]
198
+ if cluster_id in self.clusters:
199
+ cluster_instance = self.clusters[cluster_id]
200
+ else:
201
+ cluster_instance = cluster_class()
202
+ self.clusters[cluster_id] = cluster_instance
203
+
204
+ # unpack cluster attribute, using the descriptor
205
+ attribute_class: type[Clusters.ClusterAttributeDescriptor] = ALL_ATTRIBUTES[
206
+ cluster_id
207
+ ][attribute_id]
208
+ attribute_name, attribute_type = get_object_params(
209
+ cluster_class.descriptor, attribute_id
210
+ )
211
+
212
+ # we only set the value at cluster instance level and we leave
213
+ # the underlying Attributes classproperty alone
214
+ attribute_value = parse_value(
215
+ attribute_name, attribute_value, attribute_type, attribute_class().value
216
+ )
217
+ setattr(cluster_instance, attribute_name, attribute_value)
218
+
219
+ def update(self, attributes_data: dict[str, Any]) -> None:
220
+ """Update MatterEndpoint from (endpoint-specific) raw Attributes data."""
221
+ # unwrap cluster and clusterattributes from raw node data attributes
222
+ for attribute_path, attribute_value in attributes_data.items():
223
+ self.set_attribute_value(attribute_path, attribute_value)
224
+ # extract device types from Descriptor Cluster
225
+ if cluster := self.get_cluster(Clusters.Descriptor):
226
+ for dev_info in cluster.deviceTypeList:
227
+ device_type = DEVICE_TYPES.get(dev_info.deviceType)
228
+ if device_type is None:
229
+ LOGGER.debug("Found unknown device type %s", dev_info)
230
+ continue
231
+ self.device_types.add(device_type)
232
+
233
+ def __repr__(self) -> str:
234
+ """Return the representation."""
235
+ return f"<MatterEndpoint {self.endpoint_id} (node {self.node.node_id})>"
236
+
237
+
238
+ class MatterNode:
239
+ """Representation of a Matter Node."""
240
+
241
+ def __init__(self, node_data: MatterNodeData) -> None:
242
+ """Initialize MatterNode from MatterNodeData."""
243
+ self.endpoints: dict[int, MatterEndpoint] = {}
244
+ # composed devices reference to other endpoints through the partsList attribute
245
+ # create a mapping table
246
+ self._composed_endpoints: dict[int, int] = {}
247
+ self.update(node_data)
248
+
249
+ @property
250
+ def node_id(self) -> int:
251
+ """Return Node ID."""
252
+ return self.node_data.node_id
253
+
254
+ @property
255
+ def name(self) -> str | None:
256
+ """Return friendly name for this node."""
257
+ if info := self.device_info:
258
+ return cast(str, info.nodeLabel)
259
+ return None
260
+
261
+ @property
262
+ def available(self) -> bool:
263
+ """Return availability of the node."""
264
+ return self.node_data.available
265
+
266
+ @property
267
+ def device_info(self) -> Clusters.BasicInformation:
268
+ """
269
+ Return device info for this Node.
270
+
271
+ Returns BasicInformation from the Node itself (endpoint 0).
272
+ """
273
+ return self.get_cluster(0, Clusters.BasicInformation)
274
+
275
+ @property
276
+ def is_bridge_device(self) -> bool:
277
+ """Return if this Node is a Bridge/Aggregator device."""
278
+ return self.node_data.is_bridge
279
+
280
+ def get_attribute_value(
281
+ self,
282
+ endpoint: int,
283
+ cluster: type[_CLUSTER_T] | int | None,
284
+ attribute: int | type[_ATTRIBUTE_T],
285
+ ) -> Any:
286
+ """Return Matter Cluster Attribute value for given parameters."""
287
+ return self.endpoints[endpoint].get_attribute_value(cluster, attribute)
288
+
289
+ def has_cluster(
290
+ self, cluster: type[_CLUSTER_T] | int, endpoint: int | None = None
291
+ ) -> bool:
292
+ """Check if node has a specific cluster on any of the endpoints."""
293
+ return any(
294
+ x
295
+ for x in self.endpoints.values()
296
+ if x.has_cluster(cluster)
297
+ and (endpoint is None or x.endpoint_id == endpoint)
298
+ )
299
+
300
+ def get_cluster(
301
+ self, endpoint: int, cluster: type[_CLUSTER_T] | int
302
+ ) -> _CLUSTER_T | None:
303
+ """
304
+ Get a Cluster object containing all attributes.
305
+
306
+ Returns None is the Cluster is not present on the node.
307
+ """
308
+ return self.endpoints[endpoint].get_cluster(cluster)
309
+
310
+ def get_compose_parent(self, endpoint_id: int) -> MatterEndpoint | None:
311
+ """Return endpoint of parent if the endpoint belongs to a Composed device."""
312
+ if parent_id := self._composed_endpoints.get(endpoint_id):
313
+ return self.endpoints[parent_id]
314
+ return None
315
+
316
+ def get_compose_child_ids(self, endpoint_id: int) -> tuple[int, ...] | None:
317
+ """Return endpoint IDs of any child if the endpoint represents a Composed device."""
318
+ return tuple(x for x, y in self._composed_endpoints.items() if y == endpoint_id)
319
+
320
+ def update(self, node_data: MatterNodeData) -> None:
321
+ """Update MatterNode from MatterNodeData."""
322
+ self.node_data = node_data
323
+ # collect per endpoint data
324
+ endpoint_data: dict[int, dict[str, Any]] = {}
325
+ for attribute_path, attribute_data in node_data.attributes.items():
326
+ endpoint_id = int(attribute_path.split("/")[0])
327
+ if endpoint_id not in endpoint_data:
328
+ endpoint_data[endpoint_id] = {}
329
+ endpoint_data[endpoint_id][attribute_path] = attribute_data
330
+ for endpoint_id, attributes_data in endpoint_data.items():
331
+ if endpoint_id in self.endpoints:
332
+ self.endpoints[endpoint_id].update(attributes_data)
333
+ else:
334
+ self.endpoints[endpoint_id] = MatterEndpoint(
335
+ endpoint_id=endpoint_id, attributes_data=attributes_data, node=self
336
+ )
337
+ # composed devices reference to other endpoints through the partsList attribute
338
+ # create a mapping table to quickly map this
339
+ for endpoint in self.endpoints.values():
340
+ if RootNode in endpoint.device_types:
341
+ # ignore root endpoint
342
+ continue
343
+ if Aggregator in endpoint.device_types:
344
+ # ignore Bridge endpoint
345
+ # (as that will also use partsList to indicate its child's)
346
+ continue
347
+ descriptor = endpoint.get_cluster(Clusters.Descriptor)
348
+ if descriptor is None:
349
+ LOGGER.warning(
350
+ "Found endpoint without a Descriptor: Node %s, endpoint %s",
351
+ self.node_id,
352
+ endpoint.endpoint_id,
353
+ )
354
+ continue
355
+ if descriptor.partsList:
356
+ for endpoint_id in descriptor.partsList:
357
+ self._composed_endpoints[endpoint_id] = endpoint.endpoint_id
358
+
359
+ def update_attribute(self, attribute_path: str, new_value: Any) -> None:
360
+ """Handle Attribute value update."""
361
+ endpoint_id = int(attribute_path.split("/")[0])
362
+ if endpoint_id not in self.endpoints:
363
+ # race condition when a bridge is in the process of adding a new endpoint
364
+ return
365
+ self.endpoints[endpoint_id].set_attribute_value(attribute_path, new_value)
366
+
367
+ def __repr__(self) -> str:
368
+ """Return the representation."""
369
+ return f"<MatterNode {self.node_id}>"
370
+
371
+
372
+ class NodeType(Enum):
373
+ """Custom Enum with Matter node types, used for diagnostics."""
374
+
375
+ END_DEVICE = "end_device"
376
+ SLEEPY_END_DEVICE = "sleepy_end_device"
377
+ ROUTING_END_DEVICE = "routing_end_device"
378
+ BRIDGE = "bridge"
379
+ UNKNOWN = "unknown"
380
+
381
+
382
+ class NetworkType(Enum):
383
+ """Custom Enum with Matter network types used for diagnostics."""
384
+
385
+ THREAD = "thread"
386
+ WIFI = "wifi"
387
+ ETHERNET = "ethernet"
388
+ UNKNOWN = "unknown"
389
+
390
+
391
+ @dataclass
392
+ class NodeDiagnostics:
393
+ """
394
+ Representation of a Node diagnostics message.
395
+
396
+ This a custom model intended to be (easily) consumed by Home Assistant
397
+ and constructed from various cluster attribute values.
398
+ """
399
+
400
+ node_id: int
401
+ network_type: NetworkType
402
+ node_type: NodeType
403
+ network_name: str | None # WiFi SSID or Thread network name
404
+ # TODO: rename to ip_addresses in next major version (typo kept for API compatibility)
405
+ ip_adresses: list[str]
406
+ mac_address: str | None
407
+ available: bool
408
+ active_fabrics: list[MatterFabricData]
409
+ active_fabric_index: int
410
+
411
+ @property
412
+ def ip_addresses(self) -> list[str]:
413
+ """Return IP addresses (correctly-spelled alias for ip_adresses)."""
414
+ return self.ip_adresses
@@ -0,0 +1 @@
1
+ """Provide common files for the Matter Server."""
@@ -0,0 +1,8 @@
1
+ """Constants that are shared between server and client."""
2
+
3
+ # schema version is used to determine compatibility between server and client
4
+ # bump schema if we add new features and/or make other (breaking) changes
5
+ SCHEMA_VERSION = 11
6
+
7
+
8
+ VERBOSE_LOG_LEVEL = 5