Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 82 additions & 22 deletions majordom_matter/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from aiohttp import ClientSession
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
from chip.clusters.Objects import Identify
from chip.clusters.Objects import ClosureControl, Identify, OperationalState
from majordom_integration_sdk.controller import AbstractController
from majordom_integration_sdk.discovery.ble_discovery import BLEDiscoveryInfo, BLEDiscoveryService
from majordom_integration_sdk.schemas.base import NonEmptyStr
Expand All @@ -29,7 +29,15 @@
MatterUnsupportedParameter,
)
from .mapper import MatterMapper
from .matter_spec import MAIN_PARAMETER_BY_CLUSTER, DefaultParams
from .matter_spec import (
CLOSURE_MAIN_STATE,
MAIN_PARAMETER_BY_CLUSTER,
STATE_TOGGLE_ATTRIBUTES,
STATE_TOGGLE_COMMANDS,
AttributeKey,
DefaultParams,
MainParameterSpec,
)
from .model import (
MatterDevice,
MatterDeviceIntegrationData,
Expand Down Expand Up @@ -359,7 +367,19 @@ async def send_command(self, command: DeviceCommand, device: MatterDevice, param
if parameter.integration_data.type is MatterParameterTypeEnum.command:
await self._execute_cluster_command(node, endpoint_id, cluster, parameter, command)
elif parameter.integration_data.type is MatterParameterTypeEnum.attribute:
await self._write_cluster_attribute(node, endpoint_id, cluster_id, parameter, command)
attribute_id = parameter.integration_data.attribute_id
if attribute_id is not None and AttributeKey(cluster_id, attribute_id) in STATE_TOGGLE_ATTRIBUTES:
cluster_command = self._state_toggle_command(
node, endpoint_id, cluster, attribute_id, command.value
)
await self._matter_client.send_device_command(
node.node_id,
endpoint_id,
cluster_command,
timed_request_timeout_ms=1000 if cluster_command.must_use_timed_invoke else None,
)
else:
await self._write_cluster_attribute(node, endpoint_id, cluster_id, parameter, command)
except UnknownError as e:
error = str(e)

Expand Down Expand Up @@ -613,6 +633,43 @@ async def _execute_cluster_command(
)
return

@staticmethod
def _state_toggle_command(node: MatterNode, endpoint_id: int, cluster, attribute_id: int, value) -> ClusterCommand:
"""The command that sets a read-only state attribute to `value` (see STATE_TOGGLE_ATTRIBUTES)."""
current = node.get_attribute_value(endpoint_id, cluster.id, attribute_id)
key = AttributeKey(cluster.id, attribute_id)
if key == CLOSURE_MAIN_STATE:
if current in (
ClosureControl.Enums.MainStateEnum.kMoving,
ClosureControl.Enums.MainStateEnum.kWaitingForMotion,
):
return ClosureControl.Commands.Stop()
overall = node.get_attribute_value(
endpoint_id, cluster.id, ClosureControl.Attributes.OverallCurrentState.attribute_id
)
closed = getattr(overall, "position", None) == ClosureControl.Enums.CurrentPositionEnum.kFullyClosed
target = ClosureControl.Enums.TargetPositionEnum
return ClosureControl.Commands.MoveTo(
position=target(target.kMoveToFullyOpen if closed else target.kMoveToFullyClosed)
)
if key == AttributeKey(
OperationalState.id, OperationalState.Attributes.OperationalState.attribute_id
) and current not in (
OperationalState.Enums.OperationalStateEnum.kRunning,
OperationalState.Enums.OperationalStateEnum.kPaused,
):
raise MatterUnexpectedError(
"OperationalState only pauses or resumes a running cycle; it never starts one from idle"
)
command_id = STATE_TOGGLE_COMMANDS[key].get(value)
if command_id is None:
raise MatterUnexpectedError(f"{cluster.__class__.__name__} state {value!r} cannot be set from MajorDom")
return next(
command_class
for _, command_class in inspect.getmembers(cluster.Commands, inspect.isclass)
if issubclass(command_class, ClusterCommand) and command_class.command_id == command_id
)()

async def _write_cluster_attribute(
self, node, endpoint_id: int, cluster_id: int, parameter: MatterParameter, command: DeviceCommand
):
Expand Down Expand Up @@ -645,25 +702,28 @@ def _get_main_parameter(
# 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():
for cluster_id, cluster_spec in MAIN_PARAMETER_BY_CLUSTER.items():
if cluster_id not in endpoint.clusters:
continue

if cluster_id == 0x00000202: # FanControl
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
parameter_id = self._mapper.attribute_parameter_uuid(
device_id, endpoint_id, cluster_id, spec.command_or_attribute_id
)
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
)

if parameter_id in candidates:
return candidates[parameter_id], spec.default_params
# An empty list means the device didn't report it: assume supported.
supported_attribute_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFFB) or []
accepted_command_ids: list[int] = node.get_attribute_value(endpoint_id, cluster_id, 0xFFF9) or []
spec: MainParameterSpec | None = cluster_spec
while spec is not None:
spec_id = spec.command_or_attribute_id
if spec.is_attribute:
# A state toggle also needs every command it taps through.
needed_command_ids = STATE_TOGGLE_COMMANDS.get(AttributeKey(cluster_id, spec_id), {}).values()
supported = (not supported_attribute_ids or spec_id in supported_attribute_ids) and (
not accepted_command_ids or set(needed_command_ids) <= set(accepted_command_ids)
)
parameter_id = self._mapper.attribute_parameter_uuid(
device_id, endpoint_id, cluster_id, spec_id
)
else:
supported = not accepted_command_ids or spec_id in accepted_command_ids
parameter_id = self._mapper.command_parameter_uuid(device_id, endpoint_id, cluster_id, spec_id)
if supported and parameter_id in candidates:
return candidates[parameter_id], spec.default_params
spec = spec.fallback
return None, None
83 changes: 57 additions & 26 deletions majordom_matter/matter_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,57 +411,88 @@ class ScaledField(NamedTuple):
}


