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.
- matter_python_client-0.4.1.dist-info/METADATA +106 -0
- matter_python_client-0.4.1.dist-info/RECORD +22 -0
- matter_python_client-0.4.1.dist-info/WHEEL +5 -0
- matter_python_client-0.4.1.dist-info/top_level.txt +1 -0
- matter_server/__init__.py +1 -0
- matter_server/client/__init__.py +5 -0
- matter_server/client/client.py +806 -0
- matter_server/client/connection.py +177 -0
- matter_server/client/exceptions.py +63 -0
- matter_server/client/models/__init__.py +1 -0
- matter_server/client/models/device_types.py +952 -0
- matter_server/client/models/node.py +414 -0
- matter_server/common/__init__.py +1 -0
- matter_server/common/const.py +8 -0
- matter_server/common/custom_clusters.py +1371 -0
- matter_server/common/errors.py +94 -0
- matter_server/common/helpers/__init__.py +0 -0
- matter_server/common/helpers/api.py +70 -0
- matter_server/common/helpers/json.py +48 -0
- matter_server/common/helpers/util.py +359 -0
- matter_server/common/models.py +273 -0
- matter_server/py.typed +0 -0
|
@@ -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
|