1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Handle google gdata spreadsheet service."""
6
7
8from optparse import OptionParser
9import glob
10import os
11import shutil
12import subprocess
13import tempfile
14
15import gdata.gauth
16import gdata.spreadsheets.client
17
18from authenticator import SpreadsheetAuthorizer
19import mtb
20import test_conf as conf
21
22from common_util import print_and_exit
23from firmware_constants import GV
24
25
26# Skip operations about the Summary worksheet.
27# There are two tables in the worksheet. But it seems that there are problems
28# with table operations in the new Google Sheets for present.
29skip_summary_flag = True
30
31
32SHEET_DESCRIPTION = ('This sheet is autogenerated from autotest/client/'
33                     'site_tests/firmware_TouchMTB/spreadsheet.py')
34
35# Old Google Spreadsheet that suffered from 400K cells limit.
36# Leave it here for reference.
37# 'Touchpad relative pressure readings' spreadsheet key which is copied from the
38# url of the spreadsheet https://docs.google.com/a/google.com/spreadsheet/ccc?
39# key=0Ah6uZRmm2hgYdG4wX0JaQkVqa2gybTQwMnRfNmxsR1E
40
41# New style Google Spreadsheet: cells limit is 2 million.
42# 'Touchpad relative pressure readings (new Google Sheets)' spreadsheet key
43# which is copied from the url of the spreadsheet
44# https://docs.google.com/a/google.com/spreadsheets/d/1kE2lhfjTiq5o7Z5DqnJhMXrGbfUSa1rXbfhKopdIrYc/
45TARGET_SPREADSHEET_KEY = '1kE2lhfjTiq5o7Z5DqnJhMXrGbfUSa1rXbfhKopdIrYc'
46
47RESULTS = ['result:', '=B4*I5+J5', '=C4*I5+J5', '=D4*I5+J5', '=E4*I5+J5',
48           '=F4*I5+J5', '=G4*I5+J5', '=H4*I5+J5']
49ACTUALS = ['actual:', '11.945895975', '25.517564775', '46.566217775',
50           '76.976808975', '107.513063775', '151.746650975', '248.8453439']
51AVERAGES = ['mean:', '=AVERAGE(B6:B405)', '=AVERAGE(C6:C405)',
52            '=AVERAGE(D6:D405)', '=AVERAGE(E6:E405)', '=AVERAGE(F6:F405)',
53            '=AVERAGE(G6:G405)', '=AVERAGE(H6:H405)', 'slope:', 'icept']
54HEADERS = ['', 'size0', 'size1', 'size2', 'size3', 'size4', 'size5', 'size6',
55           '=transpose(Regress(B4:F4,B3:F3))']
56
57MEASURED = ['H4', 'G4', 'F4', 'E4', 'D4', 'C4', 'B4']
58COMPUTED = ['H2', 'G2', 'F2', 'E2', 'D2', 'C2', 'B2']
59
60SLOPE_CELL = (5, 9)
61INTERCEPT_CELL = (5, 10)
62
63CELLS = [
64  RESULTS,
65  ACTUALS,
66  AVERAGES,
67  HEADERS
68]
69
70CROSS_SHEET_CELL = '=\'%s\'!%s'
71SUMMARY_WORKSHEET_TITLE = 'Summary'
72TABLE_COMPUTED = 'Computed Pressures'
73TABLE_MEASURED = 'Measured Pressures'
74
75options = None
76ACTUAL_SURFACE_AREA = [ '11.945895975', '25.517564775', '46.566217775',
77                        '76.976808975', '107.513063775', '151.746650975',
78                        '248.8453439' ]
79results_table = { 'A': 'device', 'B': 'size6', 'C': 'size5', 'D': 'size4',
80                  'E': 'size3', 'F': 'size2', 'G': 'size1', 'H': 'size0' }
81surface_title = [ 'surface area', '248.8453439', '151.746650975',
82                  '107.513063775', '76.976808975', '46.566217775',
83                  '25.517564775', '11.945895975' ]
84diameter_title = [ 'diameter (mm)', '17.8', '13.9', '11.7', '9.9', '7.7',
85                   '5.7', '3.9']
86
87def print_verbose(msg):
88    """Print the message if options.verbose is True.
89
90    @param msg: the message to print
91    """
92    if options.verbose:
93        print msg
94
95
96class GestureEventFiles:
97    """Get gesture event files and parse the pressure values."""
98    DEFAULT_RESULT_DIR = 'latest'
99    FILE_PATTERN = '{}.%s*.dat'.format(conf.PRESSURE_CALIBRATION)
100    PRESSURE_LIST_MAX_SIZE = 80 * 5
101
102    def __init__(self):
103        self._get_machine_ip()
104        self._get_result_dir()
105        self._get_gesture_event_files()
106        self._get_event_pressures()
107        self._get_list_of_pressure_dicts()
108
109    def __del__(self):
110        self._cleanup()
111
112    def _cleanup(self):
113        """Remove the temporary directory that holds the gesture event files."""
114        if os.path.isdir(self.event_dir):
115            print 'Removing tmp directory "%s" .... ' % self.event_dir
116            try:
117                shutil.rmtree(self.event_dir)
118            except Exception as e:
119                msg = 'Error in removing tmp directory ("%s"): %s'
120                print_and_exit(msg % (self.event_dir, e))
121
122    def _cleanup_and_exit(self, err_msg):
123        """Clean up and exit with the error message.
124
125        @param err_msg: the error message to print
126        """
127        self._cleanup()
128        print_and_exit(err_msg)
129
130    def _get_machine_ip(self):
131        """Get the ip address of the chromebook machine."""
132        if options.device:
133            self.machine_ip = options.device
134            return
135        msg = '\nEnter the ip address (xx.xx.xx.xx) of the chromebook machine: '
136        self.machine_ip = raw_input(msg)
137
138    def _get_result_dir(self):
139        """Get the test result directory located in the chromebook machine."""
140        if not options.result_dir:
141            print '\nEnter the test result directory located in the machine.'
142            print 'It is a directory under %s' % conf.log_root_dir
143            print ('If you have just performed the pressure calibration test '
144                   'on the machine,\n' 'you could just press ENTER to use '
145                   'the default "latest" directory.')
146            result_dir = raw_input('Enter test result directory: ')
147            if result_dir == '':
148                result_dir = self.DEFAULT_RESULT_DIR
149        else:
150            result_dir = options.result_dir
151        self.result_dir = os.path.join(conf.log_root_dir, result_dir)
152
153    def _get_gesture_event_files(self):
154        """Scp the gesture event files in the result_dir in machine_ip."""
155        try:
156            self.event_dir = tempfile.mkdtemp(prefix='touch_firmware_test_')
157        except Exception as e:
158            err_msg = 'Error in creating tmp directory (%s): %s'
159            self._cleanup_and_exit(err_msg % (self.event_dir, e))
160
161        # Try to scp the gesture event files from the chromebook machine to
162        # the event_dir created above on the host.
163        # An example gesture event file looks like
164        #   pressure_calibration.size0-lumpy-fw_11.27-calibration-20130307.dat
165        filepath = os.path.join(self.result_dir, self.FILE_PATTERN % '')
166        cmd = ('scp -o UserKnownHostsFile=/dev/null ' +
167               '-o StrictHostKeyChecking=no root@%s:%s %s') % (
168          self.machine_ip, filepath, self.event_dir)
169        try:
170            print ('scp gesture event files from "machine_ip:%s" to %s\n' %
171                   (self.machine_ip, self.event_dir))
172            subprocess.call(cmd.split())
173        except subprocess.CalledProcessError as e:
174            self._cleanup_and_exit('Error in executing "%s": %s' % (cmd, e))
175
176    def _get_event_pressures(self):
177        """Parse the gesture event files to get the pressure values."""
178        self.pressures = {}
179        self.len_pressures = {}
180        for s in GV.SIZE_LIST:
181            # Get the gesture event file for every finger size.
182            filepath = os.path.join(self.event_dir, self.FILE_PATTERN % s)
183            event_files = glob.glob(filepath)
184            if not event_files:
185                err_msg = 'Error: there is no gesture event file for size %s'
186                self._cleanup_and_exit(err_msg % s)
187
188            # Use the latest event file for the size if there are multiple ones.
189            event_files.sort()
190            event_file = event_files[-1]
191
192            # Get the list of pressures in the event file.
193            mtb_packets = mtb.get_mtb_packets_from_file(event_file)
194            target_slot = 0
195            list_z = mtb_packets.get_slot_data(target_slot, 'pressure')
196            len_z = len(list_z)
197            if self.PRESSURE_LIST_MAX_SIZE > len_z:
198                bgn_index = 0
199                end_index = len_z
200            else:
201                # Get the middle segment of the list of pressures.
202                bgn_index = (len_z - self.PRESSURE_LIST_MAX_SIZE) / 2
203                end_index = (len_z + self.PRESSURE_LIST_MAX_SIZE) / 2
204            self.pressures[s] = list_z[bgn_index : end_index]
205            self.len_pressures[s] = len(self.pressures[s])
206
207    def _get_list_of_pressure_dicts(self):
208        """Get a list of pressure dictionaries."""
209        self.list_of_pressure_dicts = []
210        for index in range(max(self.len_pressures.values())):
211            pressure_dict = {}
212            for s in GV.SIZE_LIST:
213                if index < self.len_pressures[s]:
214                    pressure_dict[s] = str(self.pressures[s][index])
215            self.list_of_pressure_dicts.append(pressure_dict)
216            print_verbose('      row %4d: %s' % (index, str(pressure_dict)))
217
218class PressureSpreadsheet(object):
219    """A spreadsheet class to perform pressures calibration in worksheets."""
220    WORKSHEET_ROW_COUNT = 1000
221    WORKSHEET_COL_COUNT = 20
222    START_ROW_NUMBER = 2
223    DATA_BEGIN_COLUMN = 2
224    COMPUTED_TABLE_ROW = 12
225    MEASURED_TABLE_ROW = 3
226
227    def __init__(self, worksheet_title):
228        """Initialize the spreadsheet and the worksheet
229
230        @param spreadsheet_title: the spreadsheet title
231        @param worksheet_title: the worksheet title
232        """
233        self.computed_table = None
234        self.feed = None
235        self.measured_table = None
236        self.number_records = 0
237        self.spreadsheet_key = TARGET_SPREADSHEET_KEY
238        self.ss_client = gdata.spreadsheets.client.SpreadsheetsClient()
239        self.summary_feed = None
240        self.worksheet_title = worksheet_title
241        authorizer = SpreadsheetAuthorizer()
242        if not authorizer.authorize(self.ss_client):
243            raise "Please check the access permission of the spreadsheet"
244        self._get_new_worksheet_by_title(worksheet_title)
245        if not skip_summary_flag:
246            self._get_summary_tables()
247
248    def _get_worksheet_entry_by_title(self, worksheet_title):
249        """Check if the worksheet title exists?"""
250        worksheet_feed = self.ss_client.get_worksheets(self.spreadsheet_key)
251        for entry in worksheet_feed.entry:
252            if entry.title.text == worksheet_title:
253                return entry
254        return None
255
256    def _get_new_worksheet_by_title(self, worksheet_title,
257                                    row_count=WORKSHEET_ROW_COUNT,
258                                    col_count=WORKSHEET_COL_COUNT):
259        """Create a new worksheet using the title.
260
261        If the worksheet title already exists, using a new title name such as
262        "Copy n of title", where n = 2, 3, ..., MAX_TITLE_DUP + 1
263
264        @param title: the worksheet title
265        @param row_count: the number of rows in the worksheet
266        @param col_count: the number of columns in the worksheet
267
268        """
269        MAX_TITLE_DUP = 10
270        new_worksheet_title = worksheet_title
271        for i in range(2, MAX_TITLE_DUP + 2):
272            if not self._get_worksheet_entry_by_title(new_worksheet_title):
273                break
274            new_worksheet_title = 'Copy %d of %s' % (i, worksheet_title)
275            self.worksheet_title = new_worksheet_title
276        else:
277            msg = 'Too many duplicate copies of the worksheet title: %s.'
278            print_and_exit(msg % worksheet_title)
279
280        # Add the new worksheet and get the worksheet_id.
281        worksheet_entry = self.ss_client.add_worksheet(self.spreadsheet_key,
282                                                       new_worksheet_title,
283                                                       row_count,
284                                                       col_count)
285        worksheet_id = worksheet_entry.get_worksheet_id()
286        self.feed = gdata.spreadsheets.data.build_batch_cells_update(
287                    self.spreadsheet_key, worksheet_id)
288        self.feed.add_set_cell(1, 1, SHEET_DESCRIPTION)
289
290
291    def _insert_pressure_data(self, list_of_pressure_dicts):
292        """Insert the lists of pressures of all finger sizes to a worksheet.
293
294        @param list_of_pressure_dicts: a list of pressure dictionaries
295        """
296        # Set column headers for figner sizes
297        for row in range(len(CELLS)):
298            for column in range(len(CELLS[row])):
299                self.feed.add_set_cell(self.START_ROW_NUMBER + row,
300                                       column + 1,
301                                       CELLS[row][column])
302
303        # Insert the pressures row by row
304        row = self.START_ROW_NUMBER + len(CELLS)
305        for pressure_dict in list_of_pressure_dicts:
306            print_verbose('      %s' % str(pressure_dict))
307            col = self.DATA_BEGIN_COLUMN
308            for size in GV.SIZE_LIST:
309                if size in pressure_dict:
310                    self.feed.add_set_cell(row, col, pressure_dict[size])
311                col = col + 1
312            row = row + 1
313
314    def _insert_summary_record(self, table, row_source):
315        """ Paste a record into measured/computed table in summary sheet from
316            a row in source sheet.
317
318        Append one record in the table first as the gdata api does not allow
319        formula in the record data for add_record(). In other word, we have
320        to fill data with add_set_cell() one-by-one instead.
321
322        @param table: Target table entry
323        @param row_source: row in source sheet
324        """
325        record = { 'device' : self.worksheet_title }
326        self.ss_client.add_record(self.spreadsheet_key,
327                                  table.get_table_id(),
328                                  record)
329        row_target = int(table.data.start_row) + int(table.data.num_rows)
330        # As there will be one record inserted measured table, we need to
331        # increase the row number by one for computed_table
332        if table == self.computed_table:
333            row_target = row_target + 1
334        for i in range(len(row_source)):
335            formula = CROSS_SHEET_CELL % (self.worksheet_title, row_source[i])
336            self.summary_feed.add_set_cell(row_target,
337                                           i + 2,
338                                           formula)
339
340    def insert_pressures_to_worksheet(self, list_of_pressure_dicts):
341        """Insert the lists of pressures of all finger sizes to a new worksheet.
342
343        @param list_of_pressure_dicts: a list of pressure dictionaries
344        """
345        self.number_records = len(list_of_pressure_dicts)
346        print 'Insert the data to device worksheet...'
347        self._insert_pressure_data(list_of_pressure_dicts)
348
349        print 'Finalizing the insertion...'
350        self.ss_client.batch(self.feed, force=True)
351
352        if not skip_summary_flag:
353            print 'Insert the data to summary worksheet...'
354            self._insert_summary_record(self.computed_table, COMPUTED)
355            self._insert_summary_record(self.measured_table, MEASURED)
356            print 'Finalizing the insertion...'
357            self.ss_client.batch(self.summary_feed, force=True)
358
359    def _get_summary_table(self, title, header_row, start_row):
360        """Insert the lists of pressures of all finger sizes to a new worksheet.
361
362        @param title: Title of the table
363        @param header_row: Row of the header in the table
364        @param start_row: starting row of data in the table
365        """
366        tables = self.ss_client.get_tables(self.spreadsheet_key)
367        for table in tables.entry:
368            if table.title.text == title:
369                return table
370        # table is not created yet
371        table = self.ss_client.add_table(self.spreadsheet_key,
372                                         title,
373                                         title,
374                                         SUMMARY_WORKSHEET_TITLE,
375                                         header_row,
376                                         0,
377                                         start_row,
378                                         gdata.spreadsheets.data.INSERT_MODE,
379                                         results_table)
380        # add additional table descriptions
381        if title == 'Computed Pressures':
382            row = self.COMPUTED_TABLE_ROW - 1
383            for col in range(len(surface_title)):
384                self.summary_feed.add_set_cell(row, col + 1, surface_title[col])
385            for col in range(len(diameter_title)):
386                self.summary_feed.add_set_cell(row + 1, col + 1,
387                                               diameter_title[col])
388        return table
389
390    def _delete_nonexist_table(self, title):
391        """Remove the nonexist table entries.
392
393        @param title: Title of the table
394        """
395        tables = self.ss_client.get_tables(self.spreadsheet_key)
396        for table in tables.entry:
397            if table.title.text == title:
398                self.ss_client.delete(table)
399
400    def _get_summary_tables(self):
401        """Insert the results of all finger sizes to summary worksheet."""
402        entry = self._get_worksheet_entry_by_title(SUMMARY_WORKSHEET_TITLE)
403        if not entry:
404            self._delete_nonexist_table(TABLE_MEASURED)
405            self._delete_nonexist_table(TABLE_COMPUTED)
406            entry = self.ss_client.add_worksheet(self.spreadsheet_key,
407                                                 SUMMARY_WORKSHEET_TITLE,
408                                                 self.WORKSHEET_ROW_COUNT,
409                                                 self.WORKSHEET_COL_COUNT)
410        self.summary_feed = gdata.spreadsheets.data.build_batch_cells_update(
411                            self.spreadsheet_key, entry.get_worksheet_id())
412        table_row = self.MEASURED_TABLE_ROW
413        self.measured_table = self._get_summary_table(TABLE_MEASURED,
414                                                      table_row,
415                                                      table_row + 1)
416        table_row = self.COMPUTED_TABLE_ROW
417        self.computed_table = self._get_summary_table(TABLE_COMPUTED,
418                                                      table_row,
419                                                      table_row + 1)
420
421def get_worksheet_title():
422    """Get the worksheet title."""
423    worksheet_title = ''
424    if options.name:
425        return options.name
426    while not worksheet_title:
427        print '\nInput the name of the new worksheet to insert the events.'
428        print ('This is usually the board name with the firmware version, '
429               'e.g., Lumpy 11.27')
430        worksheet_title = raw_input('Input the new worksheet name: ')
431    return worksheet_title
432
433def print_slope_intercept(worksheet_title):
434    """read calibration data from worksheet and print it on command line
435
436    @param worksheet_title title of the worksheet to pull info from
437    """
438    # init client
439    ss_client = gdata.spreadsheets.client.SpreadsheetsClient()
440    authorizer = SpreadsheetAuthorizer()
441    if not authorizer.authorize(ss_client):
442        raise "Please check the access permission of the spreadsheet"
443
444    # look up the worksheet id
445    worksheet_id = None
446    worksheet_feed = ss_client.get_worksheets(TARGET_SPREADSHEET_KEY)
447    for entry in worksheet_feed.entry:
448        if entry.title.text == worksheet_title:
449            worksheet_id = entry.get_worksheet_id()
450    if not worksheet_id:
451        raise "cannot find worksheet" + worksheet_title
452
453    # print calibration info
454    slope_cell = ss_client.get_cell(TARGET_SPREADSHEET_KEY,
455                                    worksheet_id,
456                                    SLOPE_CELL[0], SLOPE_CELL[1])
457    print "slope=" + slope_cell.cell.numeric_value
458
459    intercept_cell = ss_client.get_cell(TARGET_SPREADSHEET_KEY,
460                                        worksheet_id,
461                                        INTERCEPT_CELL[0], INTERCEPT_CELL[1])
462    print "intercept=" + intercept_cell.cell.numeric_value
463
464def main():
465    """Parse the gesture events and insert them to the spreadsheet."""
466    worksheet_title = get_worksheet_title()
467    if options.print_info:
468        print_slope_intercept(worksheet_title)
469        return
470
471    # Get the gesture event files and parse the events.
472    list_of_pressure_dicts = GestureEventFiles().list_of_pressure_dicts
473
474    # Access the spreadsheet, and create a new worksheet to insert the events.
475    ss = PressureSpreadsheet(worksheet_title)
476    ss.insert_pressures_to_worksheet(list_of_pressure_dicts)
477
478if __name__ == '__main__':
479    parser = OptionParser()
480    parser.add_option('-d', '--device',
481                    dest='device', default=None,
482                    help='device ip address to connect to')
483    parser.add_option('--result-dir',
484                    dest='result_dir', default=None,
485                    help='test results directory on the device')
486    parser.add_option('-v', '--verbose',
487                    dest='verbose', default=False, action='store_true',
488                    help='verbose debug output')
489    parser.add_option('-n', '--name',
490                    dest='name', default=None,
491                    help='worksheet name')
492    parser.add_option('--print-info',
493                    dest='print_info', default=False, action="store_true",
494                    help='print pressure calibration info only')
495    (options, args) = parser.parse_args()
496    if len(args) > 0:
497        parser.print_help()
498        exit(-1)
499
500    options.verbose = options.verbose
501    device_ip = options.device
502    result_dir = options.result_dir
503    main()
504