blob: 841580b2035023c485e79458d379b336338d5ddd [file]
#
# Copyright (c) 2026 Project CHIP Authors
# All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared helpers for the Media File Management cluster (0x0511) test cases.
Reading TotalStorage, AvailableStorage and AvailableFiles requires Manage privilege, and
AddFile / DeleteFile require Manage to invoke, so every test in this suite expects the TH
to hold at least Manage on the endpoint under test. The default commissioning controller
is an Administrator, which subsumes Manage.
Several commands cause the DUT to pull data from the TH over BDX. ``serve_bdx_upload``
wraps that flow: it arms the controller to serve one transfer, waits for the DUT to
initiate it, and always cancels the future when nothing arrives so that a later transfer
in the same run is not blocked.
"""
import asyncio
import logging
from mobly import asserts
import matter.clusters as Clusters
from matter.bdx import BdxTransfer
from matter.clusters.Types import NullValue
from matter.interaction_model import InteractionModelError, Status
from matter.testing import matter_asserts
log = logging.getLogger(__name__)
_CLUSTER = Clusters.MediaFileManagement
# FileDescriptionStruct / command field constraints from the cluster specification.
MAX_NAME_LENGTH = 128
MAX_MIME_TYPE_LENGTH = 256
MAX_IMAGE_URI_LENGTH = 256
MAX_CLIENT_NAME_LENGTH = 128
MAX_AVAILABLE_FILES = 256
MAX_SUPPORTED_MIME_TYPES = 64
# A MIME type that no conformant DUT should advertise, used for the negative MIME cases.
UNSUPPORTED_MIME_TYPE = "application/vnd.matter.test-unsupported"
class MEDIAFILEMANAGEMENTTestBase:
# Cluster capability discovery
async def read_mfm_capabilities(self, endpoint) -> None:
"""Read AttributeList and FeatureMap, caching them for the supports_* helpers."""
self._mfm_attribute_list = await self.read_single_attribute_check_success(
endpoint=endpoint, cluster=_CLUSTER, attribute=_CLUSTER.Attributes.AttributeList)
self._mfm_feature_map = await self.read_single_attribute_check_success(
endpoint=endpoint, cluster=_CLUSTER, attribute=_CLUSTER.Attributes.FeatureMap)
def supports_mfm_attribute(self, attribute) -> bool:
return attribute.attribute_id in self._mfm_attribute_list
@property
def supports_media_sharing(self) -> bool:
return bool(self._mfm_feature_map & _CLUSTER.Bitmaps.Feature.kMediaSharing)
# Attribute reads
async def read_mfm_attribute_expect_success(self, endpoint, attribute):
return await self.read_single_attribute_check_success(endpoint=endpoint, cluster=_CLUSTER, attribute=attribute)
async def read_available_files(self, endpoint) -> list:
return await self.read_mfm_attribute_expect_success(endpoint, _CLUSTER.Attributes.AvailableFiles)
async def read_available_storage(self, endpoint) -> int:
return await self.read_mfm_attribute_expect_success(endpoint, _CLUSTER.Attributes.AvailableStorage)
async def read_supported_mime_types(self, endpoint) -> list:
return await self.read_mfm_attribute_expect_success(endpoint, _CLUSTER.Attributes.SupportedMimeTypes)
@staticmethod
def find_file_by_id(files: list, file_id: int):
"""Return the FileDescriptionStruct with this FileID, or None."""
return next((entry for entry in files if entry.fileID == file_id), None)
@staticmethod
def verify_file_description(file_description, label: str) -> None:
"""Assert a FileDescriptionStruct satisfies its specification constraints."""
matter_asserts.assert_valid_uint64(file_description.fileID, f"{label}.FileID")
matter_asserts.assert_is_string(file_description.name, f"{label}.Name")
matter_asserts.assert_string_length(file_description.name, f"{label}.Name", max_length=MAX_NAME_LENGTH)
matter_asserts.assert_valid_uint64(file_description.size, f"{label}.Size")
matter_asserts.assert_is_string(file_description.mimeType, f"{label}.MimeType")
matter_asserts.assert_string_length(
file_description.mimeType, f"{label}.MimeType", max_length=MAX_MIME_TYPE_LENGTH)
matter_asserts.assert_is_string(file_description.imageURI, f"{label}.ImageURI")
matter_asserts.assert_string_length(
file_description.imageURI, f"{label}.ImageURI", max_length=MAX_IMAGE_URI_LENGTH)
# MIME type selection
@staticmethod
def pick_supported_mime_type(supported_mime_types: list, fallback: str) -> str:
"""Return a MIME type the DUT accepts; an empty list means no restriction."""
return supported_mime_types[0] if supported_mime_types else fallback
@staticmethod
def pick_unsupported_mime_type(supported_mime_types: list) -> str | None:
"""Return a MIME type the DUT does not advertise, or None when it restricts nothing.
An empty SupportedMimeTypes list means the DUT imposes no restriction, so no
negative case can be constructed.
"""
if not supported_mime_types:
return None
if UNSUPPORTED_MIME_TYPE not in supported_mime_types:
return UNSUPPORTED_MIME_TYPE
for index in range(100):
candidate = f"{UNSUPPORTED_MIME_TYPE}-{index}"
if candidate not in supported_mime_types:
return candidate
return None
# BDX
def bdx_uri(self, designator: str) -> str:
"""Build a bdx: URI pointing at the TH, as required for the ImageURI fields.
The specification requires the bdx: scheme pointing at the client's node id; the
OTA Provider cluster fixes the shape as bdx://<16 upper-case hex digits>/<designator>.
"""
return f"bdx://{self.default_controller.nodeId:016X}/{designator}"
async def serve_bdx_upload(self, data: bytes, timeout_sec: float = 30.0,
send_command=None) -> BdxTransfer.InitMessage | None:
"""Serve one BDX transfer that the DUT initiates in order to pull ``data``.
``send_command`` is the coroutine that triggers the DUT. It is started before the
wait because a DUT may open the transfer before its command response is delivered,
and it is always awaited to completion so its result or exception is not lost.
Returns the transfer's init message, or None if the DUT never started a transfer.
"""
bdx_future = self.default_controller.TestOnlyPrepareToSendBdxData(data)
command_task = asyncio.ensure_future(send_command) if send_command is not None else None
try:
waitables = [bdx_future] + ([command_task] if command_task else [])
done, _ = await asyncio.wait(waitables, timeout=timeout_sec,
return_when=asyncio.FIRST_COMPLETED)
# The command response often lands first; keep waiting out the budget for the
# transfer itself rather than giving up on the first completion.
if bdx_future not in done:
await asyncio.wait([bdx_future], timeout=timeout_sec)
init_message = None
if bdx_future.done() and not bdx_future.cancelled():
transfer: BdxTransfer.BdxTransfer = bdx_future.result()
init_message = transfer.init_message
log.info("DUT initiated a BDX transfer for designator %r (length %s)",
init_message.FileDesignator, init_message.Length)
await transfer.accept_and_send_data()
else:
log.info("The DUT did not initiate a BDX transfer within %.0fs", timeout_sec)
return init_message
finally:
# The controller requires an unused future to be cancelled so it does not
# interfere with a later transfer in the same run.
if not bdx_future.done():
bdx_future.cancel()
if command_task is not None:
# Awaiting here rather than cancelling keeps the command's response (or its
# error) available to the caller, which asserts on it.
await command_task
# Preconditions
async def ensure_file_available(self, endpoint, name: str, size: int, fallback_mime_type: str,
bdx_timeout_sec: float) -> tuple[list, int | None]:
"""Return AvailableFiles, first adding a file with AddFile when the DUT has none.
Several test cases require at least one file on the DUT. Returns the file list and
the FileID added here (None when the DUT already had a file), so the caller can
remove only what it created.
"""
available_files = await self.read_available_files(endpoint)
if available_files:
return available_files, None
log.info("AvailableFiles is empty; adding a file to satisfy the precondition")
supported_mime_types = await self.read_supported_mime_types(endpoint)
mime_type = self.pick_supported_mime_type(supported_mime_types, fallback_mime_type)
response = None
async def send_add_file():
nonlocal response
response = await self.send_add_file(
endpoint, name=name, size=size, mime_type=mime_type, image_uri=self.bdx_uri("thumbnail.jpg"))
await self.serve_bdx_upload(bytes(size), timeout_sec=bdx_timeout_sec, send_command=send_add_file())
asserts.assert_is_not_none(response, "No AddFileResponse was received while adding a precondition file")
asserts.assert_equal(response.status, _CLUSTER.Enums.FileStatusEnum.kSuccess,
"Could not add a precondition file; AddFile did not succeed")
asserts.assert_true(response.fileID is not NullValue, "AddFile reported success but returned a null FileID")
available_files = await self.read_available_files(endpoint)
asserts.assert_true(available_files, "The DUT must have at least one file after AddFile succeeded")
return available_files, response.fileID
# Commands
async def send_add_file(self, endpoint, name: str, size: int, mime_type: str, image_uri: str):
"""Send AddFile and return the AddFileResponse."""
return await self.send_single_cmd(
cmd=_CLUSTER.Commands.AddFile(name=name, size=size, mimeType=mime_type, imageURI=image_uri),
endpoint=endpoint)
async def send_delete_file(self, endpoint, file_id: int) -> None:
"""Send DeleteFile, expecting success."""
await self.send_single_cmd(cmd=_CLUSTER.Commands.DeleteFile(fileID=file_id), endpoint=endpoint)
async def send_delete_file_expect_failure(self, endpoint, file_id: int) -> InteractionModelError:
"""Send DeleteFile for a FileID that does not exist and return the resulting error.
The specification's Effect on Receipt is explicit: "If the specified FileID does not
exist, the server SHALL respond with a status of NOT_FOUND." DeleteFile carries no
response payload, so the status arrives on the invoke itself.
"""
try:
await self.send_single_cmd(cmd=_CLUSTER.Commands.DeleteFile(fileID=file_id), endpoint=endpoint)
asserts.fail(f"DeleteFile with the non-existent FileID {file_id} should not have succeeded")
except InteractionModelError as e:
asserts.assert_equal(
e.status, Status.NotFound,
f"DeleteFile for a non-existent FileID must be rejected with NOT_FOUND (0x8b), got {e}")
log.info("DeleteFile for a non-existent FileID was rejected with %s", e)
return e
async def send_offer_file(self, endpoint, client_name: str, name: str, size: int, mime_type: str,
image_uri: str) -> None:
await self.send_single_cmd(
cmd=_CLUSTER.Commands.OfferFile(clientName=client_name, name=name, size=size,
mimeType=mime_type, imageURI=image_uri),
endpoint=endpoint)
def offer_file_command(self, client_name: str, name: str, size: int, mime_type: str, image_uri: str):
return _CLUSTER.Commands.OfferFile(clientName=client_name, name=name, size=size,
mimeType=mime_type, imageURI=image_uri)
async def send_request_shared_files(self, endpoint, client_name: str, request_id: int,
supported_mime_types=None) -> None:
await self.send_single_cmd(
cmd=_CLUSTER.Commands.RequestSharedFiles(
clientName=client_name, requestID=request_id,
supportedMimeTypes=supported_mime_types),
endpoint=endpoint)
async def send_get_shared_file(self, endpoint, response_id: int):
"""Send GetSharedFile and return the GetSharedFileResponse."""
return await self.send_single_cmd(
cmd=_CLUSTER.Commands.GetSharedFile(responseID=response_id), endpoint=endpoint)