diff --git a/majordom_matter/controller.py b/majordom_matter/controller.py index 3a7ba79..2680ce9 100644 --- a/majordom_matter/controller.py +++ b/majordom_matter/controller.py @@ -14,7 +14,7 @@ from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.device import CredentialsType, Discovery, ProvidedCredentials from majordom_integration_sdk.schemas.event import DeviceParameterChange -from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole +from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole, ParameterVisibility from matter_server.client import MatterClient from matter_server.client.models.node import MatterNode from matter_server.common.errors import UnknownError @@ -272,14 +272,15 @@ async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentia f"(endpoint {endpoint_id}, cluster {cluster_id})" ) value = node.get_attribute_value(endpoint_id, cluster_id, attribute_id) - value = self._mapper.apply_attribute_scale(cluster_id, attribute_id, value) device.parameters.append( - MatterParameterState(**parameter.__dict__, value=self._mapper.normalize_value(value)) + MatterParameterState( + **parameter.__dict__, + value=self._mapper.attribute_value(cluster_id, attribute_id, value), + ) ) - main_parameter_id, default_value = self._get_main_parameter(device.id, node) - main_parameter = next((p for p in device.parameters if p.id == main_parameter_id), None) - device.main_parameter = main_parameter_id if main_parameter is not None else None + main_parameter, default_value = self._get_main_parameter(device.id, node, device.parameters) + device.main_parameter = main_parameter.id if main_parameter is not None else None if main_parameter is not None and default_value is not None: if main_parameter.integration_data.type is MatterParameterTypeEnum.attribute: main_parameter.default_value = self._mapper.normalize_value(default_value) @@ -288,7 +289,7 @@ async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentia main_parameter.integration_data.default_arguments = default_value else: raise MatterUnexpectedError( - f"Command main parameter {main_parameter_id} expected dict default arguments, " + f"Command main parameter {main_parameter.id} expected dict default arguments, " f"got {type(default_value).__name__}: {default_value!r}" ) if main_parameter is not None and main_parameter.data_type is ParameterDataType.struct: @@ -328,7 +329,6 @@ async def fetch(self, device: MatterDevice): continue attribute_id = getattr(attribute, "attribute_id", -1) value = node.get_attribute_value(endpoint_id, cluster_id, attribute_id) - value = self._mapper.apply_attribute_scale(cluster_id, attribute_id, value) parameter_id = self._mapper.attribute_parameter_uuid( device.id, endpoint_id, cluster_id, attribute_id ) @@ -336,7 +336,7 @@ async def fetch(self, device: MatterDevice): DeviceParameterChange( device_id=device.id, parameter_id=parameter_id, - value=value if isinstance(value, str | int | float | bool) else None, + value=self._mapper.attribute_value(cluster_id, attribute_id, value), ) ) @@ -537,8 +537,8 @@ def callback(event_type, new_value): event = DeviceParameterChange( device_id=device_id, parameter_id=parameter_id, - value=self._mapper.normalize_value( - self._mapper.apply_attribute_scale(cluster_id, attribute_id, new_value) + value=self._mapper.attribute_value( + cluster_id, attribute_id, self._mapper.parse_raw_attribute(cluster_id, attribute_id, new_value) ), ) asyncio.create_task(self.dependencies.output.controller_did_receive_events(self, [event])) @@ -578,7 +578,6 @@ async def _execute_cluster_command( self, node, endpoint_id: int, cluster, parameter: MatterParameter, command: DeviceCommand ): command_id = parameter.integration_data.command_id - time_requested_timeout = None if command_id is None or command_id < 0: raise MatterUnexpectedError(f"Invalid command_id: {command_id}") @@ -599,8 +598,7 @@ async def _execute_cluster_command( if not getattr(cmd_class, "is_client", True): continue - if cluster.id == 0x00000101: # DoorLock - time_requested_timeout = 1000 + time_requested_timeout = 1000 if cmd_class.must_use_timed_invoke else None if isinstance(arguments, dict): arguments = self._mapper.arguments_by_name( command.device_id, endpoint_id, cluster.id, command_id, cmd_class, arguments @@ -640,7 +638,12 @@ async def _write_cluster_attribute( value = self._mapper.parse_data_for_attribute(attribute_cls, command.value) if attribute_cls else command.value await self._matter_client.write_attribute(node.node_id, attribute_path, value) - def _get_main_parameter(self, device_id: UUID, node: MatterNode) -> tuple[UUID | None, DefaultParams]: + def _get_main_parameter( + self, device_id: UUID, node: MatterNode, parameters: list[MatterParameterState] + ) -> tuple[MatterParameterState | None, DefaultParams]: + # Only `user` parameters can be main (see can_be_main_parameter, which also needs the + # default_value set from these defaults) — skip hidden ones so they don't shadow a later match. + candidates = {p.id: p for p in parameters if p.visibility is ParameterVisibility.user} for endpoint_id, endpoint in node.endpoints.items(): for cluster_id, spec in MAIN_PARAMETER_BY_CLUSTER.items(): if cluster_id not in endpoint.clusters: @@ -650,21 +653,17 @@ def _get_main_parameter(self, device_id: UUID, node: MatterNode) -> tuple[UUID | supported_attribute_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFFB) or [] if supported_attribute_ids and spec.command_or_attribute_id not in supported_attribute_ids: continue - return ( - self._mapper.attribute_parameter_uuid( - device_id, endpoint_id, cluster_id, spec.command_or_attribute_id - ), - spec.default_params, + parameter_id = self._mapper.attribute_parameter_uuid( + device_id, endpoint_id, cluster_id, spec.command_or_attribute_id ) - - accepted_command_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFF9) or [] - if accepted_command_ids and spec.command_or_attribute_id not in accepted_command_ids: - continue - - return ( - self._mapper.command_parameter_uuid( + else: + accepted_command_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFF9) or [] + if accepted_command_ids and spec.command_or_attribute_id not in accepted_command_ids: + continue + parameter_id = self._mapper.command_parameter_uuid( device_id, endpoint_id, cluster_id, spec.command_or_attribute_id - ), - spec.default_params, - ) + ) + + if parameter_id in candidates: + return candidates[parameter_id], spec.default_params return None, None diff --git a/majordom_matter/mapper.py b/majordom_matter/mapper.py index 7c69508..8f12c25 100644 --- a/majordom_matter/mapper.py +++ b/majordom_matter/mapper.py @@ -4,11 +4,12 @@ import logging from collections.abc import Callable from dataclasses import fields, is_dataclass +from math import floor, log10 from typing import Any, Union, get_args, get_origin, get_type_hints from uuid import UUID from chip.clusters.CHIPClusters import ChipClusters -from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand +from chip.clusters.ClusterObjects import ALL_ATTRIBUTES, ALL_CLUSTERS, ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable, NullValue from chip.tlv import TLVReader from majordom_integration_sdk.schemas.device import CredentialsType @@ -19,18 +20,22 @@ ParameterUnit, ParameterVisibility, ) -from matter_server.client.models.node import MatterNode +from matter_server.client.models.node import MatterNode, get_object_params +from matter_server.common.helpers.util import parse_value from .matter_spec import ( ATTRIBUTE_MIN_STEPS, ATTRIBUTE_SCALE, ATTRIBUTE_UNITS, + COMMAND_FIELD_SCALE, EVERYDAY_COMMANDS, FIELD_TYPE_TO_DATA_TYPE, METADATA_SOURCES, MIN_MAX_VALUE, SYSTEM_ATTRIBUTES, SYSTEM_CLUSTERS, + SYSTEM_COMMANDS, + TEXT_BYTES_FIELDS, AttributeKey, classify_attribute, ) @@ -45,6 +50,20 @@ def _unwrap_optional(field_type: Any) -> Any: return args[0] if args else field_type +def _parse_bytes(raw: Any, text: bool) -> bytes: + """A `string` field is UTF-8 text; a `data` one is base64, as normalize_value emits it.""" + if isinstance(raw, str): + return raw.encode("utf-8") if text else base64.b64decode(raw, validate=True) + return bytes(raw) + + +def _to_wire(raw: Any, scale: float | None) -> Any: + """The inverse of apply_attribute_scale: a display-unit number back to its wire integer.""" + if scale is None or not isinstance(raw, int | float) or isinstance(raw, bool): + return raw + return round(raw / scale) + + class MatterMapper: def __init__( self, @@ -186,9 +205,23 @@ def apply_attribute_scale(self, cluster_id: int, attribute_id: int, value: Any) if is_dataclass(value) and hasattr(value, "energy"): value = value.energy if isinstance(value, (int, float)) and not isinstance(value, bool): - return value * scale + # Round off float noise (2183 * 0.01 = 21.830000000000002) to the scale's decimals. + return round(value * scale, max(0, -floor(log10(scale)))) return value + def parse_raw_attribute(self, cluster_id: int, attribute_id: int, raw: Any) -> Any: + """A subscription update is matter-server's raw JSON (a struct keyed by field id); parse it + into the chip type the node holds, as MatterEndpoint.set_attribute_value does.""" + attribute_class = ALL_ATTRIBUTES.get(cluster_id, {}).get(attribute_id) + if attribute_class is None: + return raw + name, attribute_type = get_object_params(ALL_CLUSTERS[cluster_id].descriptor, attribute_id) + return parse_value(name, raw, attribute_type, attribute_class().value) + + def attribute_value(self, cluster_id: int, attribute_id: int, value: Any) -> Any: + """A raw attribute value as the Hub stores it — one path for pairing, fetch and the subscription.""" + return self.normalize_value(self.apply_attribute_scale(cluster_id, attribute_id, value)) + def resolve_runtime_bounds( self, key, cluster_id: int, endpoint, name: str, default_min, default_max ) -> tuple[Any, Any]: @@ -218,6 +251,15 @@ def resolve_runtime_bounds( ) return min_v, max_v + def resolve_runtime_step(self, key: AttributeKey, endpoint, default: int | float | None) -> int | float | None: + """A parameter's step from the device's own step attribute (in the parameter's wire unit), + else the spec's ATTRIBUTE_MIN_STEPS default.""" + source = METADATA_SOURCES.get(key) + if source is None or source.step_attr is None: + return default + raw = self.normalize_value(endpoint.get_attribute_value(key.cluster_id, source.step_attr)) + return default if raw is None else self.apply_attribute_scale(key.cluster_id, key.attribute_id, raw) + def get_min_max_value(self, attribute, value) -> tuple[int | None, int | None]: """ Encodes the attribute value to TLV to discover its wire type, @@ -245,20 +287,17 @@ def parse_data_for_command(self, cmd_class: type, data: dict) -> dict: continue raw = data[field.name] field_type = _unwrap_optional(hints.get(field.name, field.type)) + key = (getattr(cmd_class, "cluster_id", None), getattr(cmd_class, "command_id", None), field.name) if raw is None: result[field.name] = NullValue elif isinstance(field_type, type) and issubclass(field_type, enum.Enum): result[field.name] = field_type(int(raw)) elif field_type in (bytes, bytearray): - if isinstance(raw, str): - result[field.name] = raw.encode("utf-8") - elif isinstance(raw, (list, tuple)): - result[field.name] = bytes(raw) - else: - result[field.name] = bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw + result[field.name] = _parse_bytes(raw, key in TEXT_BYTES_FIELDS) else: - result[field.name] = raw + scaled = COMMAND_FIELD_SCALE.get(key) + result[field.name] = _to_wire(raw, scaled.scale if scaled else None) return result @@ -282,22 +321,16 @@ def parse_data_for_attribute(self, attribute_cls: type, raw: Any) -> Any: attr_type = getattr(attribute_cls, "attribute_type", None) field_type = getattr(attr_type, "Type", None) if attr_type else None - # Unwrap Optional[X] / Union[X, None] — same as in parse_data_for_command - if get_origin(field_type): - args = [a for a in get_args(field_type) if a is not type(None)] - field_type = args[0] if args else field_type + field_type = _unwrap_optional(field_type) if isinstance(field_type, type) and issubclass(field_type, enum.Enum): return field_type(int(raw)) if field_type in (bytes, bytearray): - if isinstance(raw, str): - return raw.encode("utf-8") - if isinstance(raw, (list, tuple)): - return bytes(raw) - return bytes(raw) if not isinstance(raw, (bytes, bytearray)) else raw + return _parse_bytes(raw, text=False) - return raw + key = AttributeKey(getattr(attribute_cls, "cluster_id", -1), getattr(attribute_cls, "attribute_id", -1)) + return _to_wire(raw, ATTRIBUTE_SCALE.get(key)) # ------------------------------------------------------------------------- # Node parsing: matter SDK cluster data -> MatterParameter @@ -328,7 +361,7 @@ def parse_commands( # Command visibility: system-cluster commands hidden; everyday one-tap actions -> # user; every other command (schedule/credential/log management) -> setting. - if on_system_cluster: + if on_system_cluster or (cluster_id, command_id) in SYSTEM_COMMANDS: visibility = ParameterVisibility.system elif (cluster_id, command_id) in EVERYDAY_COMMANDS: visibility = ParameterVisibility.user @@ -367,12 +400,19 @@ def parse_commands( if matched is not None: data_type = matched + if (cluster_id, command_id, field.name) in TEXT_BYTES_FIELDS: + data_type = ParameterDataType.string + scaled = COMMAND_FIELD_SCALE.get((cluster_id, command_id, field.name)) + if scaled is not None: + data_type = ParameterDataType.decimal + args.append( Parameter( id=self.command_field_uuid(device_id, endpoint_id, cluster_id, command_id, field.name), name=field.name, data_type=data_type, - unit=ParameterUnit.plain, + min_step=scaled.scale if scaled else None, + unit=scaled.unit if scaled else ParameterUnit.plain, role=ParameterRole.control, valid_values=valid_values, visibility=ParameterVisibility.setting, @@ -472,14 +512,16 @@ def parse_attributes( min_value, max_value = self.resolve_runtime_bounds( # priority 1: device's own limit attrs key, cluster_id, endpoint, name, min_value, max_value ) - scale = ATTRIBUTE_SCALE.get(AttributeKey(cluster_id, attribute_id)) - if scale is not None: - min_value = min_value * scale if min_value is not None else None - max_value = max_value * scale if max_value is not None else None + min_value = self.apply_attribute_scale(cluster_id, attribute_id, min_value) + max_value = self.apply_attribute_scale(cluster_id, attribute_id, max_value) value = self.apply_attribute_scale(cluster_id, attribute_id, raw_value) try: - data_type = self.get_parameter_data_type_from_value(self.normalize_value(value)) + data_type = ( + ParameterDataType.data + if _unwrap_optional(getattr(attr_type, "Type", None)) is bytes + else self.get_parameter_data_type_from_value(self.normalize_value(value)) + ) except ValueError: # Value type isn't one we know how to represent yet. Skip just this attribute # rather than aborting the whole pairing — the device stays usable, only this @@ -499,7 +541,7 @@ def parse_attributes( min_value=min_value, max_value=max_value, valid_values=valid_values, - min_step=ATTRIBUTE_MIN_STEPS.get(AttributeKey(cluster_id, attribute_id)), + min_step=self.resolve_runtime_step(key, endpoint, ATTRIBUTE_MIN_STEPS.get(key)), # Our spec table wins; the harvested HA unit fills gaps it leaves plain. unit=ATTRIBUTE_UNITS.get(key, spec.unit or ParameterUnit.plain), role=role, diff --git a/majordom_matter/matter_spec.py b/majordom_matter/matter_spec.py index b42e3ff..ef02b1f 100644 --- a/majordom_matter/matter_spec.py +++ b/majordom_matter/matter_spec.py @@ -54,6 +54,21 @@ 0x40, # FixedLabel 0x41, # UserLabel 0x62, # ScenesManagement + 0x46, # IcdManagement + 0x451, # WiFiNetworkManagement + 0x452, # ThreadBorderRouterManagement + 0x453, # ThreadNetworkDirectory + 0x752, # JointFabricDatastore + 0x753, # JointFabricAdministrator + 0x801, # TlsCertificateManagement + 0x802, # TlsClientManagement + 0xFFF1FC05, # UnitTesting +} + +# Commands hidden on otherwise user-facing clusters. +SYSTEM_COMMANDS: set[tuple[int, int]] = { + (0x101, 0x28), # DoorLock.SetAliroReaderConfig (Aliro crypto material, like the Aliro* attributes) + (0x101, 0x29), # DoorLock.ClearAliroReaderConfig } SYSTEM_ATTRIBUTES: set[int] = { @@ -129,6 +144,32 @@ class AttributeKey(NamedTuple): 0x403, 0x0 ): ParameterUnit.pascal, # PressureMeasurement.MeasuredValue (raw deci-kPa, see ATTRIBUTE_SCALE) AttributeKey(0x2F, 0xC): ParameterUnit.percentage, # PowerSource.BatPercentRemaining + # The remaining temperatures (scaled below); Outdoor/Calibration/OccupiedSetback get celsius from the HA harvest. + AttributeKey(0x201, 0x3): ParameterUnit.celsius, # Thermostat.AbsMinHeatSetpointLimit + AttributeKey(0x201, 0x4): ParameterUnit.celsius, # Thermostat.AbsMaxHeatSetpointLimit + AttributeKey(0x201, 0x5): ParameterUnit.celsius, # Thermostat.AbsMinCoolSetpointLimit + AttributeKey(0x201, 0x6): ParameterUnit.celsius, # Thermostat.AbsMaxCoolSetpointLimit + AttributeKey(0x201, 0x13): ParameterUnit.celsius, # Thermostat.UnoccupiedCoolingSetpoint + AttributeKey(0x201, 0x14): ParameterUnit.celsius, # Thermostat.UnoccupiedHeatingSetpoint + AttributeKey(0x201, 0x15): ParameterUnit.celsius, # Thermostat.MinHeatSetpointLimit + AttributeKey(0x201, 0x16): ParameterUnit.celsius, # Thermostat.MaxHeatSetpointLimit + AttributeKey(0x201, 0x17): ParameterUnit.celsius, # Thermostat.MinCoolSetpointLimit + AttributeKey(0x201, 0x18): ParameterUnit.celsius, # Thermostat.MaxCoolSetpointLimit + AttributeKey(0x201, 0x19): ParameterUnit.celsius, # Thermostat.MinSetpointDeadBand + AttributeKey(0x201, 0x31): ParameterUnit.celsius, # Thermostat.SetpointChangeAmount + AttributeKey(0x201, 0x35): ParameterUnit.celsius, # Thermostat.OccupiedSetbackMin + AttributeKey(0x201, 0x36): ParameterUnit.celsius, # Thermostat.OccupiedSetbackMax + AttributeKey(0x201, 0x37): ParameterUnit.celsius, # Thermostat.UnoccupiedSetback + AttributeKey(0x201, 0x38): ParameterUnit.celsius, # Thermostat.UnoccupiedSetbackMin + AttributeKey(0x201, 0x39): ParameterUnit.celsius, # Thermostat.UnoccupiedSetbackMax + AttributeKey(0x201, 0x3A): ParameterUnit.celsius, # Thermostat.EmergencyHeatDelta + AttributeKey(0x201, 0x46): ParameterUnit.celsius, # Thermostat.ACCoilTemperature + AttributeKey(0x56, 0x1): ParameterUnit.celsius, # TemperatureControl.MinTemperature + AttributeKey(0x56, 0x2): ParameterUnit.celsius, # TemperatureControl.MaxTemperature + AttributeKey(0x56, 0x3): ParameterUnit.celsius, # TemperatureControl.Step + AttributeKey(0x402, 0x1): ParameterUnit.celsius, # TemperatureMeasurement.MinMeasuredValue + AttributeKey(0x402, 0x2): ParameterUnit.celsius, # TemperatureMeasurement.MaxMeasuredValue + AttributeKey(0x402, 0x3): ParameterUnit.celsius, # TemperatureMeasurement.Tolerance } # Multiplier applied to a raw attribute value to convert it into ATTRIBUTE_UNITS' base unit @@ -140,6 +181,40 @@ class AttributeKey(NamedTuple): AttributeKey(0x91, 0x1): 1e-6, # ElectricalEnergyMeasurement.CumulativeEnergyImported: mWh -> kWh AttributeKey(0x404, 0x0): 0.1, # FlowMeasurement.MeasuredValue: deci-m3/h -> m3/h AttributeKey(0x403, 0x0): 100, # PressureMeasurement.MeasuredValue: deci-kPa -> Pa + # Matter's int16 temperatures are 0.01 °C; the 8-bit Signed/UnsignedTemperature ones are 0.1 °C. + AttributeKey(0x201, 0x0): 0.01, # Thermostat.LocalTemperature + AttributeKey(0x201, 0x1): 0.01, # Thermostat.OutdoorTemperature + AttributeKey(0x201, 0x3): 0.01, # Thermostat.AbsMinHeatSetpointLimit + AttributeKey(0x201, 0x4): 0.01, # Thermostat.AbsMaxHeatSetpointLimit + AttributeKey(0x201, 0x5): 0.01, # Thermostat.AbsMinCoolSetpointLimit + AttributeKey(0x201, 0x6): 0.01, # Thermostat.AbsMaxCoolSetpointLimit + AttributeKey(0x201, 0x10): 0.1, # Thermostat.LocalTemperatureCalibration (int8) + AttributeKey(0x201, 0x11): 0.01, # Thermostat.OccupiedCoolingSetpoint + AttributeKey(0x201, 0x12): 0.01, # Thermostat.OccupiedHeatingSetpoint + AttributeKey(0x201, 0x13): 0.01, # Thermostat.UnoccupiedCoolingSetpoint + AttributeKey(0x201, 0x14): 0.01, # Thermostat.UnoccupiedHeatingSetpoint + AttributeKey(0x201, 0x15): 0.01, # Thermostat.MinHeatSetpointLimit + AttributeKey(0x201, 0x16): 0.01, # Thermostat.MaxHeatSetpointLimit + AttributeKey(0x201, 0x17): 0.01, # Thermostat.MinCoolSetpointLimit + AttributeKey(0x201, 0x18): 0.01, # Thermostat.MaxCoolSetpointLimit + AttributeKey(0x201, 0x19): 0.1, # Thermostat.MinSetpointDeadBand (int8) + AttributeKey(0x201, 0x31): 0.01, # Thermostat.SetpointChangeAmount (TemperatureDifference) + AttributeKey(0x201, 0x34): 0.1, # Thermostat.OccupiedSetback (uint8) + AttributeKey(0x201, 0x35): 0.1, # Thermostat.OccupiedSetbackMin (uint8) + AttributeKey(0x201, 0x36): 0.1, # Thermostat.OccupiedSetbackMax (uint8) + AttributeKey(0x201, 0x37): 0.1, # Thermostat.UnoccupiedSetback (uint8) + AttributeKey(0x201, 0x38): 0.1, # Thermostat.UnoccupiedSetbackMin (uint8) + AttributeKey(0x201, 0x39): 0.1, # Thermostat.UnoccupiedSetbackMax (uint8) + AttributeKey(0x201, 0x3A): 0.1, # Thermostat.EmergencyHeatDelta (uint8) + AttributeKey(0x201, 0x46): 0.01, # Thermostat.ACCoilTemperature + AttributeKey(0x56, 0x0): 0.01, # TemperatureControl.TemperatureSetpoint + AttributeKey(0x56, 0x1): 0.01, # TemperatureControl.MinTemperature + AttributeKey(0x56, 0x2): 0.01, # TemperatureControl.MaxTemperature + AttributeKey(0x56, 0x3): 0.01, # TemperatureControl.Step + AttributeKey(0x402, 0x0): 0.01, # TemperatureMeasurement.MeasuredValue + AttributeKey(0x402, 0x1): 0.01, # TemperatureMeasurement.MinMeasuredValue + AttributeKey(0x402, 0x2): 0.01, # TemperatureMeasurement.MaxMeasuredValue + AttributeKey(0x402, 0x3): 0.01, # TemperatureMeasurement.Tolerance } @@ -148,11 +223,21 @@ class AttributeKey(NamedTuple): AttributeKey(0x0300, 0x0000): 1, # CurrentHue (verified: id 0x0000, uint8, step 1) AttributeKey(0x0300, 0x0001): 1, # CurrentSaturation (verified: id 0x0001, uint8, step 1) AttributeKey(0x0300, 0x0007): 1, # ColorTemperatureMireds (verified: id 0x0007, uint16, step 1) - AttributeKey(0x0201, 0x0010): 0.01, # LocalTemperatureCalibration (SignedTemperature, -2.5°C to 2.5°C) + AttributeKey(0x0201, 0x0010): 0.1, # LocalTemperatureCalibration (SignedTemperature, -2.5°C to 2.5°C) AttributeKey(0x0201, 0x0011): 0.01, # OccupiedCoolingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0012): 0.01, # OccupiedHeatingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0013): 0.01, # UnoccupiedCoolingSetpoint (temperature type, 0.01°C resolution) AttributeKey(0x0201, 0x0014): 0.01, # UnoccupiedHeatingSetpoint (temperature type, 0.01°C resolution) + AttributeKey(0x0201, 0x0015): 0.01, # MinHeatSetpointLimit (temperature type, 0.01°C resolution) + AttributeKey(0x0201, 0x0016): 0.01, # MaxHeatSetpointLimit (temperature type, 0.01°C resolution) + AttributeKey(0x0201, 0x0017): 0.01, # MinCoolSetpointLimit (temperature type, 0.01°C resolution) + AttributeKey(0x0201, 0x0018): 0.01, # MaxCoolSetpointLimit (temperature type, 0.01°C resolution) + AttributeKey(0x0201, 0x0019): 0.1, # MinSetpointDeadBand (SignedTemperature, 0.1°C resolution) + AttributeKey(0x0201, 0x0034): 0.1, # OccupiedSetback (UnsignedTemperature, 0.1°C resolution) + AttributeKey(0x0201, 0x0037): 0.1, # UnoccupiedSetback (UnsignedTemperature, 0.1°C resolution) + AttributeKey( + 0x0056, 0x0000 + ): 0.01, # TemperatureSetpoint (temperature type; the device's Step wins, see METADATA_SOURCES) AttributeKey(0x0402, 0x0000): 0.01, # MeasuredValue (temperature type, 0.01°C resolution) AttributeKey(0x0405, 0x0000): 0.01, # MeasuredValue (0.01% resolution) AttributeKey(0x0403, 0x0000): 10, # MeasuredValue (raw step 0.1 deci-kPa, scaled x100 to Pa -> 10 Pa) @@ -164,12 +249,13 @@ class AttributeKey(NamedTuple): class MetadataSource(NamedTuple): - """Sibling attributes whose runtime VALUES provide a parameter's min/max — the device's own + """Sibling attributes whose runtime VALUES provide a parameter's min/max/step — the device's own limit attributes (the ones we hide from the UI as metadata). Priority 1 in the resolver: runtime sibling value > spec table > wire-type default.""" min_attr: int | None = None max_attr: int | None = None + step_attr: int | None = None METADATA_SOURCES: dict[AttributeKey, MetadataSource] = { @@ -182,6 +268,7 @@ class MetadataSource(NamedTuple): AttributeKey(0x201, 0x11): MetadataSource(0x05, 0x06), # OccupiedCoolingSetpoint <- AbsMin/MaxCoolSetpointLimit AttributeKey(0x201, 0x12): MetadataSource(0x03, 0x04), # OccupiedHeatingSetpoint <- AbsMin/MaxHeatSetpointLimit AttributeKey(0x300, 0x07): MetadataSource(0x400B, 0x400C), # ColorTemperatureMireds <- physical min/max mireds + AttributeKey(0x056, 0x00): MetadataSource(0x01, 0x02, 0x03), # TemperatureSetpoint <- Min/MaxTemperature, Step } @@ -190,6 +277,31 @@ class MetadataSource(NamedTuple): int: ParameterDataType.integer, float: ParameterDataType.decimal, str: ParameterDataType.string, + bytes: ParameterDataType.data, +} + + +class ScaledField(NamedTuple): + """A command field sent in a scaled wire unit, like ATTRIBUTE_SCALE: the user enters (and + default_params give) `unit`; the wire value is that divided by `scale`.""" + + unit: ParameterUnit + scale: float + + +COMMAND_FIELD_SCALE: dict[tuple[int, int, str], ScaledField] = { + (0x56, 0x0, "targetTemperature"): ScaledField(ParameterUnit.celsius, 0.01), # TemperatureControl.SetTemperature + (0x201, 0x0, "amount"): ScaledField(ParameterUnit.celsius, 0.1), # Thermostat.SetpointRaiseLower +} + +# `bytes` command fields the user types as text: a UTF-8 `string` instead of base64 `data`. +TEXT_BYTES_FIELDS: set[tuple[int, int, str]] = { + (0x101, 0x00, "PINCode"), # DoorLock.LockDoor + (0x101, 0x01, "PINCode"), # DoorLock.UnlockDoor + (0x101, 0x03, "PINCode"), # DoorLock.UnlockWithTimeout + (0x101, 0x27, "PINCode"), # DoorLock.UnboltDoor + (0x101, 0x22, "credentialData"), # DoorLock.SetCredential (a PIN; RFID/biometrics are enrolled at the lock) + (0x802, 0x00, "hostname"), # TlsClientManagement.ProvisionEndpoint } @@ -291,6 +403,11 @@ class MetadataSource(NamedTuple): (0x506, 0x0), (0x506, 0x1), (0x506, 0x2), # MediaPlayback Play / Pause / Stop + (0x104, 0x0), + (0x104, 0x1), # ClosureControl Stop / MoveTo + (0x556, 0x0), # Chime PlayChimeSound + (0x081, 0x0), + (0x081, 0x1), # ValveConfigurationAndControl Open / Close } @@ -319,18 +436,20 @@ class MainParameterSpec(NamedTuple): 0x00000202: MainParameterSpec(0x00000000, 0x04), # FanControl.FanMode.On(attribute) 0x00000056: MainParameterSpec( 0x00000000, - {"targetTemperature": 22}, + {"targetTemperature": 22}, # °C, see COMMAND_FIELD_SCALE # TemperatureControl.SetTemperature (targetTemperature/targetTemperatureLevel are # feature-gated & mutually exclusive; targetTemperature covers the common "TN" case) ), 0x00000060: MainParameterSpec(0x00000002, None), # OperationalState.Start 0x00000061: MainParameterSpec(0x00000003, None), # RVCOperationalState.Resume - 0x0000005F: MainParameterSpec(0x00000001, {"timeToAdd": 30}), # MicrowaveOvenControl.AddMoreTime + # Unverified: OperationalState.Start wins on a Microwave Oven anyway; to verify on real devices. + # 0x0000005F: MainParameterSpec(0x00000001, {"timeToAdd": 30}), # MicrowaveOvenControl.AddMoreTime 0x00000050: MainParameterSpec(0x00000000, {"newMode": 0}), # ModeSelect.ChangeToMode 0x00000101: MainParameterSpec(0x00000001, {"PINCode": None}), # DoorLock.UnlockDoor # 0x0000005C: MainParameterSpec(0x00000000, None), # SmokeCoAlarm.SelfTestRequest 0x00000506: MainParameterSpec(0x00000000, None), # MediaPlayback.Play - 0x00000509: MainParameterSpec(0x00000000, {"keyCode": 0x44}), # KeyPadInpud.SendKey.Play + # Unverified: a raw remote-key sender, shadowed by OnOff/MediaPlayback; to verify on real devices. + # 0x00000509: MainParameterSpec(0x00000000, {"keyCode": 0x44}), # KeyPadInpud.SendKey.Play 0x00000102: MainParameterSpec( 0x00000000, None, @@ -343,11 +462,14 @@ class MainParameterSpec(NamedTuple): # ClosureControl.MoveTo{position=MoveToFullyOpen} — open is the everyday one-tap; # ClosureControl has no no-arg Open command (a latched closure may also need latch=False) ), - 0x00000099: MainParameterSpec(0x00000001, None), # EnergyEvse.Disable - 0x0000009E: MainParameterSpec(0x00000000, {"newMode": 0}), # WaterHeaterMode.ChangeToMode - 0x00000553: MainParameterSpec( - 0x00000000, {"streamUsage": 0, "originatingEndpointID": 0} - ), # WebRTCTransportProvider.SolicitOffer + # Unverified: one-way (EnableCharging needs current limits) and stops a car charge; to verify on real devices. + # 0x00000099: MainParameterSpec(0x00000001, None), # EnergyEvse.Disable + # Unverified: mode 0 is vendor-defined and may not exist; to verify on real devices. + # 0x0000009E: MainParameterSpec(0x00000000, {"newMode": 0}), # WaterHeaterMode.ChangeToMode + # Unverified: needs a WebRTC requestor session on the hub; to verify on real devices. + # 0x00000553: MainParameterSpec( + # 0x00000000, {"streamUsage": 0, "originatingEndpointID": 0} + # ), # WebRTCTransportProvider.SolicitOffer 0x00000556: MainParameterSpec(0x00000000, None), # Chime.PlayChimeSound 0x00000081: MainParameterSpec(0x00000000, None), # ValveConfigurationAndControl.Open } diff --git a/tests/test_command_parameters.py b/tests/test_command_parameters.py index c4ff422..e82dc5b 100644 --- a/tests/test_command_parameters.py +++ b/tests/test_command_parameters.py @@ -1,9 +1,13 @@ """Command -> Parameter mapping: a command with arguments is a `struct` whose `fields` are its arguments, and its value is keyed by those sub-parameter ids (see the SDK's ParameterDataType).""" -from typing import cast +import base64 +import inspect +from typing import cast, get_type_hints from uuid import UUID, uuid4, uuid5 +from chip.clusters import Objects +from chip.clusters.ClusterObjects import Cluster, ClusterCommand from chip.clusters.Objects import ( AccessControl, ApplicationLauncher, @@ -13,11 +17,18 @@ Messages, OnOff, ServiceArea, + TemperatureControl, + TemperatureMeasurement, + Thermostat, + ThreadNetworkDirectory, + TlsClientManagement, ) -from majordom_integration_sdk.schemas.parameter import ParameterDataType +from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterUnit, ParameterVisibility from matter_server.client.models.node import MatterNode -from majordom_matter.mapper import MatterMapper +from majordom_matter.mapper import MatterMapper, _unwrap_optional +from majordom_matter.matter_spec import ATTRIBUTE_UNITS, MAIN_PARAMETER_BY_CLUSTER, AttributeKey +from majordom_matter.matter_spec_ha import MATTER_HA_ATTRIBUTE_UX DEVICE_ID = UUID(int=1) @@ -81,3 +92,163 @@ def test_list_argument_round_trips_as_is(): command = CameraAvStreamManagement.Commands.SetStreamPriorities data = _mapper().parse_data_for_command(command, {"streamPriorities": [1, 2]}) assert data == {"streamPriorities": [1, 2]} + + +def test_bytes_argument_is_base64_data(): + command = Thermostat.Commands.SetActivePresetRequest + assert _field(Thermostat, command, "presetHandle").data_type == ParameterDataType.data + data = _mapper().parse_data_for_command(command, {"presetHandle": base64.b64encode(b"\x00\xff").decode()}) + assert data == {"presetHandle": b"\x00\xff"} + + +def _pin_code_commands() -> list[tuple[type[Cluster], type]]: + return [ + (cluster, command) + for _, cluster in inspect.getmembers(Objects, inspect.isclass) + if issubclass(cluster, Cluster) and hasattr(cluster, "Commands") + for _, command in inspect.getmembers(cluster.Commands, inspect.isclass) + if issubclass(command, ClusterCommand) + and getattr(command, "is_client", True) + and _unwrap_optional(get_type_hints(command).get("PINCode")) is bytes + ] + + +def test_typed_bytes_arguments_are_utf8_text(): + typed = [(cluster, command, "PINCode") for cluster, command in _pin_code_commands()] + assert len(typed) >= 4 # DoorLock Lock/Unlock/UnlockWithTimeout/Unbolt + typed += [ + (DoorLock, DoorLock.Commands.SetCredential, "credentialData"), + (TlsClientManagement, TlsClientManagement.Commands.ProvisionEndpoint, "hostname"), + ] + for cluster, command, name in typed: + assert _field(cluster, command, name).data_type == ParameterDataType.string, (command, name) + assert _mapper().parse_data_for_command(command, {name: "1234"}) == {name: b"1234"} + + +def test_bytes_attribute_is_data_and_round_trips(): + attribute = ThreadNetworkDirectory.Attributes.PreferredExtendedPanID + raw = b"\xde\xad\x00\xbe\xef\x00\x01\x02" + + class _Node: + def get_attribute_value(self, endpoint_id, cluster_id, attribute_id): + return [attribute.attribute_id] if attribute_id == 0xFFFB else None + + class _Endpoint: + def get_attribute_value(self, cluster_id, attribute_id): + return raw + + mapper = _mapper() + params = mapper.parse_attributes( + DEVICE_ID, 1, ThreadNetworkDirectory.id, ThreadNetworkDirectory, _Endpoint(), cast(MatterNode, _Node()) + ) + assert [p.data_type for p in params] == [ParameterDataType.data] + assert mapper.parse_data_for_attribute(attribute, mapper.normalize_value(raw)) == raw + + +def test_infrastructure_commands_are_hidden(): + clusters = ( + Objects.IcdManagement, + Objects.WiFiNetworkManagement, + Objects.ThreadBorderRouterManagement, + ThreadNetworkDirectory, + Objects.JointFabricDatastore, + Objects.JointFabricAdministrator, + Objects.TlsCertificateManagement, + TlsClientManagement, + Objects.UnitTesting, + ) + for cluster in clusters: + params = _mapper().parse_commands(DEVICE_ID, 1, cluster.id, cluster, cast(MatterNode, _FakeNode())) + assert params and {p.visibility for p in params} == {ParameterVisibility.system}, cluster.__name__ + + +def test_aliro_reader_commands_are_hidden(): + for command in (DoorLock.Commands.SetAliroReaderConfig, DoorLock.Commands.ClearAliroReaderConfig): + assert _command(DoorLock, command).visibility == ParameterVisibility.system + assert _command(DoorLock, DoorLock.Commands.UnlockDoor).visibility == ParameterVisibility.user + + +# Matter's int16 temperature types are 0.01 °C; the 8-bit Signed/UnsignedTemperature ones are 0.1 °C. +TEMPERATURE_SCALES: dict[AttributeKey, float] = { + **{ + AttributeKey(Thermostat.id, attribute_id): 0.01 + for attribute_id in (0x00, 0x01, 0x03, 0x04, 0x05, 0x06, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x31) + }, + AttributeKey(Thermostat.id, 0x46): 0.01, # ACCoilTemperature + **{ + AttributeKey(Thermostat.id, attribute_id): 0.1 + for attribute_id in (0x10, 0x19, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A) + }, + **{AttributeKey(TemperatureControl.id, attribute_id): 0.01 for attribute_id in (0x00, 0x01, 0x02, 0x03)}, + **{AttributeKey(TemperatureMeasurement.id, attribute_id): 0.01 for attribute_id in (0x00, 0x01, 0x02, 0x03)}, +} + + +def test_temperature_attributes_read_and_write_in_celsius(): + mapper = _mapper() + celsius = {key for key, unit in ATTRIBUTE_UNITS.items() if unit is ParameterUnit.celsius} | { + AttributeKey(*key) for key, (_visibility, _role, unit) in MATTER_HA_ATTRIBUTE_UX.items() if unit == "celsius" + } + assert celsius == TEMPERATURE_SCALES.keys() + for key, scale in TEMPERATURE_SCALES.items(): + raw, expected = (3, 0.3) if scale == 0.1 else (2183, 21.83) + assert mapper.apply_attribute_scale(key.cluster_id, key.attribute_id, raw) == expected, key + + assert mapper.parse_data_for_attribute(Thermostat.Attributes.OccupiedHeatingSetpoint, 21.83) == 2183 + assert mapper.parse_data_for_attribute(Thermostat.Attributes.UnoccupiedHeatingSetpoint, 21.83) == 2183 + assert mapper.parse_data_for_attribute(Thermostat.Attributes.LocalTemperatureCalibration, -0.3) == -3 + assert mapper.parse_data_for_attribute(Thermostat.Attributes.MinSetpointDeadBand, 0.3) == 3 + + +def _temperature_setpoint(values: dict[int, int]): + class _Node: + def get_attribute_value(self, endpoint_id, cluster_id, attribute_id): + return list(values) if attribute_id == 0xFFFB else None + + class _Endpoint: + def get_attribute_value(self, cluster_id, attribute_id): + return values.get(attribute_id) + + params = _mapper().parse_attributes( + DEVICE_ID, 1, TemperatureControl.id, TemperatureControl, _Endpoint(), cast(MatterNode, _Node()) + ) + return next(p for p in params if p.integration_data.attribute_id == 0x00) + + +def test_temperature_setpoint_takes_bounds_and_step_from_the_device(): + param = _temperature_setpoint({0x00: 2200, 0x01: 1600, 0x02: 3000, 0x03: 50}) + assert (param.min_value, param.max_value, param.min_step, param.unit) == (16, 30, 0.5, ParameterUnit.celsius) + assert _temperature_setpoint({0x00: 2200}).min_step == 0.01 + + +def test_temperature_bounds_are_celsius(): + values = {0x12: 2000, 0x03: 700, 0x04: 3000} # OccupiedHeatingSetpoint <- AbsMin/MaxHeatSetpointLimit + + class _Node: + def get_attribute_value(self, endpoint_id, cluster_id, attribute_id): + return [0x12] if attribute_id == 0xFFFB else None + + class _Endpoint: + def get_attribute_value(self, cluster_id, attribute_id): + return values.get(attribute_id) + + [param] = _mapper().parse_attributes( + DEVICE_ID, 1, Thermostat.id, Thermostat, _Endpoint(), cast(MatterNode, _Node()) + ) + assert (param.min_value, param.max_value, param.unit) == (7, 30, ParameterUnit.celsius) + + +def test_temperature_command_fields_are_celsius(): + set_temperature = TemperatureControl.Commands.SetTemperature + field = _field(TemperatureControl, set_temperature, "targetTemperature") + assert (field.data_type, field.unit) == (ParameterDataType.decimal, ParameterUnit.celsius) + default = MAIN_PARAMETER_BY_CLUSTER[TemperatureControl.id].default_params + assert isinstance(default, dict) + assert _mapper().parse_data_for_command(set_temperature, default) == {"targetTemperature": 2200} + assert _mapper().parse_data_for_command(set_temperature, {"targetTemperature": 21.83}) == { + "targetTemperature": 2183 + } + + raise_lower = Thermostat.Commands.SetpointRaiseLower # amount is in 0.1 °C steps + assert _field(Thermostat, raise_lower, "amount").unit == ParameterUnit.celsius + assert _mapper().parse_data_for_command(raise_lower, {"mode": 0, "amount": 1.5})["amount"] == 15 diff --git a/tests/test_controller_stub.py b/tests/test_controller_stub.py index 188ac36..773508a 100644 --- a/tests/test_controller_stub.py +++ b/tests/test_controller_stub.py @@ -340,3 +340,102 @@ async def test_pairing_code_of_unknown_length_is_rejected(controller, value: str await _pair(ctrl, discovery, ProvidedCredentials(type=CredentialsType.code, value=value)) with_code.assert_not_awaited() on_network.assert_not_awaited() + + +async def test_timed_invoke_follows_the_command(controller, monkeypatch): + from chip.clusters.Objects import AdministratorCommissioning, OnOff + + ctrl, _output = controller + device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) + async with ctrl.dependencies.make_device_repository() as repo: + device = await repo.get(device_id, as_=MatterDevice) + state = await repo.state(device_id, MatterDeviceState) + timeouts: list[int | None] = [] + + async def send_device_command(node_id, endpoint_id, command, timed_request_timeout_ms=None, **kwargs): + timeouts.append(timed_request_timeout_ms) + + monkeypatch.setattr(ctrl._matter_client, "send_device_command", send_device_command) + for endpoint_id, cluster_id, command_id in ( + (0, AdministratorCommissioning.id, AdministratorCommissioning.Commands.RevokeCommissioning.command_id), + (13, OnOff.id, OnOff.Commands.Toggle.command_id), + ): + param_id = ctrl._mapper.command_parameter_uuid(device_id, endpoint_id, cluster_id, command_id) + param = next(p for p in state.parameters if p.id == param_id) + await ctrl.send_command(DeviceCommand(device_id=device_id, parameter_id=param.id, value=None), device, param) + assert timeouts == [1000, None] + + +async def test_hidden_main_candidate_does_not_block_a_visible_one(controller, monkeypatch): + from chip.clusters.Objects import LevelControl, OnOff + + from majordom_matter.matter_spec import MainParameterSpec + + monkeypatch.setattr( + "majordom_matter.controller.MAIN_PARAMETER_BY_CLUSTER", + { + LevelControl.id: MainParameterSpec(LevelControl.Commands.Step.command_id, None), # a `setting` + OnOff.id: MainParameterSpec(OnOff.Commands.Toggle.command_id, None), + }, + ) + ctrl, _output = controller + device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) + async with ctrl.dependencies.make_device_repository() as repo: + state = await repo.state(device_id, MatterDeviceState) + assert state.main_parameter == ctrl._mapper.command_parameter_uuid(device_id, 13, OnOff.id, 0x02) + assert state.can_set_main_parameter(state.main_parameter) + + +async def test_fetch_normalizes_values_like_the_subscription(controller): + from chip.clusters.Objects import BasicInformation, Descriptor, NetworkCommissioning + + ctrl, output = controller + device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) + async with ctrl.dependencies.make_device_repository() as repo: + device = await repo.get(device_id, as_=MatterDevice) + last_network_id = ctrl._mapper.attribute_parameter_uuid(device_id, 0, NetworkCommissioning.id, 0x06) + server_list = ctrl._mapper.attribute_parameter_uuid(device_id, 0, Descriptor.id, 0x01) + capability_minima = ctrl._mapper.attribute_parameter_uuid(device_id, 0, BasicInformation.id, 0x13) + + ctrl._matter_client.fire_attribute_update("0/49/6", b"\x01\x02\xff") # bytes + await _wait(lambda: any(e.parameter_id == last_network_id for e in output.events)) + subscribed = next(e.value for e in output.events if e.parameter_id == last_network_id) + before = len(output.events) + await ctrl.fetch(device) + fetched = {e.parameter_id: e.value for e in output.events[before:]} + + assert fetched[last_network_id] == subscribed == "AQL/" + assert fetched[server_list] == [29, 31, 40, 42, 48, 49, 50, 51, 52, 60, 62, 63, 64] + assert fetched[capability_minima] == {"caseSessionsPerFabric": 3, "subscriptionsPerFabric": 65535} + + +async def test_subscription_parses_raw_json_like_fetch(controller): + from chip.clusters.Objects import BasicInformation, ElectricalEnergyMeasurement + from matter_server.common.models import EventType + + ctrl, output = controller + device_id = await _commission(ctrl, ctrl.dependencies.make_device_repository) + async with ctrl.dependencies.make_device_repository() as repo: + device = await repo.get(device_id, as_=MatterDevice) + + def subscribed(parameter_id: UUID): + return next((e.value for e in reversed(output.events) if e.parameter_id == parameter_id), None) + + # matter-server pushes raw JSON: a struct keyed by field id, not by name. + capability_minima = ctrl._mapper.attribute_parameter_uuid(device_id, 0, BasicInformation.id, 0x13) + ctrl._matter_client.fire_attribute_update("0/40/19", {"0": 4, "1": 3}) + await _wait(lambda: subscribed(capability_minima) is not None) + pushed = subscribed(capability_minima) + before = len(output.events) + await ctrl.fetch(device) + fetched = {e.parameter_id: e.value for e in output.events[before:]} + assert pushed == fetched[capability_minima] + assert fetched[capability_minima] == {"caseSessionsPerFabric": 4, "subscriptionsPerFabric": 3} + + # A scaled struct: the energy (mWh) is unwrapped and scaled to kWh, as pairing/fetch do. + cluster_id = ElectricalEnergyMeasurement.id + energy = ctrl._mapper.attribute_parameter_uuid(device_id, 1, cluster_id, 0x01) + ctrl._make_callback(device_id, energy, cluster_id, 0x01)(EventType.ATTRIBUTE_UPDATED, {"0": 5_000_000}) + await _wait(lambda: subscribed(energy) is not None) + parsed = ElectricalEnergyMeasurement.Structs.EnergyMeasurementStruct(energy=5_000_000) + assert subscribed(energy) == ctrl._mapper.attribute_value(cluster_id, 0x01, parsed) == 5 diff --git a/tests/test_param_ux.py b/tests/test_param_ux.py index 2acd1fc..a246c79 100644 --- a/tests/test_param_ux.py +++ b/tests/test_param_ux.py @@ -87,3 +87,25 @@ def test_ladder_harvested_ha_and_fallback(): # a wholly-uncurated attribute falls to the warning fallback _, source = classify_attribute(0x0ABC, 0x0001, "mystery", writable=True, in_system_cluster=False) assert source.startswith("fallback") + + +def test_every_main_parameter_is_user_visible(): + from majordom_matter.matter_spec import EVERYDAY_COMMANDS, MAIN_PARAMETER_BY_CLUSTER + + for cluster_id, spec in MAIN_PARAMETER_BY_CLUSTER.items(): + key = (cluster_id, spec.command_or_attribute_id) + assert key in EVERYDAY_COMMANDS or AttributeKey(*key) in EVERYDAY_CONTROL_ATTRIBUTES, hex(cluster_id) + + +def test_unverified_command_mains_are_disabled(): + from majordom_matter.matter_spec import MAIN_PARAMETER_BY_CLUSTER + + # MicrowaveOvenControl, KeypadInput, EnergyEvse, WaterHeaterMode, WebRTCTransportProvider + assert MAIN_PARAMETER_BY_CLUSTER.keys().isdisjoint({0x5F, 0x509, 0x99, 0x9E, 0x553}) + + +def test_one_way_mains_have_their_counter_command_visible(): + from majordom_matter.matter_spec import EVERYDAY_COMMANDS + + assert {(0x104, 0x0), (0x104, 0x1)} <= EVERYDAY_COMMANDS # ClosureControl Stop / MoveTo + assert {(0x081, 0x0), (0x081, 0x1)} <= EVERYDAY_COMMANDS # ValveConfigurationAndControl Open / Close