diff --git a/nmostesting/suites/BCP0080101Test.py b/nmostesting/suites/BCP0080101Test.py index 840274f1..2e00260d 100644 --- a/nmostesting/suites/BCP0080101Test.py +++ b/nmostesting/suites/BCP0080101Test.py @@ -12,14 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. + +import uuid + from enum import Enum from jinja2 import Template from random import randint from requests.compat import json +from time import sleep, time + from ..GenericTest import GenericTest, NMOSTestException from ..IS05Utils import IS05Utils from ..IS12Utils import IS12Utils -from ..MS05Utils import NcMethodStatus, NcPropertyId +from ..MS05Utils import NcMethodStatus, NcObjectProperties, NcPropertyId, NcTouchpointNmos from ..TestHelper import get_default_ip, get_mocks_hostname from .MS0501Test import MS0501Test @@ -32,6 +37,9 @@ RECEIVER_MONITOR_CLASS_ID = [1, 2, 2, 1] +bcp_008_01_spec_root = "https://specs.amwa.tv/bcp-008-01/branches/v1.0-dev/docs/" +ms_05_02_spec_root = "https://specs.amwa.tv/ms-05-02/branches/v1.0.x/docs/" + class NcReceiverMonitorProperties(Enum): # NcStatusMonitor properties @@ -59,12 +67,35 @@ def _missing_(cls, _): return cls.UNKNOWN +class NcConnectionStatus(Enum): + Inactive = 0 + Healthy = 1 + PartiallyHealthy = 2 + Unhealthy = 3 + UNKNOWN = 9999 + + @classmethod + def _missing_(cls, _): + return cls.UNKNOWN + + class BCP0080101Test(GenericTest): """ Runs Tests covering BCP-008-01 """ + class TestMetadata(): + def __init__(self, checked=False, error=False, error_msg="", link=""): + self.checked = checked + self.error = error + self.error_msg = error_msg + self.link = link + def __init__(self, apis, node, **kwargs): - GenericTest.__init__(self, apis, **kwargs) + # Don't auto-test /transportfile as it is permitted to generate a 404 when master_enable is false + omit_paths = [ + "/single/senders/{senderId}/transportfile" + ] + GenericTest.__init__(self, apis, omit_paths, **kwargs) self.is12_utils = IS12Utils(apis) # Instantiate MS0501Tests to access automatic tests # Hmmm, should the automatic tests be factored into the utils to allow all @@ -87,6 +118,9 @@ def set_up_tests(self): host = get_mocks_hostname() if CONFIG.ENABLE_HTTPS else get_default_ip() self.mock_node_base_url = self.protocol + '://' + host + ':' + str(self.mock_node.port) + '/' + # Initialize cached test results + self.check_touchpoint_metadata = BCP0080101Test.TestMetadata() + # Override basics to include the MS-05 auto tests def basics(self): results = super().basics() @@ -102,75 +136,96 @@ def tear_down_tests(self): # Clean up Websocket resources self.is12_utils.close_ncp_websocket() - def get_receiver_monitors(self, test): + def _status_ok(self, method_result): + if not hasattr(method_result, 'status'): + return False + return method_result.status == NcMethodStatus.OK \ + or method_result.status == NcMethodStatus.PropertyDeprecated + + def _get_receiver_monitors(self, test): if len(self.receiver_monitors): return self.receiver_monitors device_model = self.is12_utils.query_device_model(test) - self.receiver_monitors = device_model.find_members_by_class_id(RECEIVER_MONITOR_CLASS_ID, include_derived=True, recurse=True, get_objects=True) + self.receiver_monitors = device_model.find_members_by_class_id(RECEIVER_MONITOR_CLASS_ID, + include_derived=True, + recurse=True, + get_objects=True) return self.receiver_monitors def test_01(self, test): """Check that statusReportingDelay can be set to values within the published constraints""" - receiver_monitors = self.get_receiver_monitors(test) + receiver_monitors = self._get_receiver_monitors(test) if len(receiver_monitors) == 0: return test.UNCLEAR("No NcReceiverMonitors found in Device Model") default_status_reporting_delay = 3 for monitor in receiver_monitors: - methodResult = self.is12_utils.set_property(test, NcReceiverMonitorProperties.STATUS_REPORTING_DELAY.value, default_status_reporting_delay, oid=monitor.oid, role_path=monitor.role_path) - if methodResult.status != NcMethodStatus.OK: - return test.FAIL(f"SetProperty error: Error setting statusReportingDelay on ReceiverMonitor, oid={monitor.oid}, role path={monitor.role_path}") + method_result = self.is12_utils.set_property( + test, NcReceiverMonitorProperties.STATUS_REPORTING_DELAY.value, + default_status_reporting_delay, + oid=monitor.oid, role_path=monitor.role_path) - methodResult = self.is12_utils.get_property(test, NcReceiverMonitorProperties.STATUS_REPORTING_DELAY.value, oid=monitor.oid, role_path=monitor.role_path) - if methodResult.status != NcMethodStatus.OK: - return test.FAIL(f"GetProperty error: Error getting statusReportingDelay on ReceiverMonitor, oid={monitor.oid}, role path={monitor.role_path}") + if not self._status_ok(method_result): + return test.FAIL("SetProperty error: Error setting statusReportingDelay on ReceiverMonitor, " + f"oid={monitor.oid}, role path={monitor.role_path}") - if methodResult.value != default_status_reporting_delay: - return test.FAIL(f"Unexpected statusReportingDelay on ReceiverMonitor. Expected={default_status_reporting_delay} actual={methodResult.value}, oid={monitor.oid}, role path={monitor.role_path}") + method_result = self.is12_utils.get_property( + test, NcReceiverMonitorProperties.STATUS_REPORTING_DELAY.value, + oid=monitor.oid, role_path=monitor.role_path) - return test.PASS() + if not self._status_ok(method_result): + return test.FAIL("GetProperty error: Error getting statusReportingDelay on ReceiverMonitor, " + f"oid={monitor.oid}, role path={monitor.role_path}") - def test_02(self, test): - """Check Receiver Monitor transition to Healthy state""" + if method_result.value != default_status_reporting_delay: + return test.FAIL("Unexpected statusReportingDelay on ReceiverMonitor. " + f"Expected={default_status_reporting_delay} actual={method_result.value}, " + f"oid={monitor.oid}, role path={monitor.role_path}") - # For each receiver in the NuT create a sender with appopriate transport parameters + return test.PASS() + def _make_receiver_sdp_params(self, test): + + rtp_receivers = [] + # For each receiver in the NuT make appropriate SDP params valid, resources = self.do_request("GET", self.node_url + "receivers") if not valid: return False, "Node API did not respond as expected: {}".format(resources) try: for resource in resources.json(): - self.is04_receivers.append(resource) + if resource["transport"].startswith("urn:x-nmos:transport:rtp"): + rtp_receivers.append(resource) except json.JSONDecodeError: - return False, "Non-JSON response returned from Node API" + raise NMOSTestException(test.FAIL("Non-JSON response returned from Node API")) + + sdp_templates = {} + sdp_templates["raw"] = open("test_data/sdp/video-2022-7.sdp").read() + sdp_templates["jxsv"] = open("test_data/sdp/video-jxsv.sdp").read() + sdp_templates["audio"] = open("test_data/sdp/audio.sdp").read() + sdp_templates["smpte291"] = open("test_data/sdp/data.sdp").read() + sdp_templates["SMPTE2022-6"] = open("test_data/sdp/mux.sdp").read() - video_sdp = open("test_data/sdp/video.sdp").read() - video_jxsv_sdp = open("test_data/sdp/video-jxsv.sdp").read() - audio_sdp = open("test_data/sdp/audio.sdp").read() - data_sdp = open("test_data/sdp/data.sdp").read() - mux_sdp = open("test_data/sdp/mux.sdp").read() + default_media_types = {} + default_media_types["urn:x-nmos:format:video"] = "video/raw" + default_media_types["urn:x-nmos:format:audio"] = "audio/L24" + default_media_types["urn:x-nmos:format:data"] = "video/smpte291" + default_media_types["urn:x-nmos:format:mux"] = "video/SMPTE2022-6" - rtp_receivers = [receiver for receiver in self.is04_receivers - if receiver["transport"].startswith("urn:x-nmos:transport:rtp")] + sdp_params = {} for receiver in rtp_receivers: caps = receiver["caps"] - if receiver["format"] == "urn:x-nmos:format:video": - media_type = caps["media_types"][0] if "media_types" in caps else "video/raw" - elif receiver["format"] == "urn:x-nmos:format:audio": - media_type = caps["media_types"][0] if "media_types" in caps else "audio/L24" - elif receiver["format"] == "urn:x-nmos:format:data": - media_type = caps["media_types"][0] if "media_types" in caps else "video/smpte291" - elif receiver["format"] == "urn:x-nmos:format:mux": - media_type = caps["media_types"][0] if "media_types" in caps else "video/SMPTE2022-6" + if receiver["format"] in default_media_types.keys(): + media_type = caps["media_types"][0] \ + if "media_types" in caps else default_media_types[receiver["format"]] else: - return test.FAIL("Unexpected Receiver format: {}".format(receiver["format"])) + continue supported_media_types = [ "video/raw", @@ -182,25 +237,16 @@ def test_02(self, test): "video/SMPTE2022-6" ] if media_type not in supported_media_types: - if not warn_sdp_untested: - warn_sdp_untested = "Could not test Receiver {} because this test cannot generate SDP data " \ - "for media_type '{}'".format(receiver["id"], media_type) continue media_type, media_subtype = media_type.split("/") - if media_type == "video": - if media_subtype == "raw": - template_file = video_sdp - elif media_subtype == "jxsv": - template_file = video_jxsv_sdp - elif media_subtype == "smpte291": - template_file = data_sdp - elif media_subtype == "SMPTE2022-6": - template_file = mux_sdp - elif media_type == "audio": - if media_subtype in ["L16", "L24", "L32"]: - template_file = audio_sdp + if media_type == "video" and media_subtype in sdp_templates.keys(): + template_file = sdp_templates[media_subtype] + elif media_type == "audio" and media_subtype in ["L16", "L24", "L32"]: + template_file = sdp_templates["audio"] + else: + continue template = Template(template_file, keep_trailing_newline=True) @@ -208,28 +254,142 @@ def test_02(self, test): dst_ip = "232.40.50.{}".format(randint(1, 254)) dst_port = randint(5000, 5999) - sdp_file = template.render({**CONFIG.SDP_PREFERENCES, - 'src_ip': src_ip, - 'dst_ip': dst_ip, - 'dst_port': dst_port, - 'media_subtype': media_subtype - }) - - url = "single/receivers/{}/staged".format(receiver["id"]) - data = {"sender_id": None, "transport_file": {"data": sdp_file, "type": "application/sdp"}} - valid, response = self.is05_utils.checkCleanRequestJSON("PATCH", url, data) - - if valid: - print(response) - - print(self.mock_node) - # # Set up connection on the mock node - # valid, response = self.do_request('GET', self.mock_node_base_url - # + 'x-nmos/connection/' + self.connection_api_version + '/single/senders/' - # + sender['id'] + '/transportfile') - # transport_file = response.content.decode() - # activate_json = {"activation": {"mode": "activate_immediate"}, - # "master_enable": True, - # "sender_id": sender['id'], - # "transport_file": {"data": transport_file, "type": "application/sdp"}} - # self.node.patch_staged('receivers', receiver['id'], activate_json) \ No newline at end of file + sdp_params[receiver["id"]] = template.render({**CONFIG.SDP_PREFERENCES, + 'src_ip': src_ip, + 'dst_ip': dst_ip, + 'dst_port': dst_port, + 'media_subtype': media_subtype + } + ) + + return sdp_params + + def _get_property(self, test, property_id, oid, role_path): + """Get a property and handle any error""" + method_result = self.is12_utils.get_property(test, property_id, oid=oid, role_path=role_path) + + if not self._status_ok(method_result): + raise NMOSTestException(test.FAIL(method_result.errorMessage)) + + return method_result.value + + def _set_property(self, test, property_id, value, oid, role_path): + """Set a property and handle any error""" + method_result = self.is12_utils.set_property(test, property_id, value, oid=oid, role_path=role_path) + + if not self._status_ok(method_result): + raise NMOSTestException(test.FAIL(method_result.errorMessage)) + + return method_result + + def _get_touchpoint_resource(self, test, oid, role_path): + # The touchpoints property of any NcReceiverMonitor MUST have one or more touchpoints of which + # one and only one entry MUST be of type NcTouchpointNmos where + # the resourceType field MUST be set to “receiver” and + # the id field MUST be set to the associated IS-04 receiver UUID. + spec_link = f"{bcp_008_01_spec_root}Overview.html#touchpoints-and-is-04-receivers" + + touchpoint_resources = [] + + touchpoints = self._get_property(test, NcObjectProperties.TOUCHPOINTS.value, oid, role_path) + + for touchpoint in touchpoints: + if "contextNamespace" not in touchpoint: + self.check_touchpoint_metadata.error = True + self.check_touchpoint_metadata.error_msg = "Touchpoint doesn't obey MS-05-02 schema" + self.check_touchpoint_metadata.link = f"{ms_05_02_spec_root}Framework.html#nctouchpoint" + continue + + if "resource" in touchpoint: + touchpoint_resources.append(touchpoint) + + if len(touchpoint_resources) != 1: + self.check_touchpoint_metadata.error = True + self.check_touchpoint_metadata.error_msg = "One and only one touchpoint MUST be of type NcTouchpointNmos" + self.check_touchpoint_metadata.link = spec_link + return None + + touchpoint_resource = NcTouchpointNmos(touchpoint_resources[0]) + + if touchpoint_resource.resource["resourceType"] != "receiver": + self.check_touchpoint_metadata.error = True + self.check_touchpoint_metadata.error_msg = "Touchpoint resourceType field MUST be set to 'receiver'" + self.check_touchpoint_metadata.link = spec_link + return None + + self.check_touchpoint_metadata.checked = True + + return touchpoint_resource + + def test_02(self, test): + """Check Receiver Monitor transition to Healthy state""" + + checked = False + + sdp_params = self._make_receiver_sdp_params(test) + + for receiver_monitor in self._get_receiver_monitors(test): + + touchpoint_resource = self._get_touchpoint_resource(test, + receiver_monitor.oid, + receiver_monitor.role_path) + + if touchpoint_resource is None or touchpoint_resource.resource["id"] not in sdp_params: + continue + + # Check initial connection status + connection_status = self._get_property(test, NcReceiverMonitorProperties.CONNECTION_STATUS.value, + receiver_monitor.oid, receiver_monitor.role_path) + + if connection_status != NcConnectionStatus.Inactive.value: + continue + + # Set status reporting delay to 3 seconds + status_reporting_delay = 3 + self._set_property(test, NcReceiverMonitorProperties.STATUS_REPORTING_DELAY.value, + status_reporting_delay, + receiver_monitor.oid, receiver_monitor.role_path) + # Start timer + start_time = time() + + # Patch receiver + receiver_id = touchpoint_resource.resource["id"] + url = "single/receivers/{}/staged".format(receiver_id) + activate_json = {"activation": {"mode": "activate_immediate"}, + "master_enable": True, + "sender_id": str(uuid.uuid4()), + "transport_file": {"data": sdp_params[receiver_id], "type": "application/sdp"} + } + + valid, response = self.is05_utils.checkCleanRequestJSON("PATCH", url, activate_json) + if not valid: + return test.FAIL("Error patching Receiver " + str(response)) + + # Status should stay healthy for status_reporting_delay minus one second + while (time() - start_time) < (status_reporting_delay - 1.0): + + # Check connection status + connection_status = self._get_property(test, NcReceiverMonitorProperties.CONNECTION_STATUS.value, + receiver_monitor.oid, receiver_monitor.role_path) + + if connection_status != NcConnectionStatus.Healthy.value: + return test.FAIL("Expect the status to stay healthy") + + sleep(0.2) + + # There is no actual stream so expect the connection status to become less healthy + sleep(2.0) + + # Check connection status + connection_status = self._get_property(test, NcReceiverMonitorProperties.CONNECTION_STATUS.value, + receiver_monitor.oid, receiver_monitor.role_path) + + if connection_status == NcConnectionStatus.Healthy.value: + return test.FAIL("Not expecting healthy connection") + + checked = True + + if checked: + return test.PASS() + + return test.UNCLEAR("Unable to find any testable Receiver Monitors") diff --git a/test_data/sdp/video-2022-7.sdp b/test_data/sdp/video-2022-7.sdp new file mode 100644 index 00000000..afee6727 --- /dev/null +++ b/test_data/sdp/video-2022-7.sdp @@ -0,0 +1,21 @@ +v=0 +o=- 1543226715 1543226715 IN IP4 {{ src_ip }} +s=Demo Video Stream +t=0 0 +a=group:DUP PRIMARY SECONDARY +m=video {{ dst_port }} RTP/AVP 97 +c=IN IP4 {{ dst_ip }}/32 +a=source-filter: incl IN IP4 {{ dst_ip }} {{ src_ip }} +a=ts-refclk:ptp=IEEE1588-2008:EC-46-70-FF-FE-00-CE-DE:0 +a=rtpmap:97 raw/90000 +a=fmtp:97 sampling={{ sampling }}; width={{ width }}; height={{ height }}; depth={{ depth }}; {{ "interlace; " if interlace }}SSN=ST2110-20:2017; colorimetry={{ colorimetry }}; PM=2110GPM; TP={{ TP }}; TCS={{ TCS }}; exactframerate={{ exactframerate }} +a=mediaclk:direct=0 +a=mid:PRIMARY +m=video {{ dst_port }} RTP/AVP 97 +c=IN IP4 {{ dst_ip }}/32 +a=source-filter: incl IN IP4 {{ dst_ip }} {{ src_ip }} +a=ts-refclk:ptp=IEEE1588-2008:EC-46-70-FF-FE-00-CE-DE:0 +a=rtpmap:97 raw/90000 +a=fmtp:97 sampling={{ sampling }}; width={{ width }}; height={{ height }}; depth={{ depth }}; {{ "interlace; " if interlace }}SSN=ST2110-20:2017; colorimetry={{ colorimetry }}; PM=2110GPM; TP={{ TP }}; TCS={{ TCS }}; exactframerate={{ exactframerate }} +a=mediaclk:direct=0 +a=mid:SECONDARY \ No newline at end of file