1#!/usr/bin/env python
2# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6# This module is the entry point for pseudomodem. Though honestly, I can't think
7# of any case when you want to use this module directly. Instead, use the
8# |pseudomodem_context| module that provides a way to launch pseudomodem in a
9# child process.
10
11import argparse
12import dbus
13import dbus.mainloop.glib
14import gobject
15import imp
16import json
17import logging
18import os
19import os.path
20import signal
21import sys
22import testing
23import traceback
24
25import logging_setup
26import modem_cdma
27import modem_3gpp
28import modemmanager
29import sim
30import state_machine_factory as smf
31
32import common
33from autotest_lib.client.cros.cellular import mm1_constants
34
35# Flags used by pseudomodem modules only that are defined below in
36# ParserArguments.
37CLI_FLAG = '--cli'
38EXIT_ERROR_FILE_FLAG = '--exit-error-file'
39
40class PseudoModemManager(object):
41    """
42    The main class to be used to launch the pseudomodem.
43
44    There should be only one instance of this class that orchestrates
45    pseudomodem.
46
47    """
48
49    def Setup(self, opts):
50        """
51        Call |Setup| to prepare pseudomodem to be launched.
52
53        @param opts: The options accepted by pseudomodem. See top level function
54                |ParseArguments| for details.
55
56        """
57        self._opts = opts
58
59        self._in_exit_sequence = False
60        self._manager = None
61        self._modem = None
62        self._state_machine_factory = None
63        self._sim = None
64        self._mainloop = None
65
66        self._dbus_loop = dbus.mainloop.glib.DBusGMainLoop()
67        self._bus = dbus.SystemBus(private=True, mainloop=self._dbus_loop)
68        self._bus_name = dbus.service.BusName(mm1_constants.I_MODEM_MANAGER,
69                                              self._bus)
70        logging.info('Exported dbus service with well known name: |%s|',
71                     self._bus_name.get_name())
72
73        self._SetupPseudomodemParts()
74        logging.info('Pseudomodem setup completed!')
75
76
77    def StartBlocking(self):
78        """
79        Start pseudomodem operation.
80
81        This call blocks untill |GracefulExit| is called from some other
82        context.
83
84        """
85        self._mainloop = gobject.MainLoop()
86        self._mainloop.run()
87
88
89    def GracefulExit(self):
90        """ Stop pseudomodem operation and clean up. """
91        if self._in_exit_sequence:
92            logging.debug('Already exiting.')
93            return
94
95        self._in_exit_sequence = True
96        logging.info('pseudomodem shutdown sequence initiated...')
97        # Guard each step by its own try...catch, because we want to attempt
98        # each step irrespective of whether the earlier ones succeeded.
99        try:
100            if self._manager:
101                self._manager.Remove(self._modem)
102        except Exception as e:
103            logging.warning('Error while exiting: %s', repr(e))
104        try:
105            if self._mainloop:
106                self._mainloop.quit()
107        except Exception as e:
108            logging.warning('Error while exiting: %s', repr(e))
109
110        logging.info('pseudomodem: Bye! Bye!')
111
112
113    def _SetupPseudomodemParts(self):
114        """
115        Contructs all pseudomodem objects, but does not start operation.
116
117        Three main objects are created: the |Modem|, the |Sim|, and the
118        |StateMachineFactory|. This objects may be instantiations of the default
119        classes, or of user provided classes, depending on options provided.
120
121        """
122        self._ReadCustomParts()
123
124        use_3gpp = (self._opts.family == '3GPP')
125
126        if not self._modem and not self._state_machine_factory:
127            self._state_machine_factory = smf.StateMachineFactory()
128            logging.info('Created default state machine factory.')
129
130        if use_3gpp and not self._sim:
131            self._sim = sim.SIM(sim.SIM.Carrier('test'),
132                                mm1_constants.MM_MODEM_ACCESS_TECHNOLOGY_GSM,
133                                locked=self._opts.locked)
134            logging.info('Created default 3GPP SIM.')
135
136        # Store this constant here because the variable name is too long.
137        network_available = dbus.types.UInt32(
138                mm1_constants.MM_MODEM_3GPP_NETWORK_AVAILABILITY_AVAILABLE)
139        if not self._modem:
140            if use_3gpp:
141                technology_gsm = dbus.types.UInt32(
142                        mm1_constants.MM_MODEM_ACCESS_TECHNOLOGY_GSM)
143                networks = [modem_3gpp.Modem3gpp.GsmNetwork(
144                        'Roaming Network Long ' + str(i),
145                        'Roaming Network Short ' + str(i),
146                        '00100' + str(i + 1),
147                        network_available,
148                        technology_gsm)
149                        for i in xrange(self._opts.roaming_networks)]
150                # TODO(armansito): Support "not activated" initialization option
151                # for 3GPP carriers.
152                self._modem = modem_3gpp.Modem3gpp(
153                        self._state_machine_factory,
154                        roaming_networks=networks)
155                logging.info('Created default 3GPP modem.')
156            else:
157                self._modem = modem_cdma.ModemCdma(
158                        self._state_machine_factory,
159                        modem_cdma.ModemCdma.CdmaNetwork(
160                                activated=self._opts.activated))
161                logging.info('Created default CDMA modem.')
162
163        # Everyone gets the |_bus|, woohoo!
164        self._manager = modemmanager.ModemManager(self._bus)
165        self._modem.SetBus(self._bus)  # Also sets it on StateMachineFactory.
166        self._manager.Add(self._modem)
167
168        # Unfortunately, setting the SIM has to be deferred until everyone has
169        # their BUS set. |self._sim| exists if the user provided one, or if the
170        # modem family is |3GPP|.
171        if self._sim:
172            self._modem.SetSIM(self._sim)
173
174        # The testing interface can be brought up now that we have the bus.
175        self._testing_object = testing.Testing(self._modem, self._bus)
176
177
178    def _ReadCustomParts(self):
179        """
180        Loads user provided implementations of pseudomodem objects.
181
182        The user can provide their own implementations of the |Modem|, |Sim| or
183        |StateMachineFactory| classes.
184
185        """
186        if not self._opts.test_module:
187            return
188
189        test_module = self._LoadCustomPartsModule(self._opts.test_module)
190
191        if self._opts.test_modem_class:
192            self._modem = self._CreateCustomObject(test_module,
193                                                   self._opts.test_modem_class,
194                                                   self._opts.test_modem_arg)
195
196        if self._opts.test_sim_class:
197            self._sim = self._CreateCustomObject(test_module,
198                                                 self._opts.test_sim_class,
199                                                 self._opts.test_sim_arg)
200
201        if self._opts.test_state_machine_factory_class:
202            if self._opts.test_modem_class:
203                logging.warning(
204                        'User provided a |Modem| implementation as well as a '
205                        '|StateMachineFactory|. Ignoring the latter.')
206            else:
207                self._state_machine_factory = self._CreateCustomObject(
208                        test_module,
209                        self._opts.test_state_machine_factory_class,
210                        self._opts.test_state_machine_factory_arg)
211
212
213    def _CreateCustomObject(self, test_module, class_name, arg_file_name):
214        """
215        Create the custom object specified by test.
216
217        @param test_module: The loaded module that implemets the custom object.
218        @param class_name: Name of the class implementing the custom object.
219        @param arg_file_name: Absolute path to file containing list of arguments
220                taken by |test_module|.|class_name| constructor in json.
221        @returns: A brand new object of the custom type.
222        @raises: AttributeError if the class definition is not found;
223                ValueError if |arg_file| does not contain valid json
224                representaiton of a python list.
225                Other errors may be raised during object creation.
226
227        """
228        arg = None
229        if arg_file_name:
230            arg_file = open(arg_file_name, 'rb')
231            try:
232                arg = json.load(arg_file)
233            finally:
234                arg_file.close()
235            if not isinstance(arg, list):
236                raise ValueError('Argument must be a python list.')
237
238        class_def = getattr(test_module, class_name)
239        try:
240            if arg:
241                logging.debug('Loading test class %s%s',
242                              class_name, str(arg))
243                return class_def(*arg)
244            else:
245                logging.debug('Loading test class %s', class_def)
246                return class_def()
247        except Exception as e:
248            logging.error('Exception raised when instantiating class %s: %s',
249                          class_name, str(e))
250            raise
251
252
253    def _LoadCustomPartsModule(self, module_abs_path):
254        """
255        Loads the given file as a python module.
256
257        The loaded module *is* added to |sys.modules|.
258
259        @param module_abs_path: Absolute path to the file to be loaded.
260        @returns: The loaded module.
261        @raises: ImportError if the module can not be loaded, or if another
262                 module with the same name is already loaded.
263
264        """
265        path, name = os.path.split(module_abs_path)
266        name, _ = os.path.splitext(name)
267
268        if name in sys.modules:
269            raise ImportError('A module named |%s| is already loaded.' %
270                              name)
271
272        logging.debug('Loading module %s from %s', name, path)
273        module_file, filepath, data = imp.find_module(name, [path])
274        try:
275            module = imp.load_module(name, module_file, filepath, data)
276        except Exception as e:
277            logging.error(
278                    'Exception raised when loading test module from %s: %s',
279                    module_abs_path, str(e))
280            raise
281        finally:
282            module_file.close()
283        return module
284
285
286# ##############################################################################
287# Public static functions.
288def ParseArguments(arg_string=None):
289    """
290    The main argument parser.
291
292    Pseudomodem is a command line tool.
293    Since pseudomodem is a highly customizable tool, the command line arguments
294    are expected to be quite complex.
295    We use argparse to keep the command line options easy to use.
296
297    @param arg_string: If not None, the string to parse. If none, |sys.argv| is
298            used to obtain the argument string.
299    @returns: The parsed options object.
300
301    """
302    parser = argparse.ArgumentParser(
303            description="Run pseudomodem to simulate a modem using the "
304                        "modemmanager-next DBus interface.")
305
306    parser.add_argument(
307            CLI_FLAG,
308            action='store_true',
309            default=False,
310            help='Launch the command line interface in foreground to interact '
311                 'with the launched pseudomodem process. This argument is used '
312                 'by |pseudomodem_context|. pseudomodem itself ignores it.')
313    parser.add_argument(
314            EXIT_ERROR_FILE_FLAG,
315            default=None,
316            help='If provided, full path to file to which pseudomodem should '
317                 'dump the error condition before exiting, in case of a crash. '
318                 'The file is not created if it does not already exist.')
319
320    modem_arguments = parser.add_argument_group(
321            title='Modem options',
322            description='Options to customize the modem exported.')
323    modem_arguments.add_argument(
324            '--family', '-f',
325            choices=['3GPP', 'CDMA'],
326            default='3GPP')
327
328    gsm_arguments = parser.add_argument_group(
329            title='3GPP options',
330            description='Options specific to 3GPP modems. [Only make sense '
331                        'when modem family is 3GPP]')
332
333    gsm_arguments.add_argument(
334            '--roaming-networks', '-r',
335            type=_NonNegInt,
336            default=0,
337            metavar='<# networks>',
338            help='Number of roaming networks available')
339
340    cdma_arguments = parser.add_argument_group(
341            title='CDMA options',
342            description='Options specific to CDMA modems. [Only make sense '
343                        'when modem family is CDMA]')
344
345    sim_arguments = parser.add_argument_group(
346            title='SIM options',
347            description='Options to customize the SIM in the modem. [Only make '
348                        'sense when modem family is 3GPP]')
349    sim_arguments.add_argument(
350            '--activated',
351            type=bool,
352            default=True,
353            help='Determine whether the SIM is activated')
354    sim_arguments.add_argument(
355            '--locked', '-l',
356            type=bool,
357            default=False,
358            help='Determine whether the SIM is in locked state')
359
360    testing_arguments = parser.add_argument_group(
361            title='Testing interface options',
362            description='Options to modify how the tests or user interacts '
363                        'with pseudomodem')
364    testing_arguments = parser.add_argument(
365            '--interactive-state-machines-all',
366            type=bool,
367            default=False,
368            help='Launch all state machines in interactive mode.')
369    testing_arguments = parser.add_argument(
370            '--interactive-state-machine',
371            type=str,
372            default=None,
373            help='Launch the specified state machine in interactive mode. May '
374                 'be repeated to specify multiple machines.')
375
376    customize_arguments = parser.add_argument_group(
377            title='Customizable modem options',
378            description='Options to customize the emulated modem.')
379    customize_arguments.add_argument(
380            '--test-module',
381            type=str,
382            default=None,
383            metavar='CUSTOM_MODULE',
384            help='Absolute path to the module with custom definitions.')
385    customize_arguments.add_argument(
386            '--test-modem-class',
387            type=str,
388            default=None,
389            metavar='MODEM_CLASS',
390            help='Name of the class in CUSTOM_MODULE that implements the modem '
391                 'to load.')
392    customize_arguments.add_argument(
393            '--test-modem-arg',
394            type=str,
395            default=None,
396            help='Absolute path to the json description of argument list '
397                 'taken by MODEM_CLASS.')
398    customize_arguments.add_argument(
399            '--test-sim-class',
400            type=str,
401            default=None,
402            metavar='SIM_CLASS',
403            help='Name of the class in CUSTOM_MODULE that implements the SIM '
404                 'to load.')
405    customize_arguments.add_argument(
406            '--test-sim-arg',
407            type=str,
408            default=None,
409            help='Aboslute path to the json description of argument list '
410                 'taken by SIM_CLASS')
411    customize_arguments.add_argument(
412            '--test-state-machine-factory-class',
413            type=str,
414            default=None,
415            metavar='SMF_CLASS',
416            help='Name of the class in CUSTOM_MODULE that impelements the '
417                 'state machine factory to load. Only used if MODEM_CLASS is '
418                 'not provided.')
419    customize_arguments.add_argument(
420            '--test-state-machine-factory-arg',
421            type=str,
422            default=None,
423            help='Absolute path to the json description of argument list '
424                 'taken by SMF_CLASS')
425
426    opts = parser.parse_args(arg_string)
427
428    # Extra sanity checks.
429    if opts.family == 'CDMA' and opts.roaming_networks > 0:
430        raise argparse.ArgumentTypeError('CDMA networks do not support '
431                                         'roaming networks.')
432
433    test_objects = (opts.test_modem_class or
434                    opts.test_sim_class or
435                    opts.test_state_machine_factory_class)
436    if not opts.test_module and test_objects:
437        raise argparse.ArgumentTypeError('test_module is required with any '
438                                         'other customization arguments.')
439
440    if opts.test_modem_class and opts.test_state_machine_factory_class:
441        logging.warning('test-state-machine-factory-class will be ignored '
442                        'because test-modem-class was provided.')
443
444    return opts
445
446
447def ExtractExitError(dump_file_path):
448    """
449    Gets the exit error left behind by a crashed pseudomodem.
450
451    If there is a file at |dump_file_path|, extracts the error and the traceback
452    left behind by the child process. This function is intended to be used by
453    the launching process to parse the error file left behind by pseudomodem.
454
455    @param dump_file_path: Full path to the file to read.
456    @returns: (error_reason, error_traceback)
457            error_reason: str. The one line reason for error that should be
458                    used to raise exceptions.
459            error_traceback: A list of str. This is the traceback left
460                    behind by the child process, if any. May be [].
461
462    """
463    error_reason = 'No detailed reason found :('
464    error_traceback = []
465    if dump_file_path:
466        try:
467            dump_file = open(dump_file_path, 'rb')
468            error_reason = dump_file.readline().strip()
469            error_traceback = dump_file.readlines()
470            dump_file.close()
471        except OSError as e:
472            logging.error('Could not open dump file %s: %s',
473                          dump_file_path, str(e))
474    return error_reason, error_traceback
475
476
477# The single global instance of PseudoModemManager.
478_pseudo_modem_manager = None
479
480
481# ##############################################################################
482# Private static functions.
483def _NonNegInt(value):
484    value = int(value)
485    if value < 0:
486        raise argparse.ArgumentTypeError('%s is not a non-negative int' % value)
487    return value
488
489
490def _DumpExitError(dump_file_path, exc):
491    """
492    Dump information about the raised exception in the exit error file.
493
494    Format of file dumped:
495    - First line is the reason for the crash.
496    - Subsequent lines are the traceback from the exception raised.
497
498    We expect the file to exist, because we want the launching context (that
499    will eventually read the error dump) to create and own the file.
500
501    @param dump_file_path: Full path to file to which we should dump.
502    @param exc: The exception raised.
503
504    """
505    if not dump_file_path:
506        return
507
508    if not os.path.isfile(dump_file_path):
509        logging.error('File |%s| does not exist. Can not dump exit error.',
510                      dump_file_path)
511        return
512
513    try:
514        dump_file = open(dump_file_path, 'wb')
515    except IOError as e:
516        logging.error('Could not open file |%s| to dump exit error. '
517                      'Exception raised when opening file: %s',
518                      dump_file_path, str(e))
519        return
520
521    dump_file.write(str(exc) + '\n')
522    dump_file.writelines(traceback.format_exc())
523    dump_file.close()
524
525
526def sig_handler(signum, frame):
527    """
528    Top level signal handler to handle user interrupt.
529
530    @param signum: The signal received.
531    @param frame: Ignored.
532    """
533    global _pseudo_modem_manager
534    logging.debug('Signal handler called with signal %d', signum)
535    if _pseudo_modem_manager:
536        _pseudo_modem_manager.GracefulExit()
537
538
539def main():
540    """
541    This is the entry point for raw pseudomodem.
542
543    You should not be running this module as a script. If you're trying to run
544    pseudomodem from the command line, see |pseudomodem_context| module.
545
546    """
547    global _pseudo_modem_manager
548
549    logging_setup.SetupLogging()
550
551    logging.info('Pseudomodem commandline: [%s]', str(sys.argv))
552    opts = ParseArguments()
553
554    signal.signal(signal.SIGINT, sig_handler)
555    signal.signal(signal.SIGTERM, sig_handler)
556
557    try:
558        _pseudo_modem_manager = PseudoModemManager()
559        _pseudo_modem_manager.Setup(opts)
560        _pseudo_modem_manager.StartBlocking()
561    except Exception as e:
562        logging.error('Caught exception at top level: %s', str(e))
563        _DumpExitError(opts.exit_error_file, e)
564        _pseudo_modem_manager.GracefulExit()
565        raise
566
567
568if __name__ == '__main__':
569    main()
570