# Read-only state attributes a main-parameter tap sets through a command: the attribute's
# `default_value` is a two-value cycle, the Hub sends the next value, and the controller sends the
# command for it. They are `control` (the Hub forwards commands for control parameters only).
# A state outside the cycle taps to the cycle's first (lowest) value — the safer direction.
STATE_TOGGLE_COMMANDS: dict[AttributeKey, dict[int, int]] = {
AttributeKey(0x101, 0x0): {1: 0x00, 2: 0x01}, # DoorLock.LockState Locked→LockDoor, Unlocked→UnlockDoor
AttributeKey(0x081, 0x4): {0: 0x01, 1: 0x00}, # Valve.CurrentState Closed→Close, Open→Open
AttributeKey(0x102, 0x8): {0: 0x00, 100: 0x01}, # WindowCovering.LiftPercentage 0→UpOrOpen, 100→DownOrClose
AttributeKey(0x506, 0x0): {0: 0x00, 1: 0x01}, # MediaPlayback.CurrentState Playing→Play, Paused→Pause
AttributeKey(0x061, 0x4): {1: 0x03, 2: 0x00}, # RvcOperationalState Running→Resume, Paused→Pause
AttributeKey(0x060, 0x4): {1: 0x03, 2: 0x00}, # OperationalState Running→Resume, Paused→Pause
}
# ClosureControl.MainState: a struct position can't cycle, so it's a one-value button and the
# controller picks Stop / MoveTo from the current state (see MatterController._state_toggle_command).
CLOSURE_MAIN_STATE = AttributeKey(0x104, 0x1)
STATE_TOGGLE_ATTRIBUTES: set[AttributeKey] = {*STATE_TOGGLE_COMMANDS, CLOSURE_MAIN_STATE}


# Arguments to send along with a main-parameter command/attribute when the parameter itself
# doesn't carry an obvious "activate" value (e.g. a mode/setpoint command). None means the
# command takes no arguments; an int means a raw attribute value (only used for the FanControl
# case below, where command_or_attribute_id is actually an attribute id).
DefaultParams = dict[str, Any] | int | None
# command takes no arguments; for an attribute main parameter it's the `default_value`: an int is
# a one-value button, a set a tap cycle.
DefaultParams = dict[str, Any] | int | set[int] | None


class MainParameterSpec(NamedTuple):
"""Value of MAIN_PARAMETER_BY_CLUSTER, keyed by cluster_id: which command (or — for
FanControl specifically — attribute) makes a sensible main_parameter for that cluster,
and what default_params to send with it if the parameter has no obvious "activate"
value. Iteration order is priority order — the first cluster below that's present on
the device wins."""
"""Value of MAIN_PARAMETER_BY_CLUSTER, keyed by cluster_id: which command (or, with
`is_attribute`, attribute) makes a sensible main_parameter for that cluster, and what
default_params to send with it if the parameter has no obvious "activate" value. Iteration
order is priority order — the first cluster below that's present on the device wins.
`fallback` is tried when the device lacks this one (an optional attribute or command)."""

