Skip to content

Commit

Permalink
Support custom Clusters and Attributes (#430)
Browse files Browse the repository at this point in the history
Co-authored-by: Ludovic BOUÉ <[email protected]>
  • Loading branch information
marcelveldt and lboue authored Nov 10, 2023
1 parent fd69a5c commit 01c6b2f
Show file tree
Hide file tree
Showing 9 changed files with 323 additions and 119 deletions.
14 changes: 13 additions & 1 deletion matter_server/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,26 @@ async def read_attribute(
node_id: int,
attribute_path: str,
) -> Any:
"""Read a single attribute on a node."""
"""Read attribute(s) on a node."""
return await self.send_command(
APICommand.READ_ATTRIBUTE,
require_schema=4,
node_id=node_id,
attribute_path=attribute_path,
)

async def refresh_attribute(
self,
node_id: int,
attribute_path: str,
) -> Any:
"""Read attribute(s) on a node and store the updated value(s)."""
updated_values = await self.read_attribute(node_id, attribute_path)
if not isinstance(updated_values, dict):
updated_values = {attribute_path: updated_values}
for attr_path, value in updated_values.items():
self._nodes[node_id].update_attribute(attr_path, value)

async def write_attribute(
self,
node_id: int,
Expand Down
159 changes: 159 additions & 0 deletions matter_server/client/models/clusters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Various models and helpers for (custom) Matter clusters."""

from dataclasses import dataclass
from typing import ClassVar

from chip import ChipUtility
from chip.clusters.ClusterObjects import (
Cluster,
ClusterAttributeDescriptor,
ClusterObjectDescriptor,
ClusterObjectFieldDescriptor,
)
from chip.tlv import float32

# pylint: disable=invalid-name,arguments-renamed,no-self-argument


@dataclass
class EveEnergyCluster(Cluster):
"""Custom (vendor-specific) cluster for Eve Energy plug."""

id: ClassVar[int] = 0x130AFC01

@ChipUtility.classproperty
def descriptor(cls) -> ClusterObjectDescriptor:
"""Return descriptor for this cluster."""
return ClusterObjectDescriptor(
Fields=[
ClusterObjectFieldDescriptor(
Label="watt", Tag=0x130A000A, Type=float32
),
ClusterObjectFieldDescriptor(
Label="wattAccumulated", Tag=0x130A000B, Type=float32
),
ClusterObjectFieldDescriptor(
Label="wattAccumulatedControlPoint", Tag=0x130A000E, Type=float32
),
ClusterObjectFieldDescriptor(
Label="voltage", Tag=0x130A0008, Type=float32
),
ClusterObjectFieldDescriptor(
Label="current", Tag=0x130A0009, Type=float32
),
]
)

watt: float32 | None = None
wattAccumulated: float32 | None = None
wattAccumulatedControlPoint: float32 | None = None
voltage: float32 | None = None
current: float32 | None = None

class Attributes:
"""Attributes for the EveEnergy Cluster."""

@dataclass
class Watt(ClusterAttributeDescriptor):
"""Watt Attribute within the EveEnergy Cluster."""

@ChipUtility.classproperty
def cluster_id(cls) -> int:
"""Return cluster id."""
return 0x130AFC01

@ChipUtility.classproperty
def attribute_id(cls) -> int:
"""Return attribute id."""
return 0x130A000A

@ChipUtility.classproperty
def attribute_type(cls) -> ClusterObjectFieldDescriptor:
"""Return attribute type."""
return ClusterObjectFieldDescriptor(Type=float32)

value: float32 = 0

@dataclass
class WattAccumulated(ClusterAttributeDescriptor):
"""WattAccumulated Attribute within the EveEnergy Cluster."""

@ChipUtility.classproperty
def cluster_id(cls) -> int:
"""Return cluster id."""
return 0x130AFC01

@ChipUtility.classproperty
def attribute_id(cls) -> int:
"""Return attribute id."""
return 0x130A000B

@ChipUtility.classproperty
def attribute_type(cls) -> ClusterObjectFieldDescriptor:
"""Return attribute type."""
return ClusterObjectFieldDescriptor(Type=float32)

value: float32 = 0

@dataclass
class wattAccumulatedControlPoint(ClusterAttributeDescriptor):
"""wattAccumulatedControlPoint Attribute within the EveEnergy Cluster."""

@ChipUtility.classproperty
def cluster_id(cls) -> int:
"""Return cluster id."""
return 0x130AFC01

@ChipUtility.classproperty
def attribute_id(cls) -> int:
"""Return attribute id."""
return 0x130A000E

@ChipUtility.classproperty
def attribute_type(cls) -> ClusterObjectFieldDescriptor:
"""Return attribute type."""
return ClusterObjectFieldDescriptor(Type=float32)

value: float32 = 0

@dataclass
class Voltage(ClusterAttributeDescriptor):
"""Voltage Attribute within the EveEnergy Cluster."""

@ChipUtility.classproperty
def cluster_id(cls) -> int:
"""Return cluster id."""
return 0x130AFC01

@ChipUtility.classproperty
def attribute_id(cls) -> int:
"""Return attribute id."""
return 0x130A0008

@ChipUtility.classproperty
def attribute_type(cls) -> ClusterObjectFieldDescriptor:
"""Return attribute type."""
return ClusterObjectFieldDescriptor(Type=float32)

value: float32 = 0

@dataclass
class Current(ClusterAttributeDescriptor):
"""Current Attribute within the EveEnergy Cluster."""

@ChipUtility.classproperty
def cluster_id(cls) -> int:
"""Return cluster id."""
return 0x130AFC01

@ChipUtility.classproperty
def attribute_id(cls) -> int:
"""Return attribute id."""
return 0x130A0009

@ChipUtility.classproperty
def attribute_type(cls) -> ClusterObjectFieldDescriptor:
"""Return attribute type."""
return ClusterObjectFieldDescriptor(Type=float32)

value: float32 = 0
29 changes: 24 additions & 5 deletions matter_server/client/models/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ def device_info(
return self.node.device_info

def has_cluster(self, cluster: type[_CLUSTER_T] | int) -> bool:
"""Check if endpoint has a specific cluster."""
"""
Check if endpoint has a specific cluster.
Provide the cluster to lookup either as the class/type or the id.
"""
if isinstance(cluster, type):
return cluster.id in self.clusters
return cluster in self.clusters
Expand All @@ -105,6 +109,7 @@ def get_cluster(self, cluster: type[_CLUSTER_T] | int) -> _CLUSTER_T | None:
"""
Get a full Cluster object containing all attributes.
Provide the cluster to lookup either as the class/type or the id.
Return None if the Cluster is not present on the node.
"""
if isinstance(cluster, type):
Expand All @@ -115,12 +120,12 @@ def get_attribute_value(
self,
cluster: type[_CLUSTER_T] | int | None,
attribute: int | type[_ATTRIBUTE_T],
) -> type[_ATTRIBUTE_T] | Clusters.ClusterAttributeDescriptor | None:
) -> Any:
"""
Return Matter Cluster Attribute object for given parameters.
Send cluster as None to derive it from the Attribute.,
you must provide the attribute as type/class in that case.
Either supply a cluster id and attribute id or omit cluster
and supply the Attribute class/type.
"""
if cluster is None:
# allow sending None for Cluster to auto resolve it from the Attribute
Expand Down Expand Up @@ -149,7 +154,12 @@ def has_attribute(
cluster: type[_CLUSTER_T] | int | None,
attribute: int | type[_ATTRIBUTE_T],
) -> bool:
"""Perform a quick check if the endpoint has a specific attribute."""
"""
Perform a quick check if the endpoint has a specific attribute.
Either supply a cluster id and attribute id or omit cluster
and supply the Attribute class/type.
"""
if cluster is None:
if isinstance(attribute, int):
raise TypeError("Attribute can not be integer if Cluster is omitted")
Expand All @@ -171,6 +181,15 @@ def set_attribute_value(self, attribute_path: str, attribute_value: Any) -> None
Do not modify the data directly from a consumer.
"""
_, cluster_id, attribute_id = parse_attribute_path(attribute_path)
if (
cluster_id not in ALL_CLUSTERS
or cluster_id not in ALL_ATTRIBUTES
or attribute_id not in ALL_ATTRIBUTES[cluster_id]
):
# guard for unknown/custom clusters/attributes
return
assert cluster_id is not None # for mypy
assert attribute_id is not None # for mypy
cluster_class: type[Clusters.Cluster] = ALL_CLUSTERS[cluster_id]
if cluster_id in self.clusters:
cluster_instance = self.clusters[cluster_id]
Expand Down
2 changes: 1 addition & 1 deletion matter_server/common/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

# schema version is used to determine compatibility between server and client
# bump schema if we add new features and/or make other (breaking) changes
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
12 changes: 1 addition & 11 deletions matter_server/common/helpers/json.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
"""Helpers to work with (de)serializing of json."""

from base64 import b64encode
from dataclasses import is_dataclass
from typing import Any

from chip.clusters.Types import Nullable
from chip.tlv import float32, uint
import orjson

from .util import dataclass_to_dict

JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError)
JSON_DECODE_EXCEPTIONS = (orjson.JSONDecodeError,)

Expand All @@ -27,10 +24,6 @@ def json_encoder_default(obj: Any) -> Any:
return float(obj)
if isinstance(obj, uint):
return int(obj)
if hasattr(obj, "as_dict"):
return obj.as_dict()
if is_dataclass(obj):
return dataclass_to_dict(obj)
if isinstance(obj, Nullable):
return None
if isinstance(obj, bytes):
Expand All @@ -39,17 +32,14 @@ def json_encoder_default(obj: Any) -> Any:
return str(obj)
if type(obj) is type: # pylint: disable=unidiomatic-typecheck
return f"{obj.__module__}.{obj.__qualname__}"

raise TypeError


def json_dumps(data: Any) -> str:
"""Dump json string."""
return orjson.dumps(
data,
option=orjson.OPT_NON_STR_KEYS
| orjson.OPT_INDENT_2
| orjson.OPT_PASSTHROUGH_DATACLASS,
option=orjson.OPT_NON_STR_KEYS | orjson.OPT_INDENT_2,
default=json_encoder_default,
).decode("utf-8")

Expand Down
Loading

0 comments on commit 01c6b2f

Please sign in to comment.