Skip to content

Commit 1087352

Browse files
tehampsonpull[bot]
authored andcommitted
Enable chip-repl to send batch commands (#30851)
This enables chip-repl to send Batch commands in a single invoke request command. This is required in order to automate testing csg test plan for batch invoke commands.
1 parent 2e7de2b commit 1087352

File tree

8 files changed

+359
-21
lines changed

8 files changed

+359
-21
lines changed

src/controller/python/chip/ChipDeviceCtrl.py

+39
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,45 @@ async def SendCommand(self, nodeid: int, endpoint: int, payload: ClusterObjects.
901901
interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs, suppressResponse=suppressResponse).raise_on_error()
902902
return await future
903903

904+
async def SendBatchCommands(self, nodeid: int, commands: typing.List[ClusterCommand.InvokeRequestInfo],
905+
timedRequestTimeoutMs: typing.Optional[int] = None,
906+
interactionTimeoutMs: typing.Optional[int] = None, busyWaitMs: typing.Optional[int] = None,
907+
suppressResponse: typing.Optional[bool] = None):
908+
'''
909+
Send a batch of cluster-object encapsulated commands to a node and get returned a future that can be awaited upon to receive
910+
the responses. If a valid responseType is passed in, that will be used to deserialize the object. If not,
911+
the type will be automatically deduced from the metadata received over the wire.
912+
913+
nodeId: Target's Node ID
914+
commands: A list of InvokeRequestInfo containing the commands to invoke.
915+
timedWriteTimeoutMs: Timeout for a timed invoke request. Omit or set to 'None' to indicate a non-timed request.
916+
interactionTimeoutMs: Overall timeout for the interaction. Omit or set to 'None' to have the SDK automatically compute the
917+
right timeout value based on transport characteristics as well as the responsiveness of the target.
918+
busyWaitMs: How long to wait in ms after sending command to device before performing any other operations.
919+
suppressResponse: Do not send a response to this action
920+
921+
Returns:
922+
- List of command responses in the same order as what was given in `commands`. The type of the response is defined by the command.
923+
- A value of `None` indicates success.
924+
- If only a single command fails, for example with `UNSUPPORTED_COMMAND`, the corresponding index associated with the command will,
925+
contain `interaction_model.Status.UnsupportedCommand`.
926+
- If a command is not responded to by server, command will contain `interaction_model.Status.Failure`
927+
Raises:
928+
- InteractionModelError if error with sending of InvokeRequestMessage fails as a whole.
929+
'''
930+
self.CheckIsActive()
931+
932+
eventLoop = asyncio.get_running_loop()
933+
future = eventLoop.create_future()
934+
935+
device = self.GetConnectedDeviceSync(nodeid, timeoutMs=interactionTimeoutMs)
936+
937+
ClusterCommand.SendBatchCommands(
938+
future, eventLoop, device.deviceProxy, commands,
939+
timedRequestTimeoutMs=timedRequestTimeoutMs,
940+
interactionTimeoutMs=interactionTimeoutMs, busyWaitMs=busyWaitMs, suppressResponse=suppressResponse).raise_on_error()
941+
return await future
942+
904943
def SendGroupCommand(self, groupid: int, payload: ClusterObjects.ClusterCommand, busyWaitMs: typing.Union[None, int] = None):
905944
'''
906945
Send a group cluster-object encapsulated command to a group_id and get returned a future

src/controller/python/chip/clusters/Command.py

+152-7
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from asyncio.futures import Future
2424
from ctypes import CFUNCTYPE, c_bool, c_char_p, c_size_t, c_uint8, c_uint16, c_uint32, c_void_p, py_object
2525
from dataclasses import dataclass
26-
from typing import Type, Union
26+
from typing import List, Optional, Type, Union
2727

2828
import chip.exceptions
2929
import chip.interaction_model
@@ -42,6 +42,13 @@ class CommandPath:
4242
CommandId: int
4343

4444

45+
@dataclass
46+
class InvokeRequestInfo:
47+
EndpointId: int
48+
Command: ClusterCommand
49+
ResponseType: Optional[Type] = None
50+
51+
4552
@dataclass
4653
class Status:
4754
IMStatus: int
@@ -94,7 +101,11 @@ def _handleResponse(self, path: CommandPath, status: Status, response: bytes):
94101
else:
95102
self._future.set_result(None)
96103

97-
def handleResponse(self, path: CommandPath, status: Status, response: bytes):
104+
def handleResponse(self, path: CommandPath, index: int, status: Status, response: bytes):
105+
# For AsyncCommandTransaction we only expect to ever get one response so we don't bother
106+
# checking `index`. We just share a callback API with batch commands. If we ever get a
107+
# second call to `handleResponse` we will see a different error on trying to set future
108+
# that has already been set.
98109
self._event_loop.call_soon_threadsafe(
99110
self._handleResponse, path, status, response)
100111

@@ -105,9 +116,79 @@ def _handleError(self, imError: Status, chipError: PyChipError, exception: Excep
105116
self._future.set_exception(chipError.to_exception())
106117
else:
107118
try:
119+
# If you got an exception from this call other than AttributeError please
120+
# add it to the except block below. We changed Exception->AttributeError as
121+
# that is what we thought we are trying to catch here.
122+
self._future.set_exception(
123+
chip.interaction_model.InteractionModelError(chip.interaction_model.Status(imError.IMStatus), imError.ClusterStatus))
124+
except AttributeError:
125+
logger.exception("Failed to map interaction model status received: %s. Remapping to Failure." % imError)
126+
self._future.set_exception(chip.interaction_model.InteractionModelError(
127+
chip.interaction_model.Status.Failure, imError.ClusterStatus))
128+
129+
def handleError(self, status: Status, chipError: PyChipError):
130+
self._event_loop.call_soon_threadsafe(
131+
self._handleError, status, chipError, None
132+
)
133+
134+
def handleDone(self):
135+
ctypes.pythonapi.Py_DecRef(ctypes.py_object(self))
136+
137+
138+
class AsyncBatchCommandsTransaction:
139+
def __init__(self, future: Future, eventLoop, expectTypes: List[Type]):
140+
self._event_loop = eventLoop
141+
self._future = future
142+
self._expect_types = expectTypes
143+
default_im_failure = chip.interaction_model.InteractionModelError(
144+
chip.interaction_model.Status.NoCommandResponse)
145+
self._responses = [default_im_failure] * len(expectTypes)
146+
147+
def _handleResponse(self, path: CommandPath, index: int, status: Status, response: bytes):
148+
if index > len(self._responses):
149+
self._handleError(status, 0, IndexError(f"CommandSenderCallback has given us an unexpected index value {index}"))
150+
return
151+
152+
if (len(response) == 0):
153+
self._responses[index] = None
154+
else:
155+
# If a type hasn't been assigned, let's auto-deduce it.
156+
if (self._expect_types[index] is None):
157+
self._expect_types[index] = FindCommandClusterObject(False, path)
158+
159+
if self._expect_types[index]:
160+
try:
161+
# If you got an exception from this call other than AttributeError please
162+
# add it to the except block below. We changed Exception->AttributeError as
163+
# that is what we thought we are trying to catch here.
164+
self._responses[index] = self._expect_types[index].FromTLV(response)
165+
except AttributeError as ex:
166+
self._handleError(status, 0, ex)
167+
else:
168+
self._responses[index] = None
169+
170+
def handleResponse(self, path: CommandPath, index: int, status: Status, response: bytes):
171+
self._event_loop.call_soon_threadsafe(
172+
self._handleResponse, path, index, status, response)
173+
174+
def _handleError(self, imError: Status, chipError: PyChipError, exception: Exception):
175+
if self._future.done():
176+
# TODO Right now this even callback happens if there was a real IM Status error on one command.
177+
# We need to update OnError to allow providing a CommandRef that we can try associating with it.
178+
logger.exception(f"Recieved another error, but we have sent error. imError:{imError}, chipError {chipError}")
179+
return
180+
if exception:
181+
self._future.set_exception(exception)
182+
elif chipError != 0:
183+
self._future.set_exception(chipError.to_exception())
184+
else:
185+
try:
186+
# If you got an exception from this call other than AttributeError please
187+
# add it to the except block below. We changed Exception->AttributeError as
188+
# that is what we thought we are trying to catch here.
108189
self._future.set_exception(
109190
chip.interaction_model.InteractionModelError(chip.interaction_model.Status(imError.IMStatus), imError.ClusterStatus))
110-
except Exception:
191+
except AttributeError:
111192
logger.exception("Failed to map interaction model status received: %s. Remapping to Failure." % imError)
112193
self._future.set_exception(chip.interaction_model.InteractionModelError(
113194
chip.interaction_model.Status.Failure, imError.ClusterStatus))
@@ -117,20 +198,29 @@ def handleError(self, status: Status, chipError: PyChipError):
117198
self._handleError, status, chipError, None
118199
)
119200

201+
def _handleDone(self):
202+
self._future.set_result(self._responses)
203+
ctypes.pythonapi.Py_DecRef(ctypes.py_object(self))
204+
205+
def handleDone(self):
206+
self._event_loop.call_soon_threadsafe(
207+
self._handleDone
208+
)
209+
120210

121211
_OnCommandSenderResponseCallbackFunct = CFUNCTYPE(
122-
None, py_object, c_uint16, c_uint32, c_uint32, c_uint16, c_uint8, c_void_p, c_uint32)
212+
None, py_object, c_uint16, c_uint32, c_uint32, c_size_t, c_uint16, c_uint8, c_void_p, c_uint32)
123213
_OnCommandSenderErrorCallbackFunct = CFUNCTYPE(
124214
None, py_object, c_uint16, c_uint8, PyChipError)
125215
_OnCommandSenderDoneCallbackFunct = CFUNCTYPE(
126216
None, py_object)
127217

128218

129219
@_OnCommandSenderResponseCallbackFunct
130-
def _OnCommandSenderResponseCallback(closure, endpoint: int, cluster: int, command: int,
220+
def _OnCommandSenderResponseCallback(closure, endpoint: int, cluster: int, command: int, index: int,
131221
imStatus: int, clusterStatus: int, payload, size):
132222
data = ctypes.string_at(payload, size)
133-
closure.handleResponse(CommandPath(endpoint, cluster, command), Status(
223+
closure.handleResponse(CommandPath(endpoint, cluster, command), index, Status(
134224
imStatus, clusterStatus), data[:])
135225

136226

@@ -141,7 +231,7 @@ def _OnCommandSenderErrorCallback(closure, imStatus: int, clusterStatus: int, ch
141231

142232
@_OnCommandSenderDoneCallbackFunct
143233
def _OnCommandSenderDoneCallback(closure):
144-
ctypes.pythonapi.Py_DecRef(ctypes.py_object(closure))
234+
closure.handleDone()
145235

146236

147237
def TestOnlySendCommandTimedRequestFlagWithNoTimedInvoke(future: Future, eventLoop, responseType, device, commandPath, payload):
@@ -201,6 +291,59 @@ def SendCommand(future: Future, eventLoop, responseType: Type, device, commandPa
201291
))
202292

203293

294+
def SendBatchCommands(future: Future, eventLoop, device, commands: List[InvokeRequestInfo],
295+
timedRequestTimeoutMs: Optional[int] = None, interactionTimeoutMs: Optional[int] = None, busyWaitMs: Optional[int] = None,
296+
suppressResponse: Optional[bool] = None) -> PyChipError:
297+
''' Send a cluster-object encapsulated command to a device and does the following:
298+
- On receipt of a successful data response, returns the cluster-object equivalent through the provided future.
299+
- None (on a successful response containing no data)
300+
- Raises an exception if any errors are encountered.
301+
302+
If no response type is provided above, the type will be automatically deduced.
303+
304+
If a valid timedRequestTimeoutMs is provided, a timed interaction will be initiated instead.
305+
If a valid interactionTimeoutMs is provided, the interaction will terminate with a CHIP_ERROR_TIMEOUT if a response
306+
has not been received within that timeout. If it isn't provided, a sensible value will be automatically computed that
307+
accounts for the underlying characteristics of both the transport and the responsiveness of the receiver.
308+
'''
309+
handle = chip.native.GetLibraryHandle()
310+
311+
responseTypes = []
312+
commandargs = []
313+
for command in commands:
314+
clusterCommand = command.Command
315+
responseType = command.ResponseType
316+
if (responseType is not None) and (not issubclass(responseType, ClusterCommand)):
317+
raise ValueError("responseType must be a ClusterCommand or None")
318+
if clusterCommand.must_use_timed_invoke and timedRequestTimeoutMs is None or timedRequestTimeoutMs == 0:
319+
raise chip.interaction_model.InteractionModelError(chip.interaction_model.Status.NeedsTimedInteraction)
320+
321+
commandPath = chip.interaction_model.CommandPathIBStruct.build({
322+
"EndpointId": command.EndpointId,
323+
"ClusterId": clusterCommand.cluster_id,
324+
"CommandId": clusterCommand.command_id})
325+
payloadTLV = clusterCommand.ToTLV()
326+
327+
commandargs.append(c_char_p(commandPath))
328+
commandargs.append(c_char_p(bytes(payloadTLV)))
329+
commandargs.append(c_size_t(len(payloadTLV)))
330+
331+
responseTypes.append(responseType)
332+
333+
transaction = AsyncBatchCommandsTransaction(future, eventLoop, responseTypes)
334+
ctypes.pythonapi.Py_IncRef(ctypes.py_object(transaction))
335+
336+
return builtins.chipStack.Call(
337+
lambda: handle.pychip_CommandSender_SendBatchCommands(
338+
py_object(transaction), device,
339+
c_uint16(0 if timedRequestTimeoutMs is None else timedRequestTimeoutMs),
340+
c_uint16(0 if interactionTimeoutMs is None else interactionTimeoutMs),
341+
c_uint16(0 if busyWaitMs is None else busyWaitMs),
342+
c_bool(False if suppressResponse is None else suppressResponse),
343+
c_size_t(len(commands)), *commandargs)
344+
)
345+
346+
204347
def SendGroupCommand(groupId: int, devCtrl: c_void_p, payload: ClusterCommand, busyWaitMs: Union[None, int] = None) -> PyChipError:
205348
''' Send a cluster-object encapsulated group command to a device and does the following:
206349
- None (on a successful response containing no data)
@@ -227,6 +370,8 @@ def Init():
227370

228371
setter.Set('pychip_CommandSender_SendCommand',
229372
PyChipError, [py_object, c_void_p, c_uint16, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16, c_bool])
373+
setter.Set('pychip_CommandSender_SendBatchCommands',
374+
PyChipError, [py_object, c_void_p, c_uint16, c_uint16, c_uint16, c_bool, c_size_t])
230375
setter.Set('pychip_CommandSender_TestOnlySendCommandTimedRequestNoTimedInvoke',
231376
PyChipError, [py_object, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_uint16, c_bool])
232377
setter.Set('pychip_CommandSender_SendGroupCommand',

0 commit comments

Comments
 (0)