command_or_attribute_id: int
default_params: DefaultParams
is_attribute: bool = False
fallback: "MainParameterSpec | None" = None


MAIN_PARAMETER_BY_CLUSTER: dict[int, MainParameterSpec] = {
0x00000006: MainParameterSpec(0x00000002, None), # OnOff.Toggle
# Thermostat intentionally has NO one-tap: "raise or lower?" isn't a sensible tile action —
# tapping a thermostat should open its screen, not nudge a setpoint blind.
0x00000202: MainParameterSpec(0x00000000, 0x04), # FanControl.FanMode.On(attribute)
0x00000202: MainParameterSpec(0x00000000, {0x00, 0x04}, is_attribute=True), # FanControl.FanMode Off↔On
0x00000056: MainParameterSpec(
0x00000000,
{"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
# OperationalState pauses/resumes a running cycle and never Starts one from idle (an oven or a
# microwave must not start in one tap); without Pause/Resume there's no main parameter.
0x00000060: MainParameterSpec(0x00000004, {1, 2}, is_attribute=True), # OperationalState Running↔Paused
0x00000061: MainParameterSpec(
0x00000004,
{1, 2},
is_attribute=True,
fallback=MainParameterSpec(0x00000003, None), # RVCOperationalState.Resume
), # RvcOperationalState Running↔Paused
# 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
0x00000101: MainParameterSpec(0x00000000, {1, 2}, is_attribute=True), # DoorLock.LockState Locked↔Unlocked
# 0x0000005C: MainParameterSpec(0x00000000, None), # SmokeCoAlarm.SelfTestRequest
0x00000506: MainParameterSpec(0x00000000, None), # MediaPlayback.Play
0x00000506: MainParameterSpec(
0x00000000,
{0, 1},
is_attribute=True,
fallback=MainParameterSpec(0x00000000, None), # MediaPlayback.Play
), # MediaPlayback.CurrentState Playing↔Paused
# 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,
# WindowCovering.UpOrOpen — the everyday one-tap for a cover (open); StopMotion
# (0x02) only matters mid-motion, so it's not the primary action
),
0x00000104: MainParameterSpec(
0x00000001,
{"position": 1},
# 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)
),
0x00000008,
{0, 100},
is_attribute=True,
# WindowCovering.UpOrOpen — a cover without position awareness keeps the one-way open;
# StopMotion (0x02) only matters mid-motion, so it's not the primary action
fallback=MainParameterSpec(0x00000000, None),
), # WindowCovering.CurrentPositionLiftPercentage open↔closed
# ClosureControl.MainState, a one-value button (Moving): MoveTo FullyClosed unless closed,
# else FullyOpen; Stop while moving (see STATE_TOGGLE_ATTRIBUTES)
0x00000104: MainParameterSpec(0x00000001, 1, is_attribute=True),
# 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.
Expand All @@ -471,7 +502,7 @@ class MainParameterSpec(NamedTuple):
# 0x00000000, {"streamUsage": 0, "originatingEndpointID": 0}
# ), # WebRTCTransportProvider.SolicitOffer
0x00000556: MainParameterSpec(0x00000000, None), # Chime.PlayChimeSound
0x00000081: MainParameterSpec(0x00000000, None), # ValveConfigurationAndControl.Open
0x00000081: MainParameterSpec(0x00000004, {0, 1}, is_attribute=True), # Valve.CurrentState Closed↔Open
}


Expand All @@ -490,7 +521,7 @@ def _our_attribute_ux() -> dict[AttributeKey, UxSpec]:
out: dict[AttributeKey, UxSpec] = {}
for key in USER_READINGS:
out[key] = UxSpec(ParameterVisibility.user, ParameterRole.sensor)
for key in EVERYDAY_CONTROL_ATTRIBUTES:
for key in EVERYDAY_CONTROL_ATTRIBUTES | STATE_TOGGLE_ATTRIBUTES:
out[key] = UxSpec(ParameterVisibility.user, ParameterRole.control)
return out

Expand Down
Loading
Loading