• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2#
3#   Copyright 2020 - 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
17import csv
18from datetime import datetime
19import logging
20import tempfile
21
22from acts.libs.proc import job
23import yaml
24
25
26class BitsClientError(Exception):
27    pass
28
29
30# An arbitrary large number of seconds.
31ONE_YEAR = str(3600 * 24 * 365)
32EPOCH = datetime.utcfromtimestamp(0)
33
34
35def _to_ns(timestamp):
36    """Returns the numerical value of a timestamp in nanoseconds since epoch.
37
38    Args:
39        timestamp: Either a number or a datetime.
40
41    Returns:
42        Rounded timestamp if timestamp is numeric, number of nanoseconds since
43        epoch if timestamp is instance of datetime.datetime.
44    """
45    if isinstance(timestamp, datetime):
46        return int((timestamp - EPOCH).total_seconds() * 1e9)
47    elif isinstance(timestamp, (float, int)):
48        return int(timestamp)
49    raise ValueError('%s can not be converted to a numerical representation of '
50                     'nanoseconds.' % type(timestamp))
51
52
53class BitsClient(object):
54    """Helper class to issue bits' commands"""
55
56    def __init__(self, binary, service, service_config):
57        """Constructs a BitsClient.
58
59        Args:
60            binary: The location of the bits.par client binary.
61            service: A bits_service.BitsService object. The service is expected
62              to be previously setup.
63            service_config: The bits_service_config.BitsService object used to
64              start the service on service_port.
65        """
66        self._log = logging.getLogger()
67        self._binary = binary
68        self._service = service
69        self._server_config = service_config
70
71    def _acquire_monsoon(self):
72        """Gets hold of a Monsoon so no other processes can use it.
73        Only works if there is a monsoon."""
74        self._log.debug('acquiring monsoon')
75        self.run_cmd('--collector',
76                     'Monsoon',
77                     '--collector_cmd',
78                     'acquire_monsoon', timeout=10)
79
80    def _release_monsoon(self):
81        self._log.debug('releasing monsoon')
82        self.run_cmd('--collector',
83                     'Monsoon',
84                     '--collector_cmd',
85                     'release_monsoon', timeout=10)
86
87    def run_cmd(self, *args, timeout=60):
88        """Executes a generic bits.par command.
89
90        Args:
91            args: A bits.par command as a tokenized array. The path to the
92              binary and the service port are provided by default, cmd should
93              only contain the remaining tokens of the desired command.
94            timeout: Number of seconds to wait for the command to finish before
95              forcibly killing it.
96        """
97        result = job.run([self._binary, '--port',
98                          self._service.port] + [str(arg) for arg in args],
99                         timeout=timeout)
100        return result.stdout
101
102    def export(self, collection_name, path):
103        """Exports a collection to its bits persistent format.
104
105        Exported files can be shared and opened through the Bits UI.
106
107        Args:
108            collection_name: Collection to be exported.
109            path: Where the resulting file should be created. Bits requires that
110            the resulting file ends in .7z.bits.
111        """
112        if not path.endswith('.7z.bits'):
113            raise BitsClientError('Bits\' collections can only be exported to '
114                                  'files ending in .7z.bits, got %s' % path)
115        self._log.debug('exporting collection %s to %s',
116                        collection_name,
117                        path)
118        self.run_cmd('--name',
119                     collection_name,
120                     '--ignore_gaps',
121                     '--export',
122                     '--export_path',
123                     path,
124                     timeout=600)
125
126    def export_as_csv(self, channels, collection_name, output_file):
127        """Export bits data as CSV.
128
129        Writes the selected channel data to the given output_file. Note that
130        the first line of the file contains headers.
131
132        Args:
133          channels: A list of string pattern matches for the channel to be
134            retrieved. For example, ":mW" will export all power channels,
135            ":mV" will export all voltage channels, "C1_01__" will export
136            power/voltage/current for the first fail of connector 1.
137          collection_name: A string for a collection that is sampling.
138          output_file: A string file path where the CSV will be written.
139        """
140        channels_arg = ','.join(channels)
141        cmd = ['--csvfile',
142               output_file,
143               '--name',
144               collection_name,
145               '--ignore_gaps',
146               '--csv_rawtimestamps',
147               '--channels',
148               channels_arg]
149        if self._server_config.has_virtual_metrics_file:
150            cmd = cmd + ['--vm_file', 'default']
151        self._log.debug(
152            'exporting csv for collection %s to %s, with channels %s',
153            collection_name, output_file, channels_arg)
154        self.run_cmd(*cmd, timeout=600)
155
156    def add_markers(self, collection_name, markers):
157        """Appends markers to a collection.
158
159        These markers are displayed in the Bits UI and are useful to label
160        important test events.
161
162        Markers can only be added to collections that have not been
163        closed / stopped. Markers need to be added in chronological order,
164        this function ensures that at least the markers added in each
165        call are sorted in chronological order, but if this function
166        is called multiple times, then is up to the user to ensure that
167        the subsequent batches of markers are for timestamps higher (newer)
168        than all the markers passed in previous calls to this function.
169
170        Args:
171            collection_name: The name of the collection to add markers to.
172            markers: A list of tuples of the shape:
173
174             [(<nano_seconds_since_epoch or datetime>, <marker text>),
175              (<nano_seconds_since_epoch or datetime>, <marker text>),
176              (<nano_seconds_since_epoch or datetime>, <marker text>),
177              ...
178            ]
179        """
180        # sorts markers in chronological order before adding them. This is
181        # required by go/pixel-bits
182        for ts, marker in sorted(markers, key=lambda x: _to_ns(x[0])):
183            self._log.debug('Adding marker at %s: %s', str(ts), marker)
184            self.run_cmd('--name',
185                         collection_name,
186                         '--log_ts',
187                         str(_to_ns(ts)),
188                         '--log',
189                         marker,
190                         timeout=10)
191
192    def get_metrics(self, collection_name, start=None, end=None):
193        """Extracts metrics for a period of time.
194
195        Args:
196            collection_name: The name of the collection to get metrics from
197            start: Numerical nanoseconds since epoch until the start of the
198            period of interest or datetime. If not provided, start will be the
199            beginning of the collection.
200            end: Numerical nanoseconds since epoch until the end of the
201            period of interest or datetime. If not provided, end will be the
202            end of the collection.
203        """
204        with tempfile.NamedTemporaryFile(prefix='bits_metrics') as tf:
205            cmd = ['--name',
206                   collection_name,
207                   '--ignore_gaps',
208                   '--aggregates_yaml_path',
209                   tf.name]
210
211            if start is not None:
212                cmd = cmd + ['--abs_start_time', str(_to_ns(start))]
213            if end is not None:
214                cmd = cmd + ['--abs_stop_time', str(_to_ns(end))]
215            if self._server_config.has_virtual_metrics_file:
216                cmd = cmd + ['--vm_file', 'default']
217
218            self.run_cmd(*cmd)
219            with open(tf.name) as mf:
220                self._log.debug(
221                    'bits aggregates for collection %s [%s-%s]: %s' % (
222                        collection_name, start, end,
223                        mf.read()))
224
225            with open(tf.name) as mf:
226                return yaml.safe_load(mf)
227
228    def disconnect_usb(self):
229        """Disconnects the monsoon's usb. Only works if there is a monsoon"""
230        self._log.debug('disconnecting monsoon\'s usb')
231        self.run_cmd('--collector',
232                     'Monsoon',
233                     '--collector_cmd',
234                     'usb_disconnect', timeout=10)
235
236    def start_collection(self, collection_name, default_sampling_rate=1000):
237        """Indicates Bits to start a collection.
238
239        Args:
240            collection_name: Name to give to the collection to be started.
241            Collection names must be unique at Bits' service level. If multiple
242            collections must be taken within the context of the same Bits'
243            service, ensure that each collection is given a different one.
244            default_sampling_rate: Samples per second to be collected
245        """
246
247        cmd = ['--name',
248               collection_name,
249               '--non_blocking',
250               '--time',
251               ONE_YEAR,
252               '--default_sampling_rate',
253               str(default_sampling_rate)]
254
255        if self._server_config.has_kibbles:
256            cmd = cmd + ['--disk_space_saver']
257
258        self._log.debug('starting collection %s', collection_name)
259        self.run_cmd(*cmd, timeout=10)
260
261    def connect_usb(self):
262        """Connects the monsoon's usb. Only works if there is a monsoon."""
263        cmd = ['--collector',
264               'Monsoon',
265               '--collector_cmd',
266               'usb_connect']
267        self._log.debug('connecting monsoon\'s usb')
268        self.run_cmd(*cmd, timeout=10)
269
270    def stop_collection(self, collection_name):
271        """Stops the active collection."""
272        self._log.debug('stopping collection %s', collection_name)
273        self.run_cmd('--name',
274                     collection_name,
275                     '--stop')
276        self._log.debug('stopped collection %s', collection_name)
277
278    def list_devices(self):
279        """Lists devices managed by the bits_server this client is connected
280        to.
281
282        Returns:
283            bits' output when called with --list devices.
284        """
285        self._log.debug('listing devices')
286        result = self.run_cmd('--list', 'devices', timeout=20)
287        return result
288
289    def list_channels(self, collection_name):
290        """Finds all the available channels in a given collection.
291
292        Args:
293            collection_name: The name of the collection to get channels from.
294        """
295        metrics = self.get_metrics(collection_name)
296        return [channel['name'] for channel in metrics['data']]
297
298    def export_as_monsoon_format(self, dest_path, collection_name,
299                                 channel_pattern):
300        """Exports data from a collection in monsoon style.
301
302        This function exists because there are tools that have been built on
303        top of the monsoon format. To be able to leverage such tools we need
304        to make the data compliant with the format.
305
306        The monsoon format is:
307
308        <time_since_epoch_in_secs> <amps>
309
310        Args:
311            dest_path: Path where the resulting file will be generated.
312            collection_name: The name of the Bits' collection to export data
313            from.
314            channel_pattern: A regex that matches the Bits' channel to be used
315            as source of data. If there are multiple matching channels, only the
316            first one will be used. The channel is always assumed to be
317            expressed en milli-amps, the resulting format requires amps, so the
318            values coming from the first matching channel will always be
319            multiplied by 1000.
320        """
321        with tempfile.NamedTemporaryFile(prefix='bits_csv_') as tmon:
322            self.export_as_csv([channel_pattern], collection_name, tmon.name)
323
324            self._log.debug(
325                'massaging bits csv to monsoon format for collection'
326                ' %s', collection_name)
327            with open(tmon.name) as csv_file:
328                reader = csv.reader(csv_file)
329                headers = next(reader)
330                self._log.debug('csv headers %s', headers)
331                with open(dest_path, 'w') as dest:
332                    for row in reader:
333                        ts = float(row[0]) / 1e9
334                        amps = float(row[1]) / 1e3
335                        dest.write('%.7f %.12f\n' % (ts, amps))
336