1#!/usr/bin/env python3 2# 3# Copyright 2019 - The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17from zeep import client 18from acts.libs.proc import job 19from xml.etree import ElementTree 20import requests 21import asyncio 22import time 23import threading 24import re 25import os 26import logging 27 28 29class Contest(object): 30 """ Controller interface for Rohde Schwarz CONTEST sequencer software. """ 31 32 # Remote Server parameter / operation names 33 TESTPLAN_PARAM = 'Testplan' 34 TESTPLAN_VERSION_PARAM = 'TestplanVersion' 35 KEEP_ALIVE_PARAM = 'KeepContestAlive' 36 START_TESTPLAN_OPERATION = 'StartTestplan' 37 38 # Results dictionary keys 39 POS_ERROR_KEY = 'pos_error' 40 TTFF_KEY = 'ttff' 41 SENSITIVITY_KEY = 'sensitivity' 42 43 # Waiting times 44 OUTPUT_WAITING_INTERVAL = 5 45 46 # Maximum number of times to retry if the Contest system is not responding 47 MAXIMUM_OUTPUT_READ_RETRIES = 25 48 49 # Root directory for the FTP server in the remote computer 50 FTP_ROOT = 'D:\\Logs\\' 51 52 def __init__(self, logger, remote_ip, remote_port, automation_listen_ip, 53 automation_port, dut_on_func, dut_off_func, ftp_usr, ftp_pwd): 54 """ 55 Initializes the Contest software controller. 56 57 Args: 58 logger: a logger handle. 59 remote_ip: the Remote Server's IP address. 60 remote_port: port number used by the Remote Server. 61 automation_listen_ip: local IP address in which to listen for 62 Automation Server connections. 63 automation_port: port used for Contest's DUT automation requests. 64 dut_on_func: function to turn the DUT on. 65 dut_off_func: function to turn the DUT off. 66 ftp_usr: username to login to the FTP server on the remote host 67 ftp_pwd: password to authenticate ftp_user in the ftp server 68 """ 69 self.log = logger 70 self.ftp_user = ftp_usr 71 self.ftp_pass = ftp_pwd 72 73 self.remote_server_ip = remote_ip 74 75 server_url = 'http://{}:{}/RemoteServer'.format(remote_ip, remote_port) 76 77 # Initialize the SOAP client to interact with Contest's Remote Server 78 try: 79 self.soap_client = client.Client(server_url + '/RemoteServer?wsdl') 80 except requests.exceptions.ConnectionError: 81 self.log.error('Could not connect to the remote endpoint. Is ' 82 'Remote Server running on the Windows computer?') 83 raise 84 85 # Assign a value to asyncio_loop in case the automation server is not 86 # started 87 self.asyncio_loop = None 88 89 # Start the automation server if an IP and port number were passed 90 if automation_listen_ip and automation_port: 91 self.start_automation_server(automation_port, automation_listen_ip, 92 dut_on_func, dut_off_func) 93 94 def start_automation_server(self, automation_port, automation_listen_ip, 95 dut_on_func, dut_off_func): 96 """ Starts the Automation server in a separate process. 97 98 Args: 99 automation_listen_ip: local IP address in which to listen for 100 Automation Server connections. 101 automation_port: port used for Contest's DUT automation requests. 102 dut_on_func: function to turn the DUT on. 103 dut_off_func: function to turn the DUT off. 104 """ 105 106 # Start an asyncio event loop to run the automation server 107 self.asyncio_loop = asyncio.new_event_loop() 108 109 # Start listening for automation requests on a separate thread. This 110 # will start a new thread in which a socket will listen for incoming 111 # connections and react to Contest's automation commands 112 113 def start_automation_server(asyncio_loop): 114 AutomationServer(self.log, automation_port, automation_listen_ip, 115 dut_on_func, dut_off_func, asyncio_loop) 116 117 automation_daemon = threading.Thread( 118 target=start_automation_server, args=[self.asyncio_loop]) 119 automation_daemon.start() 120 121 def execute_testplan(self, testplan): 122 """ Executes a test plan with Contest's Remote Server sequencer. 123 124 Waits until and exit code is provided in the output. Logs the ouput with 125 the class logger and pulls the json report from the server if the test 126 succeeds. 127 128 Arg: 129 testplan: the test plan's name in the Contest system 130 131 Returns: 132 a dictionary with test results if the test finished successfully, 133 and None if it finished with an error exit code. 134 """ 135 136 self.soap_client.service.DoSetParameterValue(self.TESTPLAN_PARAM, 137 testplan) 138 self.soap_client.service.DoSetParameterValue( 139 self.TESTPLAN_VERSION_PARAM, 16) 140 self.soap_client.service.DoSetParameterValue(self.KEEP_ALIVE_PARAM, 141 'true') 142 143 # Remote Server sometimes doesn't respond to the request immediately and 144 # frequently times out producing an exception. A shorter timeout will 145 # throw the exception earlier and allow the script to continue. 146 with self.soap_client.options(timeout=5): 147 try: 148 self.soap_client.service.DoStartOperation( 149 self.START_TESTPLAN_OPERATION) 150 except requests.exceptions.ReadTimeout: 151 pass 152 153 self.log.info('Started testplan {} in Remote Server.'.format(testplan)) 154 155 testplan_directory = None 156 read_retries = 0 157 158 while True: 159 160 time.sleep(self.OUTPUT_WAITING_INTERVAL) 161 output = self.soap_client.service.DoGetOutput() 162 163 # Output might be None while the instrument is busy. 164 if output: 165 self.log.debug(output) 166 167 # Obtain the path to the folder where reports generated by the 168 # test equipment will be stored in the remote computer 169 if not testplan_directory: 170 prefix = re.escape('Testplan Directory: ' + self.FTP_ROOT) 171 match = re.search('(?<={}).+(?=\\\\)'.format(prefix), 172 output) 173 if match: 174 testplan_directory = match.group(0) 175 176 # An exit code in the output indicates that the measurement is 177 # completed. 178 match = re.search('(?<=Exit code: )-?\d+', output) 179 if match: 180 exit_code = int(match.group(0)) 181 break 182 183 # Reset the not-responding counter 184 read_retries = 0 185 186 else: 187 # If the output has been None for too many retries in a row, 188 # the testing instrument is assumed to be unresponsive. 189 read_retries += 1 190 if read_retries == self.MAXIMUM_OUTPUT_READ_RETRIES: 191 raise RuntimeError('The Contest test sequencer is not ' 192 'responding.') 193 194 self.log.info( 195 'Contest testplan finished with exit code {}.'.format(exit_code)) 196 197 if exit_code in [0, 1]: 198 self.log.info('Testplan reports are stored in {}.'.format( 199 testplan_directory)) 200 201 return self.pull_test_results(testplan_directory) 202 203 def pull_test_results(self, testplan_directory): 204 """ Downloads the test reports from the remote host and parses the test 205 summary to obtain the results. 206 207 Args: 208 testplan_directory: directory where to look for reports generated 209 by the test equipment in the remote computer 210 211 Returns: 212 a JSON object containing the test results 213 """ 214 215 if not testplan_directory: 216 raise ValueError('Invalid testplan directory.') 217 218 # Download test reports from the remote host 219 job.run('wget -r --user={} --password={} -P {} ftp://{}/{}'.format( 220 self.ftp_user, self.ftp_pass, logging.log_path, 221 self.remote_server_ip, testplan_directory)) 222 223 # Open the testplan directory 224 testplan_path = os.path.join(logging.log_path, self.remote_server_ip, 225 testplan_directory) 226 227 # Find the report.json file in the testcase folder 228 dir_list = os.listdir(testplan_path) 229 xml_path = None 230 231 for dir in dir_list: 232 if 'TestCaseName' in dir: 233 xml_path = os.path.join(testplan_path, dir, 234 'SummaryReport.xml') 235 break 236 237 if not xml_path: 238 raise RuntimeError('Could not find testcase directory.') 239 240 # Return the obtained report as a dictionary 241 xml_tree = ElementTree.ElementTree() 242 xml_tree.parse(source=xml_path) 243 244 results_dictionary = {} 245 246 col_iterator = xml_tree.iter('column') 247 for col in col_iterator: 248 # Look in the text of the first child for the required metrics 249 if col.text == '2D position error [m]': 250 results_dictionary[self.POS_ERROR_KEY] = { 251 'min': float(next(col_iterator).text), 252 'med': float(next(col_iterator).text), 253 'avg': float(next(col_iterator).text), 254 'max': float(next(col_iterator).text) 255 } 256 elif col.text == 'Time to first fix [s]': 257 results_dictionary[self.TTFF_KEY] = { 258 'min': float(next(col_iterator).text), 259 'med': float(next(col_iterator).text), 260 'avg': float(next(col_iterator).text), 261 'max': float(next(col_iterator).text) 262 } 263 264 message_iterator = xml_tree.iter('message') 265 for message in message_iterator: 266 # Look for the line showing sensitivity 267 if message.text: 268 # The typo in 'successfull' is intended as it is present in the 269 # test logs generated by the Contest system. 270 match = re.search('(?<=Margin search completed, the lowest ' 271 'successfull output power is )-?\d+.?\d+' 272 '(?= dBm)', message.text) 273 if match: 274 results_dictionary[self.SENSITIVITY_KEY] = float( 275 match.group(0)) 276 break 277 278 return results_dictionary 279 280 def destroy(self): 281 """ Closes all open connections and kills running threads. """ 282 if self.asyncio_loop: 283 # Stopping the asyncio loop will let the Automation Server exit 284 self.asyncio_loop.call_soon_threadsafe(self.asyncio_loop.stop) 285 286 287class AutomationServer: 288 """ Server object that handles DUT automation requests from Contest's Remote 289 Server. 290 """ 291 292 def __init__(self, logger, port, listen_ip, dut_on_func, dut_off_func, 293 asyncio_loop): 294 """ Initializes the Automation Server. 295 296 Opens a listening socket using a asyncio and waits for incoming 297 connections. 298 299 Args: 300 logger: a logger handle 301 port: port used for Contest's DUT automation requests 302 listen_ip: local IP in which to listen for connections 303 dut_on_func: function to turn the DUT on 304 dut_off_func: function to turn the DUT off 305 asyncio_loop: asyncio event loop to listen and process incoming 306 data asynchronously 307 """ 308 309 self.log = logger 310 311 # Define a protocol factory that will provide new Protocol 312 # objects to the server created by asyncio. This Protocol 313 # objects will handle incoming commands 314 def aut_protocol_factory(): 315 return self.AutomationProtocol(logger, dut_on_func, dut_off_func) 316 317 # Each client connection will create a new protocol instance 318 coro = asyncio_loop.create_server(aut_protocol_factory, listen_ip, 319 port) 320 321 self.server = asyncio_loop.run_until_complete(coro) 322 323 # Serve requests until Ctrl+C is pressed 324 self.log.info('Automation Server listening on {}'.format( 325 self.server.sockets[0].getsockname())) 326 asyncio_loop.run_forever() 327 328 class AutomationProtocol(asyncio.Protocol): 329 """ Defines the protocol for communication with Contest's Automation 330 client. """ 331 332 AUTOMATION_DUT_ON = 'DUT_SWITCH_ON' 333 AUTOMATION_DUT_OFF = 'DUT_SWITCH_OFF' 334 AUTOMATION_OK = 'OK' 335 336 NOTIFICATION_TESTPLAN_START = 'AtTestplanStart' 337 NOTIFICATION_TESTCASE_START = 'AtTestcaseStart' 338 NOTIFICATION_TESCASE_END = 'AfterTestcase' 339 NOTIFICATION_TESTPLAN_END = 'AfterTestplan' 340 341 def __init__(self, logger, dut_on_func, dut_off_func): 342 """ Keeps the function handles to be used upon incoming requests. 343 344 Args: 345 logger: a logger handle 346 dut_on_func: function to turn the DUT on 347 dut_off_func: function to turn the DUT off 348 """ 349 350 self.log = logger 351 self.dut_on_func = dut_on_func 352 self.dut_off_func = dut_off_func 353 354 def connection_made(self, transport): 355 """ Called when a connection has been established. 356 357 Args: 358 transport: represents the socket connection. 359 """ 360 361 # Keep a reference to the transport as it will allow to write 362 # data to the socket later. 363 self.transport = transport 364 365 peername = transport.get_extra_info('peername') 366 self.log.info('Connection from {}'.format(peername)) 367 368 def data_received(self, data): 369 """ Called when some data is received. 370 371 Args: 372 data: non-empty bytes object containing the incoming data 373 """ 374 command = data.decode() 375 376 # Remove the line break and newline characters at the end 377 command = re.sub('\r?\n$', '', command) 378 379 self.log.info("Command received from Contest's Automation " 380 "client: {}".format(command)) 381 382 if command == self.AUTOMATION_DUT_ON: 383 self.log.info("Contest's Automation client requested to set " 384 "DUT to on state.") 385 self.send_ok() 386 self.dut_on_func() 387 return 388 elif command == self.AUTOMATION_DUT_OFF: 389 self.log.info("Contest's Automation client requested to set " 390 "DUT to off state.") 391 self.dut_off_func() 392 self.send_ok() 393 elif command.startswith(self.NOTIFICATION_TESTPLAN_START): 394 self.log.info('Test plan is starting.') 395 self.send_ok() 396 elif command.startswith(self.NOTIFICATION_TESTCASE_START): 397 self.log.info('Test case is starting.') 398 self.send_ok() 399 elif command.startswith(self.NOTIFICATION_TESCASE_END): 400 self.log.info('Test case finished.') 401 self.send_ok() 402 elif command.startswith(self.NOTIFICATION_TESTPLAN_END): 403 self.log.info('Test plan finished.') 404 self.send_ok() 405 else: 406 self.log.error('Unhandled automation command: ' + command) 407 raise ValueError() 408 409 def send_ok(self): 410 """ Sends an OK message to the Automation server. """ 411 self.log.info("Sending OK response to Contest's Automation client") 412 self.transport.write( 413 bytearray( 414 self.AUTOMATION_OK + '\n', 415 encoding='utf-8', 416 )) 417 418 def eof_received(self): 419 """ Called when the other end signals it won’t send any more 420 data. 421 """ 422 self.log.info('Received EOF from Contest Automation client.') 423