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